You've already forked immich
mirror of
https://github.com/immich-app/immich.git
synced 2025-08-10 23:22:22 +02:00
upload new photos in background with a service (#382)
* properly done background backup service * new concurrency/locking management with heartbeat fix communication erros with Kotlin plugin on start/stop service methods better error handling for BackgroundService public methods Add default notification message when service is running * configurable WiFi & charging requirement for service * use translations in background service
This commit is contained in:
committed by
GitHub
parent
f35ebec7c6
commit
33b1410d82
@@ -80,5 +80,8 @@ flutter {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
||||
implementation "androidx.work:work-runtime-ktx:$work_version"
|
||||
implementation "androidx.concurrent:concurrent-futures:$concurrent_version"
|
||||
implementation "com.google.guava:guava:$guava_version"
|
||||
}
|
||||
|
@@ -1,6 +0,0 @@
|
||||
package com.example.immich_mobile
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity: FlutterActivity() {
|
||||
}
|
@@ -0,0 +1,98 @@
|
||||
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
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
"initialize" -> { // needs to be called prior to any other method
|
||||
val args = call.arguments<ArrayList<*>>()!!
|
||||
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
.edit().putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long).apply()
|
||||
result.success(true)
|
||||
}
|
||||
"start" -> {
|
||||
val args = call.arguments<ArrayList<*>>()!!
|
||||
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)
|
||||
}
|
||||
"stop" -> {
|
||||
BackupWorker.stopWork(ctx)
|
||||
result.success(true)
|
||||
}
|
||||
"isEnabled" -> {
|
||||
result.success(BackupWorker.isEnabled(ctx))
|
||||
}
|
||||
"disableBatteryOptimizations" -> {
|
||||
if(!BackupWorker.isIgnoringBatteryOptimizations(ctx)) {
|
||||
val args = call.arguments<ArrayList<*>>()!!
|
||||
val text = args.get(0) as String
|
||||
Toast.makeText(ctx, text, Toast.LENGTH_LONG).show()
|
||||
val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
intent.setData(Uri.parse("package:" + ctx.getPackageName()))
|
||||
try {
|
||||
ctx.startActivity(intent)
|
||||
} catch(e: Exception) {
|
||||
intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||
try {
|
||||
ctx.startActivity(intent)
|
||||
} catch (e2: Exception) {
|
||||
return result.success(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
result.success(true)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val TAG = "BackgroundServicePlugin"
|
@@ -0,0 +1,333 @@
|
||||
package app.alextran.immich
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
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
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.dart.DartExecutor
|
||||
import io.flutter.embedding.engine.loader.FlutterLoader
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.view.FlutterCallbackInformation
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Worker executed by Android WorkManager to perform backup in background
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ctx, params), MethodChannel.MethodCallHandler {
|
||||
|
||||
private val resolvableFuture = ResolvableFuture.create<Result>()
|
||||
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)
|
||||
|
||||
override fun startWork(): ListenableFuture<ListenableWorker.Result> {
|
||||
|
||||
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)
|
||||
}
|
||||
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)!!
|
||||
setForegroundAsync(createForegroundInfo(title))
|
||||
}
|
||||
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() {
|
||||
// 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)
|
||||
}
|
||||
// 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 stopEngine(result: Result?) {
|
||||
if (result != null) {
|
||||
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),
|
||||
retries = inputData.getInt(DATA_KEY_RETRIES, 0) + 1)
|
||||
}
|
||||
engine?.destroy()
|
||||
engine = null
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, r: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"initialized" ->
|
||||
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())
|
||||
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),
|
||||
retries = inputData.getInt(DATA_KEY_RETRIES, 0) + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
"updateNotification" -> {
|
||||
val args = call.arguments<ArrayList<*>>()!!
|
||||
val title = args.get(0) as String
|
||||
val content = args.get(1) as String
|
||||
if (isIgnoringBatteryOptimizations) {
|
||||
setForegroundAsync(createForegroundInfo(title, content))
|
||||
}
|
||||
}
|
||||
"showError" -> {
|
||||
val args = call.arguments<ArrayList<*>>()!!
|
||||
val title = args.get(0) as String
|
||||
val content = args.get(1) as String
|
||||
showError(title, content)
|
||||
}
|
||||
else -> r.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showError(title: String, content: String) {
|
||||
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ERROR_ID)
|
||||
.setContentTitle(title)
|
||||
.setTicker(title)
|
||||
.setContentText(content)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
val notificationId = SystemClock.uptimeMillis() as Int
|
||||
notificationManager.notify(notificationId, notification)
|
||||
}
|
||||
|
||||
private fun createForegroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null): ForegroundInfo {
|
||||
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
|
||||
.setContentTitle(title)
|
||||
.setTicker(title)
|
||||
.setContentText(content)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
return ForegroundInfo(1, notification)
|
||||
}
|
||||
|
||||
@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_SERVICE_ENABLED = "serviceEnabled"
|
||||
const val SHARED_PREF_NOTIFICATION_TITLE = "notificationTitle"
|
||||
|
||||
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 NOTIFICATION_CHANNEL_ID = "immich/backgroundService"
|
||||
private const val NOTIFICATION_CHANNEL_ERROR_ID = "immich/backgroundServiceError"
|
||||
private const val NOTIFICATION_DEFAULT_TITLE = "Immich"
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
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)
|
||||
}
|
||||
|
||||
private fun enqueueMoreWork(context: Context,
|
||||
immediate: Boolean = false,
|
||||
keepExisting: Boolean = false,
|
||||
requireUnmeteredNetwork: Boolean = false,
|
||||
requireCharging: Boolean = false,
|
||||
retries: Int = 0) {
|
||||
if (!isEnabled(context)) {
|
||||
return
|
||||
}
|
||||
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)
|
||||
.setBackoffCriteria(
|
||||
BackoffPolicy.EXPONENTIAL,
|
||||
OneTimeWorkRequest.MIN_BACKOFF_MILLIS,
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 val flutterLoader = FlutterLoader()
|
||||
}
|
||||
}
|
||||
|
||||
private const val TAG = "BackupWorker"
|
@@ -1,6 +1,13 @@
|
||||
package app.alextran.immich
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
|
||||
class MainActivity: FlutterActivity() {
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
flutterEngine.getPlugins().add(BackgroundServicePlugin())
|
||||
}
|
||||
|
||||
}
|
||||
|
Reference in New Issue
Block a user