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>
@ -79,4 +98,4 @@
<data android:scheme="geo" /> <data android:scheme="geo" />
</intent> </intent>
</queries> </queries>
</manifest> </manifest>

View File

@ -1,115 +1,123 @@
package app.alextran.immich package app.alextran.immich
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
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
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.*
/**
/** * Android plugin for Dart `BackgroundService`
* Android plugin for Dart `BackgroundService` *
* * Receives messages/method calls from the foreground Dart side to manage
* Receives messages/method calls from the foreground Dart side to manage * the background service, e.g. start (enqueue), stop (cancel)
* the background service, e.g. start (enqueue), stop (cancel) */
*/ 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) {
onAttachedToEngine(binding.applicationContext, binding.binaryMessenger)
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { }
onAttachedToEngine(binding.applicationContext, binding.binaryMessenger)
} private fun onAttachedToEngine(ctx: Context, messenger: BinaryMessenger) {
context = ctx
private fun onAttachedToEngine(ctx: Context, messenger: BinaryMessenger) { methodChannel = MethodChannel(messenger, "immich/foregroundChannel")
context = ctx methodChannel?.setMethodCallHandler(this)
methodChannel = MethodChannel(messenger, "immich/foregroundChannel") }
methodChannel?.setMethodCallHandler(this)
} override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
onDetachedFromEngine()
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { }
onDetachedFromEngine()
} private fun onDetachedFromEngine() {
methodChannel?.setMethodCallHandler(null)
private fun onDetachedFromEngine() { methodChannel = null
methodChannel?.setMethodCallHandler(null) }
methodChannel = null
} override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
val ctx = context!!
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) {
val ctx = context!! "enable" -> {
when (call.method) { val args = call.arguments<ArrayList<*>>()!!
"enable" -> { ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
val args = call.arguments<ArrayList<*>>()!! .edit()
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) .putBoolean(ContentObserverWorker.SHARED_PREF_SERVICE_ENABLED, true)
.edit() .putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args[0] as Long)
.putBoolean(ContentObserverWorker.SHARED_PREF_SERVICE_ENABLED, true) .putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, args[1] as String)
.putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long) .apply()
.putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, args.get(1) as String) ContentObserverWorker.enable(ctx, immediate = args[2] as Boolean)
.apply() result.success(true)
ContentObserverWorker.enable(ctx, immediate = args.get(2) as Boolean) }
result.success(true)
} "configure" -> {
"configure" -> { val args = call.arguments<ArrayList<*>>()!!
val args = call.arguments<ArrayList<*>>()!! val requireUnmeteredNetwork = args[0] as Boolean
val requireUnmeteredNetwork = args.get(0) as Boolean val requireCharging = args[1] as Boolean
val requireCharging = args.get(1) as Boolean val triggerUpdateDelay = (args[2] as Number).toLong()
val triggerUpdateDelay = (args.get(2) as Number).toLong() val triggerMaxDelay = (args[3] as Number).toLong()
val triggerMaxDelay = (args.get(3) as Number).toLong() ContentObserverWorker.configureWork(
ContentObserverWorker.configureWork(ctx, requireUnmeteredNetwork, requireCharging, triggerUpdateDelay, triggerMaxDelay) ctx,
result.success(true) requireUnmeteredNetwork,
} requireCharging,
"disable" -> { triggerUpdateDelay,
ContentObserverWorker.disable(ctx) triggerMaxDelay
BackupWorker.stopWork(ctx) )
result.success(true) result.success(true)
} }
"isEnabled" -> {
result.success(ContentObserverWorker.isEnabled(ctx)) "disable" -> {
} ContentObserverWorker.disable(ctx)
"isIgnoringBatteryOptimizations" -> { BackupWorker.stopWork(ctx)
result.success(BackupWorker.isIgnoringBatteryOptimizations(ctx)) result.success(true)
} }
"digestFiles" -> {
val args = call.arguments<ArrayList<String>>()!! "isEnabled" -> {
GlobalScope.launch(Dispatchers.IO) { result.success(ContentObserverWorker.isEnabled(ctx))
val buf = ByteArray(BUFSIZE) }
val digest: MessageDigest = MessageDigest.getInstance("SHA-1")
val hashes = arrayOfNulls<ByteArray>(args.size) "isIgnoringBatteryOptimizations" -> {
for (i in args.indices) { result.success(BackupWorker.isIgnoringBatteryOptimizations(ctx))
val path = args[i] }
var len = 0
try { "digestFiles" -> {
val file = FileInputStream(path) val args = call.arguments<ArrayList<String>>()!!
try { GlobalScope.launch(Dispatchers.IO) {
while (true) { val buf = ByteArray(BUFFER_SIZE)
len = file.read(buf) val digest: MessageDigest = MessageDigest.getInstance("SHA-1")
if (len != BUFSIZE) break val hashes = arrayOfNulls<ByteArray>(args.size)
digest.update(buf) for (i in args.indices) {
} val path = args[i]
} finally { var len = 0
file.close() try {
} val file = FileInputStream(path)
digest.update(buf, 0, len) file.use { assetFile ->
hashes[i] = digest.digest() while (true) {
} catch (e: Exception) { len = assetFile.read(buf)
// skip this file if (len != BUFFER_SIZE) break
Log.w(TAG, "Failed to hash file ${args[i]}: $e") digest.update(buf)
} }
} }
result.success(hashes.asList()) digest.update(buf, 0, len)
} hashes[i] = digest.digest()
} } catch (e: Exception) {
else -> result.notImplemented() // skip this file
} Log.w(TAG, "Failed to hash file ${args[i]}: $e")
} }
} }
result.success(hashes.asList())
private const val TAG = "BackgroundServicePlugin" }
private const val BUFSIZE = 2*1024*1024; }
else -> result.notImplemented()
}
}
}
private const val TAG = "BackgroundServicePlugin"
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

