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