fix(mobile): resume after the app was launched in the background (#31557)

* run the first resume after a background launch with a full local sync

* keep the untouched lines of the swift file as they were

* check the background launch at the first resume even after a pause
This commit is contained in:
Santo Shakil
2026-09-24 10:15:44 +06:00
committed by GitHub
parent 2466eaeecd
commit a4af86282d
7 changed files with 97 additions and 4 deletions
@@ -44,6 +44,11 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
Log.i(TAG, "Cancelled background upload tasks") Log.i(TAG, "Cancelled background upload tasks")
} }
override fun wasLaunchedInBackground(): Boolean {
// iOS only
return false
}
companion object { companion object {
private const val BACKGROUND_WORKER_NAME = "immich/BackgroundWorkerV1" private const val BACKGROUND_WORKER_NAME = "immich/BackgroundWorkerV1"
private const val RETRY_WORKER_NAME = "immich/BackgroundWorkerRetryV1" private const val RETRY_WORKER_NAME = "immich/BackgroundWorkerRetryV1"
@@ -22,6 +22,12 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
print("BackgroundWorkerApiImpl:disableUploadWorker Disabled background workers") 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 taskIDs = Bundle.main.object(forInfoDictionaryKey: "BGTaskSchedulerPermittedIdentifiers") as! [String]
private static let refreshTaskID = taskIDs.first { $0.hasSuffix(".refreshUpload") }! private static let refreshTaskID = taskIDs.first { $0.hasSuffix(".refreshUpload") }!
private static let processingTaskID = taskIDs.first { $0.hasSuffix(".processingUpload") }! private static let processingTaskID = taskIDs.first { $0.hasSuffix(".processingUpload") }!
@@ -30,6 +36,7 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
public static func registerBackgroundWorkers() { public static func registerBackgroundWorkers() {
BGTaskScheduler.shared.register( BGTaskScheduler.shared.register(
forTaskWithIdentifier: processingTaskID, using: nil) { task in forTaskWithIdentifier: processingTaskID, using: nil) { task in
launchedInBackground = true
if task is BGProcessingTask { if task is BGProcessingTask {
handleBackgroundProcessing(task: task as! BGProcessingTask) handleBackgroundProcessing(task: task as! BGProcessingTask)
} }
@@ -37,6 +44,7 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
BGTaskScheduler.shared.register( BGTaskScheduler.shared.register(
forTaskWithIdentifier: refreshTaskID, using: nil) { task in forTaskWithIdentifier: refreshTaskID, using: nil) { task in
launchedInBackground = true
if task is BGAppRefreshTask { if task is BGAppRefreshTask {
handleBackgroundRefresh(task: task as! BGAppRefreshTask) handleBackgroundRefresh(task: task as! BGAppRefreshTask)
} }
@@ -57,6 +57,8 @@ class BackgroundWorkerFgService {
} }
Future<void> disable() => _foregroundHostApi.disable(); Future<void> disable() => _foregroundHostApi.disable();
Future<bool> wasLaunchedInBackground() => _foregroundHostApi.wasLaunchedInBackground();
} }
class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
@@ -22,6 +22,8 @@ enum AppLifeCycleEnum { active, inactive, paused, resumed, detached, hidden }
class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> { class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
final Ref _ref; final Ref _ref;
bool _wasPaused = false; bool _wasPaused = false;
bool _firstLaunch = true;
bool _fullSyncPending = false;
// Add operation coordination // Add operation coordination
Completer<void>? _resumeOperation; Completer<void>? _resumeOperation;
@@ -61,8 +63,14 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
} }
Future<void> _performResume() async { Future<void> _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 // no need to resume because app was never really paused
if (!_wasPaused) { if (!_wasPaused && !_fullSyncPending) {
_log.info("Resume skipped, app was never paused"); _log.info("Resume skipped, app was never paused");
return; return;
} }
@@ -121,7 +129,11 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
try { try {
bool syncSuccess = false; bool syncSuccess = false;
await Future.wait([ 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 { _safeRun(() async {
syncSuccess = await backgroundManager.syncRemote(); syncSuccess = await backgroundManager.syncRemote();
}, "syncRemote"), }, "syncRemote"),
+3
View File
@@ -27,6 +27,9 @@ abstract class BackgroundWorkerFgHostApi {
void configure(BackgroundWorkerSettings settings); void configure(BackgroundWorkerSettings settings);
void disable(); void disable();
// iOS Only: true when the OS started the process in the background for one of its background tasks
bool wasLaunchedInBackground();
} }
@HostApi() @HostApi()
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
@@ -99,6 +100,8 @@ void main() {
late Completer<ServerVersion?> serverVersion; late Completer<ServerVersion?> serverVersion;
late MockServerInfoService serverInfoService; late MockServerInfoService serverInfoService;
late MockBackgroundWorkerLockService lockService; late MockBackgroundWorkerLockService lockService;
late MockBackgroundWorkerFgService fgService;
late MockBackgroundSyncManager backgroundSync;
late ProviderContainer container; late ProviderContainer container;
late TestWebsocketNotifier websocket; late TestWebsocketNotifier websocket;
late AppLifeCycleNotifier lifeCycle; late AppLifeCycleNotifier lifeCycle;
@@ -125,7 +128,8 @@ void main() {
serverVersion = Completer<ServerVersion?>(); serverVersion = Completer<ServerVersion?>();
serverInfoService = MockServerInfoService(); serverInfoService = MockServerInfoService();
lockService = MockBackgroundWorkerLockService(); lockService = MockBackgroundWorkerLockService();
final backgroundSync = MockBackgroundSyncManager(); fgService = MockBackgroundWorkerFgService();
backgroundSync = MockBackgroundSyncManager();
serverVersionCount = 0; serverVersionCount = 0;
memoryLaneBuilds = 0; memoryLaneBuilds = 0;
@@ -135,6 +139,7 @@ void main() {
}); });
when(() => lockService.lock()).thenAnswer((_) async {}); when(() => lockService.lock()).thenAnswer((_) async {});
when(() => lockService.unlock()).thenAnswer((_) async {}); when(() => lockService.unlock()).thenAnswer((_) async {});
when(() => fgService.wasLaunchedInBackground()).thenAnswer((_) async => false);
when(() => backgroundSync.cancelResumeSyncs()).thenAnswer((_) async {}); when(() => backgroundSync.cancelResumeSyncs()).thenAnswer((_) async {});
when(() => backgroundSync.syncLocal(full: any(named: 'full'))).thenAnswer((_) async {}); when(() => backgroundSync.syncLocal(full: any(named: 'full'))).thenAnswer((_) async {});
when(() => backgroundSync.syncRemote()).thenAnswer((_) async => true); when(() => backgroundSync.syncRemote()).thenAnswer((_) async => true);
@@ -149,6 +154,7 @@ void main() {
}), }),
backupProvider.overrideWith((_) => TestDriftBackupNotifier()), backupProvider.overrideWith((_) => TestDriftBackupNotifier()),
backgroundWorkerLockServiceProvider.overrideWithValue(lockService), backgroundWorkerLockServiceProvider.overrideWithValue(lockService),
backgroundWorkerFgServiceProvider.overrideWithValue(fgService),
backgroundSyncProvider.overrideWithValue(backgroundSync), backgroundSyncProvider.overrideWithValue(backgroundSync),
appConfigProvider.overrideWithValue(defaultConfig), appConfigProvider.overrideWithValue(defaultConfig),
notificationPermissionProvider.overrideWith((_) => TestNotificationPermissionNotifier()), notificationPermissionProvider.overrideWith((_) => TestNotificationPermissionNotifier()),
@@ -160,9 +166,14 @@ void main() {
], ],
); );
lifeCycle = container.read(appStateProvider.notifier); lifeCycle = container.read(appStateProvider.notifier);
container.read(websocketProvider);
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
}); });
tearDown(() => container.dispose()); tearDown(() {
debugDefaultTargetPlatformOverride = null;
container.dispose();
});
Future<void> startResume() async { Future<void> startResume() async {
await lifeCycle.handleAppPause(); await lifeCycle.handleAppPause();
@@ -229,4 +240,54 @@ void main() {
expect(memoryLaneBuilds, 2); 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);
});
} }
+2
View File
@@ -61,3 +61,5 @@ class MockWidgetService extends Mock implements WidgetService {}
class MockBackgroundUploadService extends Mock implements BackgroundUploadService {} class MockBackgroundUploadService extends Mock implements BackgroundUploadService {}
class MockBackgroundWorkerLockService extends Mock implements BackgroundWorkerLockService {} class MockBackgroundWorkerLockService extends Mock implements BackgroundWorkerLockService {}
class MockBackgroundWorkerFgService extends Mock implements BackgroundWorkerFgService {}