Files
Immich/mobile/lib/providers/app_life_cycle.provider.dart
T
Santo ShakilandGitHub a4af86282d 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
2026-09-24 10:15:44 +06:00

249 lines
8.0 KiB
Dart

import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/permission.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:logging/logging.dart';
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;
Completer<void>? _pauseOperation;
final _log = Logger("AppLifeCycleNotifier");
AppLifeCycleNotifier(this._ref) : super(AppLifeCycleEnum.active);
Future<void> handleAppResume() async {
state = AppLifeCycleEnum.resumed;
_log.info("App resumed");
// Prevent overlapping resume operations
if (_resumeOperation != null && !_resumeOperation!.isCompleted) {
await _resumeOperation!.future;
return;
}
// Cancel any ongoing pause operation
if (_pauseOperation != null && !_pauseOperation!.isCompleted) {
_pauseOperation!.complete();
}
_resumeOperation = Completer<void>();
try {
await _performResume();
} catch (e, stackTrace) {
_log.severe("Error during app resume", e, stackTrace);
} finally {
if (!_resumeOperation!.isCompleted) {
_resumeOperation!.complete();
}
_resumeOperation = null;
}
}
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 && !_fullSyncPending) {
_log.info("Resume skipped, app was never paused");
return;
}
_wasPaused = false;
final isAuthenticated = _ref.read(authProvider).isAuthenticated;
// Needs to be logged in
if (isAuthenticated) {
// switch endpoint if needed
final endpoint = await _ref.read(authProvider.notifier).setOpenApiServiceEndpoint();
_log.info("Using server URL: $endpoint");
await _ref.read(serverInfoProvider.notifier).getServerVersion();
}
if (!_shouldContinueOperation()) {
_wasPaused = true;
return;
}
_ref.read(websocketProvider.notifier).connect();
await _handleBetaTimelineResume();
await _ref.read(notificationPermissionProvider.notifier).getNotificationPermission();
await _ref.read(galleryPermissionNotifier.notifier).getGalleryPermissionStatus();
}
Future<void> _safeRun(Future<void> Function() action, String debugName) async {
if (!_shouldContinueOperation()) {
return;
}
try {
await action();
} catch (e, stackTrace) {
_log.warning("Error during $debugName operation", e, stackTrace);
}
}
Future<void> _handleBetaTimelineResume() async {
unawaited(_ref.read(backgroundWorkerLockServiceProvider).lock());
// Give isolates time to complete any ongoing database transactions
await Future.delayed(const Duration(milliseconds: 500));
final backgroundManager = _ref.read(backgroundSyncProvider);
// Drop any sync that froze mid-flight while the app was suspended so resume
// starts fresh instead of awaiting the stale task (#28082). cancelResumeSyncs
// clears the task refs synchronously, so the syncs below see a clean slate.
unawaited(backgroundManager.cancelResumeSyncs());
final isAlbumLinkedSyncEnable = _ref.read(appConfigProvider).backup.syncAlbums;
try {
bool syncSuccess = false;
await Future.wait([
_safeRun(() {
final full = CurrentPlatform.isAndroid || _fullSyncPending;
_fullSyncPending = false;
return backgroundManager.syncLocal(full: full);
}, "syncLocal"),
_safeRun(() async {
syncSuccess = await backgroundManager.syncRemote();
}, "syncRemote"),
]);
_ref.invalidate(memoryLaneProvider);
_ref.invalidate(allMemoriesProvider);
if (syncSuccess) {
await Future.wait([
_safeRun(backgroundManager.hashAssets, "hashAssets").then((_) {
unawaited(_resumeBackup());
}),
_resumeBackup(),
_safeRun(backgroundManager.syncCloudIds, "syncCloudIds"),
]);
} else {
await _safeRun(backgroundManager.hashAssets, "hashAssets");
}
if (isAlbumLinkedSyncEnable) {
await _safeRun(backgroundManager.syncLinkedAlbum, "syncLinkedAlbum");
}
} catch (e, stackTrace) {
_log.severe("Error during background sync", e, stackTrace);
}
}
Future<void> _resumeBackup() async {
final isEnableBackup = _ref.read(appConfigProvider).backup.enabled;
if (isEnableBackup) {
final currentUser = Store.tryGet(StoreKey.currentUser);
if (currentUser != null) {
await _safeRun(
() => _ref.read(backupProvider.notifier).startForegroundBackup(currentUser.id),
"handleBackupResume",
);
}
}
}
// Helper method to check if operations should continue
bool _shouldContinueOperation() {
return [AppLifeCycleEnum.resumed, AppLifeCycleEnum.active].contains(state) &&
(_resumeOperation?.isCompleted == false || _resumeOperation == null);
}
void handleAppInactivity() {
state = AppLifeCycleEnum.inactive;
// do not stop/clean up anything on inactivity: issued on every orientation change
}
Future<void> handleAppPause() async {
state = AppLifeCycleEnum.paused;
_wasPaused = true;
_log.info("App paused");
// Prevent overlapping pause operations
if (_pauseOperation != null && !_pauseOperation!.isCompleted) {
await _pauseOperation!.future;
return;
}
// Cancel any ongoing resume operation
if (_resumeOperation != null && !_resumeOperation!.isCompleted) {
_resumeOperation!.complete();
}
_pauseOperation = Completer<void>();
try {
unawaited(_ref.read(backgroundWorkerLockServiceProvider).unlock());
await _performPause();
} catch (e, stackTrace) {
_log.severe("Error during app pause", e, stackTrace);
} finally {
if (!_pauseOperation!.isCompleted) {
_pauseOperation!.complete();
}
_pauseOperation = null;
}
}
Future<void> _performPause() {
if (_ref.read(authProvider).isAuthenticated) {
_ref.read(backupProvider.notifier).stopForegroundBackup(reason: "the app being sent to the background");
_ref.read(websocketProvider.notifier).disconnect();
}
return LogService.I.flush().catchError((_) {});
}
Future<void> handleAppDetached() async {
state = AppLifeCycleEnum.detached;
unawaited(_ref.read(backgroundWorkerLockServiceProvider).unlock());
// Flush logs before closing database
try {
await LogService.I.flush();
} catch (_) {}
}
void handleAppHidden() {
state = AppLifeCycleEnum.hidden;
// do not stop/clean up anything on inactivity: issued on every orientation change
}
}
final appStateProvider = StateNotifierProvider<AppLifeCycleNotifier, AppLifeCycleEnum>((ref) {
return AppLifeCycleNotifier(ref);
});