diff --git a/mobile/lib/domain/utils/background_sync.dart b/mobile/lib/domain/utils/background_sync.dart index 1cb6820abb..4160a5f7bc 100644 --- a/mobile/lib/domain/utils/background_sync.dart +++ b/mobile/lib/domain/utils/background_sync.dart @@ -24,6 +24,7 @@ class BackgroundSyncManager { Cancelable? _syncTask; Cancelable? _syncWebsocketTask; Cancelable? _deviceAlbumSyncTask; + Cancelable? _linkedAlbumSyncTask; Cancelable? _hashTask; BackgroundSyncManager({ @@ -53,6 +54,12 @@ class BackgroundSyncManager { _syncWebsocketTask?.cancel(); _syncWebsocketTask = null; + if (_linkedAlbumSyncTask != null) { + futures.add(_linkedAlbumSyncTask!.future); + } + _linkedAlbumSyncTask?.cancel(); + _linkedAlbumSyncTask = null; + try { await Future.wait(futures); } on CanceledError { @@ -158,8 +165,14 @@ class BackgroundSyncManager { } Future syncLinkedAlbum() { - final task = runInIsolateGentle(computation: syncLinkedAlbumsIsolated); - return task.future; + if (_linkedAlbumSyncTask != null) { + return _linkedAlbumSyncTask!.future; + } + + _linkedAlbumSyncTask = runInIsolateGentle(computation: syncLinkedAlbumsIsolated); + return _linkedAlbumSyncTask!.whenComplete(() { + _linkedAlbumSyncTask = null; + }); } } diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index d7cb7dbaa4..3da653444c 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/domain/utils/isolate_lock_manager.dart'; @@ -34,6 +33,12 @@ class AppLifeCycleNotifier extends StateNotifier { final Ref _ref; bool _wasPaused = false; + // Add operation coordination + Completer? _resumeOperation; + Completer? _pauseOperation; + + final _log = Logger("AppLifeCycleNotifier"); + AppLifeCycleNotifier(this._ref) : super(AppLifeCycleEnum.active); AppLifeCycleEnum getAppState() { @@ -43,6 +48,32 @@ class AppLifeCycleNotifier extends StateNotifier { void handleAppResume() async { state = AppLifeCycleEnum.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 { // no need to resume because app was never really paused if (!_wasPaused) return; _wasPaused = false; @@ -53,9 +84,7 @@ class AppLifeCycleNotifier extends StateNotifier { if (isAuthenticated) { // switch endpoint if needed final endpoint = await _ref.read(authProvider.notifier).setOpenApiServiceEndpoint(); - if (kDebugMode) { - debugPrint("Using server URL: $endpoint"); - } + _log.info("Using server URL: $endpoint"); if (!Store.isBetaTimelineEnabled) { final permission = _ref.watch(galleryPermissionNotifier); @@ -81,52 +110,10 @@ class AppLifeCycleNotifier extends StateNotifier { break; } } else { - _ref.read(backupProvider.notifier).cancelBackup(); - final lockManager = _ref.read(isolateLockManagerProvider(kIsolateLockManagerPort)); - - lockManager.requestHolderToClose(); - debugPrint("Requested lock holder to close on resume"); - await lockManager.acquireLock(); - debugPrint("Lock acquired for background sync on resume"); - - final backgroundManager = _ref.read(backgroundSyncProvider); - // Ensure proper cleanup before starting new background tasks - try { - await Future.wait([ - Future(() async { - await backgroundManager.syncLocal(); - Logger("AppLifeCycleNotifier").fine("Hashing assets after syncLocal"); - // Check if app is still active before hashing - if ([AppLifeCycleEnum.resumed, AppLifeCycleEnum.active].contains(state)) { - await backgroundManager.hashAssets(); - } - }), - backgroundManager.syncRemote(), - ]).then((_) async { - final isEnableBackup = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup); - - final isAlbumLinkedSyncEnable = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums); - - if (isEnableBackup) { - final currentUser = _ref.read(currentUserProvider); - if (currentUser == null) { - return; - } - - await _ref.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id); - } - - if (isAlbumLinkedSyncEnable) { - await backgroundManager.syncLinkedAlbum(); - } - }); - } catch (e, stackTrace) { - Logger("AppLifeCycleNotifier").severe("Error during background sync", e, stackTrace); - } + _ref.read(websocketProvider.notifier).connect(); + await _handleBetaTimelineResume(); } - _ref.read(websocketProvider.notifier).connect(); - await _ref.read(notificationPermissionProvider.notifier).getNotificationPermission(); await _ref.read(galleryPermissionNotifier.notifier).getGalleryPermissionStatus(); @@ -138,6 +125,103 @@ class AppLifeCycleNotifier extends StateNotifier { } } + Future _handleBetaTimelineResume() async { + _ref.read(backupProvider.notifier).cancelBackup(); + final lockManager = _ref.read(isolateLockManagerProvider(kIsolateLockManagerPort)); + + // Give isolates time to complete any ongoing database transactions + await Future.delayed(const Duration(milliseconds: 500)); + + lockManager.requestHolderToClose(); + + // Add timeout to prevent deadlock on lock acquisition + try { + await lockManager.acquireLock().timeout( + const Duration(seconds: 10), + onTimeout: () { + _log.warning("Lock acquisition timed out, proceeding without lock"); + throw TimeoutException("Lock acquisition timed out", const Duration(seconds: 10)); + }, + ); + } catch (e) { + _log.warning("Failed to acquire lock: $e"); + return; + } + + final backgroundManager = _ref.read(backgroundSyncProvider); + final isAlbumLinkedSyncEnable = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums); + + try { + // Run operations sequentially with state checks and error handling for each + if (_shouldContinueOperation()) { + try { + await backgroundManager.syncLocal(); + } catch (e, stackTrace) { + _log.warning("Failed syncLocal: $e", e, stackTrace); + } + } + + // Check if app is still active before hashing + if (_shouldContinueOperation()) { + try { + await backgroundManager.hashAssets(); + } catch (e, stackTrace) { + _log.warning("Failed hashAssets: $e", e, stackTrace); + } + } + + // Check if app is still active before remote sync + if (_shouldContinueOperation()) { + try { + await backgroundManager.syncRemote(); + } catch (e, stackTrace) { + _log.warning("Failed syncRemote: $e", e, stackTrace); + } + + if (isAlbumLinkedSyncEnable && _shouldContinueOperation()) { + try { + await backgroundManager.syncLinkedAlbum(); + } catch (e, stackTrace) { + _log.warning("Failed syncLinkedAlbum: $e", e, stackTrace); + } + } + } + + // Handle backup resume only if still active + if (_shouldContinueOperation()) { + final isEnableBackup = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup); + + if (isEnableBackup) { + final currentUser = _ref.read(currentUserProvider); + if (currentUser != null) { + try { + await _ref.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id); + _log.fine("Completed backup resume"); + } catch (e, stackTrace) { + _log.warning("Failed backup resume: $e", e, stackTrace); + } + } + } + } + } catch (e, stackTrace) { + _log.severe("Error during background sync", e, stackTrace); + } finally { + // Ensure lock is released even if operations fail + try { + lockManager.releaseLock(); + _log.fine("Lock released after background sync operations"); + } catch (lockError) { + _log.warning("Failed to release lock after error: $lockError"); + } + } + } + + // 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 @@ -147,6 +231,32 @@ class AppLifeCycleNotifier extends StateNotifier { state = AppLifeCycleEnum.paused; _wasPaused = true; + // 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 { + await _performPause(); + } catch (e, stackTrace) { + _log.severe("Error during app pause", e, stackTrace); + } finally { + if (!_pauseOperation!.isCompleted) { + _pauseOperation!.complete(); + } + _pauseOperation = null; + } + } + + Future _performPause() async { if (_ref.read(authProvider).isAuthenticated) { if (!Store.isBetaTimelineEnabled) { // Do not cancel backup if manual upload is in progress @@ -155,10 +265,26 @@ class AppLifeCycleNotifier extends StateNotifier { } } else { final backgroundManager = _ref.read(backgroundSyncProvider); - await backgroundManager.cancel(); - await backgroundManager.cancelLocal(); - _ref.read(isolateLockManagerProvider(kIsolateLockManagerPort)).releaseLock(); - debugPrint("Lock released on app pause"); + + // Cancel operations with extended timeout to allow database transactions to complete + try { + await Future.wait([ + backgroundManager.cancel().timeout(const Duration(seconds: 10)), + backgroundManager.cancelLocal().timeout(const Duration(seconds: 10)), + ]).timeout(const Duration(seconds: 15)); + + // Give additional time for isolates to clean up database connections + await Future.delayed(const Duration(milliseconds: 1000)); + } catch (e) { + _log.warning("Timeout during background cancellation: $e"); + } + + // Always release the lock, even if cancellation failed + try { + _ref.read(isolateLockManagerProvider(kIsolateLockManagerPort)).releaseLock(); + } catch (e) { + _log.warning("Failed to release lock on pause: $e"); + } } _ref.read(websocketProvider.notifier).disconnect(); @@ -166,9 +292,7 @@ class AppLifeCycleNotifier extends StateNotifier { try { LogService.I.flush(); - } catch (e) { - // Ignore flush errors during pause - } + } catch (_) {} } Future handleAppDetached() async { @@ -177,9 +301,7 @@ class AppLifeCycleNotifier extends StateNotifier { // Flush logs before closing database try { LogService.I.flush(); - } catch (e) { - // Ignore flush errors during shutdown - } + } catch (_) {} // Close Isar database safely try { @@ -187,9 +309,7 @@ class AppLifeCycleNotifier extends StateNotifier { if (isar != null && isar.isOpen) { await isar.close(); } - } catch (e) { - // Ignore close errors during shutdown - } + } catch (_) {} if (Store.isBetaTimelineEnabled) { _ref.read(isolateLockManagerProvider(kIsolateLockManagerPort)).releaseLock(); @@ -199,9 +319,7 @@ class AppLifeCycleNotifier extends StateNotifier { // no guarantee this is called at all try { _ref.read(manualUploadProvider.notifier).cancelBackup(); - } catch (e) { - // Ignore errors during shutdown - } + } catch (_) {} } void handleAppHidden() { diff --git a/mobile/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/immich_sliver_app_bar.dart index 78fa607666..ee111851ad 100644 --- a/mobile/lib/widgets/common/immich_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/immich_sliver_app_bar.dart @@ -296,7 +296,7 @@ class _SyncStatusIndicatorState extends ConsumerState<_SyncStatusIndicator> with @override Widget build(BuildContext context) { final syncStatus = ref.watch(syncStatusProvider); - final isSyncing = syncStatus.isRemoteSyncing; + final isSyncing = syncStatus.isRemoteSyncing || syncStatus.isLocalSyncing; // Control animations based on sync status if (isSyncing) {