mirror of
https://github.com/zapstore/zapstore.git
synced 2026-09-14 03:05:06 +00:00
620 lines
22 KiB
Dart
620 lines
22 KiB
Dart
import 'dart:async';
|
|
import 'dart:io' show File, Platform;
|
|
import 'dart:isolate';
|
|
import 'dart:ui' show PlatformDispatcher;
|
|
|
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart' show rootBundle;
|
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:models/models.dart';
|
|
import 'package:path/path.dart' as path;
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:purplebase/purplebase.dart';
|
|
import 'package:amber_signer/amber_signer.dart';
|
|
import 'package:zapstore/services/app_restart_service.dart';
|
|
import 'package:zapstore/services/background_update_service.dart';
|
|
import 'package:zapstore/services/log_service.dart';
|
|
import 'package:zapstore/services/notification_service.dart';
|
|
import 'package:zapstore/services/settings_service.dart';
|
|
import 'package:zapstore/router.dart';
|
|
import 'package:zapstore/services/package_manager/package_manager.dart';
|
|
import 'package:zapstore/theme.dart';
|
|
import 'package:zapstore/services/package_manager/android_package_manager.dart';
|
|
import 'package:zapstore/services/package_manager/dummy_package_manager.dart';
|
|
import 'package:zapstore/services/deep_link_service.dart';
|
|
import 'package:zapstore/services/device_backup_service.dart';
|
|
import 'package:zapstore/services/device_key_service.dart';
|
|
import 'package:zapstore/services/device_private_event_service.dart';
|
|
import 'package:zapstore/services/device_private_sync_service.dart';
|
|
import 'package:zapstore/services/device_state_service.dart';
|
|
import 'package:zapstore/services/app_catalog_relay_service.dart';
|
|
import 'package:zapstore/utils/debug_utils.dart';
|
|
import 'package:zapstore/utils/extensions.dart';
|
|
import 'package:zapstore/widgets/breathing_logo.dart';
|
|
|
|
/// Global provider container so handlers outside the widget tree can
|
|
/// reach Riverpod state.
|
|
late final ProviderContainer _providerContainer;
|
|
|
|
/// Receives uncaught isolate errors from the main isolate. Held at
|
|
/// top level so it survives for the lifetime of the app — without
|
|
/// this reference the port could be garbage-collected and the
|
|
/// isolate listener would silently stop firing.
|
|
// ignore: unused_element
|
|
RawReceivePort? _isolateErrorPort;
|
|
|
|
void main() {
|
|
// Everything that touches the Flutter binding or schedules async work
|
|
// for the app MUST run inside the same zone as `runApp`. Otherwise the
|
|
// engine throws a "Zone mismatch" assertion because zone-specific
|
|
// configuration (error handlers, microtask hooks) would be split
|
|
// between the root zone and the guarded zone.
|
|
runZonedGuarded(() {
|
|
WidgetsFlutterBinding.ensureInitialized();
|
|
|
|
// Install all four error sinks BEFORE runApp.
|
|
// LogService.I works pre-init (writes go to the ring buffer until
|
|
// disk is ready); init() is awaited just below to enable disk.
|
|
_installErrorHandlers();
|
|
|
|
// Bring up disk logging. Not awaited — if path_provider fails the
|
|
// LogService falls back to ring-buffer-only and we continue.
|
|
unawaited(
|
|
LogService.I.init(isolateName: 'main').then((_) {
|
|
LogService.I.info(
|
|
'app starting',
|
|
tag: 'app',
|
|
fields: {
|
|
'platform': Platform.operatingSystem,
|
|
'version': Platform.operatingSystemVersion,
|
|
},
|
|
);
|
|
}),
|
|
);
|
|
|
|
_providerContainer = ProviderContainer(
|
|
overrides: [
|
|
storageNotifierProvider.overrideWith(PurplebaseStorageNotifier.new),
|
|
packageManagerProvider.overrideWith(
|
|
(ref) => Platform.isAndroid
|
|
? AndroidPackageManager(ref)
|
|
: DummyPackageManager(ref),
|
|
),
|
|
],
|
|
observers: const [LoggingProviderObserver()],
|
|
);
|
|
|
|
runApp(
|
|
UncontrolledProviderScope(
|
|
container: _providerContainer,
|
|
child: const ZapstoreApp(),
|
|
),
|
|
);
|
|
}, (error, stack) => _logUncaught(error, stack, source: 'zone'));
|
|
}
|
|
|
|
/// Wires the four error sinks documented in FEAT-005:
|
|
/// * `FlutterError.onError` — sync framework errors
|
|
/// * `PlatformDispatcher.instance.onError` — engine / uncaught Dart
|
|
/// * `runZonedGuarded` — async errors in the root zone
|
|
/// * `Isolate.current.addErrorListener` — main-isolate errors that
|
|
/// bypass zones
|
|
///
|
|
/// In debug builds errors continue to be dumped to the console via
|
|
/// `FlutterError.presentError` so `flutter run` still shows them.
|
|
void _installErrorHandlers() {
|
|
FlutterError.onError = (details) {
|
|
FlutterError.presentError(details);
|
|
_logUncaught(
|
|
details.exception,
|
|
details.stack,
|
|
source: 'flutter',
|
|
library: details.library,
|
|
);
|
|
};
|
|
|
|
PlatformDispatcher.instance.onError = (error, stack) {
|
|
_logUncaught(error, stack, source: 'platform_dispatcher');
|
|
// Returning true tells the engine the error is handled (we logged
|
|
// it). Returning false would cause the engine to terminate the
|
|
// isolate on some platforms.
|
|
return true;
|
|
};
|
|
|
|
// Errors that escape to the isolate level (e.g. unhandled errors in
|
|
// a Future created in a foreign zone) come back via this port.
|
|
final port = RawReceivePort((dynamic pair) {
|
|
// Isolate sends a [errorString, stackString] list.
|
|
if (pair is List && pair.length == 2) {
|
|
final err = pair[0]?.toString() ?? 'unknown';
|
|
final stack = pair[1] == null
|
|
? null
|
|
: StackTrace.fromString(pair[1].toString());
|
|
_logUncaught(err, stack, source: 'isolate');
|
|
}
|
|
});
|
|
Isolate.current.addErrorListener(port.sendPort);
|
|
_isolateErrorPort = port;
|
|
}
|
|
|
|
/// Common entry point for all four sinks. Always non-blocking. On a
|
|
/// fatal sink (Flutter / PlatformDispatcher) we also flush the log
|
|
/// synchronously so the entry survives an immediate crash.
|
|
void _logUncaught(
|
|
Object error,
|
|
StackTrace? stack, {
|
|
required String source,
|
|
String? library,
|
|
}) {
|
|
LogService.I.fatal(
|
|
'uncaught error',
|
|
tag: 'crash',
|
|
fields: {'source': source, if (library != null) 'library': library},
|
|
err: error,
|
|
stack: stack,
|
|
);
|
|
// Best-effort durable flush. Swallows its own errors.
|
|
try {
|
|
LogService.I.flushSync();
|
|
} catch (_) {}
|
|
}
|
|
|
|
class ZapstoreApp extends HookConsumerWidget {
|
|
const ZapstoreApp({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final notifier =
|
|
ref.read(storageNotifierProvider.notifier) as PurplebaseStorageNotifier;
|
|
final title = 'Zapstore';
|
|
|
|
// Watch initialization state for error overlay display
|
|
final initState = ref.watch(appInitializationProvider);
|
|
|
|
// One coordinator handles every successful Amber key transition, including
|
|
// auto sign-in and every manual sign-in entry point.
|
|
ref.listen<String?>(Signer.activePubkeyProvider, (previous, next) {
|
|
if (next == null || next == previous) return;
|
|
onSignInSuccess(ref.read(refProvider));
|
|
});
|
|
|
|
// Listen to app lifecycle and check for updates when app regains focus
|
|
useEffect(() {
|
|
final observer = _AppLifecycleObserver(ref);
|
|
WidgetsBinding.instance.addObserver(observer);
|
|
return () => WidgetsBinding.instance.removeObserver(observer);
|
|
}, []);
|
|
|
|
// Listen to connectivity changes and trigger ensureConnected when going online
|
|
useEffect(() {
|
|
final connectivity = Connectivity();
|
|
StreamSubscription<List<ConnectivityResult>>? subscription;
|
|
|
|
// Check initial connectivity state
|
|
connectivity.checkConnectivity().then((results) {
|
|
notifier.connect();
|
|
unawaited(
|
|
ref.read(devicePrivateEventServiceProvider).processPendingEvents(),
|
|
);
|
|
unawaited(ref.read(appCatalogRelayServiceProvider).checkForUpdates());
|
|
});
|
|
|
|
// Listen to connectivity changes
|
|
subscription = connectivity.onConnectivityChanged.listen((results) {
|
|
notifier.connect();
|
|
if (results.any((result) => result != ConnectivityResult.none)) {
|
|
unawaited(
|
|
ref.read(devicePrivateEventServiceProvider).processPendingEvents(),
|
|
);
|
|
unawaited(ref.read(appCatalogRelayServiceProvider).checkForUpdates());
|
|
}
|
|
});
|
|
|
|
return () => subscription?.cancel();
|
|
}, []);
|
|
|
|
// Automatically sign out when Amber is uninstalled while signed in.
|
|
ref.listen<bool>(
|
|
packageManagerProvider.select(
|
|
(state) => state.installed.containsKey(kAmberPackageId),
|
|
),
|
|
(previous, isAmberInstalled) {
|
|
if (previous != true || isAmberInstalled) return;
|
|
if (ref.read(Signer.activePubkeyProvider) == null) return;
|
|
|
|
unawaited(() async {
|
|
try {
|
|
await ref.read(amberSignerProvider).signOut();
|
|
|
|
final toastContext =
|
|
rootNavigatorKey.currentState?.overlay?.context;
|
|
if (toastContext != null && toastContext.mounted) {
|
|
toastContext.showInfo('Amber was removed, you were signed out');
|
|
}
|
|
} catch (error, stack) {
|
|
LogService.I.warn(
|
|
'auto sign-out after Amber uninstall failed',
|
|
tag: 'amber',
|
|
err: error,
|
|
stack: stack,
|
|
);
|
|
}
|
|
}());
|
|
},
|
|
);
|
|
|
|
// Always show the main app UI, even during initialization
|
|
return MaterialApp.router(
|
|
title: title,
|
|
theme: darkTheme,
|
|
routerConfig: ref.watch(routerProvider),
|
|
debugShowCheckedModeBanner: false,
|
|
builder: (context, child) {
|
|
// Limit text scale factor to prevent extreme sizes on different devices
|
|
final mediaQuery = MediaQuery.of(context);
|
|
final constrainedTextScale = mediaQuery.textScaler
|
|
.scale(1.0)
|
|
.clamp(1.0, 1.2);
|
|
final constrainedTextScaler = TextScaler.linear(constrainedTextScale);
|
|
|
|
// Show error overlay if initialization failed (do not block UI during loading)
|
|
if (initState is AsyncError) {
|
|
return MediaQuery(
|
|
data: mediaQuery.copyWith(textScaler: constrainedTextScaler),
|
|
child: Stack(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
|
child: child!,
|
|
),
|
|
// Error overlay
|
|
Container(
|
|
color: Colors.black54,
|
|
child: Center(
|
|
child: Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.error_outline,
|
|
size: 64,
|
|
color: Colors.red,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'Initialization Error',
|
|
style: context.textTheme.headlineSmall,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
initState.error.toString(),
|
|
textAlign: TextAlign.center,
|
|
style: context.textTheme.bodyMedium,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return MediaQuery(
|
|
data: mediaQuery.copyWith(textScaler: constrainedTextScaler),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
|
child: child!,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class ZapstoreHome extends StatelessWidget {
|
|
const ZapstoreHome({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
body: Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const BreathingLogo(size: 120),
|
|
const SizedBox(height: 24),
|
|
Text('Zapstore', style: context.textTheme.headlineMedium),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Permissionless app store for Nostr',
|
|
style: context.textTheme.bodyLarge,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Ready as soon as local storage is usable.
|
|
///
|
|
/// UI skeleton gates MUST depend on this provider — NOT on
|
|
/// [appInitializationProvider] — because this phase does not touch the
|
|
/// network. See spec/guidelines/INVARIANTS.md → "Local data must be
|
|
/// sufficient to render meaningful UI state".
|
|
final storageReadyProvider = FutureProvider<void>((ref) async {
|
|
final dir = await getApplicationSupportDirectory();
|
|
final dbPath = path.join(dir.path, 'zapstore.db');
|
|
final relayService = ref.read(appCatalogRelayServiceProvider);
|
|
final hasRelayHandoff = await relayService.hasRestartHandoff();
|
|
|
|
// Clear storage if requested from a clear all operation
|
|
await maybeClearStorage(dbPath);
|
|
|
|
// Seed database on first launch so new users see content immediately
|
|
await _maybeCopySeedDatabase(dbPath, skip: hasRelayHandoff);
|
|
|
|
final settings = await ref.read(settingsServiceProvider).load();
|
|
|
|
// Apply persisted log level (default is `debug`).
|
|
LogService.I.level = settings.logLevel;
|
|
|
|
// Initialize storage with local relay config.
|
|
// NOTE: `initializationProvider` opens SQLite and starts the worker
|
|
// isolate. It does NOT establish relay connections — those happen
|
|
// lazily on first query or via notifier.connect().
|
|
await ref.read(
|
|
initializationProvider(
|
|
StorageConfiguration(
|
|
databasePath: dbPath,
|
|
// Accepted kind 10067 events must remain publishable and restorable
|
|
// after a remote preview, so their verified signatures are retained.
|
|
keepSignatures: true,
|
|
defaultQuerySource: LocalAndRemoteSource(
|
|
relays: 'AppCatalog',
|
|
stream: false,
|
|
),
|
|
defaultRelays: {
|
|
'default': {kDefaultRelay},
|
|
'bootstrap': {kDefaultRelay},
|
|
'AppCatalog': {kDefaultRelay},
|
|
'social': {
|
|
'wss://relay.damus.io',
|
|
'wss://relay.primal.net',
|
|
'wss://nos.lol',
|
|
},
|
|
'vertex': {'wss://relay.vertexlab.io'},
|
|
},
|
|
responseTimeout: Duration(seconds: 6),
|
|
),
|
|
).future,
|
|
);
|
|
|
|
// Initialize device key signer — must be registered before any encrypted
|
|
// queries fire, so EncryptableModel.prepareAfterLoading can find it.
|
|
final deviceKeyService = ref.read(deviceKeyServiceProvider);
|
|
final hasExistingDeviceKey = await deviceKeyService.hasPrivateKey();
|
|
ref.read(isNewDeviceKeyProvider.notifier).state = !hasExistingDeviceKey;
|
|
final deviceKey = await deviceKeyService.getOrCreatePrivateKey();
|
|
final deviceSigner = Bip340PrivateKeySigner(deviceKey, ref);
|
|
await deviceSigner.signIn(setAsActive: false);
|
|
ref.read(devicePubkeyProvider.notifier).state = deviceSigner.pubkey;
|
|
|
|
// Resolve the accepted device-authored relay list from local storage. A
|
|
// restart handoff is restored into the fresh database here, then erased.
|
|
await relayService.initializeAcceptedRelays();
|
|
});
|
|
|
|
/// Full app initialization — runs everything non-UI-critical after
|
|
/// [storageReadyProvider] resolves. Includes auto-sign-in (which may
|
|
/// trigger cache warm-up queries), so this provider is NOT safe to use
|
|
/// as a skeleton gate offline.
|
|
final appInitializationProvider = FutureProvider<void>((ref) async {
|
|
await ref.read(storageReadyProvider.future);
|
|
|
|
// There is no bootstrap publication. Mark portable state usable before
|
|
// background draft mining and relay work begin.
|
|
await ref.read(deviceStateProvider.notifier).bootstrap();
|
|
|
|
// Private relay ingestion is one-shot, non-streaming, and never gates UI.
|
|
unawaited(ref.read(devicePrivateSyncProvider.notifier).start());
|
|
unawaited(ref.read(devicePrivateEventServiceProvider).processPendingEvents());
|
|
|
|
// Initialize device capabilities (used for dynamic download concurrency)
|
|
await DeviceCapabilitiesCache.initialize();
|
|
|
|
// Record app open time (retained for settings / device-key restore)
|
|
await ref
|
|
.read(settingsServiceProvider)
|
|
.update((s) => s.copyWith(lastAppOpened: DateTime.now()));
|
|
|
|
// Ensure installed packages are available before anything categorizes
|
|
final packageManager = ref.read(packageManagerProvider.notifier);
|
|
await packageManager.syncInstalledPackages();
|
|
|
|
final backgroundService = ref.read(backgroundUpdateServiceProvider);
|
|
unawaited(backgroundService.initialize());
|
|
|
|
await ref.read(deepLinkServiceProvider).initialize();
|
|
|
|
await _attemptAutoSignIn(ref);
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
unawaited(ref.read(appCatalogRelayServiceProvider).checkForUpdates());
|
|
});
|
|
});
|
|
|
|
// AmberSigner provider for Nostr authentication
|
|
// Uses SecureStoragePubkeyPersistence to survive database clears
|
|
final amberSignerProvider = Provider<AmberSigner>(
|
|
(ref) => AmberSigner(ref, persistence: SecureStoragePubkeyPersistence()),
|
|
);
|
|
|
|
/// Copy the bundled seed database on first launch so the UI has content
|
|
/// before relay data arrives. No-op if the database already exists.
|
|
/// Skipped during a relay-event restart handoff because the seed was built
|
|
/// from [kDefaultRelay] and would be wrong for the accepted custom list.
|
|
Future<void> _maybeCopySeedDatabase(String dbPath, {bool skip = false}) async {
|
|
final dbFile = File(dbPath);
|
|
if (skip || dbFile.existsSync()) return;
|
|
|
|
try {
|
|
final seedData = await rootBundle.load('assets/seed.db');
|
|
await dbFile.create(recursive: true);
|
|
await dbFile.writeAsBytes(
|
|
seedData.buffer.asUint8List(
|
|
seedData.offsetInBytes,
|
|
seedData.lengthInBytes,
|
|
),
|
|
flush: true,
|
|
);
|
|
} catch (e, st) {
|
|
// Non-fatal: the app works fine without the seed — just a cold start.
|
|
LogService.I.warn(
|
|
'seed database copy failed',
|
|
tag: 'init',
|
|
err: e,
|
|
stack: st,
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _attemptAutoSignIn(Ref ref) async {
|
|
try {
|
|
await ref.read(amberSignerProvider).attemptAutoSignIn();
|
|
} catch (e, st) {
|
|
// Auto sign-in fails on first install — that's fine, just continue.
|
|
// Logged at debug because this is expected for new users.
|
|
LogService.I.debug(
|
|
'auto sign-in attempt failed',
|
|
tag: 'amber',
|
|
err: e,
|
|
stack: st,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Warm the contact-list cache after successful Amber session activation.
|
|
///
|
|
/// Runs for both interactive sign-in and [AmberSigner.attemptAutoSignIn]
|
|
/// session restore. Device-key backup checks are NOT done here — those are
|
|
/// interactive-only via [signInWithAmber] so cold-start restore never asks
|
|
/// Amber to decrypt the backup.
|
|
///
|
|
/// Contact-list fetch is best-effort and MUST NOT block the init chain.
|
|
void onSignInSuccess(Ref ref) {
|
|
final pubkey = ref.read(Signer.activePubkeyProvider);
|
|
if (pubkey == null) return;
|
|
ref.read(deviceBackupServiceProvider).beginWork();
|
|
|
|
final storage =
|
|
ref.read(storageNotifierProvider.notifier) as PurplebaseStorageNotifier;
|
|
|
|
unawaited(
|
|
storage
|
|
.query(
|
|
RequestFilter<ContactList>(authors: {pubkey}).toRequest(),
|
|
source: const RemoteSource(relays: 'social', stream: false),
|
|
subscriptionPrefix: 'app-contact-list',
|
|
)
|
|
.catchError((error, stack) {
|
|
LogService.I.debug(
|
|
'contact list warm-up failed',
|
|
tag: 'signin',
|
|
err: error,
|
|
);
|
|
return const <ContactList>[];
|
|
}),
|
|
);
|
|
}
|
|
|
|
/// Interactive Amber sign-in, then offer device-key backup create/restore.
|
|
///
|
|
/// Session restore must use [AmberSigner.attemptAutoSignIn] instead so Amber
|
|
/// is not asked to decrypt the device-key backup on every app entry.
|
|
Future<void> signInWithAmber(Ref ref) async {
|
|
await ref.read(amberSignerProvider).signIn();
|
|
await maybeOfferDeviceBackup(ref);
|
|
}
|
|
|
|
/// Observes app lifecycle events and manages package/storage state
|
|
class _AppLifecycleObserver with WidgetsBindingObserver {
|
|
_AppLifecycleObserver(this._ref);
|
|
|
|
final WidgetRef _ref;
|
|
|
|
/// Handle permission grants that happened while app was backgrounded
|
|
Future<void> _checkPermissionGrants() async {
|
|
final packageManager = _ref.read(packageManagerProvider.notifier);
|
|
if (packageManager is! AndroidPackageManager) return;
|
|
|
|
final hasPermission = await packageManager.hasPermission();
|
|
if (!hasPermission) return;
|
|
|
|
// Advance any operations waiting for permission
|
|
final state = _ref.read(packageManagerProvider);
|
|
final awaitingPermission = state.operations.entries
|
|
.where((e) => e.value is AwaitingPermission)
|
|
.map((e) => e.key)
|
|
.toList(growable: false);
|
|
|
|
for (final appId in awaitingPermission) {
|
|
await packageManager.onPermissionGranted(appId);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
final notifier =
|
|
_ref.read(storageNotifierProvider.notifier)
|
|
as PurplebaseStorageNotifier;
|
|
final packageManager = _ref.read(packageManagerProvider.notifier);
|
|
|
|
if (state == AppLifecycleState.resumed) {
|
|
// Record app open time (retained for settings / device-key restore)
|
|
unawaited(_recordAppOpened());
|
|
|
|
// Sync installed packages to detect installs that completed while backgrounded
|
|
unawaited(packageManager.syncInstalledPackages());
|
|
|
|
// Check for permission grants that happened in settings
|
|
unawaited(_checkPermissionGrants());
|
|
|
|
// Reconnect storage/relay connections
|
|
notifier.connect();
|
|
unawaited(
|
|
_ref.read(devicePrivateEventServiceProvider).processPendingEvents(),
|
|
);
|
|
unawaited(_ref.read(appCatalogRelayServiceProvider).checkForUpdates());
|
|
} else if (state == AppLifecycleState.paused) {
|
|
_ref.read(appCatalogRelayServiceProvider).cancelCurrentCheck();
|
|
_ref.read(devicePrivateSyncProvider.notifier).cancel();
|
|
_ref
|
|
.read(deviceBackupServiceProvider)
|
|
.cancelCurrentWork(_ref.read(refProvider));
|
|
notifier.disconnect();
|
|
// Flush any pending log entries to disk before the OS may freeze
|
|
// or kill us, so diagnostics survive backgrounding.
|
|
unawaited(LogService.I.flush());
|
|
} else if (state == AppLifecycleState.detached) {
|
|
// Last chance before the engine tears down — sync flush.
|
|
LogService.I.flushSync();
|
|
}
|
|
}
|
|
|
|
/// Record that the user opened the app.
|
|
Future<void> _recordAppOpened() async {
|
|
await SettingsService().update(
|
|
(s) => s.copyWith(lastAppOpened: DateTime.now()),
|
|
);
|
|
}
|
|
}
|