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