From 49610de4b3b153676888b7fd6066232c0b086089 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 15 Aug 2024 11:36:43 -0500 Subject: [PATCH] chore(mobile): update target SDK version (#11719) * chore(mobile): update target SDK version * background service * remove print statements * remove extra line * format kotlin * Correct permission --- mobile/android/app/build.gradle | 2 +- .../android/app/src/main/AndroidManifest.xml | 49 +- .../example/mobile/BackgroundServicePlugin.kt | 238 +++---- .../kotlin/com/example/mobile/BackupWorker.kt | 619 +++++++++--------- .../example/mobile/ContentObserverWorker.kt | 288 ++++---- 5 files changed, 626 insertions(+), 570 deletions(-) diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index a26d055cba..52750232cc 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -46,7 +46,7 @@ android { defaultConfig { applicationId "app.alextran.immich" minSdkVersion 26 - targetSdkVersion 33 + targetSdkVersion 34 versionCode flutterVersionCode.toInteger() versionName flutterVersionName } diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 1bac79daf5..9222b38de0 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -1,9 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + @@ -51,23 +80,13 @@ + tools:node="remove" /> - - - - - - - - - - - - + + + @@ -79,4 +98,4 @@ - \ 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 6541ad5755..8520413cff 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,115 +1,123 @@ -package app.alextran.immich - -import android.content.Context -import android.util.Log -import io.flutter.embedding.engine.plugins.FlutterPlugin -import io.flutter.plugin.common.BinaryMessenger -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel -import java.security.MessageDigest -import java.io.File -import java.io.FileInputStream -import kotlinx.coroutines.* - -/** - * Android plugin for Dart `BackgroundService` - * - * Receives messages/method calls from the foreground Dart side to manage - * the background service, e.g. start (enqueue), stop (cancel) - */ -class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler { - - private var methodChannel: MethodChannel? = null - private var context: Context? = null - private val sha1: MessageDigest = MessageDigest.getInstance("SHA-1") - - override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { - onAttachedToEngine(binding.applicationContext, binding.binaryMessenger) - } - - private fun onAttachedToEngine(ctx: Context, messenger: BinaryMessenger) { - context = ctx - methodChannel = MethodChannel(messenger, "immich/foregroundChannel") - methodChannel?.setMethodCallHandler(this) - } - - override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { - onDetachedFromEngine() - } - - private fun onDetachedFromEngine() { - methodChannel?.setMethodCallHandler(null) - methodChannel = null - } - - override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { - val ctx = context!! - when (call.method) { - "enable" -> { - val args = call.arguments>()!! - ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) - .edit() - .putBoolean(ContentObserverWorker.SHARED_PREF_SERVICE_ENABLED, true) - .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) - } - "configure" -> { - val args = call.arguments>()!! - val requireUnmeteredNetwork = args.get(0) as Boolean - val requireCharging = args.get(1) as Boolean - val triggerUpdateDelay = (args.get(2) as Number).toLong() - val triggerMaxDelay = (args.get(3) as Number).toLong() - ContentObserverWorker.configureWork(ctx, requireUnmeteredNetwork, requireCharging, triggerUpdateDelay, triggerMaxDelay) - result.success(true) - } - "disable" -> { - ContentObserverWorker.disable(ctx) - BackupWorker.stopWork(ctx) - result.success(true) - } - "isEnabled" -> { - result.success(ContentObserverWorker.isEnabled(ctx)) - } - "isIgnoringBatteryOptimizations" -> { - result.success(BackupWorker.isIgnoringBatteryOptimizations(ctx)) - } - "digestFiles" -> { - val args = call.arguments>()!! - GlobalScope.launch(Dispatchers.IO) { - val buf = ByteArray(BUFSIZE) - val digest: MessageDigest = MessageDigest.getInstance("SHA-1") - val hashes = arrayOfNulls(args.size) - for (i in args.indices) { - val path = args[i] - var len = 0 - try { - val file = FileInputStream(path) - try { - while (true) { - len = file.read(buf) - if (len != BUFSIZE) break - digest.update(buf) - } - } finally { - file.close() - } - digest.update(buf, 0, len) - hashes[i] = digest.digest() - } catch (e: Exception) { - // skip this file - Log.w(TAG, "Failed to hash file ${args[i]}: $e") - } - } - result.success(hashes.asList()) - } - } - else -> result.notImplemented() - } - } -} - -private const val TAG = "BackgroundServicePlugin" -private const val BUFSIZE = 2*1024*1024; +package app.alextran.immich + +import android.content.Context +import android.util.Log +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.security.MessageDigest +import java.io.FileInputStream +import kotlinx.coroutines.* + +/** + * Android plugin for Dart `BackgroundService` + * + * Receives messages/method calls from the foreground Dart side to manage + * the background service, e.g. start (enqueue), stop (cancel) + */ +class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler { + + private var methodChannel: MethodChannel? = null + private var context: Context? = null + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + onAttachedToEngine(binding.applicationContext, binding.binaryMessenger) + } + + private fun onAttachedToEngine(ctx: Context, messenger: BinaryMessenger) { + context = ctx + methodChannel = MethodChannel(messenger, "immich/foregroundChannel") + methodChannel?.setMethodCallHandler(this) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + onDetachedFromEngine() + } + + private fun onDetachedFromEngine() { + methodChannel?.setMethodCallHandler(null) + methodChannel = null + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + val ctx = context!! + when (call.method) { + "enable" -> { + val args = call.arguments>()!! + ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) + .edit() + .putBoolean(ContentObserverWorker.SHARED_PREF_SERVICE_ENABLED, true) + .putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args[0] as Long) + .putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, args[1] as String) + .apply() + ContentObserverWorker.enable(ctx, immediate = args[2] as Boolean) + result.success(true) + } + + "configure" -> { + val args = call.arguments>()!! + val requireUnmeteredNetwork = args[0] as Boolean + val requireCharging = args[1] as Boolean + val triggerUpdateDelay = (args[2] as Number).toLong() + val triggerMaxDelay = (args[3] as Number).toLong() + ContentObserverWorker.configureWork( + ctx, + requireUnmeteredNetwork, + requireCharging, + triggerUpdateDelay, + triggerMaxDelay + ) + result.success(true) + } + + "disable" -> { + ContentObserverWorker.disable(ctx) + BackupWorker.stopWork(ctx) + result.success(true) + } + + "isEnabled" -> { + result.success(ContentObserverWorker.isEnabled(ctx)) + } + + "isIgnoringBatteryOptimizations" -> { + result.success(BackupWorker.isIgnoringBatteryOptimizations(ctx)) + } + + "digestFiles" -> { + val args = call.arguments>()!! + GlobalScope.launch(Dispatchers.IO) { + val buf = ByteArray(BUFFER_SIZE) + val digest: MessageDigest = MessageDigest.getInstance("SHA-1") + val hashes = arrayOfNulls(args.size) + for (i in args.indices) { + val path = args[i] + var len = 0 + try { + val file = FileInputStream(path) + file.use { assetFile -> + while (true) { + len = assetFile.read(buf) + if (len != BUFFER_SIZE) break + digest.update(buf) + } + } + digest.update(buf, 0, len) + hashes[i] = digest.digest() + } catch (e: Exception) { + // skip this file + Log.w(TAG, "Failed to hash file ${args[i]}: $e") + } + } + result.success(hashes.asList()) + } + } + + else -> result.notImplemented() + } + } +} + +private const val TAG = "BackgroundServicePlugin" +private const val BUFFER_SIZE = 2 * 1024 * 1024; 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 b6b78c2cba..052a4e4c1f 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 @@ -4,6 +4,7 @@ import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.content.Context +import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE import android.os.Build import android.os.Handler import android.os.Looper @@ -40,323 +41,351 @@ import java.util.concurrent.TimeUnit * Called by Android WorkManager when all constraints for the work are met, * i.e. battery is not low and optionally Wifi and charging are active. */ -class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ctx, params), MethodChannel.MethodCallHandler { +class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ctx, params), + MethodChannel.MethodCallHandler { - private val resolvableFuture = ResolvableFuture.create() - private var engine: FlutterEngine? = null - 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 - private var notificationBuilder: NotificationCompat.Builder? = null - private var notificationDetailBuilder: NotificationCompat.Builder? = null - private var fgFuture: ListenableFuture? = null + private val resolvableFuture = ResolvableFuture.create() + private var engine: FlutterEngine? = null + 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 + private var notificationBuilder: NotificationCompat.Builder? = null + private var notificationDetailBuilder: NotificationCompat.Builder? = null + private var fgFuture: ListenableFuture? = null - override fun startWork(): ListenableFuture { + override fun startWork(): ListenableFuture { - Log.d(TAG, "startWork") + Log.d(TAG, "startWork") - val ctx = applicationContext + val ctx = applicationContext - if (!flutterLoader.initialized()) { - flutterLoader.startInitialization(ctx) + if (!flutterLoader.initialized()) { + flutterLoader.startInitialization(ctx) + } + + // Create a Notification channel + createChannel() + + Log.d(TAG, "isIgnoringBatteryOptimizations $isIgnoringBatteryOptimizations") + 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)!! + showInfo(getInfoBuilder(title, indeterminate = true).build()) + } + + engine = FlutterEngine(ctx) + + flutterLoader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) { + runDart() + } + + return resolvableFuture + } + + /** + * Starts the Dart runtime/engine and calls `_nativeEntry` function in + * `background.service.dart` to run the actual backup logic. + */ + private fun runDart() { + val callbackDispatcherHandle = applicationContext.getSharedPreferences( + SHARED_PREF_NAME, Context.MODE_PRIVATE + ).getLong(SHARED_PREF_CALLBACK_KEY, 0L) + val callbackInformation = + FlutterCallbackInformation.lookupCallbackInformation(callbackDispatcherHandle) + val appBundlePath = flutterLoader.findAppBundlePath() + + engine?.let { engine -> + backgroundChannel = MethodChannel(engine.dartExecutor, "immich/backgroundChannel") + backgroundChannel.setMethodCallHandler(this@BackupWorker) + engine.dartExecutor.executeDartCallback( + DartExecutor.DartCallback( + applicationContext.assets, + appBundlePath, + callbackInformation + ) + ) + } + } + + 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 { + backgroundChannel.invokeMethod("systemStop", null) + } + waitOnSetForegroundAsync() + // cannot await/get(block) on resolvableFuture as its already cancelled (would throw CancellationException) + // instead, wait for 5 seconds until forcefully stopping backup work + Handler(Looper.getMainLooper()).postDelayed({ + stopEngine(null) + }, 5000) + } + + private fun waitOnSetForegroundAsync() { + val fgFuture = this.fgFuture + if (fgFuture != null && !fgFuture.isCancelled && !fgFuture.isDone) { + try { + fgFuture.get(500, TimeUnit.MILLISECONDS) + } catch (e: Exception) { + // ignored, there is nothing to be done + } + } + } + + private fun stopEngine(result: Result?) { + clearBackgroundNotification() + engine?.destroy() + engine = null + if (result != null) { + Log.d(TAG, "stopEngine result=${result}") + resolvableFuture.set(result) + } + waitOnSetForegroundAsync() + } + + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + override fun onMethodCall(call: MethodCall, r: MethodChannel.Result) { + when (call.method) { + "initialized" -> { + timeBackupStarted = SystemClock.uptimeMillis() + backgroundChannel.invokeMethod( + "onAssetsChanged", + null, + object : MethodChannel.Result { + override fun notImplemented() { + stopEngine(Result.failure()) + } + + override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + stopEngine(Result.failure()) + } + + override fun success(receivedResult: Any?) { + val success = receivedResult as Boolean + stopEngine(if (success) Result.success() else Result.retry()) + } + } + ) + } + + "updateNotification" -> { + val args = call.arguments>()!! + val title = args[0] as String? + val content = args[1] as String? + val progress = args[2] as Int + val max = args[3] as Int + val indeterminate = args[4] as Boolean + val isDetail = args[5] as Boolean + val onlyIfFG = args[6] as Boolean + if (!onlyIfFG || isIgnoringBatteryOptimizations) { + showInfo( + getInfoBuilder(title, content, isDetail, progress, max, indeterminate).build(), + isDetail + ) } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - // Create a Notification channel if necessary - createChannel() - } - 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)!! - showInfo(getInfoBuilder(title, indeterminate=true).build()) - } - engine = FlutterEngine(ctx) + } - flutterLoader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) { - runDart() - } + "showError" -> { + val args = call.arguments>()!! + val title = args[0] as String + val content = args[1] as String? + val individualTag = args[2] as String? + showError(title, content, individualTag) + } - return resolvableFuture + "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() + } + } + + private fun showError(title: String, content: String?, individualTag: String?) { + val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ERROR_ID) + .setContentTitle(title) + .setTicker(title) + .setContentText(content) + .setSmallIcon(R.mipmap.ic_launcher) + .build() + notificationManager.notify(individualTag, NOTIFICATION_ERROR_ID, notification) + } + + private fun clearErrorNotifications() { + notificationManager.cancel(NOTIFICATION_ERROR_ID) + } + + private fun clearBackgroundNotification() { + notificationManager.cancel(NOTIFICATION_ID) + notificationManager.cancel(NOTIFICATION_DETAIL_ID) + } + + private fun showInfo(notification: Notification, isDetail: Boolean = false) { + val id = if (isDetail) NOTIFICATION_DETAIL_ID else NOTIFICATION_ID + + if (isIgnoringBatteryOptimizations && !isDetail) { + fgFuture = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + setForegroundAsync(ForegroundInfo(id, notification, FOREGROUND_SERVICE_TYPE_SHORT_SERVICE)) + } else { + setForegroundAsync(ForegroundInfo(id, notification)) + } + } else { + notificationManager.notify(id, notification) + } + } + + private fun getInfoBuilder( + title: String? = null, + content: String? = null, + isDetail: Boolean = false, + progress: Int = 0, + max: Int = 0, + indeterminate: Boolean = false, + ): NotificationCompat.Builder { + var builder = if (isDetail) notificationDetailBuilder else notificationBuilder + if (builder == null) { + builder = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setOnlyAlertOnce(true) + .setOngoing(true) + if (isDetail) { + notificationDetailBuilder = builder + } else { + notificationBuilder = builder + } + } + if (title != null) { + builder.setTicker(title).setContentTitle(title) + } + if (content != null) { + builder.setContentText(content) + } + return builder.setProgress(max, progress, indeterminate) + } + + private fun createChannel() { + val foreground = NotificationChannel( + NOTIFICATION_CHANNEL_ID, + NOTIFICATION_CHANNEL_ID, + NotificationManager.IMPORTANCE_LOW + ) + notificationManager.createNotificationChannel(foreground) + val error = NotificationChannel( + NOTIFICATION_CHANNEL_ERROR_ID, + NOTIFICATION_CHANNEL_ERROR_ID, + NotificationManager.IMPORTANCE_HIGH + ) + notificationManager.createNotificationChannel(error) + } + + companion object { + const val SHARED_PREF_NAME = "immichBackgroundService" + const val SHARED_PREF_CALLBACK_KEY = "callbackDispatcherHandle" + const val SHARED_PREF_NOTIFICATION_TITLE = "notificationTitle" + const val SHARED_PREF_LAST_CHANGE = "lastChange" + + 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 NOTIFICATION_DETAIL_ID = 3 + private const val ONE_MINUTE = 60000L + + /** + * Enqueues the BackupWorker to run once the constraints are met + */ + 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") } /** - * Starts the Dart runtime/engine and calls `_nativeEntry` function in - * `background.service.dart` to run the actual backup logic. + * Updates the constraints of an already enqueued BackupWorker */ - private fun runDart() { - val callbackDispatcherHandle = applicationContext.getSharedPreferences( - SHARED_PREF_NAME, Context.MODE_PRIVATE).getLong(SHARED_PREF_CALLBACK_KEY, 0L) - val callbackInformation = FlutterCallbackInformation.lookupCallbackInformation(callbackDispatcherHandle) - val appBundlePath = flutterLoader.findAppBundlePath() - - engine?.let { engine -> - backgroundChannel = MethodChannel(engine.dartExecutor, "immich/backgroundChannel") - backgroundChannel.setMethodCallHandler(this@BackupWorker) - engine.dartExecutor.executeDartCallback( - DartExecutor.DartCallback( - applicationContext.assets, - appBundlePath, - callbackInformation - ) - ) - } - } - - 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 { - backgroundChannel.invokeMethod("systemStop", null) - } - waitOnSetForegroundAsync() - // cannot await/get(block) on resolvableFuture as its already cancelled (would throw CancellationException) - // instead, wait for 5 seconds until forcefully stopping backup work - Handler(Looper.getMainLooper()).postDelayed({ - stopEngine(null) - }, 5000) - } - - private fun waitOnSetForegroundAsync() { - val fgFuture = this.fgFuture - if (fgFuture != null && !fgFuture.isCancelled() && !fgFuture.isDone()) { - try { - fgFuture.get(500, TimeUnit.MILLISECONDS) - } - catch (e: Exception) { - // ignored, there is nothing to be done + 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.state == 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") + } } - private fun stopEngine(result: Result?) { - clearBackgroundNotification() - engine?.destroy() - engine = null - if (result != null) { - Log.d(TAG, "stopEngine result=${result}") - resolvableFuture.set(result) - } - waitOnSetForegroundAsync() + /** + * Stops the currently running worker (if any) and removes it from the work queue + */ + fun stopWork(context: Context) { + WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME_BACKUP) + Log.d(TAG, "stopWork: BackupWorker cancelled") } - override fun onMethodCall(call: MethodCall, r: MethodChannel.Result) { - when (call.method) { - "initialized" -> { - timeBackupStarted = SystemClock.uptimeMillis() - backgroundChannel.invokeMethod( - "onAssetsChanged", - null, - object : MethodChannel.Result { - override fun notImplemented() { - stopEngine(Result.failure()) - } - - override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { - stopEngine(Result.failure()) - } - - override fun success(receivedResult: Any?) { - val success = receivedResult as Boolean - stopEngine(if(success) Result.success() else Result.retry()) - } - } - ) - } - "updateNotification" -> { - val args = call.arguments>()!! - val title = args.get(0) as String? - val content = args.get(1) as String? - val progress = args.get(2) as Int - val max = args.get(3) as Int - val indeterminate = args.get(4) as Boolean - val isDetail = args.get(5) as Boolean - val onlyIfFG = args.get(6) as Boolean - if (!onlyIfFG || isIgnoringBatteryOptimizations) { - showInfo(getInfoBuilder(title, content, isDetail, progress, max, indeterminate).build(), isDetail) - } - } - "showError" -> { - val args = call.arguments>()!! - val title = args.get(0) as String - val content = args.get(1) as String? - val individualTag = args.get(2) as String? - 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() - } + /** + * Returns `true` if the app is ignoring battery optimizations + */ + fun isIgnoringBatteryOptimizations(ctx: Context): Boolean { + val powerManager = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager + return powerManager.isIgnoringBatteryOptimizations(ctx.packageName) } - private fun showError(title: String, content: String?, individualTag: String?) { - val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ERROR_ID) - .setContentTitle(title) - .setTicker(title) - .setContentText(content) - .setSmallIcon(R.mipmap.ic_launcher) - .build() - notificationManager.notify(individualTag, NOTIFICATION_ERROR_ID, notification) + 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 fun clearErrorNotifications() { - notificationManager.cancel(NOTIFICATION_ERROR_ID) - } - - private fun clearBackgroundNotification() { - notificationManager.cancel(NOTIFICATION_ID) - notificationManager.cancel(NOTIFICATION_DETAIL_ID) - } - - private fun showInfo(notification: Notification, isDetail: Boolean = false) { - val id = if(isDetail) NOTIFICATION_DETAIL_ID else NOTIFICATION_ID - if (isIgnoringBatteryOptimizations && !isDetail) { - fgFuture = setForegroundAsync(ForegroundInfo(id, notification)) - } else { - notificationManager.notify(id, notification) - } - } - - private fun getInfoBuilder( - title: String? = null, - content: String? = null, - isDetail: Boolean = false, - progress: Int = 0, - max: Int = 0, - indeterminate: Boolean = false, - ): NotificationCompat.Builder { - var builder = if(isDetail) notificationDetailBuilder else notificationBuilder - if (builder == null) { - builder = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID) - .setSmallIcon(R.mipmap.ic_launcher) - .setOnlyAlertOnce(true) - .setOngoing(true) - if (isDetail) { - notificationDetailBuilder = builder - } else { - notificationBuilder = builder - } - } - if (title != null) { - builder.setTicker(title).setContentTitle(title) - } - if (content != null) { - builder.setContentText(content) - } - return builder.setProgress(max, progress, indeterminate) - } - - @RequiresApi(Build.VERSION_CODES.O) - private fun createChannel() { - val foreground = NotificationChannel(NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_ID, NotificationManager.IMPORTANCE_LOW) - notificationManager.createNotificationChannel(foreground) - val error = NotificationChannel(NOTIFICATION_CHANNEL_ERROR_ID, NOTIFICATION_CHANNEL_ERROR_ID, NotificationManager.IMPORTANCE_HIGH) - notificationManager.createNotificationChannel(error) - } - - companion object { - const val SHARED_PREF_NAME = "immichBackgroundService" - const val SHARED_PREF_CALLBACK_KEY = "callbackDispatcherHandle" - const val SHARED_PREF_NOTIFICATION_TITLE = "notificationTitle" - const val SHARED_PREF_LAST_CHANGE = "lastChange" - - 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 NOTIFICATION_DETAIL_ID = 3 - private const val ONE_MINUTE = 60000L - - /** - * Enqueues the BackupWorker to run once the constraints are met - */ - 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") - } - - /** - * 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.state == 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}") - } - } - - /** - * Stops the currently running worker (if any) and removes it from the work queue - */ - fun stopWork(context: Context) { - WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME_BACKUP) - Log.d(TAG, "stopWork: BackupWorker cancelled") - } - - /** - * Returns `true` if the app is ignoring battery optimizations - */ - fun isIgnoringBatteryOptimizations(ctx: Context): Boolean { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - val pwrm = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager - val name = ctx.packageName - return pwrm.isIgnoringBatteryOptimizations(name) - } - return true - } - - 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() - } + private val flutterLoader = FlutterLoader() + } } private const val TAG = "BackupWorker" 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 index 59ca6d5638..9cb2ec7779 100644 --- a/mobile/android/app/src/main/kotlin/com/example/mobile/ContentObserverWorker.kt +++ b/mobile/android/app/src/main/kotlin/com/example/mobile/ContentObserverWorker.kt @@ -1,144 +1,144 @@ -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" - const val SHARED_PREF_TRIGGER_UPDATE_DELAY = "triggerUpdateDelay" - const val SHARED_PREF_TRIGGER_MAX_DELAY = "triggerMaxDelay" - - private const val TASK_NAME_OBSERVER = "immich/ContentObserver" - - /** - * Enqueues the `ContentObserverWorker`. - * - * @param context Android Context - */ - fun enable(context: Context, immediate: Boolean = false) { - 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, - triggerUpdateDelay: Long = 5000, - triggerMaxDelay: Long = 50000) { - 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) - .putLong(SHARED_PREF_TRIGGER_UPDATE_DELAY, triggerUpdateDelay) - .putLong(SHARED_PREF_TRIGGER_MAX_DELAY, triggerMaxDelay) - .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 sp = context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) - 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(sp.getLong(SHARED_PREF_TRIGGER_UPDATE_DELAY, 5000), TimeUnit.MILLISECONDS) - .setTriggerContentMaxDelay(sp.getLong(SHARED_PREF_TRIGGER_MAX_DELAY, 50000), TimeUnit.MILLISECONDS) - .build() - - val work = OneTimeWorkRequest.Builder(ContentObserverWorker::class.java) - .setConstraints(constraints) - .build() - WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME_OBSERVER, policy, work) - } - - fun startBackupWorker(context: Context, delayMilliseconds: Long) { - val sp = context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) - if (!sp.getBoolean(SHARED_PREF_SERVICE_ENABLED, false)) - return - 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 +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 (triggeredContentUris.size > 0) { + startBackupWorker(applicationContext, delayMilliseconds = 0) + } + enqueueObserverWorker(applicationContext, ExistingWorkPolicy.REPLACE) + return Result.success() + } + + companion object { + const val SHARED_PREF_SERVICE_ENABLED = "serviceEnabled" + private const val SHARED_PREF_REQUIRE_WIFI = "requireWifi" + private const val SHARED_PREF_REQUIRE_CHARGING = "requireCharging" + private const val SHARED_PREF_TRIGGER_UPDATE_DELAY = "triggerUpdateDelay" + private const val SHARED_PREF_TRIGGER_MAX_DELAY = "triggerMaxDelay" + + private const val TASK_NAME_OBSERVER = "immich/ContentObserver" + + /** + * Enqueues the `ContentObserverWorker`. + * + * @param context Android Context + */ + fun enable(context: Context, immediate: Boolean = false) { + 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, + triggerUpdateDelay: Long = 5000, + triggerMaxDelay: Long = 50000) { + 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) + .putLong(SHARED_PREF_TRIGGER_UPDATE_DELAY, triggerUpdateDelay) + .putLong(SHARED_PREF_TRIGGER_MAX_DELAY, triggerMaxDelay) + .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) + .result + .get() + Log.d(TAG, "workManagerAppClearedWorkaround") + } + + private fun enqueueObserverWorker(context: Context, policy: ExistingWorkPolicy) { + val sp = context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) + 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(sp.getLong(SHARED_PREF_TRIGGER_UPDATE_DELAY, 5000), TimeUnit.MILLISECONDS) + .setTriggerContentMaxDelay(sp.getLong(SHARED_PREF_TRIGGER_MAX_DELAY, 50000), TimeUnit.MILLISECONDS) + .build() + + val work = OneTimeWorkRequest.Builder(ContentObserverWorker::class.java) + .setConstraints(constraints) + .build() + WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME_OBSERVER, policy, work) + } + + fun startBackupWorker(context: Context, delayMilliseconds: Long) { + val sp = context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) + if (!sp.getBoolean(SHARED_PREF_SERVICE_ENABLED, false)) + return + 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"