diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerApiImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerApiImpl.kt index b971da45c..59de05e51 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerApiImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerApiImpl.kt @@ -44,6 +44,11 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi { Log.i(TAG, "Cancelled background upload tasks") } + override fun wasLaunchedInBackground(): Boolean { + // iOS only + return false + } + companion object { private const val BACKGROUND_WORKER_NAME = "immich/BackgroundWorkerV1" private const val RETRY_WORKER_NAME = "immich/BackgroundWorkerRetryV1" diff --git a/mobile/ios/Runner/Background/BackgroundWorkerApiImpl.swift b/mobile/ios/Runner/Background/BackgroundWorkerApiImpl.swift index 0eee970e7..7e2823e03 100644 --- a/mobile/ios/Runner/Background/BackgroundWorkerApiImpl.swift +++ b/mobile/ios/Runner/Background/BackgroundWorkerApiImpl.swift @@ -22,6 +22,12 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi { print("BackgroundWorkerApiImpl:disableUploadWorker Disabled background workers") } + func wasLaunchedInBackground() throws -> Bool { + return BackgroundWorkerApiImpl.launchedInBackground + } + + // Stays false until iOS runs one of the background tasks + private static var launchedInBackground = false private static let taskIDs = Bundle.main.object(forInfoDictionaryKey: "BGTaskSchedulerPermittedIdentifiers") as! [String] private static let refreshTaskID = taskIDs.first { $0.hasSuffix(".refreshUpload") }! private static let processingTaskID = taskIDs.first { $0.hasSuffix(".processingUpload") }! @@ -30,6 +36,7 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi { public static func registerBackgroundWorkers() { BGTaskScheduler.shared.register( forTaskWithIdentifier: processingTaskID, using: nil) { task in + launchedInBackground = true if task is BGProcessingTask { handleBackgroundProcessing(task: task as! BGProcessingTask) } @@ -37,6 +44,7 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi { BGTaskScheduler.shared.register( forTaskWithIdentifier: refreshTaskID, using: nil) { task in + launchedInBackground = true if task is BGAppRefreshTask { handleBackgroundRefresh(task: task as! BGAppRefreshTask) } diff --git a/mobile/lib/domain/services/background_worker.service.dart b/mobile/lib/domain/services/background_worker.service.dart index 6286d3748..560f4aa56 100644 --- a/mobile/lib/domain/services/background_worker.service.dart +++ b/mobile/lib/domain/services/background_worker.service.dart @@ -57,6 +57,8 @@ class BackgroundWorkerFgService { } Future disable() => _foregroundHostApi.disable(); + + Future wasLaunchedInBackground() => _foregroundHostApi.wasLaunchedInBackground(); } class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index 8a5b95c8b..33c76fdd2 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -22,6 +22,8 @@ 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; @@ -61,8 +63,14 @@ class AppLifeCycleNotifier extends StateNotifier { } 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) { + if (!_wasPaused && !_fullSyncPending) { _log.info("Resume skipped, app was never paused"); return; } @@ -121,7 +129,11 @@ class AppLifeCycleNotifier extends StateNotifier { try { bool syncSuccess = false; await Future.wait([ - _safeRun(() => backgroundManager.syncLocal(full: CurrentPlatform.isAndroid), "syncLocal"), + _safeRun(() { + final full = CurrentPlatform.isAndroid || _fullSyncPending; + _fullSyncPending = false; + return backgroundManager.syncLocal(full: full); + }, "syncLocal"), _safeRun(() async { syncSuccess = await backgroundManager.syncRemote(); }, "syncRemote"), diff --git a/mobile/pigeon/background_worker_api.dart b/mobile/pigeon/background_worker_api.dart index dd62dd069..e8b35fac8 100644 --- a/mobile/pigeon/background_worker_api.dart +++ b/mobile/pigeon/background_worker_api.dart @@ -27,6 +27,9 @@ abstract class BackgroundWorkerFgHostApi { void configure(BackgroundWorkerSettings settings); void disable(); + + // iOS Only: true when the OS started the process in the background for one of its background tasks + bool wasLaunchedInBackground(); } @HostApi() diff --git a/mobile/test/providers/app_life_cycle_provider_test.dart b/mobile/test/providers/app_life_cycle_provider_test.dart index 87766e94b..da6f7cf5c 100644 --- a/mobile/test/providers/app_life_cycle_provider_test.dart +++ b/mobile/test/providers/app_life_cycle_provider_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; @@ -99,6 +100,8 @@ void main() { late Completer serverVersion; late MockServerInfoService serverInfoService; late MockBackgroundWorkerLockService lockService; + late MockBackgroundWorkerFgService fgService; + late MockBackgroundSyncManager backgroundSync; late ProviderContainer container; late TestWebsocketNotifier websocket; late AppLifeCycleNotifier lifeCycle; @@ -125,7 +128,8 @@ void main() { serverVersion = Completer(); serverInfoService = MockServerInfoService(); lockService = MockBackgroundWorkerLockService(); - final backgroundSync = MockBackgroundSyncManager(); + fgService = MockBackgroundWorkerFgService(); + backgroundSync = MockBackgroundSyncManager(); serverVersionCount = 0; memoryLaneBuilds = 0; @@ -135,6 +139,7 @@ void main() { }); when(() => lockService.lock()).thenAnswer((_) async {}); when(() => lockService.unlock()).thenAnswer((_) async {}); + when(() => fgService.wasLaunchedInBackground()).thenAnswer((_) async => false); when(() => backgroundSync.cancelResumeSyncs()).thenAnswer((_) async {}); when(() => backgroundSync.syncLocal(full: any(named: 'full'))).thenAnswer((_) async {}); when(() => backgroundSync.syncRemote()).thenAnswer((_) async => true); @@ -149,6 +154,7 @@ void main() { }), backupProvider.overrideWith((_) => TestDriftBackupNotifier()), backgroundWorkerLockServiceProvider.overrideWithValue(lockService), + backgroundWorkerFgServiceProvider.overrideWithValue(fgService), backgroundSyncProvider.overrideWithValue(backgroundSync), appConfigProvider.overrideWithValue(defaultConfig), notificationPermissionProvider.overrideWith((_) => TestNotificationPermissionNotifier()), @@ -160,9 +166,14 @@ void main() { ], ); lifeCycle = container.read(appStateProvider.notifier); + container.read(websocketProvider); + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; }); - tearDown(() => container.dispose()); + tearDown(() { + debugDefaultTargetPlatformOverride = null; + container.dispose(); + }); Future startResume() async { await lifeCycle.handleAppPause(); @@ -229,4 +240,54 @@ void main() { expect(memoryLaneBuilds, 2); }); + + test('first resume runs when the app was launched in the background', () async { + when(() => fgService.wasLaunchedInBackground()).thenAnswer((_) async => true); + websocket.throwOnConnect = false; + serverVersion.complete(); + await lifeCycle.handleAppResume(); + + expect(lifeCycle.state, AppLifeCycleEnum.resumed); + expect(serverVersionCount, 1); + expect(websocket.connectCount, 1); + verify(() => backgroundSync.syncLocal(full: true)).called(1); + + await lifeCycle.handleAppPause(); + await lifeCycle.handleAppResume(); + + verify(() => backgroundSync.syncLocal(full: false)).called(1); + }); + + test('a background launch does not resume twice without a pause', () async { + when(() => fgService.wasLaunchedInBackground()).thenAnswer((_) async => true); + websocket.throwOnConnect = false; + serverVersion.complete(); + await lifeCycle.handleAppResume(); + await lifeCycle.handleAppResume(); + + expect(serverVersionCount, 1); + expect(websocket.connectCount, 1); + verify(() => backgroundSync.syncLocal(full: true)).called(1); + }); + + test('pause before the first sync keeps the full sync for the next resume', () async { + when(() => fgService.wasLaunchedInBackground()).thenAnswer((_) async => true); + websocket.throwOnConnect = false; + unawaited(lifeCycle.handleAppResume()); + await untilCalled(() => serverInfoService.getServerVersion()); + await lifeCycle.handleAppPause(); + await releaseResume(); + + await lifeCycle.handleAppResume(); + + verify(() => backgroundSync.syncLocal(full: true)).called(1); + }); + + test('first resume is skipped on a normal launch', () async { + await lifeCycle.handleAppResume(); + + expect(lifeCycle.state, AppLifeCycleEnum.resumed); + expect(serverVersionCount, 0); + expect(websocket.connectCount, 0); + }); } diff --git a/mobile/test/service.mocks.dart b/mobile/test/service.mocks.dart index ecf9c771b..e56eb15f1 100644 --- a/mobile/test/service.mocks.dart +++ b/mobile/test/service.mocks.dart @@ -61,3 +61,5 @@ class MockWidgetService extends Mock implements WidgetService {} class MockBackgroundUploadService extends Mock implements BackgroundUploadService {} class MockBackgroundWorkerLockService extends Mock implements BackgroundWorkerLockService {} + +class MockBackgroundWorkerFgService extends Mock implements BackgroundWorkerFgService {}