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);