From 4fe535e5e88cda4e7f3ed5c07f0ffc37e8623165 Mon Sep 17 00:00:00 2001 From: Fynn Petersen-Frey Date: Thu, 8 Sep 2022 15:36:08 +0200 Subject: [PATCH] improve Android background service reliability (#603) This change greatly reduces the chance that a backup is not performed when a new photo/video is made. Instead of combining the change trigger and additonal constraints (wifi or charging) into a single worker, these aspects are now separated. Thus, it is now reliably possible to take pictures while the wifi constraint is not satisfied and upload them hours/days later once connected to wifi without taking a new photo. As a positive side effect, this simplifies the error/retry handling by directly leveraging Android's WorkManager without workarounds. The separation also allows to notify the currently running BackupWorker that new assets were added while backing up other assets to also upload those newly added assets. Further, a new tiny service checks if the app is killed, to reschedule the content change worker and allow to detect the first new photo. Bonus: The home screen now shows backup as enabled if background backup is active. * use separate worker/task for listening on changed/added assets * use separate worker/task for performing the backup * content observer worker enqueues backup worker on each new asset * wifi/charging constraints only apply to backup worker * backupworker is notified of assets added while running to re-run * new service to catch app being killed to workaround WorkManager issue --- .../android/app/src/main/AndroidManifest.xml | 1 + .../com/example/mobile/AppClearedService.kt | 25 +++ .../example/mobile/BackgroundServicePlugin.kt | 33 ++- .../kotlin/com/example/mobile/BackupWorker.kt | 201 ++++++++---------- .../example/mobile/ContentObserverWorker.kt | 137 ++++++++++++ .../kotlin/com/example/mobile/MainActivity.kt | 7 + .../background.service.dart | 96 ++++----- .../backup/providers/backup.provider.dart | 8 +- .../modules/home/ui/immich_sliver_appbar.dart | 2 +- 9 files changed, 329 insertions(+), 181 deletions(-) create mode 100644 mobile/android/app/src/main/kotlin/com/example/mobile/AppClearedService.kt create mode 100644 mobile/android/app/src/main/kotlin/com/example/mobile/ContentObserverWorker.kt diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 9fdb4c157..af00aac41 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -12,6 +12,7 @@ + diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/AppClearedService.kt b/mobile/android/app/src/main/kotlin/com/example/mobile/AppClearedService.kt new file mode 100644 index 000000000..bbdaa27f5 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/com/example/mobile/AppClearedService.kt @@ -0,0 +1,25 @@ +package app.alextran.immich + +import android.app.Service +import android.content.Intent +import android.os.IBinder + +/** + * Catches the event when either the system or the user kills the app + * (does not apply on force close!) + */ +class AppClearedService() : Service() { + + override fun onBind(intent: Intent): IBinder? { + return null + } + + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + return START_NOT_STICKY; + } + + override fun onTaskRemoved(rootIntent: Intent) { + ContentObserverWorker.workManagerAppClearedWorkaround(applicationContext) + stopSelf(); + } +} \ No newline at end of file diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/BackgroundServicePlugin.kt b/mobile/android/app/src/main/kotlin/com/example/mobile/BackgroundServicePlugin.kt index 04aa6f1b3..bebaa579b 100644 --- a/mobile/android/app/src/main/kotlin/com/example/mobile/BackgroundServicePlugin.kt +++ b/mobile/android/app/src/main/kotlin/com/example/mobile/BackgroundServicePlugin.kt @@ -1,11 +1,6 @@ package app.alextran.immich import android.content.Context -import android.net.Uri -import android.content.Intent -import android.provider.Settings -import android.util.Log -import android.widget.Toast import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.MethodCall @@ -44,30 +39,30 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { val ctx = context!! when(call.method) { - "initialize" -> { // needs to be called prior to any other method + "enable" -> { val args = call.arguments>()!! ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) - .edit().putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long).apply() + .edit() + .putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long) + .putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, args.get(1) as String) + .apply() + ContentObserverWorker.enable(ctx, immediate = args.get(2) as Boolean) result.success(true) } - "start" -> { + "configure" -> { val args = call.arguments>()!! - val immediate = args.get(0) as Boolean - val keepExisting = args.get(1) as Boolean - val requireUnmeteredNetwork = args.get(2) as Boolean - val requireCharging = args.get(3) as Boolean - val notificationTitle = args.get(4) as String - ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) - .edit().putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, notificationTitle).apply() - BackupWorker.startWork(ctx, immediate, keepExisting, requireUnmeteredNetwork, requireCharging) - result.success(true) + val requireUnmeteredNetwork = args.get(0) as Boolean + val requireCharging = args.get(1) as Boolean + ContentObserverWorker.configureWork(ctx, requireUnmeteredNetwork, requireCharging) + result.success(true) } - "stop" -> { + "disable" -> { + ContentObserverWorker.disable(ctx) BackupWorker.stopWork(ctx) result.success(true) } "isEnabled" -> { - result.success(BackupWorker.isEnabled(ctx)) + result.success(ContentObserverWorker.isEnabled(ctx)) } "isIgnoringBatteryOptimizations" -> { result.success(BackupWorker.isIgnoringBatteryOptimizations(ctx)) diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt b/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt index 6e2795a8f..24bbd1785 100644 --- a/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt +++ b/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt @@ -8,17 +8,12 @@ import android.os.Handler import android.os.Looper import android.os.PowerManager import android.os.SystemClock -import android.provider.MediaStore -import android.provider.BaseColumns -import android.provider.MediaStore.MediaColumns -import android.provider.MediaStore.Images.Media import android.util.Log import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import androidx.concurrent.futures.ResolvableFuture import androidx.work.BackoffPolicy import androidx.work.Constraints -import androidx.work.Data import androidx.work.ForegroundInfo import androidx.work.ListenableWorker import androidx.work.NetworkType @@ -26,6 +21,7 @@ import androidx.work.WorkerParameters import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager +import androidx.work.WorkInfo import com.google.common.util.concurrent.ListenableFuture import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.dart.DartExecutor @@ -41,14 +37,7 @@ import java.util.concurrent.TimeUnit * Starts the Dart runtime/engine and calls `_nativeEntry` function in * `background.service.dart` to run the actual backup logic. * Called by Android WorkManager when all constraints for the work are met, - * i.e. a new photo/video is created on the device AND battery is not low. - * Optionally, unmetered network (wifi) and charging can be required. - * As this work is not triggered periodically, but on content change, the - * worker enqueues itself again with the same settings. - * In case the worker is stopped by the system (e.g. constraints like wifi - * are no longer met, or the system needs memory resources for more other - * more important work), the worker is replaced without the constraint on - * changed contents to run again as soon as deemed possible by the system. + * i.e. battery is not low and optionally Wifi and charging are active. */ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ctx, params), MethodChannel.MethodCallHandler { @@ -57,14 +46,13 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct private lateinit var backgroundChannel: MethodChannel private val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private val isIgnoringBatteryOptimizations = isIgnoringBatteryOptimizations(applicationContext) + private var timeBackupStarted: Long = 0L override fun startWork(): ListenableFuture { + Log.d(TAG, "startWork") + val ctx = applicationContext - // enqueue itself once again to continue to listen on added photos/videos - enqueueMoreWork(ctx, - requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true), - requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false)) if (!flutterLoader.initialized()) { flutterLoader.startInitialization(ctx) @@ -73,14 +61,16 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct // Create a Notification channel if necessary createChannel() } + val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) + .getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!! if (isIgnoringBatteryOptimizations) { // normal background services can only up to 10 minutes // foreground services are allowed to run indefinitely // requires battery optimizations to be disabled (either manually by the user // or by the system learning that immich is important to the user) - val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) - .getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!! setForegroundAsync(createForegroundInfo(title)) + } else { + showBackgroundInfo(title) } engine = FlutterEngine(ctx) @@ -115,6 +105,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct } override fun onStopped() { + Log.d(TAG, "onStopped") // called when the system has to stop this worker because constraints are // no longer met or the system needs resources for more important tasks Handler(Looper.getMainLooper()).postAtFrontOfQueue { @@ -130,24 +121,18 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct private fun stopEngine(result: Result?) { if (result != null) { + Log.d(TAG, "stopEngine result=${result}") resolvableFuture.set(result) - } else if (engine != null && inputData.getInt(DATA_KEY_RETRIES, 0) == 0) { - // stopped by system and this is the first time (content change constraints active) - // replace the task without the content constraints to finish the backup as soon as possible - enqueueMoreWork(applicationContext, - immediate = true, - requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true), - requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false), - initialDelayInMs = ONE_MINUTE, - retries = inputData.getInt(DATA_KEY_RETRIES, 0) + 1) } engine?.destroy() engine = null + clearBackgroundNotification() } override fun onMethodCall(call: MethodCall, r: MethodChannel.Result) { when (call.method) { - "initialized" -> + "initialized" -> { + timeBackupStarted = SystemClock.uptimeMillis() backgroundChannel.invokeMethod( "onAssetsChanged", null, @@ -163,25 +148,18 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct override fun success(receivedResult: Any?) { val success = receivedResult as Boolean stopEngine(if(success) Result.success() else Result.retry()) - if (!success && inputData.getInt(DATA_KEY_RETRIES, 0) == 0) { - // there was an error (e.g. server not available) - // replace the task without the content constraints to finish the backup as soon as possible - enqueueMoreWork(applicationContext, - immediate = true, - requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true), - requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false), - initialDelayInMs = ONE_MINUTE, - retries = inputData.getInt(DATA_KEY_RETRIES, 0) + 1) - } } } ) + } "updateNotification" -> { val args = call.arguments>()!! val title = args.get(0) as String val content = args.get(1) as String if (isIgnoringBatteryOptimizations) { setForegroundAsync(createForegroundInfo(title, content)) + } else { + showBackgroundInfo(title, content) } } "showError" -> { @@ -192,6 +170,14 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct showError(title, content, individualTag) } "clearErrorNotifications" -> clearErrorNotifications() + "hasContentChanged" -> { + val lastChange = applicationContext + .getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) + .getLong(SHARED_PREF_LAST_CHANGE, timeBackupStarted) + val hasContentChanged = lastChange > timeBackupStarted; + timeBackupStarted = SystemClock.uptimeMillis() + r.success(hasContentChanged) + } else -> r.notImplemented() } } @@ -211,6 +197,22 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct notificationManager.cancel(NOTIFICATION_ERROR_ID) } + private fun showBackgroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null) { + val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID) + .setContentTitle(title) + .setTicker(title) + .setContentText(content) + .setSmallIcon(R.mipmap.ic_launcher) + .setOnlyAlertOnce(true) + .setOngoing(true) + .build() + notificationManager.notify(NOTIFICATION_ID, notification) + } + + private fun clearBackgroundNotification() { + notificationManager.cancel(NOTIFICATION_ID) + } + private fun createForegroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null): ForegroundInfo { val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID) .setContentTitle(title) @@ -233,89 +235,61 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct companion object { const val SHARED_PREF_NAME = "immichBackgroundService" const val SHARED_PREF_CALLBACK_KEY = "callbackDispatcherHandle" - const val SHARED_PREF_SERVICE_ENABLED = "serviceEnabled" const val SHARED_PREF_NOTIFICATION_TITLE = "notificationTitle" + const val SHARED_PREF_LAST_CHANGE = "lastChange" - private const val TASK_NAME = "immich/photoListener" - private const val DATA_KEY_UNMETERED = "unmetered" - private const val DATA_KEY_CHARGING = "charging" - private const val DATA_KEY_RETRIES = "retries" + private const val TASK_NAME_BACKUP = "immich/BackupWorker" private const val NOTIFICATION_CHANNEL_ID = "immich/backgroundService" private const val NOTIFICATION_CHANNEL_ERROR_ID = "immich/backgroundServiceError" private const val NOTIFICATION_DEFAULT_TITLE = "Immich" private const val NOTIFICATION_ID = 1 private const val NOTIFICATION_ERROR_ID = 2 - private const val ONE_MINUTE: Long = 60000 + private const val ONE_MINUTE = 60000L /** - * Enqueues the `BackupWorker` to run when all constraints are met. - * - * @param context Android Context - * @param immediate whether to enqueue(replace) the worker without the content change constraint - * @param keepExisting if true, use `ExistingWorkPolicy.KEEP`, else `ExistingWorkPolicy.APPEND_OR_REPLACE` - * @param requireUnmeteredNetwork if true, task only runs if connected to wifi - * @param requireCharging if true, task only runs if device is charging - * @param retries retry count (should be 0 unless an error occured and this is a retry) + * Enqueues the BackupWorker to run once the constraints are met */ - fun startWork(context: Context, - immediate: Boolean = false, - keepExisting: Boolean = false, - requireUnmeteredNetwork: Boolean = false, - requireCharging: Boolean = false) { - context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) - .edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, true).apply() - enqueueMoreWork(context, immediate, keepExisting, requireUnmeteredNetwork, requireCharging) + fun enqueueBackupWorker(context: Context, + requireWifi: Boolean = false, + requireCharging: Boolean = false, + delayMilliseconds: Long = 0L) { + val workRequest = buildWorkRequest(requireWifi, requireCharging, delayMilliseconds) + WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME_BACKUP, ExistingWorkPolicy.KEEP, workRequest) + Log.d(TAG, "enqueueBackupWorker: BackupWorker enqueued") } - private fun enqueueMoreWork(context: Context, - immediate: Boolean = false, - keepExisting: Boolean = false, - requireUnmeteredNetwork: Boolean = false, - requireCharging: Boolean = false, - initialDelayInMs: Long = 0, - retries: Int = 0) { - if (!isEnabled(context)) { - return + /** + * Updates the constraints of an already enqueued BackupWorker + */ + fun updateBackupWorker(context: Context, + requireWifi: Boolean = false, + requireCharging: Boolean = false) { + try { + val wm = WorkManager.getInstance(context) + val workInfoFuture = wm.getWorkInfosForUniqueWork(TASK_NAME_BACKUP) + val workInfoList = workInfoFuture.get(1000, TimeUnit.MILLISECONDS) + if (workInfoList != null) { + for (workInfo in workInfoList) { + if (workInfo.getState() == WorkInfo.State.ENQUEUED) { + val workRequest = buildWorkRequest(requireWifi, requireCharging) + wm.enqueueUniqueWork(TASK_NAME_BACKUP, ExistingWorkPolicy.REPLACE, workRequest) + Log.d(TAG, "updateBackupWorker updated BackupWorker constraints") + return + } + } + } + Log.d(TAG, "updateBackupWorker: BackupWorker not enqueued") + } catch (e: Exception) { + Log.d(TAG, "updateBackupWorker failed: ${e}") } - val constraints = Constraints.Builder() - .setRequiredNetworkType(if (requireUnmeteredNetwork) NetworkType.UNMETERED else NetworkType.CONNECTED) - .setRequiresBatteryNotLow(true) - .setRequiresCharging(requireCharging); - if (!immediate) { - constraints - .addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true) - .addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true) - .addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true) - .addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true) - } - - val inputData = Data.Builder() - .putBoolean(DATA_KEY_CHARGING, requireCharging) - .putBoolean(DATA_KEY_UNMETERED, requireUnmeteredNetwork) - .putInt(DATA_KEY_RETRIES, retries) - .build() - - val photoCheck = OneTimeWorkRequest.Builder(BackupWorker::class.java) - .setConstraints(constraints.build()) - .setInputData(inputData) - .setInitialDelay(initialDelayInMs, TimeUnit.MILLISECONDS) - .setBackoffCriteria( - BackoffPolicy.EXPONENTIAL, - ONE_MINUTE, - TimeUnit.MILLISECONDS) - .build() - val policy = if (immediate) ExistingWorkPolicy.REPLACE else (if (keepExisting) ExistingWorkPolicy.KEEP else ExistingWorkPolicy.APPEND_OR_REPLACE) - val op = WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME, policy, photoCheck) - val result = op.getResult().get() } /** * Stops the currently running worker (if any) and removes it from the work queue */ fun stopWork(context: Context) { - context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) - .edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, false).apply() - WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME) + WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME_BACKUP) + Log.d(TAG, "stopWork: BackupWorker cancelled") } /** @@ -330,12 +304,21 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct return true } - /** - * Return true if the user has enabled the background backup service - */ - fun isEnabled(ctx: Context): Boolean { - return ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) - .getBoolean(SHARED_PREF_SERVICE_ENABLED, false) + private fun buildWorkRequest(requireWifi: Boolean = false, + requireCharging: Boolean = false, + delayMilliseconds: Long = 0L): OneTimeWorkRequest { + val constraints = Constraints.Builder() + .setRequiredNetworkType(if (requireWifi) NetworkType.UNMETERED else NetworkType.CONNECTED) + .setRequiresBatteryNotLow(true) + .setRequiresCharging(requireCharging) + .build(); + + val work = OneTimeWorkRequest.Builder(BackupWorker::class.java) + .setConstraints(constraints) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, ONE_MINUTE, TimeUnit.MILLISECONDS) + .setInitialDelay(delayMilliseconds, TimeUnit.MILLISECONDS) + .build() + return work } private val flutterLoader = FlutterLoader() diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/ContentObserverWorker.kt b/mobile/android/app/src/main/kotlin/com/example/mobile/ContentObserverWorker.kt new file mode 100644 index 000000000..ecbec640f --- /dev/null +++ b/mobile/android/app/src/main/kotlin/com/example/mobile/ContentObserverWorker.kt @@ -0,0 +1,137 @@ +package app.alextran.immich + +import android.content.Context +import android.os.SystemClock +import android.provider.MediaStore +import android.util.Log +import androidx.work.Constraints +import androidx.work.Worker +import androidx.work.WorkerParameters +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import androidx.work.Operation +import java.util.concurrent.TimeUnit + +/** + * Worker executed by Android WorkManager observing content changes (new photos/videos) + * + * Immediately enqueues the BackupWorker when running. + * As this work is not triggered periodically, but on content change, the + * worker enqueues itself again after each run. + */ +class ContentObserverWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, params) { + + override fun doWork(): Result { + if (!isEnabled(applicationContext)) { + return Result.failure() + } + if (getTriggeredContentUris().size > 0) { + startBackupWorker(applicationContext, delayMilliseconds = 0) + } + enqueueObserverWorker(applicationContext, ExistingWorkPolicy.REPLACE) + return Result.success() + } + + companion object { + const val SHARED_PREF_SERVICE_ENABLED = "serviceEnabled" + const val SHARED_PREF_REQUIRE_WIFI = "requireWifi" + const val SHARED_PREF_REQUIRE_CHARGING = "requireCharging" + + private const val TASK_NAME_OBSERVER = "immich/ContentObserver" + + /** + * Enqueues the `ContentObserverWorker`. + * + * @param context Android Context + */ + fun enable(context: Context, immediate: Boolean = false) { + // migration to remove any old active background task + WorkManager.getInstance(context).cancelUniqueWork("immich/photoListener") + + enqueueObserverWorker(context, ExistingWorkPolicy.KEEP) + Log.d(TAG, "enabled ContentObserverWorker") + if (immediate) { + startBackupWorker(context, delayMilliseconds = 5000) + } + } + + /** + * Configures the `BackupWorker` to run when all constraints are met. + * + * @param context Android Context + * @param requireWifi if true, task only runs if connected to wifi + * @param requireCharging if true, task only runs if device is charging + */ + fun configureWork(context: Context, + requireWifi: Boolean = false, + requireCharging: Boolean = false) { + context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) + .edit() + .putBoolean(SHARED_PREF_SERVICE_ENABLED, true) + .putBoolean(SHARED_PREF_REQUIRE_WIFI, requireWifi) + .putBoolean(SHARED_PREF_REQUIRE_CHARGING, requireCharging) + .apply() + BackupWorker.updateBackupWorker(context, requireWifi, requireCharging) + } + + /** + * Stops the currently running worker (if any) and removes it from the work queue + */ + fun disable(context: Context) { + context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) + .edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, false).apply() + WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME_OBSERVER) + Log.d(TAG, "disabled ContentObserverWorker") + } + + /** + * Return true if the user has enabled the background backup service + */ + fun isEnabled(ctx: Context): Boolean { + return ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) + .getBoolean(SHARED_PREF_SERVICE_ENABLED, false) + } + + /** + * Enqueue and replace the worker without the content trigger but with a short delay + */ + fun workManagerAppClearedWorkaround(context: Context) { + val work = OneTimeWorkRequest.Builder(ContentObserverWorker::class.java) + .setInitialDelay(500, TimeUnit.MILLISECONDS) + .build() + WorkManager + .getInstance(context) + .enqueueUniqueWork(TASK_NAME_OBSERVER, ExistingWorkPolicy.REPLACE, work) + .getResult() + .get() + Log.d(TAG, "workManagerAppClearedWorkaround") + } + + private fun enqueueObserverWorker(context: Context, policy: ExistingWorkPolicy) { + val constraints = Constraints.Builder() + .addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true) + .addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true) + .addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true) + .addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true) + .setTriggerContentUpdateDelay(5000, TimeUnit.MILLISECONDS) + .build() + + val work = OneTimeWorkRequest.Builder(ContentObserverWorker::class.java) + .setConstraints(constraints) + .build() + WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME_OBSERVER, policy, work) + } + + private fun startBackupWorker(context: Context, delayMilliseconds: Long) { + val sp = context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) + val requireWifi = sp.getBoolean(SHARED_PREF_REQUIRE_WIFI, true) + val requireCharging = sp.getBoolean(SHARED_PREF_REQUIRE_CHARGING, false) + BackupWorker.enqueueBackupWorker(context, requireWifi, requireCharging, delayMilliseconds) + sp.edit().putLong(BackupWorker.SHARED_PREF_LAST_CHANGE, SystemClock.uptimeMillis()).apply() + } + + } +} + +private const val TAG = "ContentObserverWorker" \ No newline at end of file diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/MainActivity.kt b/mobile/android/app/src/main/kotlin/com/example/mobile/MainActivity.kt index 77eee636e..f16acc394 100644 --- a/mobile/android/app/src/main/kotlin/com/example/mobile/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/com/example/mobile/MainActivity.kt @@ -2,6 +2,8 @@ package app.alextran.immich import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine +import android.os.Bundle +import android.content.Intent class MainActivity: FlutterActivity() { @@ -10,4 +12,9 @@ class MainActivity: FlutterActivity() { flutterEngine.getPlugins().add(BackgroundServicePlugin()) } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + startService(Intent(getBaseContext(), AppClearedService::class.java)); + } + } diff --git a/mobile/lib/modules/backup/background_service/background.service.dart b/mobile/lib/modules/backup/background_service/background.service.dart index 44c47aacd..a7b4ef046 100644 --- a/mobile/lib/modules/backup/background_service/background.service.dart +++ b/mobile/lib/modules/backup/background_service/background.service.dart @@ -4,7 +4,6 @@ import 'dart:io'; import 'dart:isolate'; import 'dart:ui' show IsolateNameServer, PluginUtilities; import 'package:cancellation_token_http/http.dart'; -import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; @@ -33,7 +32,6 @@ class BackgroundService { MethodChannel('immich/foregroundChannel'); static const MethodChannel _backgroundChannel = MethodChannel('immich/backgroundChannel'); - bool _isForegroundInitialized = false; bool _isBackgroundInitialized = false; CancellationToken? _cancellationToken; bool _canceledBySystem = false; @@ -43,32 +41,34 @@ class BackgroundService { ReceivePort? _rp; bool _errorGracePeriodExceeded = true; - bool get isForegroundInitialized { - return _isForegroundInitialized; - } - bool get isBackgroundInitialized { return _isBackgroundInitialized; } - Future _initialize() async { - final callback = PluginUtilities.getCallbackHandle(_nativeEntry)!; - var result = await _foregroundChannel - .invokeMethod('initialize', [callback.toRawHandle()]); - _isForegroundInitialized = true; - return result; - } - /// Ensures that the background service is enqueued if enabled in settings Future resumeServiceIfEnabled() async { - return await isBackgroundBackupEnabled() && - await startService(keepExisting: true); + return await isBackgroundBackupEnabled() && await enableService(); } /// Enqueues the background service - Future startService({ - bool immediate = false, - bool keepExisting = false, + Future enableService({bool immediate = false}) async { + if (!Platform.isAndroid) { + return true; + } + try { + final callback = PluginUtilities.getCallbackHandle(_nativeEntry)!; + final String title = + "backup_background_service_default_notification".tr(); + final bool ok = await _foregroundChannel + .invokeMethod('enable', [callback.toRawHandle(), title, immediate]); + return ok; + } catch (error) { + return false; + } + } + + /// Configures the background service + Future configureService({ bool requireUnmetered = true, bool requireCharging = false, }) async { @@ -76,14 +76,9 @@ class BackgroundService { return true; } try { - if (!_isForegroundInitialized) { - await _initialize(); - } - final String title = - "backup_background_service_default_notification".tr(); final bool ok = await _foregroundChannel.invokeMethod( - 'start', - [immediate, keepExisting, requireUnmetered, requireCharging, title], + 'configure', + [requireUnmetered, requireCharging], ); return ok; } catch (error) { @@ -92,15 +87,12 @@ class BackgroundService { } /// Cancels the background service (if currently running) and removes it from work queue - Future stopService() async { + Future disableService() async { if (!Platform.isAndroid) { return true; } try { - if (!_isForegroundInitialized) { - await _initialize(); - } - final ok = await _foregroundChannel.invokeMethod('stop'); + final ok = await _foregroundChannel.invokeMethod('disable'); return ok; } catch (error) { return false; @@ -113,9 +105,6 @@ class BackgroundService { return false; } try { - if (!_isForegroundInitialized) { - await _initialize(); - } return await _foregroundChannel.invokeMethod("isEnabled"); } catch (error) { return false; @@ -128,9 +117,6 @@ class BackgroundService { return true; } try { - if (!_isForegroundInitialized) { - await _initialize(); - } return await _foregroundChannel .invokeMethod('isIgnoringBatteryOptimizations'); } catch (error) { @@ -289,18 +275,11 @@ class BackgroundService { try { final bool hasAccess = await acquireLock(); if (!hasAccess) { - debugPrint("[_callHandler] could acquire lock, exiting"); + debugPrint("[_callHandler] could not acquire lock, exiting"); return false; } await translationsLoaded; final bool ok = await _onAssetsChanged(); - if (ok) { - Hive.box(backgroundBackupInfoBox).delete(backupFailedSince); - } else if (Hive.box(backgroundBackupInfoBox).get(backupFailedSince) == - null) { - Hive.box(backgroundBackupInfoBox) - .put(backupFailedSince, DateTime.now()); - } return ok; } catch (error) { debugPrint(error.toString()); @@ -343,6 +322,29 @@ class BackgroundService { } await PhotoManager.setIgnorePermissionCheck(true); + + do { + final bool backupOk = await _runBackup(backupService, backupAlbumInfo); + if (backupOk) { + await Hive.box(backgroundBackupInfoBox).delete(backupFailedSince); + await box.put( + backupInfoKey, + backupAlbumInfo, + ); + } else if (Hive.box(backgroundBackupInfoBox).get(backupFailedSince) == + null) { + Hive.box(backgroundBackupInfoBox) + .put(backupFailedSince, DateTime.now()); + return false; + } + // check for new assets added while performing backup + } while (true == + await _backgroundChannel.invokeMethod("hasContentChanged")); + return true; + } + + Future _runBackup( + BackupService backupService, HiveBackupAlbums backupAlbumInfo) async { _errorGracePeriodExceeded = _isErrorGracePeriodExceeded(); if (_canceledBySystem) { @@ -382,10 +384,6 @@ class BackgroundService { ); if (ok) { _clearErrorNotifications(); - await box.put( - backupInfoKey, - backupAlbumInfo, - ); } else { _showErrorNotification( title: "backup_background_service_error_title".tr(), diff --git a/mobile/lib/modules/backup/providers/backup.provider.dart b/mobile/lib/modules/backup/providers/backup.provider.dart index 545448197..e4a43d5a2 100644 --- a/mobile/lib/modules/backup/providers/backup.provider.dart +++ b/mobile/lib/modules/backup/providers/backup.provider.dart @@ -131,13 +131,15 @@ class BackupNotifier extends StateNotifier { ); if (state.backgroundBackup) { + bool success = true; if (!wasEnabled) { if (!await _backgroundService.isIgnoringBatteryOptimizations()) { onBatteryInfo(); } + success &= await _backgroundService.enableService(immediate: true); } - final bool success = await _backgroundService.stopService() && - await _backgroundService.startService( + success &= success && + await _backgroundService.configureService( requireUnmetered: state.backupRequireWifi, requireCharging: state.backupRequireCharging, ); @@ -155,7 +157,7 @@ class BackupNotifier extends StateNotifier { onError("backup_controller_page_background_configure_error"); } } else { - final bool success = await _backgroundService.stopService(); + final bool success = await _backgroundService.disableService(); if (!success) { state = state.copyWith(backgroundBackup: wasEnabled); onError("backup_controller_page_background_configure_error"); diff --git a/mobile/lib/modules/home/ui/immich_sliver_appbar.dart b/mobile/lib/modules/home/ui/immich_sliver_appbar.dart index 00a5b19d5..63761c578 100644 --- a/mobile/lib/modules/home/ui/immich_sliver_appbar.dart +++ b/mobile/lib/modules/home/ui/immich_sliver_appbar.dart @@ -21,7 +21,7 @@ class ImmichSliverAppBar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final BackUpState backupState = ref.watch(backupProvider); - bool isEnableAutoBackup = + bool isEnableAutoBackup = backupState.backgroundBackup || ref.watch(authenticationProvider).deviceInfo.isAutoBackup; final ServerInfoState serverInfoState = ref.watch(serverInfoProvider);