@ -1,144 +1,144 @@
package app.alextran.immich package app.alextran.immich
import android.content.Context import android.content.Context
import android.os.SystemClock import android.os.SystemClock
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Log import android.util.Log
import androidx.work.Constraints import androidx.work.Constraints
import androidx.work.Worker import androidx.work.Worker
import androidx.work.WorkerParameters 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.Operation import androidx.work.Operation
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/** /**
* Worker executed by Android WorkManager observing content changes (new photos/videos) * Worker executed by Android WorkManager observing content changes (new photos/videos)
* *
* Immediately enqueues the BackupWorker when running. * Immediately enqueues the BackupWorker when running.
* As this work is not triggered periodically, but on content change, the * As this work is not triggered periodically, but on content change, the
* worker enqueues itself again after each run. * worker enqueues itself again after each run.
*/ */
class ContentObserverWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, params) { class ContentObserverWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, params) {
override fun doWork(): Result { override fun doWork(): Result {
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)
return Result.success() return Result.success()
} }
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"
/** /**
* Enqueues the `ContentObserverWorker`. * Enqueues the `ContentObserverWorker`.
* *
* @param context Android Context * @param context Android Context
*/ */
fun enable(context: Context, immediate: Boolean = false) { fun enable(context: Context, immediate: Boolean = false) {
enqueueObserverWorker(context, ExistingWorkPolicy.KEEP) enqueueObserverWorker(context, ExistingWorkPolicy.KEEP)
Log.d(TAG, "enabled ContentObserverWorker") Log.d(TAG, "enabled ContentObserverWorker")
if (immediate) { if (immediate) {
startBackupWorker(context, delayMilliseconds = 5000) startBackupWorker(context, delayMilliseconds = 5000)
} }
} }
/** /**
* Configures the `BackupWorker` to run when all constraints are met. * Configures the `BackupWorker` to run when all constraints are met.
* *
* @param context Android Context * @param context Android Context
* @param requireWifi if true, task only runs if connected to wifi * @param requireWifi if true, task only runs if connected to wifi
* @param requireCharging if true, task only runs if device is charging * @param requireCharging if true, task only runs if device is charging
*/ */
fun configureWork(context: Context, fun configureWork(context: Context,
requireWifi: Boolean = false, requireWifi: Boolean = false,
requireCharging: Boolean = false, requireCharging: Boolean = false,
triggerUpdateDelay: Long = 5000, triggerUpdateDelay: Long = 5000,
triggerMaxDelay: Long = 50000) { triggerMaxDelay: Long = 50000) {
context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
.edit() .edit()
.putBoolean(SHARED_PREF_SERVICE_ENABLED, true) .putBoolean(SHARED_PREF_SERVICE_ENABLED, true)
.putBoolean(SHARED_PREF_REQUIRE_WIFI, requireWifi) .putBoolean(SHARED_PREF_REQUIRE_WIFI, requireWifi)
.putBoolean(SHARED_PREF_REQUIRE_CHARGING, requireCharging) .putBoolean(SHARED_PREF_REQUIRE_CHARGING, requireCharging)
.putLong(SHARED_PREF_TRIGGER_UPDATE_DELAY, triggerUpdateDelay) .putLong(SHARED_PREF_TRIGGER_UPDATE_DELAY, triggerUpdateDelay)
.putLong(SHARED_PREF_TRIGGER_MAX_DELAY, triggerMaxDelay) .putLong(SHARED_PREF_TRIGGER_MAX_DELAY, triggerMaxDelay)
.apply() .apply()
BackupWorker.updateBackupWorker(context, requireWifi, requireCharging) BackupWorker.updateBackupWorker(context, requireWifi, requireCharging)
} }
/** /**
* 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 disable(context: Context) { fun disable(context: Context) {
context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
.edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, false).apply() .edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, false).apply()
WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME_OBSERVER) WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME_OBSERVER)
Log.d(TAG, "disabled ContentObserverWorker") Log.d(TAG, "disabled ContentObserverWorker")
} }
/** /**
* Return true if the user has enabled the background backup service * Return true if the user has enabled the background backup service
*/ */
fun isEnabled(ctx: Context): Boolean { fun isEnabled(ctx: Context): Boolean {
return ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) return ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
.getBoolean(SHARED_PREF_SERVICE_ENABLED, false) .getBoolean(SHARED_PREF_SERVICE_ENABLED, false)
} }
/** /**
* Enqueue and replace the worker without the content trigger but with a short delay * Enqueue and replace the worker without the content trigger but with a short delay
*/ */
fun workManagerAppClearedWorkaround(context: Context) { fun workManagerAppClearedWorkaround(context: Context) {
val work = OneTimeWorkRequest.Builder(ContentObserverWorker::class.java) val work = OneTimeWorkRequest.Builder(ContentObserverWorker::class.java)
.setInitialDelay(500, TimeUnit.MILLISECONDS) .setInitialDelay(500, TimeUnit.MILLISECONDS)
.build() .build()
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")
} }
private fun enqueueObserverWorker(context: Context, policy: ExistingWorkPolicy) { private fun enqueueObserverWorker(context: Context, policy: ExistingWorkPolicy) {
val sp = context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) val sp = context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
val constraints = Constraints.Builder() val constraints = Constraints.Builder()
.addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true) .addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true) .addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true) .addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true) .addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true)
.setTriggerContentUpdateDelay(sp.getLong(SHARED_PREF_TRIGGER_UPDATE_DELAY, 5000), TimeUnit.MILLISECONDS) .setTriggerContentUpdateDelay(sp.getLong(SHARED_PREF_TRIGGER_UPDATE_DELAY, 5000), TimeUnit.MILLISECONDS)
.setTriggerContentMaxDelay(sp.getLong(SHARED_PREF_TRIGGER_MAX_DELAY, 50000), TimeUnit.MILLISECONDS) .setTriggerContentMaxDelay(sp.getLong(SHARED_PREF_TRIGGER_MAX_DELAY, 50000), TimeUnit.MILLISECONDS)
.build() .build()
val work = OneTimeWorkRequest.Builder(ContentObserverWorker::class.java) val work = OneTimeWorkRequest.Builder(ContentObserverWorker::class.java)
.setConstraints(constraints) .setConstraints(constraints)
.build() .build()
WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME_OBSERVER, policy, work) WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME_OBSERVER, policy, work)
} }
fun startBackupWorker(context: Context, delayMilliseconds: Long) { fun startBackupWorker(context: Context, delayMilliseconds: Long) {
val sp = context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) val sp = context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
if (!sp.getBoolean(SHARED_PREF_SERVICE_ENABLED, false)) if (!sp.getBoolean(SHARED_PREF_SERVICE_ENABLED, false))
return return
val requireWifi = sp.getBoolean(SHARED_PREF_REQUIRE_WIFI, true) val requireWifi = sp.getBoolean(SHARED_PREF_REQUIRE_WIFI, true)
val requireCharging = sp.getBoolean(SHARED_PREF_REQUIRE_CHARGING, false) val requireCharging = sp.getBoolean(SHARED_PREF_REQUIRE_CHARGING, false)
BackupWorker.enqueueBackupWorker(context, requireWifi, requireCharging, delayMilliseconds) BackupWorker.enqueueBackupWorker(context, requireWifi, requireCharging, delayMilliseconds)
sp.edit().putLong(BackupWorker.SHARED_PREF_LAST_CHANGE, SystemClock.uptimeMillis()).apply() sp.edit().putLong(BackupWorker.SHARED_PREF_LAST_CHANGE, SystemClock.uptimeMillis()).apply()
} }
} }
} }
private const val TAG = "ContentObserverWorker" private const val TAG = "ContentObserverWorker"