chore(mobile): update target SDK version (#11719)

* chore(mobile): update target SDK version

* background service

* remove print statements

* remove extra line

* format kotlin

* Correct permission
This commit is contained in:
Alex 2024-08-15 11:36:43 -05:00 committed by GitHub
parent a4506758aa
commit 49610de4b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 626 additions and 570 deletions

View File

@ -46,7 +46,7 @@ android {
defaultConfig { defaultConfig {
applicationId "app.alextran.immich" applicationId "app.alextran.immich"
minSdkVersion 26 minSdkVersion 26
targetSdkVersion 33 targetSdkVersion 34
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName
} }

View File

@ -1,9 +1,38 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="app.alextran.immich" <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="app.alextran.immich"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
<uses-permission android:name="android.permission.MANAGE_MEDIA" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
<!-- Foreground service permission -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<application android:label="Immich" android:name=".ImmichApp" android:usesCleartextTraffic="true" <application android:label="Immich" android:name=".ImmichApp" android:usesCleartextTraffic="true"
android:icon="@mipmap/ic_launcher" android:requestLegacyExternalStorage="true" android:icon="@mipmap/ic_launcher" android:requestLegacyExternalStorage="true"
android:largeHeap="true"> android:largeHeap="true">
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:directBootAware="false"
android:enabled="@bool/enable_system_foreground_service_default"
android:exported="false"
android:foregroundServiceType="dataSync|shortService" />
<meta-data <meta-data
android:name="io.flutter.embedding.android.EnableImpeller" android:name="io.flutter.embedding.android.EnableImpeller"
android:value="false" /> android:value="false" />
@ -51,23 +80,13 @@
<provider <provider
android:name="androidx.startup.InitializationProvider" android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup" android:authorities="${applicationId}.androidx-startup"
tools:node="remove"></provider> tools:node="remove" />
</application> </application>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
<uses-permission android:name="android.permission.MANAGE_MEDIA" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<queries> <queries>
<intent> <intent>

View File

@ -7,7 +7,6 @@ import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import java.security.MessageDigest import java.security.MessageDigest
import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import kotlinx.coroutines.* import kotlinx.coroutines.*
@ -19,97 +18,106 @@ import kotlinx.coroutines.*
*/ */
class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler { class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private var methodChannel: MethodChannel? = null private var methodChannel: MethodChannel? = null
private var context: Context? = null private var context: Context? = null
private val sha1: MessageDigest = MessageDigest.getInstance("SHA-1")
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
onAttachedToEngine(binding.applicationContext, binding.binaryMessenger) onAttachedToEngine(binding.applicationContext, binding.binaryMessenger)
} }
private fun onAttachedToEngine(ctx: Context, messenger: BinaryMessenger) { private fun onAttachedToEngine(ctx: Context, messenger: BinaryMessenger) {
context = ctx context = ctx
methodChannel = MethodChannel(messenger, "immich/foregroundChannel") methodChannel = MethodChannel(messenger, "immich/foregroundChannel")
methodChannel?.setMethodCallHandler(this) methodChannel?.setMethodCallHandler(this)
} }
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
onDetachedFromEngine() onDetachedFromEngine()
} }
private fun onDetachedFromEngine() { private fun onDetachedFromEngine() {
methodChannel?.setMethodCallHandler(null) methodChannel?.setMethodCallHandler(null)
methodChannel = null methodChannel = null
} }
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) {
"enable" -> { "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() .edit()
.putBoolean(ContentObserverWorker.SHARED_PREF_SERVICE_ENABLED, true) .putBoolean(ContentObserverWorker.SHARED_PREF_SERVICE_ENABLED, true)
.putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long) .putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args[0] as Long)
.putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, args.get(1) as String) .putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, args[1] as String)
.apply() .apply()
ContentObserverWorker.enable(ctx, immediate = args.get(2) as Boolean) ContentObserverWorker.enable(ctx, immediate = args[2] as Boolean)
result.success(true) result.success(true)
} }
"configure" -> {
val args = call.arguments<ArrayList<*>>()!! "configure" -> {
val requireUnmeteredNetwork = args.get(0) as Boolean val args = call.arguments<ArrayList<*>>()!!
val requireCharging = args.get(1) as Boolean val requireUnmeteredNetwork = args[0] as Boolean
val triggerUpdateDelay = (args.get(2) as Number).toLong() val requireCharging = args[1] as Boolean
val triggerMaxDelay = (args.get(3) as Number).toLong() val triggerUpdateDelay = (args[2] as Number).toLong()
ContentObserverWorker.configureWork(ctx, requireUnmeteredNetwork, requireCharging, triggerUpdateDelay, triggerMaxDelay) val triggerMaxDelay = (args[3] as Number).toLong()
result.success(true) ContentObserverWorker.configureWork(
} ctx,
"disable" -> { requireUnmeteredNetwork,
ContentObserverWorker.disable(ctx) requireCharging,
BackupWorker.stopWork(ctx) triggerUpdateDelay,
result.success(true) triggerMaxDelay
} )
"isEnabled" -> { result.success(true)
result.success(ContentObserverWorker.isEnabled(ctx)) }
}
"isIgnoringBatteryOptimizations" -> { "disable" -> {
result.success(BackupWorker.isIgnoringBatteryOptimizations(ctx)) ContentObserverWorker.disable(ctx)
} BackupWorker.stopWork(ctx)
"digestFiles" -> { result.success(true)
val args = call.arguments<ArrayList<String>>()!! }
GlobalScope.launch(Dispatchers.IO) {
val buf = ByteArray(BUFSIZE) "isEnabled" -> {
val digest: MessageDigest = MessageDigest.getInstance("SHA-1") result.success(ContentObserverWorker.isEnabled(ctx))
val hashes = arrayOfNulls<ByteArray>(args.size) }
for (i in args.indices) {
val path = args[i] "isIgnoringBatteryOptimizations" -> {
var len = 0 result.success(BackupWorker.isIgnoringBatteryOptimizations(ctx))
try { }
val file = FileInputStream(path)
try { "digestFiles" -> {
while (true) { val args = call.arguments<ArrayList<String>>()!!
len = file.read(buf) GlobalScope.launch(Dispatchers.IO) {
if (len != BUFSIZE) break val buf = ByteArray(BUFFER_SIZE)
digest.update(buf) val digest: MessageDigest = MessageDigest.getInstance("SHA-1")
} val hashes = arrayOfNulls<ByteArray>(args.size)
} finally { for (i in args.indices) {
file.close() val path = args[i]
} var len = 0
digest.update(buf, 0, len) try {
hashes[i] = digest.digest() val file = FileInputStream(path)
} catch (e: Exception) { file.use { assetFile ->
// skip this file while (true) {
Log.w(TAG, "Failed to hash file ${args[i]}: $e") len = assetFile.read(buf)
} if (len != BUFFER_SIZE) break
} digest.update(buf)
result.success(hashes.asList())
} }
}
digest.update(buf, 0, len)
hashes[i] = digest.digest()
} catch (e: Exception) {
// skip this file
Log.w(TAG, "Failed to hash file ${args[i]}: $e")
} }
else -> result.notImplemented() }
result.success(hashes.asList())
} }
}
else -> result.notImplemented()
} }
}
} }
private const val TAG = "BackgroundServicePlugin" private const val TAG = "BackgroundServicePlugin"
private const val BUFSIZE = 2*1024*1024; private const val BUFFER_SIZE = 2 * 1024 * 1024;

