1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-26 10:50:29 +02:00

improve Android background service reliability (#603)

This change greatly reduces the chance that a backup is not performed
when a new photo/video is made.
Instead of combining the change trigger and additonal constraints (wifi
or charging) into a single worker, these aspects are now separated.
Thus, it is now reliably possible to take pictures while the wifi
constraint is not satisfied and upload them hours/days later once
connected to wifi without taking a new photo.
As a positive side effect, this simplifies the error/retry handling
by directly leveraging Android's WorkManager without workarounds.
The separation also allows to notify the currently running BackupWorker
that new assets were added while backing up other assets to also upload
those newly added assets.
Further, a new tiny service checks if the app is killed, to reschedule
the content change worker and allow to detect the first new photo.
Bonus: The home screen now shows backup as enabled if background backup
is active.

* use separate worker/task for listening on changed/added assets
* use separate worker/task for performing the backup
* content observer worker enqueues backup worker on each new asset
* wifi/charging constraints only apply to backup worker
* backupworker is notified of assets added while running to re-run
* new service to catch app being killed to workaround WorkManager issue
This commit is contained in:
Fynn Petersen-Frey 2022-09-08 15:36:08 +02:00 committed by GitHub
parent de996c0a81
commit 4fe535e5e8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 329 additions and 181 deletions

View File

@ -12,6 +12,7 @@
</intent-filter> </intent-filter>
</activity> </activity>
<service android:name=".AppClearedService" android:stopWithTask="false" />
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data android:name="flutterEmbedding" android:value="2" /> <meta-data android:name="flutterEmbedding" android:value="2" />

View File

@ -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();
}
}

View File

