import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/permission.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:logging/logging.dart'; enum AppLifeCycleEnum { active, inactive, paused, resumed, detached, hidden } class AppLifeCycleNotifier extends StateNotifier { final Ref _ref; bool _wasPaused = false; bool _firstLaunch = true; bool _fullSyncPending = false; // Add operation coordination Completer? _resumeOperation; Completer? _pauseOperation; final _log = Logger("AppLifeCycleNotifier"); AppLifeCycleNotifier(this._ref) : super(AppLifeCycleEnum.active); Future handleAppResume() async { state = AppLifeCycleEnum.resumed; _log.info("App resumed"); // Prevent overlapping resume operations if (_resumeOperation != null && !_resumeOperation!.isCompleted) { await _resumeOperation!.future; return; } // Cancel any ongoing pause operation if (_pauseOperation != null && !_pauseOperation!.isCompleted) { _pauseOperation!.complete(); } _resumeOperation = Completer(); try { await _performResume(); } catch (e, stackTrace) { _log.severe("Error during app resume", e, stackTrace); } finally { if (!_resumeOperation!.isCompleted) { _resumeOperation!.complete(); } _resumeOperation = null; } } Future _performResume() async { if (_firstLaunch) { // a delta sync can miss photos taken after a background launch _fullSyncPending = await _ref.read(backgroundWorkerFgServiceProvider).wasLaunchedInBackground(); _firstLaunch = false; } // no need to resume because app was never really paused if (!_wasPaused && !_fullSyncPending) { _log.info("Resume skipped, app was never paused"); return; } _wasPaused = false; final isAuthenticated = _ref.read(authProvider).isAuthenticated; // Needs to be logged in if (isAuthenticated) { // switch endpoint if needed final endpoint = await _ref.read(authProvider.notifier).setOpenApiServiceEndpoint(); _log.info("Using server URL: $endpoint"); await _ref.read(serverInfoProvider.notifier).getServerVersion(); } if (!_shouldContinueOperation()) { _wasPaused = true; return; } _ref.read(websocketProvider.notifier).connect(); await _handleBetaTimelineResume(); await _ref.read(notificationPermissionProvider.notifier).getNotificationPermission(); await _ref.read(galleryPermissionNotifier.notifier).getGalleryPermissionStatus(); } Future _safeRun(Future Function() action, String debugName) async { if (!_shouldContinueOperation()) { return; } try { await action(); } catch (e, stackTrace) { _log.warning("Error during $debugName operation", e, stackTrace); } } Future _handleBetaTimelineResume() async { unawaited(_ref.read(backgroundWorkerLockServiceProvider).lock()); // Give isolates time to complete any ongoing database transactions await Future.delayed(const Duration(milliseconds: 500)); final backgroundManager = _ref.read(backgroundSyncProvider); // Drop any sync that froze mid-flight while the app was suspended so resume // starts fresh instead of awaiting the stale task (#28082). cancelResumeSyncs // clears the task refs synchronously, so the syncs below see a clean slate. unawaited(backgroundManager.cancelResumeSyncs()); final isAlbumLinkedSyncEnable = _ref.read(appConfigProvider).backup.syncAlbums; try { bool syncSuccess = false; await Future.wait([ _safeRun(() { final full = CurrentPlatform.isAndroid || _fullSyncPending; _fullSyncPending = false; return backgroundManager.syncLocal(full: full); }, "syncLocal"), _safeRun(() async { syncSuccess = await backgroundManager.syncRemote(); }, "syncRemote"), ]); _ref.invalidate(memoryLaneProvider); _ref.invalidate(allMemoriesProvider); if (syncSuccess) { await Future.wait([ _safeRun(backgroundManager.hashAssets, "hashAssets").then((_) { unawaited(_resumeBackup()); }), _resumeBackup(), _safeRun(backgroundManager.syncCloudIds, "syncCloudIds"), ]); } else { await _safeRun(backgroundManager.hashAssets, "hashAssets"); } if (isAlbumLinkedSyncEnable) { await _safeRun(backgroundManager.syncLinkedAlbum, "syncLinkedAlbum"); } } catch (e, stackTrace) { _log.severe("Error during background sync", e, stackTrace); } } Future _resumeBackup() async { final isEnableBackup = _ref.read(appConfigProvider).backup.enabled; if (isEnableBackup) { final currentUser = Store.tryGet(StoreKey.currentUser); if (currentUser != null) { await _safeRun( () => _ref.read(backupProvider.notifier).startForegroundBackup(currentUser.id), "handleBackupResume", ); } } } // Helper method to check if operations should continue bool _shouldContinueOperation() { return [AppLifeCycleEnum.resumed, AppLifeCycleEnum.active].contains(state) && (_resumeOperation?.isCompleted == false || _resumeOperation == null); } void handleAppInactivity() { state = AppLifeCycleEnum.inactive; // do not stop/clean up anything on inactivity: issued on every orientation change } Future handleAppPause() async { state = AppLifeCycleEnum.paused; _wasPaused = true; _log.info("App paused"); // Prevent overlapping pause operations if (_pauseOperation != null && !_pauseOperation!.isCompleted) { await _pauseOperation!.future; return; } // Cancel any ongoing resume operation if (_resumeOperation != null && !_resumeOperation!.isCompleted) { _resumeOperation!.complete(); } _pauseOperation = Completer(); try { unawaited(_ref.read(backgroundWorkerLockServiceProvider).unlock()); await _performPause(); } catch (e, stackTrace) { _log.severe("Error during app pause", e, stackTrace); } finally { if (!_pauseOperation!.isCompleted) { _pauseOperation!.complete(); } _pauseOperation = null; } } Future _performPause() { if (_ref.read(authProvider).isAuthenticated) { _ref.read(backupProvider.notifier).stopForegroundBackup(reason: "the app being sent to the background"); _ref.read(websocketProvider.notifier).disconnect(); } return LogService.I.flush().catchError((_) {}); } Future handleAppDetached() async { state = AppLifeCycleEnum.detached; unawaited(_ref.read(backgroundWorkerLockServiceProvider).unlock()); // Flush logs before closing database try { await LogService.I.flush(); } catch (_) {} } void handleAppHidden() { state = AppLifeCycleEnum.hidden; // do not stop/clean up anything on inactivity: issued on every orientation change } } final appStateProvider = StateNotifierProvider((ref) { return AppLifeCycleNotifier(ref); });