View File

@ -4,6 +4,7 @@ import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.content.Context import android.content.Context
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE
import android.os.Build import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
@ -40,323 +41,351 @@ import java.util.concurrent.TimeUnit
* Called by Android WorkManager when all constraints for the work are met, * Called by Android WorkManager when all constraints for the work are met,
* i.e. battery is not low and optionally Wifi and charging are active. * i.e. battery is not low and optionally Wifi and charging are active.
*/ */
class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ctx, params), MethodChannel.MethodCallHandler { class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ctx, params),
MethodChannel.MethodCallHandler {
private val resolvableFuture = ResolvableFuture.create<Result>() private val resolvableFuture = ResolvableFuture.create<Result>()
private var engine: FlutterEngine? = null private var engine: FlutterEngine? = null
private lateinit var backgroundChannel: MethodChannel private lateinit var backgroundChannel: MethodChannel
private val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private val notificationManager =
private val isIgnoringBatteryOptimizations = isIgnoringBatteryOptimizations(applicationContext) ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private var timeBackupStarted: Long = 0L private val isIgnoringBatteryOptimizations = isIgnoringBatteryOptimizations(applicationContext)
private var notificationBuilder: NotificationCompat.Builder? = null private var timeBackupStarted: Long = 0L
private var notificationDetailBuilder: NotificationCompat.Builder? = null private var notificationBuilder: NotificationCompat.Builder? = null
private var fgFuture: ListenableFuture<Void>? = null private var notificationDetailBuilder: NotificationCompat.Builder? = null
private var fgFuture: ListenableFuture<Void>? = null
override fun startWork(): ListenableFuture<ListenableWorker.Result> { override fun startWork(): ListenableFuture<ListenableWorker.Result> {
Log.d(TAG, "startWork") Log.d(TAG, "startWork")
val ctx = applicationContext val ctx = applicationContext
if (!flutterLoader.initialized()) { if (!flutterLoader.initialized()) {
flutterLoader.startInitialization(ctx) flutterLoader.startInitialization(ctx)
}
// Create a Notification channel
createChannel()
Log.d(TAG, "isIgnoringBatteryOptimizations $isIgnoringBatteryOptimizations")
if (isIgnoringBatteryOptimizations) {
// normal background services can only up to 10 minutes
// foreground services are allowed to run indefinitely
// requires battery optimizations to be disabled (either manually by the user
// or by the system learning that immich is important to the user)
val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
.getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!!
showInfo(getInfoBuilder(title, indeterminate = true).build())
}
engine = FlutterEngine(ctx)
flutterLoader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) {
runDart()
}
return resolvableFuture
}
/**
* Starts the Dart runtime/engine and calls `_nativeEntry` function in
* `background.service.dart` to run the actual backup logic.
*/
private fun runDart() {
val callbackDispatcherHandle = applicationContext.getSharedPreferences(
SHARED_PREF_NAME, Context.MODE_PRIVATE
).getLong(SHARED_PREF_CALLBACK_KEY, 0L)
val callbackInformation =
FlutterCallbackInformation.lookupCallbackInformation(callbackDispatcherHandle)
val appBundlePath = flutterLoader.findAppBundlePath()
engine?.let { engine ->
backgroundChannel = MethodChannel(engine.dartExecutor, "immich/backgroundChannel")
backgroundChannel.setMethodCallHandler(this@BackupWorker)
engine.dartExecutor.executeDartCallback(
DartExecutor.DartCallback(
applicationContext.assets,
appBundlePath,
callbackInformation
)
)
}
}
override fun onStopped() {
Log.d(TAG, "onStopped")
// called when the system has to stop this worker because constraints are
// no longer met or the system needs resources for more important tasks
Handler(Looper.getMainLooper()).postAtFrontOfQueue {
backgroundChannel.invokeMethod("systemStop", null)
}
waitOnSetForegroundAsync()
// cannot await/get(block) on resolvableFuture as its already cancelled (would throw CancellationException)
// instead, wait for 5 seconds until forcefully stopping backup work
Handler(Looper.getMainLooper()).postDelayed({
stopEngine(null)
}, 5000)
}
private fun waitOnSetForegroundAsync() {
val fgFuture = this.fgFuture
if (fgFuture != null && !fgFuture.isCancelled && !fgFuture.isDone) {
try {
fgFuture.get(500, TimeUnit.MILLISECONDS)
} catch (e: Exception) {
// ignored, there is nothing to be done
}
}
}
private fun stopEngine(result: Result?) {
clearBackgroundNotification()
engine?.destroy()
engine = null
if (result != null) {
Log.d(TAG, "stopEngine result=${result}")
resolvableFuture.set(result)
}
waitOnSetForegroundAsync()
}
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
override fun onMethodCall(call: MethodCall, r: MethodChannel.Result) {
when (call.method) {
"initialized" -> {
timeBackupStarted = SystemClock.uptimeMillis()
backgroundChannel.invokeMethod(
"onAssetsChanged",
null,
object : MethodChannel.Result {
override fun notImplemented() {
stopEngine(Result.failure())
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
stopEngine(Result.failure())
}
override fun success(receivedResult: Any?) {
val success = receivedResult as Boolean
stopEngine(if (success) Result.success() else Result.retry())
}
}
)
}
"updateNotification" -> {
val args = call.arguments<ArrayList<*>>()!!
val title = args[0] as String?
val content = args[1] as String?
val progress = args[2] as Int
val max = args[3] as Int
val indeterminate = args[4] as Boolean
val isDetail = args[5] as Boolean
val onlyIfFG = args[6] as Boolean
if (!onlyIfFG || isIgnoringBatteryOptimizations) {
showInfo(
getInfoBuilder(title, content, isDetail, progress, max, indeterminate).build(),
isDetail
)
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { }
// Create a Notification channel if necessary
createChannel()
}
if (isIgnoringBatteryOptimizations) {
// normal background services can only up to 10 minutes
// foreground services are allowed to run indefinitely
// requires battery optimizations to be disabled (either manually by the user
// or by the system learning that immich is important to the user)
val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
.getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!!
showInfo(getInfoBuilder(title, indeterminate=true).build())
}
engine = FlutterEngine(ctx)
flutterLoader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) { "showError" -> {
runDart() val args = call.arguments<ArrayList<*>>()!!
} val title = args[0] as String
val content = args[1] as String?
val individualTag = args[2] as String?
showError(title, content, individualTag)
}
return resolvableFuture "clearErrorNotifications" -> clearErrorNotifications()
"hasContentChanged" -> {
val lastChange = applicationContext
.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
.getLong(SHARED_PREF_LAST_CHANGE, timeBackupStarted)
val hasContentChanged = lastChange > timeBackupStarted;
timeBackupStarted = SystemClock.uptimeMillis()
r.success(hasContentChanged)
}
else -> r.notImplemented()
}
}
private fun showError(title: String, content: String?, individualTag: String?) {
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ERROR_ID)
.setContentTitle(title)
.setTicker(title)
.setContentText(content)
.setSmallIcon(R.mipmap.ic_launcher)
.build()
notificationManager.notify(individualTag, NOTIFICATION_ERROR_ID, notification)
}
private fun clearErrorNotifications() {
notificationManager.cancel(NOTIFICATION_ERROR_ID)
}
private fun clearBackgroundNotification() {
notificationManager.cancel(NOTIFICATION_ID)
notificationManager.cancel(NOTIFICATION_DETAIL_ID)
}
private fun showInfo(notification: Notification, isDetail: Boolean = false) {
val id = if (isDetail) NOTIFICATION_DETAIL_ID else NOTIFICATION_ID
if (isIgnoringBatteryOptimizations && !isDetail) {
fgFuture = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
setForegroundAsync(ForegroundInfo(id, notification, FOREGROUND_SERVICE_TYPE_SHORT_SERVICE))
} else {
setForegroundAsync(ForegroundInfo(id, notification))
}
} else {
notificationManager.notify(id, notification)
}
}
private fun getInfoBuilder(
title: String? = null,
content: String? = null,
isDetail: Boolean = false,
progress: Int = 0,
max: Int = 0,
indeterminate: Boolean = false,
): NotificationCompat.Builder {
var builder = if (isDetail) notificationDetailBuilder else notificationBuilder
if (builder == null) {
builder = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setOnlyAlertOnce(true)
.setOngoing(true)
if (isDetail) {
notificationDetailBuilder = builder
} else {
notificationBuilder = builder
}
}
if (title != null) {
builder.setTicker(title).setContentTitle(title)
}
if (content != null) {
builder.setContentText(content)
}
return builder.setProgress(max, progress, indeterminate)
}
private fun createChannel() {
val foreground = NotificationChannel(
NOTIFICATION_CHANNEL_ID,
NOTIFICATION_CHANNEL_ID,
NotificationManager.IMPORTANCE_LOW
)
notificationManager.createNotificationChannel(foreground)
val error = NotificationChannel(
NOTIFICATION_CHANNEL_ERROR_ID,
NOTIFICATION_CHANNEL_ERROR_ID,
NotificationManager.IMPORTANCE_HIGH
)
notificationManager.createNotificationChannel(error)
}
companion object {
const val SHARED_PREF_NAME = "immichBackgroundService"
const val SHARED_PREF_CALLBACK_KEY = "callbackDispatcherHandle"
const val SHARED_PREF_NOTIFICATION_TITLE = "notificationTitle"
const val SHARED_PREF_LAST_CHANGE = "lastChange"
private const val TASK_NAME_BACKUP = "immich/BackupWorker"
private const val NOTIFICATION_CHANNEL_ID = "immich/backgroundService"
private const val NOTIFICATION_CHANNEL_ERROR_ID = "immich/backgroundServiceError"
private const val NOTIFICATION_DEFAULT_TITLE = "Immich"
private const val NOTIFICATION_ID = 1
private const val NOTIFICATION_ERROR_ID = 2
private const val NOTIFICATION_DETAIL_ID = 3
private const val ONE_MINUTE = 60000L
/**
* Enqueues the BackupWorker to run once the constraints are met
*/
fun enqueueBackupWorker(
context: Context,
requireWifi: Boolean = false,
requireCharging: Boolean = false,
delayMilliseconds: Long = 0L
) {
val workRequest = buildWorkRequest(requireWifi, requireCharging, delayMilliseconds)
WorkManager.getInstance(context)
.enqueueUniqueWork(TASK_NAME_BACKUP, ExistingWorkPolicy.KEEP, workRequest)
Log.d(TAG, "enqueueBackupWorker: BackupWorker enqueued")
} }
/** /**
* Starts the Dart runtime/engine and calls `_nativeEntry` function in * Updates the constraints of an already enqueued BackupWorker
* `background.service.dart` to run the actual backup logic.
*/ */
private fun runDart() { fun updateBackupWorker(
val callbackDispatcherHandle = applicationContext.getSharedPreferences( context: Context,
SHARED_PREF_NAME, Context.MODE_PRIVATE).getLong(SHARED_PREF_CALLBACK_KEY, 0L) requireWifi: Boolean = false,
val callbackInformation = FlutterCallbackInformation.lookupCallbackInformation(callbackDispatcherHandle) requireCharging: Boolean = false
val appBundlePath = flutterLoader.findAppBundlePath() ) {
try {
engine?.let { engine -> val wm = WorkManager.getInstance(context)
backgroundChannel = MethodChannel(engine.dartExecutor, "immich/backgroundChannel") val workInfoFuture = wm.getWorkInfosForUniqueWork(TASK_NAME_BACKUP)
backgroundChannel.setMethodCallHandler(this@BackupWorker) val workInfoList = workInfoFuture.get(1000, TimeUnit.MILLISECONDS)
engine.dartExecutor.executeDartCallback( if (workInfoList != null) {
DartExecutor.DartCallback( for (workInfo in workInfoList) {
applicationContext.assets, if (workInfo.state == WorkInfo.State.ENQUEUED) {
appBundlePath, val workRequest = buildWorkRequest(requireWifi, requireCharging)
callbackInformation wm.enqueueUniqueWork(TASK_NAME_BACKUP, ExistingWorkPolicy.REPLACE, workRequest)
) Log.d(TAG, "updateBackupWorker updated BackupWorker constraints")
) return
}
}
override fun onStopped() {
Log.d(TAG, "onStopped")
// called when the system has to stop this worker because constraints are
// no longer met or the system needs resources for more important tasks
Handler(Looper.getMainLooper()).postAtFrontOfQueue {
backgroundChannel.invokeMethod("systemStop", null)
}
waitOnSetForegroundAsync()
// cannot await/get(block) on resolvableFuture as its already cancelled (would throw CancellationException)
// instead, wait for 5 seconds until forcefully stopping backup work
Handler(Looper.getMainLooper()).postDelayed({
stopEngine(null)
}, 5000)
}
private fun waitOnSetForegroundAsync() {
val fgFuture = this.fgFuture
if (fgFuture != null && !fgFuture.isCancelled() && !fgFuture.isDone()) {
try {
fgFuture.get(500, TimeUnit.MILLISECONDS)
}
catch (e: Exception) {
// ignored, there is nothing to be done
} }
}
} }
Log.d(TAG, "updateBackupWorker: BackupWorker not enqueued")
} catch (e: Exception) {
Log.d(TAG, "updateBackupWorker failed: $e")
}
} }
private fun stopEngine(result: Result?) { /**
clearBackgroundNotification() * Stops the currently running worker (if any) and removes it from the work queue
engine?.destroy() */
engine = null fun stopWork(context: Context) {
if (result != null) { WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME_BACKUP)
Log.d(TAG, "stopEngine result=${result}") Log.d(TAG, "stopWork: BackupWorker cancelled")
resolvableFuture.set(result)
}
waitOnSetForegroundAsync()
} }
override fun onMethodCall(call: MethodCall, r: MethodChannel.Result) { /**
when (call.method) { * Returns `true` if the app is ignoring battery optimizations
"initialized" -> { */
timeBackupStarted = SystemClock.uptimeMillis() fun isIgnoringBatteryOptimizations(ctx: Context): Boolean {
backgroundChannel.invokeMethod( val powerManager = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
"onAssetsChanged", return powerManager.isIgnoringBatteryOptimizations(ctx.packageName)
null,
object : MethodChannel.Result {
override fun notImplemented() {
stopEngine(Result.failure())
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
stopEngine(Result.failure())
}
override fun success(receivedResult: Any?) {
val success = receivedResult as Boolean
stopEngine(if(success) Result.success() else Result.retry())
}
}
)
}
"updateNotification" -> {
val args = call.arguments<ArrayList<*>>()!!
val title = args.get(0) as String?
val content = args.get(1) as String?
val progress = args.get(2) as Int
val max = args.get(3) as Int
val indeterminate = args.get(4) as Boolean
val isDetail = args.get(5) as Boolean
val onlyIfFG = args.get(6) as Boolean
if (!onlyIfFG || isIgnoringBatteryOptimizations) {
showInfo(getInfoBuilder(title, content, isDetail, progress, max, indeterminate).build(), isDetail)
}
}
"showError" -> {
val args = call.arguments<ArrayList<*>>()!!
val title = args.get(0) as String
val content = args.get(1) as String?
val individualTag = args.get(2) as String?
showError(title, content, individualTag)
}
"clearErrorNotifications" -> clearErrorNotifications()
"hasContentChanged" -> {
val lastChange = applicationContext
.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
.getLong(SHARED_PREF_LAST_CHANGE, timeBackupStarted)
val hasContentChanged = lastChange > timeBackupStarted;
timeBackupStarted = SystemClock.uptimeMillis()
r.success(hasContentChanged)
}
else -> r.notImplemented()
}
} }
private fun showError(title: String, content: String?, individualTag: String?) { private fun buildWorkRequest(
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ERROR_ID) requireWifi: Boolean = false,
.setContentTitle(title) requireCharging: Boolean = false,
.setTicker(title) delayMilliseconds: Long = 0L
.setContentText(content) ): OneTimeWorkRequest {
.setSmallIcon(R.mipmap.ic_launcher) val constraints = Constraints.Builder()
.build() .setRequiredNetworkType(if (requireWifi) NetworkType.UNMETERED else NetworkType.CONNECTED)
notificationManager.notify(individualTag, NOTIFICATION_ERROR_ID, notification) .setRequiresBatteryNotLow(true)
.setRequiresCharging(requireCharging)
.build();
val work = OneTimeWorkRequest.Builder(BackupWorker::class.java)
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, ONE_MINUTE, TimeUnit.MILLISECONDS)
.setInitialDelay(delayMilliseconds, TimeUnit.MILLISECONDS)
.build()
return work
} }
private fun clearErrorNotifications() { private val flutterLoader = FlutterLoader()
notificationManager.cancel(NOTIFICATION_ERROR_ID) }
}
private fun clearBackgroundNotification() {
notificationManager.cancel(NOTIFICATION_ID)
notificationManager.cancel(NOTIFICATION_DETAIL_ID)
}
private fun showInfo(notification: Notification, isDetail: Boolean = false) {
val id = if(isDetail) NOTIFICATION_DETAIL_ID else NOTIFICATION_ID
if (isIgnoringBatteryOptimizations && !isDetail) {
fgFuture = setForegroundAsync(ForegroundInfo(id, notification))
} else {
notificationManager.notify(id, notification)
}
}
private fun getInfoBuilder(
title: String? = null,
content: String? = null,
isDetail: Boolean = false,
progress: Int = 0,
max: Int = 0,
indeterminate: Boolean = false,
): NotificationCompat.Builder {
var builder = if(isDetail) notificationDetailBuilder else notificationBuilder
if (builder == null) {
builder = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setOnlyAlertOnce(true)
.setOngoing(true)
if (isDetail) {
notificationDetailBuilder = builder
} else {
notificationBuilder = builder
}
}
if (title != null) {
builder.setTicker(title).setContentTitle(title)
}
if (content != null) {
builder.setContentText(content)
}
return builder.setProgress(max, progress, indeterminate)
}
@RequiresApi(Build.VERSION_CODES.O)
private fun createChannel() {
val foreground = NotificationChannel(NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_ID, NotificationManager.IMPORTANCE_LOW)
notificationManager.createNotificationChannel(foreground)
val error = NotificationChannel(NOTIFICATION_CHANNEL_ERROR_ID, NOTIFICATION_CHANNEL_ERROR_ID, NotificationManager.IMPORTANCE_HIGH)
notificationManager.createNotificationChannel(error)
}
companion object {
const val SHARED_PREF_NAME = "immichBackgroundService"
const val SHARED_PREF_CALLBACK_KEY = "callbackDispatcherHandle"
const val SHARED_PREF_NOTIFICATION_TITLE = "notificationTitle"
const val SHARED_PREF_LAST_CHANGE = "lastChange"
private const val TASK_NAME_BACKUP = "immich/BackupWorker"
private const val NOTIFICATION_CHANNEL_ID = "immich/backgroundService"
private const val NOTIFICATION_CHANNEL_ERROR_ID = "immich/backgroundServiceError"
private const val NOTIFICATION_DEFAULT_TITLE = "Immich"
private const val NOTIFICATION_ID = 1
private const val NOTIFICATION_ERROR_ID = 2
private const val NOTIFICATION_DETAIL_ID = 3
private const val ONE_MINUTE = 60000L
/**
* Enqueues the BackupWorker to run once the constraints are met
*/
fun enqueueBackupWorker(context: Context,
requireWifi: Boolean = false,
requireCharging: Boolean = false,
delayMilliseconds: Long = 0L) {
val workRequest = buildWorkRequest(requireWifi, requireCharging, delayMilliseconds)
WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME_BACKUP, ExistingWorkPolicy.KEEP, workRequest)
Log.d(TAG, "enqueueBackupWorker: BackupWorker enqueued")
}
/**
* Updates the constraints of an already enqueued BackupWorker
*/
fun updateBackupWorker(context: Context,
requireWifi: Boolean = false,
requireCharging: Boolean = false) {
try {
val wm = WorkManager.getInstance(context)
val workInfoFuture = wm.getWorkInfosForUniqueWork(TASK_NAME_BACKUP)
val workInfoList = workInfoFuture.get(1000, TimeUnit.MILLISECONDS)
if (workInfoList != null) {
for (workInfo in workInfoList) {
if (workInfo.state == WorkInfo.State.ENQUEUED) {
val workRequest = buildWorkRequest(requireWifi, requireCharging)
wm.enqueueUniqueWork(TASK_NAME_BACKUP, ExistingWorkPolicy.REPLACE, workRequest)
Log.d(TAG, "updateBackupWorker updated BackupWorker constraints")
return
}
}
}
Log.d(TAG, "updateBackupWorker: BackupWorker not enqueued")
} catch (e: Exception) {
Log.d(TAG, "updateBackupWorker failed: ${e}")
}
}
/**
* Stops the currently running worker (if any) and removes it from the work queue
*/
fun stopWork(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME_BACKUP)
Log.d(TAG, "stopWork: BackupWorker cancelled")
}
/**
* Returns `true` if the app is ignoring battery optimizations
*/
fun isIgnoringBatteryOptimizations(ctx: Context): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val pwrm = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
val name = ctx.packageName
return pwrm.isIgnoringBatteryOptimizations(name)
}
return true
}
private fun buildWorkRequest(requireWifi: Boolean = false,
requireCharging: Boolean = false,
delayMilliseconds: Long = 0L): OneTimeWorkRequest {
val constraints = Constraints.Builder()
.setRequiredNetworkType(if (requireWifi) NetworkType.UNMETERED else NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.setRequiresCharging(requireCharging)
.build();
val work = OneTimeWorkRequest.Builder(BackupWorker::class.java)
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, ONE_MINUTE, TimeUnit.MILLISECONDS)
.setInitialDelay(delayMilliseconds, TimeUnit.MILLISECONDS)
.build()
return work
}
private val flutterLoader = FlutterLoader()
}
} }
private const val TAG = "BackupWorker" private const val TAG = "BackupWorker"

