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:
+5
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user