forked from immich-app/immich
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
improve Android background service reliability (immich-app#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
- Loading branch information
Fynn Petersen-Frey
authored
Sep 8, 2022
1 parent
de996c0
commit 4fe535e
Showing
9 changed files
with
329 additions
and
181 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
25 changes: 25 additions & 0 deletions
25
mobile/android/app/src/main/kotlin/com/example/mobile/AppClearedService.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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(); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
201 changes: 92 additions & 109 deletions
201
mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt
Large diffs are not rendered by default.
Oops, something went wrong.
137 changes: 137 additions & 0 deletions
137
mobile/android/app/src/main/kotlin/com/example/mobile/ContentObserverWorker.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.