View File

@ -26,7 +26,7 @@ class ContentObserverWorker(ctx: Context, params: WorkerParameters) : Worker(ctx
if (!isEnabled(applicationContext)) { if (!isEnabled(applicationContext)) {
return Result.failure() return Result.failure()
} }
if (getTriggeredContentUris().size > 0) { if (triggeredContentUris.size > 0) {
startBackupWorker(applicationContext, delayMilliseconds = 0) startBackupWorker(applicationContext, delayMilliseconds = 0)
} }
enqueueObserverWorker(applicationContext, ExistingWorkPolicy.REPLACE) enqueueObserverWorker(applicationContext, ExistingWorkPolicy.REPLACE)
@ -35,10 +35,10 @@ class ContentObserverWorker(ctx: Context, params: WorkerParameters) : Worker(ctx
companion object { companion object {
const val SHARED_PREF_SERVICE_ENABLED = "serviceEnabled" const val SHARED_PREF_SERVICE_ENABLED = "serviceEnabled"
const val SHARED_PREF_REQUIRE_WIFI = "requireWifi" private const val SHARED_PREF_REQUIRE_WIFI = "requireWifi"
const val SHARED_PREF_REQUIRE_CHARGING = "requireCharging" private const val SHARED_PREF_REQUIRE_CHARGING = "requireCharging"
const val SHARED_PREF_TRIGGER_UPDATE_DELAY = "triggerUpdateDelay" private const val SHARED_PREF_TRIGGER_UPDATE_DELAY = "triggerUpdateDelay"
const val SHARED_PREF_TRIGGER_MAX_DELAY = "triggerMaxDelay" private const val SHARED_PREF_TRIGGER_MAX_DELAY = "triggerMaxDelay"
private const val TASK_NAME_OBSERVER = "immich/ContentObserver" private const val TASK_NAME_OBSERVER = "immich/ContentObserver"
@ -106,7 +106,7 @@ class ContentObserverWorker(ctx: Context, params: WorkerParameters) : Worker(ctx
WorkManager WorkManager
.getInstance(context) .getInstance(context)
.enqueueUniqueWork(TASK_NAME_OBSERVER, ExistingWorkPolicy.REPLACE, work) .enqueueUniqueWork(TASK_NAME_OBSERVER, ExistingWorkPolicy.REPLACE, work)
.getResult() .result
.get() .get()
Log.d(TAG, "workManagerAppClearedWorkaround") Log.d(TAG, "workManagerAppClearedWorkaround")
} }