mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
8c7bd28864
onIosUpload runs sync local, sync remote, hash and handle backup sequentially. on the bg refresh task path that's a 20s budget from iOS, and sync + hash usually eat all of it before backup gets a turn to enqueue any candidates. these phases don't actually depend on each other. local + remote sync touch different tables. hash works off whatever's already in drift. handle backup reads candidates and just enqueues to URLSession bg. anything one phase produces in this fire shows up to the others on the next fire, and server-side dedup catches the rare race where backup enqueues something sync remote was about to mark as already uploaded. so this runs all four concurrently via Future.wait, with hash getting the full maxSeconds-1 budget instead of a fixed 5s. outer budget timeout still caps everything before iOS expires. second small change: getAssetsToHash orders by createdAt DESC instead of id ASC to match getCandidates. when hash runs inside a refresh fire it processes recent photos first.
304 lines
11 KiB
Dart
304 lines
11 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'dart:ui';
|
|
|
|
import 'package:background_downloader/background_downloader.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:immich_mobile/constants/constants.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/infrastructure/repositories/db.repository.dart';
|
|
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart';
|
|
import 'package:immich_mobile/platform/background_worker_api.g.dart';
|
|
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
|
|
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
|
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
|
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
|
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
|
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart' show nativeSyncApiProvider;
|
|
import 'package:immich_mobile/providers/user.provider.dart';
|
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
|
import 'package:immich_mobile/services/auth.service.dart';
|
|
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
|
import 'package:immich_mobile/services/localization.service.dart';
|
|
import 'package:immich_mobile/utils/bootstrap.dart';
|
|
import 'package:immich_mobile/utils/debug_print.dart';
|
|
import 'package:immich_mobile/wm_executor.dart';
|
|
import 'package:logging/logging.dart';
|
|
|
|
class BackgroundWorkerFgService {
|
|
final BackgroundWorkerFgHostApi _foregroundHostApi;
|
|
|
|
const BackgroundWorkerFgService(this._foregroundHostApi);
|
|
|
|
// TODO: Move this call to native side once old timeline is removed
|
|
Future<void> enable() => _foregroundHostApi.enable();
|
|
|
|
Future<void> saveNotificationMessage(String title, String body) =>
|
|
_foregroundHostApi.saveNotificationMessage(title, body);
|
|
|
|
Future<void> configure({int? minimumDelaySeconds, bool? requireCharging}) => _foregroundHostApi.configure(
|
|
BackgroundWorkerSettings(
|
|
minimumDelaySeconds:
|
|
minimumDelaySeconds ??
|
|
Store.get(AppSettingsEnum.backupTriggerDelay.storeKey, AppSettingsEnum.backupTriggerDelay.defaultValue),
|
|
requiresCharging:
|
|
requireCharging ??
|
|
Store.get(AppSettingsEnum.backupRequireCharging.storeKey, AppSettingsEnum.backupRequireCharging.defaultValue),
|
|
),
|
|
);
|
|
|
|
Future<void> disable() => _foregroundHostApi.disable();
|
|
}
|
|
|
|
class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
|
ProviderContainer? _ref;
|
|
final Drift _drift;
|
|
final DriftLogger _driftLogger;
|
|
final BackgroundWorkerBgHostApi _backgroundHostApi;
|
|
final _cancellationToken = Completer<void>();
|
|
final Logger _logger = Logger('BackgroundWorkerBgService');
|
|
|
|
bool _isCleanedUp = false;
|
|
|
|
BackgroundWorkerBgService({required Drift drift, required DriftLogger driftLogger})
|
|
: _drift = drift,
|
|
_driftLogger = driftLogger,
|
|
_backgroundHostApi = BackgroundWorkerBgHostApi() {
|
|
_ref = ProviderContainer(overrides: [driftProvider.overrideWith(driftOverride(drift))]);
|
|
BackgroundWorkerFlutterApi.setUp(this);
|
|
}
|
|
|
|
bool get _isBackupEnabled => _ref?.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup) ?? false;
|
|
|
|
Future<void> init() async {
|
|
try {
|
|
await Future.wait(
|
|
[
|
|
loadTranslations(),
|
|
workerManagerPatch.init(dynamicSpawning: true),
|
|
_ref?.read(authServiceProvider).setOpenApiServiceEndpoint(),
|
|
// Initialize the file downloader
|
|
FileDownloader().configure(
|
|
globalConfig: [
|
|
// maxConcurrent: 6, maxConcurrentByHost(server):6, maxConcurrentByGroup: 3
|
|
(Config.holdingQueue, (6, 6, 3)),
|
|
// On Android, if files are larger than 256MB, run in foreground service
|
|
(Config.runInForegroundIfFileLargerThan, 256),
|
|
],
|
|
),
|
|
FileDownloader().trackTasksInGroup(kDownloadGroupLivePhoto, markDownloadedComplete: false),
|
|
FileDownloader().trackTasks(),
|
|
].nonNulls,
|
|
);
|
|
|
|
configureFileDownloaderNotifications();
|
|
|
|
// Notify the host that the background worker service has been initialized and is ready to use
|
|
unawaited(_backgroundHostApi.onInitialized());
|
|
} catch (error, stack) {
|
|
_logger.severe("Failed to initialize background worker", error, stack);
|
|
unawaited(_backgroundHostApi.close());
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> onAndroidUpload() async {
|
|
_logger.info('Android background processing started');
|
|
final sw = Stopwatch()..start();
|
|
try {
|
|
if (!await _syncAssets(hashTimeout: Duration(minutes: _isBackupEnabled ? 3 : 6))) {
|
|
_logger.warning("Remote sync did not complete successfully, skipping backup");
|
|
return;
|
|
}
|
|
await _handleBackup();
|
|
} catch (error, stack) {
|
|
_logger.severe("Failed to complete Android background processing", error, stack);
|
|
} finally {
|
|
sw.stop();
|
|
_logger.info("Android background processing completed in ${sw.elapsed.inSeconds}s");
|
|
await _cleanup();
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> onIosUpload(bool isRefresh, int? maxSeconds) async {
|
|
_logger.info('iOS background upload started with maxSeconds: ${maxSeconds}s');
|
|
final sw = Stopwatch()..start();
|
|
try {
|
|
final hashTimeout = isRefresh
|
|
? Duration(seconds: (maxSeconds ?? 20) - 1)
|
|
: Duration(minutes: _isBackupEnabled ? 3 : 6);
|
|
final budget = maxSeconds != null ? Duration(seconds: maxSeconds - 1) : null;
|
|
|
|
final sync = _ref?.read(backgroundSyncProvider);
|
|
if (sync == null) {
|
|
return;
|
|
}
|
|
|
|
// Run sync local, sync remote, hash and backup concurrently so the bg
|
|
// refresh task (20s budget) can make progress on all four instead of
|
|
// racing them sequentially. Phases are independent at the data layer:
|
|
// hash and handle_backup read drift state and tolerate stale reads
|
|
// (server-side dedup catches the rare race).
|
|
final localFuture = sync.syncLocal();
|
|
final remoteFuture = sync.syncRemote();
|
|
final hashFuture = sync.hashAssets().timeout(hashTimeout, onTimeout: () {});
|
|
final backupFuture = _handleBackup();
|
|
|
|
final all = Future.wait<dynamic>([localFuture, remoteFuture, hashFuture, backupFuture]);
|
|
if (budget != null) {
|
|
await all.timeout(budget, onTimeout: () => <dynamic>[]);
|
|
} else {
|
|
await all;
|
|
}
|
|
} catch (error, stack) {
|
|
_logger.severe("Failed to complete iOS background upload", error, stack);
|
|
} finally {
|
|
sw.stop();
|
|
_logger.info("iOS background upload completed in ${sw.elapsed.inSeconds}s");
|
|
await _cleanup();
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> cancel() async {
|
|
_logger.warning("Background worker cancelled");
|
|
try {
|
|
await _cleanup();
|
|
} catch (error, stack) {
|
|
dPrint(() => 'Failed to cleanup background worker: $error with stack: $stack');
|
|
}
|
|
}
|
|
|
|
Future<void> _cleanup() async {
|
|
await runZonedGuarded(_handleCleanup, (error, stack) {
|
|
dPrint(() => "Error during background worker cleanup: $error, $stack");
|
|
});
|
|
}
|
|
|
|
Future<void> _handleCleanup() async {
|
|
// If ref is null, it means the service was never initialized properly
|
|
if (_isCleanedUp || _ref == null) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
_isCleanedUp = true;
|
|
final backgroundSyncManager = _ref?.read(backgroundSyncProvider);
|
|
final nativeSyncApi = _ref?.read(nativeSyncApiProvider);
|
|
|
|
_logger.info("Cleaning up background worker");
|
|
_cancellationToken.complete();
|
|
final cleanupFutures = [
|
|
nativeSyncApi?.cancelHashing(),
|
|
workerManagerPatch.dispose().catchError((_) async {
|
|
// Discard any errors on the dispose call
|
|
return;
|
|
}),
|
|
LogService.I.dispose(),
|
|
Store.dispose(),
|
|
|
|
backgroundSyncManager?.cancel(),
|
|
_drift.optimize(allTables: true),
|
|
];
|
|
|
|
await Future.wait(cleanupFutures.nonNulls);
|
|
await _drift.close();
|
|
await _driftLogger.close();
|
|
|
|
_ref?.dispose();
|
|
_ref = null;
|
|
} catch (error, stack) {
|
|
dPrint(() => 'Failed to cleanup background worker: $error with stack: $stack');
|
|
}
|
|
}
|
|
|
|
Future<void> _handleBackup() async {
|
|
await runZonedGuarded(
|
|
() async {
|
|
if (_isCleanedUp) {
|
|
return;
|
|
}
|
|
|
|
if (!_isBackupEnabled) {
|
|
_logger.info("Backup is disabled. Skipping backup routine");
|
|
return;
|
|
}
|
|
|
|
final currentUser = _ref?.read(currentUserProvider);
|
|
if (currentUser == null) {
|
|
_logger.warning("No current user found. Skipping backup from background");
|
|
return;
|
|
}
|
|
|
|
if (Platform.isIOS) {
|
|
return _ref?.read(driftBackupProvider.notifier).startBackupWithURLSession(currentUser.id);
|
|
}
|
|
|
|
return _ref
|
|
?.read(foregroundUploadServiceProvider)
|
|
.uploadCandidates(currentUser.id, _cancellationToken, useSequentialUpload: true);
|
|
},
|
|
(error, stack) {
|
|
dPrint(() => "Error in backup zone $error, $stack");
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<bool> _syncAssets({Duration? hashTimeout}) async {
|
|
await _ref?.read(backgroundSyncProvider).syncLocal();
|
|
if (_isCleanedUp) {
|
|
return false;
|
|
}
|
|
|
|
final isSuccess = await _ref?.read(backgroundSyncProvider).syncRemote() ?? false;
|
|
if (_isCleanedUp) {
|
|
return isSuccess;
|
|
}
|
|
|
|
var hashFuture = _ref?.read(backgroundSyncProvider).hashAssets();
|
|
if (hashTimeout != null && hashFuture != null) {
|
|
hashFuture = hashFuture.timeout(
|
|
hashTimeout,
|
|
onTimeout: () {
|
|
// Consume cancellation errors as we want to continue processing
|
|
},
|
|
);
|
|
}
|
|
|
|
await hashFuture;
|
|
return isSuccess;
|
|
}
|
|
}
|
|
|
|
class BackgroundWorkerLockService {
|
|
final BackgroundWorkerLockApi _hostApi;
|
|
const BackgroundWorkerLockService(this._hostApi);
|
|
|
|
Future<void> lock() async {
|
|
if (CurrentPlatform.isAndroid) {
|
|
return _hostApi.lock();
|
|
}
|
|
}
|
|
|
|
Future<void> unlock() async {
|
|
if (CurrentPlatform.isAndroid) {
|
|
return _hostApi.unlock();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Native entry invoked from the background worker. If renaming or moving this to a different
|
|
/// library, make sure to update the entry points and URI in native workers as well
|
|
@pragma('vm:entry-point')
|
|
Future<void> backgroundSyncNativeEntrypoint() async {
|
|
WidgetsFlutterBinding.ensureInitialized();
|
|
DartPluginRegistrant.ensureInitialized();
|
|
|
|
final (drift, logDB) = await Bootstrap.initDomain(shouldBufferLogs: false, listenStoreUpdates: false);
|
|
await BackgroundWorkerBgService(drift: drift, driftLogger: logDB).init();
|
|
}
|