mirror of
https://github.com/immich-app/immich.git
synced 2025-01-16 00:36:47 +01:00
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:
parent
a4506758aa
commit
49610de4b3
5 changed files with 626 additions and 570 deletions
|
@ -46,7 +46,7 @@ android {
|
|||
defaultConfig {
|
||||
applicationId "app.alextran.immich"
|
||||
minSdkVersion 26
|
||||
targetSdkVersion 33
|
||||
targetSdkVersion 34
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
}
|
||||
|
|
|
@ -1,9 +1,38 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="app.alextran.immich"
|
||||
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"
|
||||
android:icon="@mipmap/ic_launcher" android:requestLegacyExternalStorage="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
|
||||
android:name="io.flutter.embedding.android.EnableImpeller"
|
||||
android:value="false" />
|
||||
|
@ -51,23 +80,13 @@
|
|||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
tools:node="remove"></provider>
|
||||
tools:node="remove" />
|
||||
</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>
|
||||
<intent>
|
||||
|
@ -79,4 +98,4 @@
|
|||
<data android:scheme="geo" />
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
</manifest>
|
||||
|
|
|
@ -1,115 +1,123 @@
|
|||
package app.alextran.immich
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
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
|
||||
import java.security.MessageDigest
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
/**
|
||||
* 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
|
||||
private val sha1: MessageDigest = MessageDigest.getInstance("SHA-1")
|
||||
|
||||
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) {
|
||||
"enable" -> {
|
||||
val args = call.arguments<ArrayList<*>>()!!
|
||||
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putBoolean(ContentObserverWorker.SHARED_PREF_SERVICE_ENABLED, true)
|
||||
.putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long)
|
||||
.putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, args.get(1) as String)
|
||||
.apply()
|
||||
ContentObserverWorker.enable(ctx, immediate = args.get(2) as Boolean)
|
||||
result.success(true)
|
||||
}
|
||||
"configure" -> {
|
||||
val args = call.arguments<ArrayList<*>>()!!
|
||||
val requireUnmeteredNetwork = args.get(0) as Boolean
|
||||
val requireCharging = args.get(1) as Boolean
|
||||
val triggerUpdateDelay = (args.get(2) as Number).toLong()
|
||||
val triggerMaxDelay = (args.get(3) as Number).toLong()
|
||||
ContentObserverWorker.configureWork(ctx, requireUnmeteredNetwork, requireCharging, triggerUpdateDelay, triggerMaxDelay)
|
||||
result.success(true)
|
||||
}
|
||||
"disable" -> {
|
||||
ContentObserverWorker.disable(ctx)
|
||||
BackupWorker.stopWork(ctx)
|
||||
result.success(true)
|
||||
}
|
||||
"isEnabled" -> {
|
||||
result.success(ContentObserverWorker.isEnabled(ctx))
|
||||
}
|
||||
"isIgnoringBatteryOptimizations" -> {
|
||||
result.success(BackupWorker.isIgnoringBatteryOptimizations(ctx))
|
||||
}
|
||||
"digestFiles" -> {
|
||||
val args = call.arguments<ArrayList<String>>()!!
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val buf = ByteArray(BUFSIZE)
|
||||
val digest: MessageDigest = MessageDigest.getInstance("SHA-1")
|
||||
val hashes = arrayOfNulls<ByteArray>(args.size)
|
||||
for (i in args.indices) {
|
||||
val path = args[i]
|
||||
var len = 0
|
||||
try {
|
||||
val file = FileInputStream(path)
|
||||
try {
|
||||
while (true) {
|
||||
len = file.read(buf)
|
||||
if (len != BUFSIZE) break
|
||||
digest.update(buf)
|
||||
}
|
||||
} finally {
|
||||
file.close()
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
result.success(hashes.asList())
|
||||
}
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val TAG = "BackgroundServicePlugin"
|
||||
private const val BUFSIZE = 2*1024*1024;
|
||||
package app.alextran.immich
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
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
|
||||
import java.security.MessageDigest
|
||||
import java.io.FileInputStream
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
"enable" -> {
|
||||
val args = call.arguments<ArrayList<*>>()!!
|
||||
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putBoolean(ContentObserverWorker.SHARED_PREF_SERVICE_ENABLED, true)
|
||||
.putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args[0] as Long)
|
||||
.putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, args[1] as String)
|
||||
.apply()
|
||||
ContentObserverWorker.enable(ctx, immediate = args[2] as Boolean)
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
"configure" -> {
|
||||
val args = call.arguments<ArrayList<*>>()!!
|
||||
val requireUnmeteredNetwork = args[0] as Boolean
|
||||
val requireCharging = args[1] as Boolean
|
||||
val triggerUpdateDelay = (args[2] as Number).toLong()
|
||||
val triggerMaxDelay = (args[3] as Number).toLong()
|
||||
ContentObserverWorker.configureWork(
|
||||
ctx,
|
||||
requireUnmeteredNetwork,
|
||||
requireCharging,
|
||||
triggerUpdateDelay,
|
||||
triggerMaxDelay
|
||||
)
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
"disable" -> {
|
||||
ContentObserverWorker.disable(ctx)
|
||||
BackupWorker.stopWork(ctx)
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
"isEnabled" -> {
|
||||
result.success(ContentObserverWorker.isEnabled(ctx))
|
||||
}
|
||||
|
||||
"isIgnoringBatteryOptimizations" -> {
|
||||
result.success(BackupWorker.isIgnoringBatteryOptimizations(ctx))
|
||||
}
|
||||
|
||||
"digestFiles" -> {
|
||||
val args = call.arguments<ArrayList<String>>()!!
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val buf = ByteArray(BUFFER_SIZE)
|
||||
val digest: MessageDigest = MessageDigest.getInstance("SHA-1")
|
||||
val hashes = arrayOfNulls<ByteArray>(args.size)
|
||||
for (i in args.indices) {
|
||||
val path = args[i]
|
||||
var len = 0
|
||||
try {
|
||||
val file = FileInputStream(path)
|
||||
file.use { assetFile ->
|
||||
while (true) {
|
||||
len = assetFile.read(buf)
|
||||
if (len != BUFFER_SIZE) break
|
||||
digest.update(buf)
|
||||
}
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
result.success(hashes.asList())
|
||||
}
|
||||
}
|
||||
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val TAG = "BackgroundServicePlugin"
|
||||
private const val BUFFER_SIZE = 2 * 1024 * 1024;
|
||||
|
|
|
@ -4,6 +4,7 @@ import android.app.Notification
|
|||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
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,
|
||||
* 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 var engine: FlutterEngine? = null
|
||||
private lateinit var backgroundChannel: MethodChannel
|
||||
private val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
private val isIgnoringBatteryOptimizations = isIgnoringBatteryOptimizations(applicationContext)
|
||||
private var timeBackupStarted: Long = 0L
|
||||
private var notificationBuilder: NotificationCompat.Builder? = null
|
||||
private var notificationDetailBuilder: NotificationCompat.Builder? = null
|
||||
private var fgFuture: ListenableFuture<Void>? = null
|
||||
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)
|
||||
private var timeBackupStarted: Long = 0L
|
||||
private var notificationBuilder: NotificationCompat.Builder? = 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()) {
|
||||
flutterLoader.startInitialization(ctx)
|
||||
if (!flutterLoader.initialized()) {
|
||||
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())) {
|
||||
runDart()
|
||||
}
|
||||
"showError" -> {
|
||||
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
|
||||
* `background.service.dart` to run the actual backup logic.
|
||||
* Updates the constraints of an already enqueued BackupWorker
|
||||
*/
|
||||
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
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopEngine(result: Result?) {
|
||||
clearBackgroundNotification()
|
||||
engine?.destroy()
|
||||
engine = null
|
||||
if (result != null) {
|
||||
Log.d(TAG, "stopEngine result=${result}")
|
||||
resolvableFuture.set(result)
|
||||
}
|
||||
waitOnSetForegroundAsync()
|
||||
/**
|
||||
* 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")
|
||||
}
|
||||
|
||||
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.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()
|
||||
}
|
||||
/**
|
||||
* Returns `true` if the app is ignoring battery optimizations
|
||||
*/
|
||||
fun isIgnoringBatteryOptimizations(ctx: Context): Boolean {
|
||||
val powerManager = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
return powerManager.isIgnoringBatteryOptimizations(ctx.packageName)
|
||||
}
|
||||
|
||||
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 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 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 = 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 val flutterLoader = FlutterLoader()
|
||||
}
|
||||
}
|
||||
|
||||
private const val TAG = "BackupWorker"
|
||||
|
|
|
@ -1,144 +1,144 @@
|
|||
package app.alextran.immich
|
||||
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.Worker
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.Operation
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Worker executed by Android WorkManager observing content changes (new photos/videos)
|
||||
*
|
||||
* Immediately enqueues the BackupWorker when running.
|
||||
* As this work is not triggered periodically, but on content change, the
|
||||
* worker enqueues itself again after each run.
|
||||
*/
|
||||
class ContentObserverWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, params) {
|
||||
|
||||
override fun doWork(): Result {
|
||||
if (!isEnabled(applicationContext)) {
|
||||
return Result.failure()
|
||||
}
|
||||
if (getTriggeredContentUris().size > 0) {
|
||||
startBackupWorker(applicationContext, delayMilliseconds = 0)
|
||||
}
|
||||
enqueueObserverWorker(applicationContext, ExistingWorkPolicy.REPLACE)
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SHARED_PREF_SERVICE_ENABLED = "serviceEnabled"
|
||||
const val SHARED_PREF_REQUIRE_WIFI = "requireWifi"
|
||||
const val SHARED_PREF_REQUIRE_CHARGING = "requireCharging"
|
||||
const val SHARED_PREF_TRIGGER_UPDATE_DELAY = "triggerUpdateDelay"
|
||||
const val SHARED_PREF_TRIGGER_MAX_DELAY = "triggerMaxDelay"
|
||||
|
||||
private const val TASK_NAME_OBSERVER = "immich/ContentObserver"
|
||||
|
||||
/**
|
||||
* Enqueues the `ContentObserverWorker`.
|
||||
*
|
||||
* @param context Android Context
|
||||
*/
|
||||
fun enable(context: Context, immediate: Boolean = false) {
|
||||
enqueueObserverWorker(context, ExistingWorkPolicy.KEEP)
|
||||
Log.d(TAG, "enabled ContentObserverWorker")
|
||||
if (immediate) {
|
||||
startBackupWorker(context, delayMilliseconds = 5000)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the `BackupWorker` to run when all constraints are met.
|
||||
*
|
||||
* @param context Android Context
|
||||
* @param requireWifi if true, task only runs if connected to wifi
|
||||
* @param requireCharging if true, task only runs if device is charging
|
||||
*/
|
||||
fun configureWork(context: Context,
|
||||
requireWifi: Boolean = false,
|
||||
requireCharging: Boolean = false,
|
||||
triggerUpdateDelay: Long = 5000,
|
||||
triggerMaxDelay: Long = 50000) {
|
||||
context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putBoolean(SHARED_PREF_SERVICE_ENABLED, true)
|
||||
.putBoolean(SHARED_PREF_REQUIRE_WIFI, requireWifi)
|
||||
.putBoolean(SHARED_PREF_REQUIRE_CHARGING, requireCharging)
|
||||
.putLong(SHARED_PREF_TRIGGER_UPDATE_DELAY, triggerUpdateDelay)
|
||||
.putLong(SHARED_PREF_TRIGGER_MAX_DELAY, triggerMaxDelay)
|
||||
.apply()
|
||||
BackupWorker.updateBackupWorker(context, requireWifi, requireCharging)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the currently running worker (if any) and removes it from the work queue
|
||||
*/
|
||||
fun disable(context: Context) {
|
||||
context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
.edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, false).apply()
|
||||
WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME_OBSERVER)
|
||||
Log.d(TAG, "disabled ContentObserverWorker")
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if the user has enabled the background backup service
|
||||
*/
|
||||
fun isEnabled(ctx: Context): Boolean {
|
||||
return ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
.getBoolean(SHARED_PREF_SERVICE_ENABLED, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue and replace the worker without the content trigger but with a short delay
|
||||
*/
|
||||
fun workManagerAppClearedWorkaround(context: Context) {
|
||||
val work = OneTimeWorkRequest.Builder(ContentObserverWorker::class.java)
|
||||
.setInitialDelay(500, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
WorkManager
|
||||
.getInstance(context)
|
||||
.enqueueUniqueWork(TASK_NAME_OBSERVER, ExistingWorkPolicy.REPLACE, work)
|
||||
.getResult()
|
||||
.get()
|
||||
Log.d(TAG, "workManagerAppClearedWorkaround")
|
||||
}
|
||||
|
||||
private fun enqueueObserverWorker(context: Context, policy: ExistingWorkPolicy) {
|
||||
val sp = context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
val constraints = Constraints.Builder()
|
||||
.addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true)
|
||||
.addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
|
||||
.addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true)
|
||||
.addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true)
|
||||
.setTriggerContentUpdateDelay(sp.getLong(SHARED_PREF_TRIGGER_UPDATE_DELAY, 5000), TimeUnit.MILLISECONDS)
|
||||
.setTriggerContentMaxDelay(sp.getLong(SHARED_PREF_TRIGGER_MAX_DELAY, 50000), TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
val work = OneTimeWorkRequest.Builder(ContentObserverWorker::class.java)
|
||||
.setConstraints(constraints)
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME_OBSERVER, policy, work)
|
||||
}
|
||||
|
||||
fun startBackupWorker(context: Context, delayMilliseconds: Long) {
|
||||
val sp = context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
if (!sp.getBoolean(SHARED_PREF_SERVICE_ENABLED, false))
|
||||
return
|
||||
val requireWifi = sp.getBoolean(SHARED_PREF_REQUIRE_WIFI, true)
|
||||
val requireCharging = sp.getBoolean(SHARED_PREF_REQUIRE_CHARGING, false)
|
||||
BackupWorker.enqueueBackupWorker(context, requireWifi, requireCharging, delayMilliseconds)
|
||||
sp.edit().putLong(BackupWorker.SHARED_PREF_LAST_CHANGE, SystemClock.uptimeMillis()).apply()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private const val TAG = "ContentObserverWorker"
|
||||
package app.alextran.immich
|
||||
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.Worker
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.Operation
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Worker executed by Android WorkManager observing content changes (new photos/videos)
|
||||
*
|
||||
* Immediately enqueues the BackupWorker when running.
|
||||
* As this work is not triggered periodically, but on content change, the
|
||||
* worker enqueues itself again after each run.
|
||||
*/
|
||||
class ContentObserverWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, params) {
|
||||
|
||||
override fun doWork(): Result {
|
||||
if (!isEnabled(applicationContext)) {
|
||||
return Result.failure()
|
||||
}
|
||||
if (triggeredContentUris.size > 0) {
|
||||
startBackupWorker(applicationContext, delayMilliseconds = 0)
|
||||
}
|
||||
enqueueObserverWorker(applicationContext, ExistingWorkPolicy.REPLACE)
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SHARED_PREF_SERVICE_ENABLED = "serviceEnabled"
|
||||
private const val SHARED_PREF_REQUIRE_WIFI = "requireWifi"
|
||||
private const val SHARED_PREF_REQUIRE_CHARGING = "requireCharging"
|
||||
private const val SHARED_PREF_TRIGGER_UPDATE_DELAY = "triggerUpdateDelay"
|
||||
private const val SHARED_PREF_TRIGGER_MAX_DELAY = "triggerMaxDelay"
|
||||
|
||||
private const val TASK_NAME_OBSERVER = "immich/ContentObserver"
|
||||
|
||||
/**
|
||||
* Enqueues the `ContentObserverWorker`.
|
||||
*
|
||||
* @param context Android Context
|
||||
*/
|
||||
fun enable(context: Context, immediate: Boolean = false) {
|
||||
enqueueObserverWorker(context, ExistingWorkPolicy.KEEP)
|
||||
Log.d(TAG, "enabled ContentObserverWorker")
|
||||
if (immediate) {
|
||||
startBackupWorker(context, delayMilliseconds = 5000)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the `BackupWorker` to run when all constraints are met.
|
||||
*
|
||||
* @param context Android Context
|
||||
* @param requireWifi if true, task only runs if connected to wifi
|
||||
* @param requireCharging if true, task only runs if device is charging
|
||||
*/
|
||||
fun configureWork(context: Context,
|
||||
requireWifi: Boolean = false,
|
||||
requireCharging: Boolean = false,
|
||||
triggerUpdateDelay: Long = 5000,
|
||||
triggerMaxDelay: Long = 50000) {
|
||||
context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putBoolean(SHARED_PREF_SERVICE_ENABLED, true)
|
||||
.putBoolean(SHARED_PREF_REQUIRE_WIFI, requireWifi)
|
||||
.putBoolean(SHARED_PREF_REQUIRE_CHARGING, requireCharging)
|
||||
.putLong(SHARED_PREF_TRIGGER_UPDATE_DELAY, triggerUpdateDelay)
|
||||
.putLong(SHARED_PREF_TRIGGER_MAX_DELAY, triggerMaxDelay)
|
||||
.apply()
|
||||
BackupWorker.updateBackupWorker(context, requireWifi, requireCharging)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the currently running worker (if any) and removes it from the work queue
|
||||
*/
|
||||
fun disable(context: Context) {
|
||||
context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
.edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, false).apply()
|
||||
WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME_OBSERVER)
|
||||
Log.d(TAG, "disabled ContentObserverWorker")
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if the user has enabled the background backup service
|
||||
*/
|
||||
fun isEnabled(ctx: Context): Boolean {
|
||||
return ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
.getBoolean(SHARED_PREF_SERVICE_ENABLED, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue and replace the worker without the content trigger but with a short delay
|
||||
*/
|
||||
fun workManagerAppClearedWorkaround(context: Context) {
|
||||
val work = OneTimeWorkRequest.Builder(ContentObserverWorker::class.java)
|
||||
.setInitialDelay(500, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
WorkManager
|
||||
.getInstance(context)
|
||||
.enqueueUniqueWork(TASK_NAME_OBSERVER, ExistingWorkPolicy.REPLACE, work)
|
||||
.result
|
||||
.get()
|
||||
Log.d(TAG, "workManagerAppClearedWorkaround")
|
||||
}
|
||||
|
||||
private fun enqueueObserverWorker(context: Context, policy: ExistingWorkPolicy) {
|
||||
val sp = context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
val constraints = Constraints.Builder()
|
||||
.addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true)
|
||||
.addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
|
||||
.addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true)
|
||||
.addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true)
|
||||
.setTriggerContentUpdateDelay(sp.getLong(SHARED_PREF_TRIGGER_UPDATE_DELAY, 5000), TimeUnit.MILLISECONDS)
|
||||
.setTriggerContentMaxDelay(sp.getLong(SHARED_PREF_TRIGGER_MAX_DELAY, 50000), TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
val work = OneTimeWorkRequest.Builder(ContentObserverWorker::class.java)
|
||||
.setConstraints(constraints)
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME_OBSERVER, policy, work)
|
||||
}
|
||||
|
||||
fun startBackupWorker(context: Context, delayMilliseconds: Long) {
|
||||
val sp = context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
if (!sp.getBoolean(SHARED_PREF_SERVICE_ENABLED, false))
|
||||
return
|
||||
val requireWifi = sp.getBoolean(SHARED_PREF_REQUIRE_WIFI, true)
|
||||
val requireCharging = sp.getBoolean(SHARED_PREF_REQUIRE_CHARGING, false)
|
||||
BackupWorker.enqueueBackupWorker(context, requireWifi, requireCharging, delayMilliseconds)
|
||||
sp.edit().putLong(BackupWorker.SHARED_PREF_LAST_CHANGE, SystemClock.uptimeMillis()).apply()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private const val TAG = "ContentObserverWorker"
|
||||
|
|
Loading…
Reference in a new issue