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")
}
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"
@@ -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)
}
@@ -57,6 +57,8 @@ class BackgroundWorkerFgService {
}
Future<void> disable() => _foregroundHostApi.disable();
Future<bool> wasLaunchedInBackground() => _foregroundHostApi.wasLaunchedInBackground();
}
class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
@@ -22,6 +22,8 @@ enum AppLifeCycleEnum { active, inactive, paused, resumed, detached, hidden }
class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
final Ref _ref;
bool _wasPaused = false;
bool _firstLaunch = true;
bool _fullSyncPending = false;
// Add operation coordination
Completer<void>? _resumeOperation;
@@ -61,8 +63,14 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
}
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
if (!_wasPaused) {
if (!_wasPaused && !_fullSyncPending) {
_log.info("Resume skipped, app was never paused");
return;
}
@@ -121,7 +129,11 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
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"),
+3
View File
@@ -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()
@@ -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?> 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<ServerVersion?>();
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<void> 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);
});
}
+2
View File
@@ -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 {}