From 35b62cd016af632d2abc1ece3a48cf50a821cfb0 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Mon, 22 Sep 2025 07:15:47 +0530 Subject: [PATCH] fix: prevent background worker when main app is running (#22252) * fix: prevent background worker only when the main app is actively running * handle ref disposals better --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex --- mise.toml | 15 +++ .../app/alextran/immich/MainActivity.kt | 11 +- .../immich/background/BackgroundEngineLock.kt | 59 +++++++---- .../immich/background/BackgroundWorker.kt | 6 +- .../background/BackgroundWorkerApiImpl.kt | 41 +++---- .../background/BackgroundWorkerLock.g.kt | 95 +++++++++++++++++ .../background/BackgroundWorkerPreferences.kt | 49 +++++++++ .../services/background_worker.service.dart | 100 +++++++++++------- mobile/lib/main.dart | 5 +- .../lib/pages/common/splash_screen.page.dart | 2 + .../background_worker_lock_api.g.dart | 97 +++++++++++++++++ .../providers/app_life_cycle.provider.dart | 9 ++ .../infrastructure/platform.provider.dart | 5 + mobile/makefile | 2 + mobile/pigeon/background_worker_lock_api.dart | 17 +++ 15 files changed, 418 insertions(+), 95 deletions(-) create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerLock.g.kt create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerPreferences.kt create mode 100644 mobile/lib/platform/background_worker_lock_api.g.dart create mode 100644 mobile/pigeon/background_worker_lock_api.dart diff --git a/mise.toml b/mise.toml index 34503fb8d3..51dc3b1606 100644 --- a/mise.toml +++ b/mise.toml @@ -327,6 +327,7 @@ depends = [ "mobile:pigeon:native-sync", "mobile:pigeon:thumbnail", "mobile:pigeon:background-worker", + "mobile:pigeon:background-worker-lock", "mobile:pigeon:connectivity", ] @@ -430,6 +431,20 @@ run = [ "dart format lib/platform/background_worker_api.g.dart", ] +[tasks."mobile:pigeon:background-worker-lock"] +description = "Generate background worker lock API pigeon code" +dir = "mobile" +hide = true +sources = ["pigeon/background_worker_lock_api.dart"] +outputs = [ + "lib/platform/background_worker_lock_api.g.dart", + "android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerLock.g.kt", +] +run = [ + "dart run pigeon --input pigeon/background_worker_lock_api.dart", + "dart format lib/platform/background_worker_lock_api.g.dart", +] + [tasks."mobile:pigeon:connectivity"] description = "Generate connectivity API pigeon code" dir = "mobile" diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt index 4e811c8dfc..034f5ee72e 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt @@ -6,6 +6,7 @@ import android.os.ext.SdkExtensions import app.alextran.immich.background.BackgroundEngineLock import app.alextran.immich.background.BackgroundWorkerApiImpl import app.alextran.immich.background.BackgroundWorkerFgHostApi +import app.alextran.immich.background.BackgroundWorkerLockApi import app.alextran.immich.connectivity.ConnectivityApi import app.alextran.immich.connectivity.ConnectivityApiImpl import app.alextran.immich.images.ThumbnailApi @@ -24,11 +25,9 @@ class MainActivity : FlutterFragmentActivity() { companion object { fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) { - flutterEngine.plugins.add(BackgroundServicePlugin()) - flutterEngine.plugins.add(HttpSSLOptionsPlugin()) - flutterEngine.plugins.add(BackgroundEngineLock()) - val messenger = flutterEngine.dartExecutor.binaryMessenger + val backgroundEngineLockImpl = BackgroundEngineLock(ctx) + BackgroundWorkerLockApi.setUp(messenger, backgroundEngineLockImpl) val nativeSyncApiImpl = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || SdkExtensions.getExtensionVersion(Build.VERSION_CODES.R) < 1) { NativeSyncApiImpl26(ctx) @@ -39,6 +38,10 @@ class MainActivity : FlutterFragmentActivity() { ThumbnailApi.setUp(messenger, ThumbnailsImpl(ctx)) BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx)) ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx)) + + flutterEngine.plugins.add(BackgroundServicePlugin()) + flutterEngine.plugins.add(HttpSSLOptionsPlugin()) + flutterEngine.plugins.add(backgroundEngineLockImpl) } } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundEngineLock.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundEngineLock.kt index 6d6f45a708..d8afe32b5c 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundEngineLock.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundEngineLock.kt @@ -1,33 +1,50 @@ package app.alextran.immich.background +import android.content.Context import android.util.Log -import androidx.work.WorkManager -import io.flutter.embedding.engine.FlutterEngineCache import io.flutter.embedding.engine.plugins.FlutterPlugin import java.util.concurrent.atomic.AtomicInteger private const val TAG = "BackgroundEngineLock" -class BackgroundEngineLock : FlutterPlugin { - companion object { - const val ENGINE_CACHE_KEY = "immich::background_worker::engine" - var engineCount = AtomicInteger(0) - } +class BackgroundEngineLock(context: Context) : BackgroundWorkerLockApi, FlutterPlugin { + private val ctx: Context = context.applicationContext - override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { - // work manager task is running while the main app is opened, cancel the worker - if (engineCount.incrementAndGet() > 1 && FlutterEngineCache.getInstance() - .get(ENGINE_CACHE_KEY) != null - ) { - WorkManager.getInstance(binding.applicationContext) - .cancelUniqueWork(BackgroundWorkerApiImpl.BACKGROUND_WORKER_NAME) - FlutterEngineCache.getInstance().remove(ENGINE_CACHE_KEY) + companion object { + + private var engineCount = AtomicInteger(0) + + private fun checkAndEnforceBackgroundLock(ctx: Context) { + // work manager task is running while the main app is opened, cancel the worker + if (BackgroundWorkerPreferences(ctx).isLocked() && + engineCount.get() > 1 && + BackgroundWorkerApiImpl.isBackgroundWorkerRunning() + ) { + Log.i(TAG, "Background worker is locked, cancelling the background worker") + BackgroundWorkerApiImpl.cancelBackgroundWorker(ctx) + } + } } - Log.i(TAG, "Flutter engine attached. Attached Engines count: $engineCount") - } - override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { - engineCount.decrementAndGet() - Log.i(TAG, "Flutter engine detached. Attached Engines count: $engineCount") - } + override fun lock() { + BackgroundWorkerPreferences(ctx).setLocked(true) + checkAndEnforceBackgroundLock(ctx) + Log.i(TAG, "Background worker is locked") + } + + override fun unlock() { + BackgroundWorkerPreferences(ctx).setLocked(false) + Log.i(TAG, "Background worker is unlocked") + } + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + checkAndEnforceBackgroundLock(binding.applicationContext) + engineCount.incrementAndGet() + Log.i(TAG, "Flutter engine attached. Attached Engines count: $engineCount") + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + engineCount.decrementAndGet() + Log.i(TAG, "Flutter engine detached. Attached Engines count: $engineCount") + } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt index 7d30319af4..71d9f5ffe3 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt @@ -76,9 +76,7 @@ class BackgroundWorker(context: Context, params: WorkerParameters) : loader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) { engine = FlutterEngine(ctx) - FlutterEngineCache.getInstance().remove(BackgroundEngineLock.ENGINE_CACHE_KEY); - FlutterEngineCache.getInstance() - .put(BackgroundEngineLock.ENGINE_CACHE_KEY, engine!!) + FlutterEngineCache.getInstance().put(BackgroundWorkerApiImpl.ENGINE_CACHE_KEY, engine!!) // Register custom plugins MainActivity.registerPlugins(ctx, engine!!) @@ -192,9 +190,9 @@ class BackgroundWorker(context: Context, params: WorkerParameters) : isComplete = true engine?.destroy() engine = null - FlutterEngineCache.getInstance().remove(BackgroundEngineLock.ENGINE_CACHE_KEY); flutterApi = null notificationManager.cancel(NOTIFICATION_ID) + FlutterEngineCache.getInstance().remove(BackgroundWorkerApiImpl.ENGINE_CACHE_KEY) waitForForegroundPromotion() completionHandler.set(success) } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerApiImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerApiImpl.kt index 259e3244bc..7e0c65eb56 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerApiImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerApiImpl.kt @@ -1,7 +1,6 @@ package app.alextran.immich.background import android.content.Context -import android.content.SharedPreferences import android.provider.MediaStore import android.util.Log import androidx.work.BackoffPolicy @@ -9,6 +8,7 @@ import androidx.work.Constraints import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager +import io.flutter.embedding.engine.FlutterEngineCache import java.util.concurrent.TimeUnit private const val TAG = "BackgroundWorkerApiImpl" @@ -34,8 +34,10 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi { } companion object { - const val BACKGROUND_WORKER_NAME = "immich/BackgroundWorkerV1" + private const val BACKGROUND_WORKER_NAME = "immich/BackgroundWorkerV1" private const val OBSERVER_WORKER_NAME = "immich/MediaObserverV1" + const val ENGINE_CACHE_KEY = "immich::background_worker::engine" + fun enqueueMediaObserver(ctx: Context) { val settings = BackgroundWorkerPreferences(ctx).getSettings() @@ -73,35 +75,18 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi { Log.i(TAG, "Enqueued background worker with name: $BACKGROUND_WORKER_NAME") } - } -} -private class BackgroundWorkerPreferences(private val ctx: Context) { - companion object { - private const val SHARED_PREF_NAME = "Immich::BackgroundWorker" - private const val SHARED_PREF_MIN_DELAY_KEY = "BackgroundWorker::minDelaySeconds" - private const val SHARED_PREF_REQUIRE_CHARGING_KEY = "BackgroundWorker::requireCharging" + fun isBackgroundWorkerRunning(): Boolean { + // Easier to check if the engine is cached as we always cache the engine when starting the worker + // and remove it when the worker is finished + return FlutterEngineCache.getInstance().get(ENGINE_CACHE_KEY) != null + } - private const val DEFAULT_MIN_DELAY_SECONDS = 30L - private const val DEFAULT_REQUIRE_CHARGING = false - } + fun cancelBackgroundWorker(ctx: Context) { + WorkManager.getInstance(ctx).cancelUniqueWork(BACKGROUND_WORKER_NAME) + FlutterEngineCache.getInstance().remove(ENGINE_CACHE_KEY) - private val sp: SharedPreferences by lazy { - ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) - } - - fun updateSettings(settings: BackgroundWorkerSettings) { - sp.edit().apply { - putLong(SHARED_PREF_MIN_DELAY_KEY, settings.minimumDelaySeconds) - putBoolean(SHARED_PREF_REQUIRE_CHARGING_KEY, settings.requiresCharging) - apply() + Log.i(TAG, "Cancelled background upload task") } } - - fun getSettings(): BackgroundWorkerSettings { - return BackgroundWorkerSettings( - minimumDelaySeconds = sp.getLong(SHARED_PREF_MIN_DELAY_KEY, DEFAULT_MIN_DELAY_SECONDS), - requiresCharging = sp.getBoolean(SHARED_PREF_REQUIRE_CHARGING_KEY, DEFAULT_REQUIRE_CHARGING), - ) - } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerLock.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerLock.g.kt new file mode 100644 index 0000000000..3d00bafba2 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerLock.g.kt @@ -0,0 +1,95 @@ +// Autogenerated from Pigeon (v26.0.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon +@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") + +package app.alextran.immich.background + +import android.util.Log +import io.flutter.plugin.common.BasicMessageChannel +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MessageCodec +import io.flutter.plugin.common.StandardMethodCodec +import io.flutter.plugin.common.StandardMessageCodec +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +private object BackgroundWorkerLockPigeonUtils { + + fun wrapResult(result: Any?): List { + return listOf(result) + } + + fun wrapError(exception: Throwable): List { + return if (exception is FlutterError) { + listOf( + exception.code, + exception.message, + exception.details + ) + } else { + listOf( + exception.javaClass.simpleName, + exception.toString(), + "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception) + ) + } + } +} +private open class BackgroundWorkerLockPigeonCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return super.readValueOfType(type, buffer) + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + super.writeValue(stream, value) + } +} + +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface BackgroundWorkerLockApi { + fun lock() + fun unlock() + + companion object { + /** The codec used by BackgroundWorkerLockApi. */ + val codec: MessageCodec by lazy { + BackgroundWorkerLockPigeonCodec() + } + /** Sets up an instance of `BackgroundWorkerLockApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: BackgroundWorkerLockApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerLockApi.lock$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + api.lock() + listOf(null) + } catch (exception: Throwable) { + BackgroundWorkerLockPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerLockApi.unlock$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + api.unlock() + listOf(null) + } catch (exception: Throwable) { + BackgroundWorkerLockPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerPreferences.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerPreferences.kt new file mode 100644 index 0000000000..964ad558fd --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerPreferences.kt @@ -0,0 +1,49 @@ +package app.alextran.immich.background + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit + +class BackgroundWorkerPreferences(private val ctx: Context) { + companion object { + const val SHARED_PREF_NAME = "Immich::BackgroundWorker" + private const val SHARED_PREF_MIN_DELAY_KEY = "BackgroundWorker::minDelaySeconds" + private const val SHARED_PREF_REQUIRE_CHARGING_KEY = "BackgroundWorker::requireCharging" + private const val SHARED_PREF_LOCK_KEY = "BackgroundWorker::isLocked" + + private const val DEFAULT_MIN_DELAY_SECONDS = 30L + private const val DEFAULT_REQUIRE_CHARGING = false + } + + private val sp: SharedPreferences by lazy { + ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) + } + + fun updateSettings(settings: BackgroundWorkerSettings) { + sp.edit { + putLong(SHARED_PREF_MIN_DELAY_KEY, settings.minimumDelaySeconds) + putBoolean(SHARED_PREF_REQUIRE_CHARGING_KEY, settings.requiresCharging) + } + } + + fun getSettings(): BackgroundWorkerSettings { + return BackgroundWorkerSettings( + minimumDelaySeconds = sp.getLong(SHARED_PREF_MIN_DELAY_KEY, DEFAULT_MIN_DELAY_SECONDS), + requiresCharging = sp.getBoolean( + SHARED_PREF_REQUIRE_CHARGING_KEY, + DEFAULT_REQUIRE_CHARGING + ), + ) + } + + fun setLocked(paused: Boolean) { + sp.edit { + putBoolean(SHARED_PREF_LOCK_KEY, paused) + } + } + + fun isLocked(): Boolean { + return sp.getBoolean(SHARED_PREF_LOCK_KEY, true) + } +} + diff --git a/mobile/lib/domain/services/background_worker.service.dart b/mobile/lib/domain/services/background_worker.service.dart index 78dd1e980f..942581633f 100644 --- a/mobile/lib/domain/services/background_worker.service.dart +++ b/mobile/lib/domain/services/background_worker.service.dart @@ -10,11 +10,13 @@ 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/network_capability_extensions.dart'; +import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/generated/intl_keys.g.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'; @@ -58,7 +60,7 @@ class BackgroundWorkerFgService { } class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { - late final ProviderContainer _ref; + ProviderContainer? _ref; final Isar _isar; final Drift _drift; final DriftLogger _driftLogger; @@ -83,29 +85,31 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { BackgroundWorkerFlutterApi.setUp(this); } - bool get _isBackupEnabled => _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup); + bool get _isBackupEnabled => _ref?.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup) ?? false; Future init() async { try { HttpSSLOptions.apply(applyNative: false); - await Future.wait([ - loadTranslations(), - workerManager.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(), - _ref.read(fileMediaRepositoryProvider).enableBackgroundAccess(), - ]); + await Future.wait( + [ + loadTranslations(), + workerManager.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(), + _ref?.read(fileMediaRepositoryProvider).enableBackgroundAccess(), + ].nonNulls, + ); configureFileDownloaderNotifications(); @@ -178,15 +182,17 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { } Future _cleanup() async { - if (_isCleanedUp) { + // If ref is null, it means the service was never initialized properly + if (_isCleanedUp || _ref == null) { return; } try { - final backgroundSyncManager = _ref.read(backgroundSyncProvider); - final nativeSyncApi = _ref.read(nativeSyncApiProvider); _isCleanedUp = true; - _ref.dispose(); + final backgroundSyncManager = _ref?.read(backgroundSyncProvider); + final nativeSyncApi = _ref?.read(nativeSyncApiProvider); + _ref?.dispose(); + _ref = null; _cancellationToken.cancel(); _logger.info("Cleaning up background worker"); @@ -199,14 +205,14 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { Store.dispose(), _drift.close(), _driftLogger.close(), - backgroundSyncManager.cancel(), - nativeSyncApi.cancelHashing(), + backgroundSyncManager?.cancel(), + nativeSyncApi?.cancelHashing(), ]; if (_isar.isOpen) { cleanupFutures.add(_isar.close()); } - await Future.wait(cleanupFutures); + await Future.wait(cleanupFutures.nonNulls); _logger.info("Background worker resources cleaned up"); } catch (error, stack) { dPrint(() => 'Failed to cleanup background worker: $error with stack: $stack'); @@ -216,14 +222,18 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { Future _handleBackup() async { await runZonedGuarded( () async { - if (!_isBackupEnabled || _isCleanedUp) { + if (_isCleanedUp) { + return; + } + + if (!_isBackupEnabled) { _logger.info("[_handleBackup 1] Backup is disabled. Skipping backup routine"); return; } _logger.info("[_handleBackup 2] Enqueuing assets for backup from the background service"); - final currentUser = _ref.read(currentUserProvider); + final currentUser = _ref?.read(currentUserProvider); if (currentUser == null) { _logger.warning("[_handleBackup 3] No current user found. Skipping backup from background"); return; @@ -231,19 +241,18 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { _logger.info("[_handleBackup 4] Resume backup from background"); if (Platform.isIOS) { - return _ref.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id); + return _ref?.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id); } - final canPing = await _ref.read(serverInfoServiceProvider).ping(); + final canPing = await _ref?.read(serverInfoServiceProvider).ping() ?? false; if (!canPing) { _logger.warning("[_handleBackup 5] Server is not reachable. Skipping backup from background"); return; } - final networkCapabilities = await _ref.read(connectivityApiProvider).getCapabilities(); - + final networkCapabilities = await _ref?.read(connectivityApiProvider).getCapabilities() ?? []; return _ref - .read(uploadServiceProvider) + ?.read(uploadServiceProvider) .startBackupWithHttpClient(currentUser.id, networkCapabilities.hasWifi, _cancellationToken); }, (error, stack) { @@ -253,18 +262,18 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { } Future _syncAssets({Duration? hashTimeout}) async { - await _ref.read(backgroundSyncProvider).syncLocal(); + await _ref?.read(backgroundSyncProvider).syncLocal(); if (_isCleanedUp) { return; } - await _ref.read(backgroundSyncProvider).syncRemote(); + await _ref?.read(backgroundSyncProvider).syncRemote(); if (_isCleanedUp) { return; } - var hashFuture = _ref.read(backgroundSyncProvider).hashAssets(); - if (hashTimeout != null) { + var hashFuture = _ref?.read(backgroundSyncProvider).hashAssets(); + if (hashTimeout != null && hashFuture != null) { hashFuture = hashFuture.timeout( hashTimeout, onTimeout: () { @@ -277,6 +286,23 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { } } +class BackgroundWorkerLockService { + final BackgroundWorkerLockApi _hostApi; + const BackgroundWorkerLockService(this._hostApi); + + Future lock() async { + if (CurrentPlatform.isAndroid) { + return _hostApi.lock(); + } + } + + Future 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') diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 7d944c54ce..712ee0bd83 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -12,9 +12,11 @@ import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/locales.dart'; +import 'package:immich_mobile/domain/services/background_worker.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/generated/codegen_loader.g.dart'; +import 'package:immich_mobile/platform/background_worker_lock_api.g.dart'; import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; @@ -32,6 +34,7 @@ import 'package:immich_mobile/theme/dynamic_theme.dart'; import 'package:immich_mobile/theme/theme_data.dart'; import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/cache/widgets_binding.dart'; +import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:immich_mobile/utils/licenses.dart'; import 'package:immich_mobile/utils/migration.dart'; @@ -39,10 +42,10 @@ import 'package:intl/date_symbol_data_local.dart'; import 'package:logging/logging.dart'; import 'package:timezone/data/latest.dart'; import 'package:worker_manager/worker_manager.dart'; -import 'package:immich_mobile/utils/debug_print.dart'; void main() async { ImmichWidgetsBinding(); + unawaited(BackgroundWorkerLockService(BackgroundWorkerLockApi()).lock()); final (isar, drift, logDb) = await Bootstrap.initDB(); await Bootstrap.initDomain(isar, drift, logDb); await initApp(); diff --git a/mobile/lib/pages/common/splash_screen.page.dart b/mobile/lib/pages/common/splash_screen.page.dart index f288464c42..aa4d142381 100644 --- a/mobile/lib/pages/common/splash_screen.page.dart +++ b/mobile/lib/pages/common/splash_screen.page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; diff --git a/mobile/lib/platform/background_worker_lock_api.g.dart b/mobile/lib/platform/background_worker_lock_api.g.dart new file mode 100644 index 0000000000..9f00017dc8 --- /dev/null +++ b/mobile/lib/platform/background_worker_lock_api.g.dart @@ -0,0 +1,97 @@ +// Autogenerated from Pigeon (v26.0.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; + +import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; +import 'package:flutter/services.dart'; + +PlatformException _createConnectionError(String channelName) { + return PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); +} + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + default: + return super.readValueOfType(type, buffer); + } + } +} + +class BackgroundWorkerLockApi { + /// Constructor for [BackgroundWorkerLockApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + BackgroundWorkerLockApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + Future lock() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.BackgroundWorkerLockApi.lock$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + Future unlock() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.BackgroundWorkerLockApi.unlock$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } +} diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index b7f1b9ebb2..ec6495440a 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -15,6 +15,7 @@ import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/backup/ios_background_settings.provider.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/memory.provider.dart'; import 'package:immich_mobile/providers/notification_permission.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; @@ -138,6 +139,7 @@ class AppLifeCycleNotifier extends StateNotifier { Future _handleBetaTimelineResume() async { _ref.read(backupProvider.notifier).cancelBackup(); + unawaited(_ref.read(backgroundWorkerLockServiceProvider).lock()); // Give isolates time to complete any ongoing database transactions await Future.delayed(const Duration(milliseconds: 500)); @@ -209,6 +211,9 @@ class AppLifeCycleNotifier extends StateNotifier { _pauseOperation = Completer(); try { + if (Store.isBetaTimelineEnabled) { + unawaited(_ref.read(backgroundWorkerLockServiceProvider).unlock()); + } await _performPause(); } catch (e, stackTrace) { _log.severe("Error during app pause", e, stackTrace); @@ -240,6 +245,10 @@ class AppLifeCycleNotifier extends StateNotifier { Future handleAppDetached() async { state = AppLifeCycleEnum.detached; + if (Store.isBetaTimelineEnabled) { + unawaited(_ref.read(backgroundWorkerLockServiceProvider).unlock()); + } + // Flush logs before closing database try { LogService.I.flush(); diff --git a/mobile/lib/providers/infrastructure/platform.provider.dart b/mobile/lib/providers/infrastructure/platform.provider.dart index dec5c6905e..11c5280c02 100644 --- a/mobile/lib/providers/infrastructure/platform.provider.dart +++ b/mobile/lib/providers/infrastructure/platform.provider.dart @@ -1,12 +1,17 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/services/background_worker.service.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/platform/connectivity_api.g.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/platform/thumbnail_api.g.dart'; final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi())); +final backgroundWorkerLockServiceProvider = Provider( + (_) => BackgroundWorkerLockService(BackgroundWorkerLockApi()), +); + final nativeSyncApiProvider = Provider((_) => NativeSyncApi()); final connectivityApiProvider = Provider((_) => ConnectivityApi()); diff --git a/mobile/makefile b/mobile/makefile index 61854830d9..b90e95c902 100644 --- a/mobile/makefile +++ b/mobile/makefile @@ -9,10 +9,12 @@ pigeon: dart run pigeon --input pigeon/native_sync_api.dart dart run pigeon --input pigeon/thumbnail_api.dart dart run pigeon --input pigeon/background_worker_api.dart + dart run pigeon --input pigeon/background_worker_lock_api.dart dart run pigeon --input pigeon/connectivity_api.dart dart format lib/platform/native_sync_api.g.dart dart format lib/platform/thumbnail_api.g.dart dart format lib/platform/background_worker_api.g.dart + dart format lib/platform/background_worker_lock_api.g.dart dart format lib/platform/connectivity_api.g.dart watch: diff --git a/mobile/pigeon/background_worker_lock_api.dart b/mobile/pigeon/background_worker_lock_api.dart new file mode 100644 index 0000000000..44c5220367 --- /dev/null +++ b/mobile/pigeon/background_worker_lock_api.dart @@ -0,0 +1,17 @@ +import 'package:pigeon/pigeon.dart'; + +@ConfigurePigeon( + PigeonOptions( + dartOut: 'lib/platform/background_worker_lock_api.g.dart', + kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerLock.g.kt', + kotlinOptions: KotlinOptions(package: 'app.alextran.immich.background', includeErrorClass: false), + dartOptions: DartOptions(), + dartPackageName: 'immich_mobile', + ), +) +@HostApi() +abstract class BackgroundWorkerLockApi { + void lock(); + + void unlock(); +}