@ -1,11 +1,6 @@
package app.alextran.immich package app.alextran.immich
import android.content.Context 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.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
@ -44,30 +39,30 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
val ctx = context!! val ctx = context!!
when(call.method) { when(call.method) {
"initialize" -> { // needs to be called prior to any other method "enable" -> {
val args = call.arguments<ArrayList<*>>()!! val args = call.arguments<ArrayList<*>>()!!
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) 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) result.success(true)
} }
"start" -> { "configure" -> {
val args = call.arguments<ArrayList<*>>()!! val args = call.arguments<ArrayList<*>>()!!
val immediate = args.get(0) as Boolean val requireUnmeteredNetwork = args.get(0) as Boolean
val keepExisting = args.get(1) as Boolean val requireCharging = args.get(1) as Boolean
val requireUnmeteredNetwork = args.get(2) as Boolean ContentObserverWorker.configureWork(ctx, requireUnmeteredNetwork, requireCharging)
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) result.success(true)
} }
"stop" -> { "disable" -> {
ContentObserverWorker.disable(ctx)
BackupWorker.stopWork(ctx) BackupWorker.stopWork(ctx)
result.success(true) result.success(true)
} }
"isEnabled" -> { "isEnabled" -> {
result.success(BackupWorker.isEnabled(ctx)) result.success(ContentObserverWorker.isEnabled(ctx))
} }
"isIgnoringBatteryOptimizations" -> { "isIgnoringBatteryOptimizations" -> {
result.success(BackupWorker.isIgnoringBatteryOptimizations(ctx)) result.success(BackupWorker.isIgnoringBatteryOptimizations(ctx))

View File

@ -8,17 +8,12 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.PowerManager import android.os.PowerManager
import android.os.SystemClock 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 android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.concurrent.futures.ResolvableFuture import androidx.concurrent.futures.ResolvableFuture
import androidx.work.BackoffPolicy import androidx.work.BackoffPolicy
import androidx.work.Constraints import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ForegroundInfo import androidx.work.ForegroundInfo
import androidx.work.ListenableWorker import androidx.work.ListenableWorker
import androidx.work.NetworkType import androidx.work.NetworkType
@ -26,6 +21,7 @@ import androidx.work.WorkerParameters
import androidx.work.ExistingWorkPolicy import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.WorkInfo
import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.ListenableFuture
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.dart.DartExecutor 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 * Starts the Dart runtime/engine and calls `_nativeEntry` function in
* `background.service.dart` to run the actual backup logic. * `background.service.dart` to run the actual backup logic.
* Called by Android WorkManager when all constraints for the work are met, * 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. * i.e. battery is not low and optionally Wifi and charging are active.
* 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.
*/ */
class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ctx, params), MethodChannel.MethodCallHandler { 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 lateinit var backgroundChannel: MethodChannel
private val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val isIgnoringBatteryOptimizations = isIgnoringBatteryOptimizations(applicationContext) private val isIgnoringBatteryOptimizations = isIgnoringBatteryOptimizations(applicationContext)
private var timeBackupStarted: Long = 0L
override fun startWork(): ListenableFuture<ListenableWorker.Result> { override fun startWork(): ListenableFuture<ListenableWorker.Result> {
Log.d(TAG, "startWork")
val ctx = applicationContext 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()) { if (!flutterLoader.initialized()) {
flutterLoader.startInitialization(ctx) flutterLoader.startInitialization(ctx)
@ -73,14 +61,16 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
// Create a Notification channel if necessary // Create a Notification channel if necessary
createChannel() createChannel()
} }
val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
.getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!!
if (isIgnoringBatteryOptimizations) { if (isIgnoringBatteryOptimizations) {
// normal background services can only up to 10 minutes // normal background services can only up to 10 minutes
// foreground services are allowed to run indefinitely // foreground services are allowed to run indefinitely
// requires battery optimizations to be disabled (either manually by the user // requires battery optimizations to be disabled (either manually by the user
// or by the system learning that immich is important to 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)) setForegroundAsync(createForegroundInfo(title))
} else {
showBackgroundInfo(title)
} }
engine = FlutterEngine(ctx) engine = FlutterEngine(ctx)
@ -115,6 +105,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
} }
override fun onStopped() { override fun onStopped() {
Log.d(TAG, "onStopped")
// called when the system has to stop this worker because constraints are // called when the system has to stop this worker because constraints are
// no longer met or the system needs resources for more important tasks // no longer met or the system needs resources for more important tasks
Handler(Looper.getMainLooper()).postAtFrontOfQueue { Handler(Looper.getMainLooper()).postAtFrontOfQueue {
@ -130,24 +121,18 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
private fun stopEngine(result: Result?) { private fun stopEngine(result: Result?) {
if (result != null) { if (result != null) {
Log.d(TAG, "stopEngine result=${result}")
resolvableFuture.set(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?.destroy()
engine = null engine = null
clearBackgroundNotification()
} }
override fun onMethodCall(call: MethodCall, r: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, r: MethodChannel.Result) {
when (call.method) { when (call.method) {
"initialized" -> "initialized" -> {
timeBackupStarted = SystemClock.uptimeMillis()
backgroundChannel.invokeMethod( backgroundChannel.invokeMethod(
"onAssetsChanged", "onAssetsChanged",
null, null,
@ -163,25 +148,18 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
override fun success(receivedResult: Any?) { override fun success(receivedResult: Any?) {
val success = receivedResult as Boolean val success = receivedResult as Boolean
stopEngine(if(success) Result.success() else Result.retry()) 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" -> { "updateNotification" -> {
val args = call.arguments<ArrayList<*>>()!! val args = call.arguments<ArrayList<*>>()!!
val title = args.get(0) as String val title = args.get(0) as String
val content = args.get(1) as String val content = args.get(1) as String
if (isIgnoringBatteryOptimizations) { if (isIgnoringBatteryOptimizations) {
setForegroundAsync(createForegroundInfo(title, content)) setForegroundAsync(createForegroundInfo(title, content))
} else {
showBackgroundInfo(title, content)
} }
} }
"showError" -> { "showError" -> {
@ -192,6 +170,14 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
showError(title, content, individualTag) showError(title, content, individualTag)
} }
"clearErrorNotifications" -> clearErrorNotifications() "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() else -> r.notImplemented()
} }
} }
@ -211,6 +197,22 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
notificationManager.cancel(NOTIFICATION_ERROR_ID) 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 { private fun createForegroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null): ForegroundInfo {
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID) val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
.setContentTitle(title) .setContentTitle(title)
@ -233,89 +235,61 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
companion object { companion object {
const val SHARED_PREF_NAME = "immichBackgroundService" const val SHARED_PREF_NAME = "immichBackgroundService"
const val SHARED_PREF_CALLBACK_KEY = "callbackDispatcherHandle" 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_NOTIFICATION_TITLE = "notificationTitle"
const val SHARED_PREF_LAST_CHANGE = "lastChange"
private const val TASK_NAME = "immich/photoListener" private const val TASK_NAME_BACKUP = "immich/BackupWorker"
private const val DATA_KEY_UNMETERED = "unmetered"
private const val DATA_KEY_CHARGING = "charging"
private const val DATA_KEY_RETRIES = "retries"
private const val NOTIFICATION_CHANNEL_ID = "immich/backgroundService" private const val NOTIFICATION_CHANNEL_ID = "immich/backgroundService"
private const val NOTIFICATION_CHANNEL_ERROR_ID = "immich/backgroundServiceError" private const val NOTIFICATION_CHANNEL_ERROR_ID = "immich/backgroundServiceError"
private const val NOTIFICATION_DEFAULT_TITLE = "Immich" private const val NOTIFICATION_DEFAULT_TITLE = "Immich"
private const val NOTIFICATION_ID = 1 private const val NOTIFICATION_ID = 1
private const val NOTIFICATION_ERROR_ID = 2 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. * Enqueues the BackupWorker to run once the 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)
*/ */
fun startWork(context: Context, fun enqueueBackupWorker(context: Context,
immediate: Boolean = false, requireWifi: Boolean = false,
keepExisting: Boolean = false, requireCharging: Boolean = false,
requireUnmeteredNetwork: Boolean = false, delayMilliseconds: Long = 0L) {
requireCharging: Boolean = false) { val workRequest = buildWorkRequest(requireWifi, requireCharging, delayMilliseconds)
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME_BACKUP, ExistingWorkPolicy.KEEP, workRequest)
.edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, true).apply() Log.d(TAG, "enqueueBackupWorker: BackupWorker enqueued")
enqueueMoreWork(context, immediate, keepExisting, requireUnmeteredNetwork, requireCharging)
} }
private fun enqueueMoreWork(context: Context, /**
immediate: Boolean = false, * Updates the constraints of an already enqueued BackupWorker
keepExisting: Boolean = false, */
requireUnmeteredNetwork: Boolean = false, fun updateBackupWorker(context: Context,
requireCharging: Boolean = false, requireWifi: Boolean = false,
initialDelayInMs: Long = 0, requireCharging: Boolean = false) {
retries: Int = 0) { try {
if (!isEnabled(context)) { val wm = WorkManager.getInstance(context)
return 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 * Stops the currently running worker (if any) and removes it from the work queue
*/ */
fun stopWork(context: Context) { fun stopWork(context: Context) {
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME_BACKUP)
.edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, false).apply() Log.d(TAG, "stopWork: BackupWorker cancelled")
WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME)
} }
/** /**
@ -330,12 +304,21 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
return true return true
} }
/** private fun buildWorkRequest(requireWifi: Boolean = false,
* Return true if the user has enabled the background backup service requireCharging: Boolean = false,
*/ delayMilliseconds: Long = 0L): OneTimeWorkRequest {
fun isEnabled(ctx: Context): Boolean { val constraints = Constraints.Builder()
return ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) .setRequiredNetworkType(if (requireWifi) NetworkType.UNMETERED else NetworkType.CONNECTED)
.getBoolean(SHARED_PREF_SERVICE_ENABLED, false) .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()

View File

@ -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"

View File

@ -2,6 +2,8 @@ package app.alextran.immich
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import android.os.Bundle
import android.content.Intent
class MainActivity: FlutterActivity() { class MainActivity: FlutterActivity() {
@ -10,4 +12,9 @@ class MainActivity: FlutterActivity() {
flutterEngine.getPlugins().add(BackgroundServicePlugin()) flutterEngine.getPlugins().add(BackgroundServicePlugin())
} }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
startService(Intent(getBaseContext(), AppClearedService::class.java));
}
} }

View File

@ -4,7 +4,6 @@ import 'dart:io';
import 'dart:isolate'; import 'dart:isolate';
import 'dart:ui' show IsolateNameServer, PluginUtilities; import 'dart:ui' show IsolateNameServer, PluginUtilities;
import 'package:cancellation_token_http/http.dart'; import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -33,7 +32,6 @@ class BackgroundService {
MethodChannel('immich/foregroundChannel'); MethodChannel('immich/foregroundChannel');
static const MethodChannel _backgroundChannel = static const MethodChannel _backgroundChannel =
MethodChannel('immich/backgroundChannel'); MethodChannel('immich/backgroundChannel');
bool _isForegroundInitialized = false;
bool _isBackgroundInitialized = false; bool _isBackgroundInitialized = false;
CancellationToken? _cancellationToken; CancellationToken? _cancellationToken;
bool _canceledBySystem = false; bool _canceledBySystem = false;
@ -43,32 +41,34 @@ class BackgroundService {
ReceivePort? _rp; ReceivePort? _rp;
bool _errorGracePeriodExceeded = true; bool _errorGracePeriodExceeded = true;
bool get isForegroundInitialized {
return _isForegroundInitialized;
}
bool get isBackgroundInitialized { bool get isBackgroundInitialized {
return _isBackgroundInitialized; return _isBackgroundInitialized;
} }
Future<bool> _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 /// Ensures that the background service is enqueued if enabled in settings
Future<bool> resumeServiceIfEnabled() async { Future<bool> resumeServiceIfEnabled() async {
return await isBackgroundBackupEnabled() && return await isBackgroundBackupEnabled() && await enableService();
await startService(keepExisting: true);
} }
/// Enqueues the background service /// Enqueues the background service
Future<bool> startService({ Future<bool> enableService({bool immediate = false}) async {
bool immediate = false, if (!Platform.isAndroid) {
bool keepExisting = false, 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<bool> configureService({
bool requireUnmetered = true, bool requireUnmetered = true,
bool requireCharging = false, bool requireCharging = false,
}) async { }) async {
@ -76,14 +76,9 @@ class BackgroundService {
return true; return true;
} }
try { try {
if (!_isForegroundInitialized) {
await _initialize();
}
final String title =
"backup_background_service_default_notification".tr();
final bool ok = await _foregroundChannel.invokeMethod( final bool ok = await _foregroundChannel.invokeMethod(
'start', 'configure',
[immediate, keepExisting, requireUnmetered, requireCharging, title], [requireUnmetered, requireCharging],
); );
return ok; return ok;
} catch (error) { } catch (error) {
@ -92,15 +87,12 @@ class BackgroundService {
} }
/// Cancels the background service (if currently running) and removes it from work queue /// Cancels the background service (if currently running) and removes it from work queue
Future<bool> stopService() async { Future<bool> disableService() async {
if (!Platform.isAndroid) { if (!Platform.isAndroid) {
return true; return true;
} }
try { try {
if (!_isForegroundInitialized) { final ok = await _foregroundChannel.invokeMethod('disable');
await _initialize();
}
final ok = await _foregroundChannel.invokeMethod('stop');
return ok; return ok;
} catch (error) { } catch (error) {
return false; return false;
@ -113,9 +105,6 @@ class BackgroundService {
return false; return false;
} }
try { try {
if (!_isForegroundInitialized) {
await _initialize();
}
return await _foregroundChannel.invokeMethod("isEnabled"); return await _foregroundChannel.invokeMethod("isEnabled");
} catch (error) { } catch (error) {
return false; return false;
@ -128,9 +117,6 @@ class BackgroundService {
return true; return true;
} }
try { try {
if (!_isForegroundInitialized) {
await _initialize();
}
return await _foregroundChannel return await _foregroundChannel
.invokeMethod('isIgnoringBatteryOptimizations'); .invokeMethod('isIgnoringBatteryOptimizations');
} catch (error) { } catch (error) {
@ -289,18 +275,11 @@ class BackgroundService {
try { try {
final bool hasAccess = await acquireLock(); final bool hasAccess = await acquireLock();
if (!hasAccess) { if (!hasAccess) {
debugPrint("[_callHandler] could acquire lock, exiting"); debugPrint("[_callHandler] could not acquire lock, exiting");
return false; return false;
} }
await translationsLoaded; await translationsLoaded;
final bool ok = await _onAssetsChanged(); 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; return ok;
} catch (error) { } catch (error) {
debugPrint(error.toString()); debugPrint(error.toString());
@ -343,6 +322,29 @@ class BackgroundService {
} }
await PhotoManager.setIgnorePermissionCheck(true); 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<bool>("hasContentChanged"));
return true;
}
Future<bool> _runBackup(
BackupService backupService, HiveBackupAlbums backupAlbumInfo) async {
_errorGracePeriodExceeded = _isErrorGracePeriodExceeded(); _errorGracePeriodExceeded = _isErrorGracePeriodExceeded();
if (_canceledBySystem) { if (_canceledBySystem) {
@ -382,10 +384,6 @@ class BackgroundService {
); );
if (ok) { if (ok) {
_clearErrorNotifications(); _clearErrorNotifications();
await box.put(
backupInfoKey,
backupAlbumInfo,
);
} else { } else {
_showErrorNotification( _showErrorNotification(
title: "backup_background_service_error_title".tr(), title: "backup_background_service_error_title".tr(),

View File

@ -131,13 +131,15 @@ class BackupNotifier extends StateNotifier<BackUpState> {
); );
if (state.backgroundBackup) { if (state.backgroundBackup) {
bool success = true;
if (!wasEnabled) { if (!wasEnabled) {
if (!await _backgroundService.isIgnoringBatteryOptimizations()) { if (!await _backgroundService.isIgnoringBatteryOptimizations()) {
onBatteryInfo(); onBatteryInfo();
} }
success &= await _backgroundService.enableService(immediate: true);
} }
final bool success = await _backgroundService.stopService() && success &= success &&
await _backgroundService.startService( await _backgroundService.configureService(
requireUnmetered: state.backupRequireWifi, requireUnmetered: state.backupRequireWifi,
requireCharging: state.backupRequireCharging, requireCharging: state.backupRequireCharging,
); );
@ -155,7 +157,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
onError("backup_controller_page_background_configure_error"); onError("backup_controller_page_background_configure_error");
} }
} else { } else {
final bool success = await _backgroundService.stopService(); final bool success = await _backgroundService.disableService();
if (!success) { if (!success) {
state = state.copyWith(backgroundBackup: wasEnabled); state = state.copyWith(backgroundBackup: wasEnabled);
onError("backup_controller_page_background_configure_error"); onError("backup_controller_page_background_configure_error");

View File

@ -21,7 +21,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final BackUpState backupState = ref.watch(backupProvider); final BackUpState backupState = ref.watch(backupProvider);
bool isEnableAutoBackup = bool isEnableAutoBackup = backupState.backgroundBackup ||
ref.watch(authenticationProvider).deviceInfo.isAutoBackup; ref.watch(authenticationProvider).deviceInfo.isAutoBackup;
final ServerInfoState serverInfoState = ref.watch(serverInfoProvider); final ServerInfoState serverInfoState = ref.watch(serverInfoProvider);