mirror of https://github.com/immich-app/immich.git
feat: beta background sync (#21243)
* feat: ios background sync # Conflicts: # mobile/ios/Runner/Info.plist * feat: Android sync * add local sync worker and rename stuff * group upload notifications * uncomment onresume beta handling * rename methods --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex <alex.tran1502@gmail.com>pull/20758/head
parent
e78144ea31
commit
0df88fc22b
@ -0,0 +1,238 @@
|
|||||||
|
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
|
||||||
|
// See also: https://pub.dev/packages/pigeon
|
||||||
|
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
|
||||||
|
|
||||||
|
package app.alextran.immich.background
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import io.flutter.plugin.common.BasicMessageChannel
|
||||||
|
import io.flutter.plugin.common.BinaryMessenger
|
||||||
|
import io.flutter.plugin.common.EventChannel
|
||||||
|
import io.flutter.plugin.common.MessageCodec
|
||||||
|
import io.flutter.plugin.common.StandardMethodCodec
|
||||||
|
import io.flutter.plugin.common.StandardMessageCodec
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
private object BackgroundWorkerPigeonUtils {
|
||||||
|
|
||||||
|
fun createConnectionError(channelName: String): FlutterError {
|
||||||
|
return FlutterError("channel-error", "Unable to establish connection on channel: '$channelName'.", "") }
|
||||||
|
|
||||||
|
fun wrapResult(result: Any?): List<Any?> {
|
||||||
|
return listOf(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun wrapError(exception: Throwable): List<Any?> {
|
||||||
|
return if (exception is FlutterError) {
|
||||||
|
listOf(
|
||||||
|
exception.code,
|
||||||
|
exception.message,
|
||||||
|
exception.details
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
listOf(
|
||||||
|
exception.javaClass.simpleName,
|
||||||
|
exception.toString(),
|
||||||
|
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error class for passing custom error details to Flutter via a thrown PlatformException.
|
||||||
|
* @property code The error code.
|
||||||
|
* @property message The error message.
|
||||||
|
* @property details The error details. Must be a datatype supported by the api codec.
|
||||||
|
*/
|
||||||
|
class FlutterError (
|
||||||
|
val code: String,
|
||||||
|
override val message: String? = null,
|
||||||
|
val details: Any? = null
|
||||||
|
) : Throwable()
|
||||||
|
private open class BackgroundWorkerPigeonCodec : StandardMessageCodec() {
|
||||||
|
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||||
|
return super.readValueOfType(type, buffer)
|
||||||
|
}
|
||||||
|
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
||||||
|
super.writeValue(stream, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||||
|
interface BackgroundWorkerFgHostApi {
|
||||||
|
fun enableSyncWorker()
|
||||||
|
fun enableUploadWorker(callbackHandle: Long)
|
||||||
|
fun disableUploadWorker()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** The codec used by BackgroundWorkerFgHostApi. */
|
||||||
|
val codec: MessageCodec<Any?> by lazy {
|
||||||
|
BackgroundWorkerPigeonCodec()
|
||||||
|
}
|
||||||
|
/** Sets up an instance of `BackgroundWorkerFgHostApi` to handle messages through the `binaryMessenger`. */
|
||||||
|
@JvmOverloads
|
||||||
|
fun setUp(binaryMessenger: BinaryMessenger, api: BackgroundWorkerFgHostApi?, messageChannelSuffix: String = "") {
|
||||||
|
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableSyncWorker$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { _, reply ->
|
||||||
|
val wrapped: List<Any?> = try {
|
||||||
|
api.enableSyncWorker()
|
||||||
|
listOf(null)
|
||||||
|
} catch (exception: Throwable) {
|
||||||
|
BackgroundWorkerPigeonUtils.wrapError(exception)
|
||||||
|
}
|
||||||
|
reply.reply(wrapped)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableUploadWorker$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { message, reply ->
|
||||||
|
val args = message as List<Any?>
|
||||||
|
val callbackHandleArg = args[0] as Long
|
||||||
|
val wrapped: List<Any?> = try {
|
||||||
|
api.enableUploadWorker(callbackHandleArg)
|
||||||
|
listOf(null)
|
||||||
|
} catch (exception: Throwable) {
|
||||||
|
BackgroundWorkerPigeonUtils.wrapError(exception)
|
||||||
|
}
|
||||||
|
reply.reply(wrapped)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disableUploadWorker$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { _, reply ->
|
||||||
|
val wrapped: List<Any?> = try {
|
||||||
|
api.disableUploadWorker()
|
||||||
|
listOf(null)
|
||||||
|
} catch (exception: Throwable) {
|
||||||
|
BackgroundWorkerPigeonUtils.wrapError(exception)
|
||||||
|
}
|
||||||
|
reply.reply(wrapped)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||||
|
interface BackgroundWorkerBgHostApi {
|
||||||
|
fun onInitialized()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** The codec used by BackgroundWorkerBgHostApi. */
|
||||||
|
val codec: MessageCodec<Any?> by lazy {
|
||||||
|
BackgroundWorkerPigeonCodec()
|
||||||
|
}
|
||||||
|
/** Sets up an instance of `BackgroundWorkerBgHostApi` to handle messages through the `binaryMessenger`. */
|
||||||
|
@JvmOverloads
|
||||||
|
fun setUp(binaryMessenger: BinaryMessenger, api: BackgroundWorkerBgHostApi?, messageChannelSuffix: String = "") {
|
||||||
|
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerBgHostApi.onInitialized$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { _, reply ->
|
||||||
|
val wrapped: List<Any?> = try {
|
||||||
|
api.onInitialized()
|
||||||
|
listOf(null)
|
||||||
|
} catch (exception: Throwable) {
|
||||||
|
BackgroundWorkerPigeonUtils.wrapError(exception)
|
||||||
|
}
|
||||||
|
reply.reply(wrapped)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. */
|
||||||
|
class BackgroundWorkerFlutterApi(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") {
|
||||||
|
companion object {
|
||||||
|
/** The codec used by BackgroundWorkerFlutterApi. */
|
||||||
|
val codec: MessageCodec<Any?> by lazy {
|
||||||
|
BackgroundWorkerPigeonCodec()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun onLocalSync(maxSecondsArg: Long?, callback: (Result<Unit>) -> Unit)
|
||||||
|
{
|
||||||
|
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
|
val channelName = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onLocalSync$separatedMessageChannelSuffix"
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||||
|
channel.send(listOf(maxSecondsArg)) {
|
||||||
|
if (it is List<*>) {
|
||||||
|
if (it.size > 1) {
|
||||||
|
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||||
|
} else {
|
||||||
|
callback(Result.success(Unit))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
callback(Result.failure(BackgroundWorkerPigeonUtils.createConnectionError(channelName)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun onIosUpload(isRefreshArg: Boolean, maxSecondsArg: Long?, callback: (Result<Unit>) -> Unit)
|
||||||
|
{
|
||||||
|
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
|
val channelName = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onIosUpload$separatedMessageChannelSuffix"
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||||
|
channel.send(listOf(isRefreshArg, maxSecondsArg)) {
|
||||||
|
if (it is List<*>) {
|
||||||
|
if (it.size > 1) {
|
||||||
|
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||||
|
} else {
|
||||||
|
callback(Result.success(Unit))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
callback(Result.failure(BackgroundWorkerPigeonUtils.createConnectionError(channelName)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun onAndroidUpload(callback: (Result<Unit>) -> Unit)
|
||||||
|
{
|
||||||
|
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
|
val channelName = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onAndroidUpload$separatedMessageChannelSuffix"
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||||
|
channel.send(null) {
|
||||||
|
if (it is List<*>) {
|
||||||
|
if (it.size > 1) {
|
||||||
|
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||||
|
} else {
|
||||||
|
callback(Result.success(Unit))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
callback(Result.failure(BackgroundWorkerPigeonUtils.createConnectionError(channelName)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun cancel(callback: (Result<Unit>) -> Unit)
|
||||||
|
{
|
||||||
|
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
|
val channelName = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.cancel$separatedMessageChannelSuffix"
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||||
|
channel.send(null) {
|
||||||
|
if (it is List<*>) {
|
||||||
|
if (it.size > 1) {
|
||||||
|
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||||
|
} else {
|
||||||
|
callback(Result.success(Unit))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
callback(Result.failure(BackgroundWorkerPigeonUtils.createConnectionError(channelName)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,162 @@
|
|||||||
|
package app.alextran.immich.background
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.work.ListenableWorker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import app.alextran.immich.MainActivity
|
||||||
|
import com.google.common.util.concurrent.ListenableFuture
|
||||||
|
import com.google.common.util.concurrent.SettableFuture
|
||||||
|
import io.flutter.FlutterInjector
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.embedding.engine.dart.DartExecutor.DartCallback
|
||||||
|
import io.flutter.embedding.engine.loader.FlutterLoader
|
||||||
|
import io.flutter.view.FlutterCallbackInformation
|
||||||
|
|
||||||
|
private const val TAG = "BackgroundWorker"
|
||||||
|
|
||||||
|
enum class BackgroundTaskType {
|
||||||
|
LOCAL_SYNC,
|
||||||
|
UPLOAD,
|
||||||
|
}
|
||||||
|
|
||||||
|
class BackgroundWorker(context: Context, params: WorkerParameters) :
|
||||||
|
ListenableWorker(context, params), BackgroundWorkerBgHostApi {
|
||||||
|
private val ctx: Context = context.applicationContext
|
||||||
|
|
||||||
|
/// The Flutter loader that loads the native Flutter library and resources.
|
||||||
|
/// This must be initialized before starting the Flutter engine.
|
||||||
|
private var loader: FlutterLoader = FlutterInjector.instance().flutterLoader()
|
||||||
|
|
||||||
|
/// The Flutter engine created specifically for background execution.
|
||||||
|
/// This is a separate instance from the main Flutter engine that handles the UI.
|
||||||
|
/// It operates in its own isolate and doesn't share memory with the main engine.
|
||||||
|
/// Must be properly started, registered, and torn down during background execution.
|
||||||
|
private var engine: FlutterEngine? = null
|
||||||
|
|
||||||
|
// Used to call methods on the flutter side
|
||||||
|
private var flutterApi: BackgroundWorkerFlutterApi? = null
|
||||||
|
|
||||||
|
/// Result returned when the background task completes. This is used to signal
|
||||||
|
/// to the WorkManager that the task has finished, either successfully or with failure.
|
||||||
|
private val completionHandler: SettableFuture<Result> = SettableFuture.create()
|
||||||
|
|
||||||
|
/// Flag to track whether the background task has completed to prevent duplicate completions
|
||||||
|
private var isComplete = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (!loader.initialized()) {
|
||||||
|
loader.startInitialization(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun startWork(): ListenableFuture<Result> {
|
||||||
|
Log.i(TAG, "Starting background upload worker")
|
||||||
|
|
||||||
|
loader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) {
|
||||||
|
engine = FlutterEngine(ctx)
|
||||||
|
|
||||||
|
// Retrieve the callback handle stored by the main Flutter app
|
||||||
|
// This handle points to the Flutter function that should be executed in the background
|
||||||
|
val callbackHandle =
|
||||||
|
ctx.getSharedPreferences(BackgroundWorkerApiImpl.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||||
|
.getLong(BackgroundWorkerApiImpl.SHARED_PREF_CALLBACK_HANDLE, 0L)
|
||||||
|
|
||||||
|
if (callbackHandle == 0L) {
|
||||||
|
// Without a valid callback handle, we cannot start the Flutter background execution
|
||||||
|
complete(Result.failure())
|
||||||
|
return@ensureInitializationCompleteAsync
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the Flutter engine with the specified callback as the entry point
|
||||||
|
val callback = FlutterCallbackInformation.lookupCallbackInformation(callbackHandle)
|
||||||
|
if (callback == null) {
|
||||||
|
complete(Result.failure())
|
||||||
|
return@ensureInitializationCompleteAsync
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register custom plugins
|
||||||
|
MainActivity.registerPlugins(ctx, engine!!)
|
||||||
|
flutterApi =
|
||||||
|
BackgroundWorkerFlutterApi(binaryMessenger = engine!!.dartExecutor.binaryMessenger)
|
||||||
|
BackgroundWorkerBgHostApi.setUp(
|
||||||
|
binaryMessenger = engine!!.dartExecutor.binaryMessenger,
|
||||||
|
api = this
|
||||||
|
)
|
||||||
|
|
||||||
|
engine!!.dartExecutor.executeDartCallback(
|
||||||
|
DartCallback(ctx.assets, loader.findAppBundlePath(), callback)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return completionHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by the Flutter side when it has finished initialization and is ready to receive commands.
|
||||||
|
* Routes the appropriate task type (refresh or processing) to the corresponding Flutter method.
|
||||||
|
* This method acts as a bridge between the native Android background task system and Flutter.
|
||||||
|
*/
|
||||||
|
override fun onInitialized() {
|
||||||
|
val taskTypeIndex = inputData.getInt(BackgroundWorkerApiImpl.WORKER_DATA_TASK_TYPE, 0)
|
||||||
|
val taskType = BackgroundTaskType.entries[taskTypeIndex]
|
||||||
|
|
||||||
|
when (taskType) {
|
||||||
|
BackgroundTaskType.LOCAL_SYNC -> flutterApi?.onLocalSync(null) { handleHostResult(it) }
|
||||||
|
BackgroundTaskType.UPLOAD -> flutterApi?.onAndroidUpload { handleHostResult(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the system has to stop this worker because constraints are
|
||||||
|
* no longer met or the system needs resources for more important tasks
|
||||||
|
* This is also called when the worker has been explicitly cancelled or replaced
|
||||||
|
*/
|
||||||
|
override fun onStopped() {
|
||||||
|
Log.d(TAG, "About to stop BackupWorker")
|
||||||
|
|
||||||
|
if (isComplete) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Handler(Looper.getMainLooper()).postAtFrontOfQueue {
|
||||||
|
if (flutterApi != null) {
|
||||||
|
flutterApi?.cancel {
|
||||||
|
complete(Result.failure())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Handler(Looper.getMainLooper()).postDelayed({
|
||||||
|
complete(Result.failure())
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleHostResult(result: kotlin.Result<Unit>) {
|
||||||
|
if (isComplete) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { _ -> complete(Result.success()) },
|
||||||
|
onFailure = { _ -> onStopped() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleans up resources by destroying the Flutter engine context and invokes the completion handler.
|
||||||
|
* This method ensures that the background task is marked as complete, releases the Flutter engine,
|
||||||
|
* and notifies the caller of the task's success or failure. This is the final step in the
|
||||||
|
* background task lifecycle and should only be called once per task instance.
|
||||||
|
*
|
||||||
|
* - Parameter success: Indicates whether the background task completed successfully
|
||||||
|
*/
|
||||||
|
private fun complete(success: Result) {
|
||||||
|
isComplete = true
|
||||||
|
engine?.destroy()
|
||||||
|
flutterApi = null
|
||||||
|
completionHandler.set(success)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,92 @@
|
|||||||
|
package app.alextran.immich.background
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import androidx.work.BackoffPolicy
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.Data
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
|
import androidx.work.OneTimeWorkRequest
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
private const val TAG = "BackgroundUploadImpl"
|
||||||
|
|
||||||
|
class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
|
||||||
|
private val ctx: Context = context.applicationContext
|
||||||
|
override fun enableSyncWorker() {
|
||||||
|
enqueueMediaObserver(ctx)
|
||||||
|
Log.i(TAG, "Scheduled media observer")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun enableUploadWorker(callbackHandle: Long) {
|
||||||
|
updateUploadEnabled(ctx, true)
|
||||||
|
updateCallbackHandle(ctx, callbackHandle)
|
||||||
|
Log.i(TAG, "Scheduled background upload tasks")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun disableUploadWorker() {
|
||||||
|
updateUploadEnabled(ctx, false)
|
||||||
|
WorkManager.getInstance(ctx).cancelUniqueWork(BACKGROUND_WORKER_NAME)
|
||||||
|
Log.i(TAG, "Cancelled background upload tasks")
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val BACKGROUND_WORKER_NAME = "immich/BackgroundWorkerV1"
|
||||||
|
private const val OBSERVER_WORKER_NAME = "immich/MediaObserverV1"
|
||||||
|
|
||||||
|
const val WORKER_DATA_TASK_TYPE = "taskType"
|
||||||
|
|
||||||
|
const val SHARED_PREF_NAME = "Immich::Background"
|
||||||
|
const val SHARED_PREF_BACKUP_ENABLED = "Background::backup::enabled"
|
||||||
|
const val SHARED_PREF_CALLBACK_HANDLE = "Background::backup::callbackHandle"
|
||||||
|
|
||||||
|
private fun updateUploadEnabled(context: Context, enabled: Boolean) {
|
||||||
|
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE).edit {
|
||||||
|
putBoolean(SHARED_PREF_BACKUP_ENABLED, enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateCallbackHandle(context: Context, callbackHandle: Long) {
|
||||||
|
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE).edit {
|
||||||
|
putLong(SHARED_PREF_CALLBACK_HANDLE, callbackHandle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun enqueueMediaObserver(ctx: Context) {
|
||||||
|
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(5, TimeUnit.SECONDS)
|
||||||
|
.setTriggerContentMaxDelay(1, TimeUnit.MINUTES)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val work = OneTimeWorkRequest.Builder(MediaObserver::class.java)
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.build()
|
||||||
|
WorkManager.getInstance(ctx)
|
||||||
|
.enqueueUniqueWork(OBSERVER_WORKER_NAME, ExistingWorkPolicy.REPLACE, work)
|
||||||
|
|
||||||
|
Log.i(TAG, "Enqueued media observer worker with name: $OBSERVER_WORKER_NAME")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun enqueueBackgroundWorker(ctx: Context, taskType: BackgroundTaskType) {
|
||||||
|
val constraints = Constraints.Builder().setRequiresBatteryNotLow(true).build()
|
||||||
|
|
||||||
|
val data = Data.Builder()
|
||||||
|
data.putInt(WORKER_DATA_TASK_TYPE, taskType.ordinal)
|
||||||
|
val work = OneTimeWorkRequest.Builder(BackgroundWorker::class.java)
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
|
||||||
|
.setInputData(data.build()).build()
|
||||||
|
WorkManager.getInstance(ctx)
|
||||||
|
.enqueueUniqueWork(BACKGROUND_WORKER_NAME, ExistingWorkPolicy.REPLACE, work)
|
||||||
|
|
||||||
|
Log.i(TAG, "Enqueued background worker with name: $BACKGROUND_WORKER_NAME")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
package app.alextran.immich.background
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.work.Worker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
|
||||||
|
class MediaObserver(context: Context, params: WorkerParameters) : Worker(context, params) {
|
||||||
|
private val ctx: Context = context.applicationContext
|
||||||
|
|
||||||
|
override fun doWork(): Result {
|
||||||
|
Log.i("MediaObserver", "Content change detected, starting background worker")
|
||||||
|
|
||||||
|
// Enqueue backup worker only if there are new media changes
|
||||||
|
if (triggeredContentUris.isNotEmpty()) {
|
||||||
|
val type =
|
||||||
|
if (isBackupEnabled(ctx)) BackgroundTaskType.UPLOAD else BackgroundTaskType.LOCAL_SYNC
|
||||||
|
BackgroundWorkerApiImpl.enqueueBackgroundWorker(ctx, type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-enqueue itself to listen for future changes
|
||||||
|
BackgroundWorkerApiImpl.enqueueMediaObserver(ctx)
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isBackupEnabled(context: Context): Boolean {
|
||||||
|
val prefs =
|
||||||
|
context.getSharedPreferences(
|
||||||
|
BackgroundWorkerApiImpl.SHARED_PREF_NAME,
|
||||||
|
Context.MODE_PRIVATE
|
||||||
|
)
|
||||||
|
return prefs.getBoolean(BackgroundWorkerApiImpl.SHARED_PREF_BACKUP_ENABLED, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,245 @@
|
|||||||
|
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
|
||||||
|
// See also: https://pub.dev/packages/pigeon
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
import Flutter
|
||||||
|
#elseif os(macOS)
|
||||||
|
import FlutterMacOS
|
||||||
|
#else
|
||||||
|
#error("Unsupported platform.")
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private func wrapResult(_ result: Any?) -> [Any?] {
|
||||||
|
return [result]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func wrapError(_ error: Any) -> [Any?] {
|
||||||
|
if let pigeonError = error as? PigeonError {
|
||||||
|
return [
|
||||||
|
pigeonError.code,
|
||||||
|
pigeonError.message,
|
||||||
|
pigeonError.details,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if let flutterError = error as? FlutterError {
|
||||||
|
return [
|
||||||
|
flutterError.code,
|
||||||
|
flutterError.message,
|
||||||
|
flutterError.details,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
"\(error)",
|
||||||
|
"\(type(of: error))",
|
||||||
|
"Stacktrace: \(Thread.callStackSymbols)",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createConnectionError(withChannelName channelName: String) -> PigeonError {
|
||||||
|
return PigeonError(code: "channel-error", message: "Unable to establish connection on channel: '\(channelName)'.", details: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isNullish(_ value: Any?) -> Bool {
|
||||||
|
return value is NSNull || value == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func nilOrValue<T>(_ value: Any?) -> T? {
|
||||||
|
if value is NSNull { return nil }
|
||||||
|
return value as! T?
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private class BackgroundWorkerPigeonCodecReader: FlutterStandardReader {
|
||||||
|
}
|
||||||
|
|
||||||
|
private class BackgroundWorkerPigeonCodecWriter: FlutterStandardWriter {
|
||||||
|
}
|
||||||
|
|
||||||
|
private class BackgroundWorkerPigeonCodecReaderWriter: FlutterStandardReaderWriter {
|
||||||
|
override func reader(with data: Data) -> FlutterStandardReader {
|
||||||
|
return BackgroundWorkerPigeonCodecReader(data: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func writer(with data: NSMutableData) -> FlutterStandardWriter {
|
||||||
|
return BackgroundWorkerPigeonCodecWriter(data: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BackgroundWorkerPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
|
||||||
|
static let shared = BackgroundWorkerPigeonCodec(readerWriter: BackgroundWorkerPigeonCodecReaderWriter())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||||
|
protocol BackgroundWorkerFgHostApi {
|
||||||
|
func enableSyncWorker() throws
|
||||||
|
func enableUploadWorker(callbackHandle: Int64) throws
|
||||||
|
func disableUploadWorker() throws
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||||
|
class BackgroundWorkerFgHostApiSetup {
|
||||||
|
static var codec: FlutterStandardMessageCodec { BackgroundWorkerPigeonCodec.shared }
|
||||||
|
/// Sets up an instance of `BackgroundWorkerFgHostApi` to handle messages through the `binaryMessenger`.
|
||||||
|
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: BackgroundWorkerFgHostApi?, messageChannelSuffix: String = "") {
|
||||||
|
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
|
||||||
|
let enableSyncWorkerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableSyncWorker\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
|
if let api = api {
|
||||||
|
enableSyncWorkerChannel.setMessageHandler { _, reply in
|
||||||
|
do {
|
||||||
|
try api.enableSyncWorker()
|
||||||
|
reply(wrapResult(nil))
|
||||||
|
} catch {
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
enableSyncWorkerChannel.setMessageHandler(nil)
|
||||||
|
}
|
||||||
|
let enableUploadWorkerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableUploadWorker\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
|
if let api = api {
|
||||||
|
enableUploadWorkerChannel.setMessageHandler { message, reply in
|
||||||
|
let args = message as! [Any?]
|
||||||
|
let callbackHandleArg = args[0] as! Int64
|
||||||
|
do {
|
||||||
|
try api.enableUploadWorker(callbackHandle: callbackHandleArg)
|
||||||
|
reply(wrapResult(nil))
|
||||||
|
} catch {
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
enableUploadWorkerChannel.setMessageHandler(nil)
|
||||||
|
}
|
||||||
|
let disableUploadWorkerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disableUploadWorker\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
|
if let api = api {
|
||||||
|
disableUploadWorkerChannel.setMessageHandler { _, reply in
|
||||||
|
do {
|
||||||
|
try api.disableUploadWorker()
|
||||||
|
reply(wrapResult(nil))
|
||||||
|
} catch {
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
disableUploadWorkerChannel.setMessageHandler(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||||
|
protocol BackgroundWorkerBgHostApi {
|
||||||
|
func onInitialized() throws
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||||
|
class BackgroundWorkerBgHostApiSetup {
|
||||||
|
static var codec: FlutterStandardMessageCodec { BackgroundWorkerPigeonCodec.shared }
|
||||||
|
/// Sets up an instance of `BackgroundWorkerBgHostApi` to handle messages through the `binaryMessenger`.
|
||||||
|
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: BackgroundWorkerBgHostApi?, messageChannelSuffix: String = "") {
|
||||||
|
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
|
||||||
|
let onInitializedChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerBgHostApi.onInitialized\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
|
if let api = api {
|
||||||
|
onInitializedChannel.setMessageHandler { _, reply in
|
||||||
|
do {
|
||||||
|
try api.onInitialized()
|
||||||
|
reply(wrapResult(nil))
|
||||||
|
} catch {
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onInitializedChannel.setMessageHandler(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift.
|
||||||
|
protocol BackgroundWorkerFlutterApiProtocol {
|
||||||
|
func onLocalSync(maxSeconds maxSecondsArg: Int64?, completion: @escaping (Result<Void, PigeonError>) -> Void)
|
||||||
|
func onIosUpload(isRefresh isRefreshArg: Bool, maxSeconds maxSecondsArg: Int64?, completion: @escaping (Result<Void, PigeonError>) -> Void)
|
||||||
|
func onAndroidUpload(completion: @escaping (Result<Void, PigeonError>) -> Void)
|
||||||
|
func cancel(completion: @escaping (Result<Void, PigeonError>) -> Void)
|
||||||
|
}
|
||||||
|
class BackgroundWorkerFlutterApi: BackgroundWorkerFlutterApiProtocol {
|
||||||
|
private let binaryMessenger: FlutterBinaryMessenger
|
||||||
|
private let messageChannelSuffix: String
|
||||||
|
init(binaryMessenger: FlutterBinaryMessenger, messageChannelSuffix: String = "") {
|
||||||
|
self.binaryMessenger = binaryMessenger
|
||||||
|
self.messageChannelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
|
||||||
|
}
|
||||||
|
var codec: BackgroundWorkerPigeonCodec {
|
||||||
|
return BackgroundWorkerPigeonCodec.shared
|
||||||
|
}
|
||||||
|
func onLocalSync(maxSeconds maxSecondsArg: Int64?, completion: @escaping (Result<Void, PigeonError>) -> Void) {
|
||||||
|
let channelName: String = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onLocalSync\(messageChannelSuffix)"
|
||||||
|
let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec)
|
||||||
|
channel.sendMessage([maxSecondsArg] as [Any?]) { response in
|
||||||
|
guard let listResponse = response as? [Any?] else {
|
||||||
|
completion(.failure(createConnectionError(withChannelName: channelName)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if listResponse.count > 1 {
|
||||||
|
let code: String = listResponse[0] as! String
|
||||||
|
let message: String? = nilOrValue(listResponse[1])
|
||||||
|
let details: String? = nilOrValue(listResponse[2])
|
||||||
|
completion(.failure(PigeonError(code: code, message: message, details: details)))
|
||||||
|
} else {
|
||||||
|
completion(.success(()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func onIosUpload(isRefresh isRefreshArg: Bool, maxSeconds maxSecondsArg: Int64?, completion: @escaping (Result<Void, PigeonError>) -> Void) {
|
||||||
|
let channelName: String = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onIosUpload\(messageChannelSuffix)"
|
||||||
|
let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec)
|
||||||
|
channel.sendMessage([isRefreshArg, maxSecondsArg] as [Any?]) { response in
|
||||||
|
guard let listResponse = response as? [Any?] else {
|
||||||
|
completion(.failure(createConnectionError(withChannelName: channelName)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if listResponse.count > 1 {
|
||||||
|
let code: String = listResponse[0] as! String
|
||||||
|
let message: String? = nilOrValue(listResponse[1])
|
||||||
|
let details: String? = nilOrValue(listResponse[2])
|
||||||
|
completion(.failure(PigeonError(code: code, message: message, details: details)))
|
||||||
|
} else {
|
||||||
|
completion(.success(()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func onAndroidUpload(completion: @escaping (Result<Void, PigeonError>) -> Void) {
|
||||||
|
let channelName: String = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onAndroidUpload\(messageChannelSuffix)"
|
||||||
|
let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec)
|
||||||
|
channel.sendMessage(nil) { response in
|
||||||
|
guard let listResponse = response as? [Any?] else {
|
||||||
|
completion(.failure(createConnectionError(withChannelName: channelName)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if listResponse.count > 1 {
|
||||||
|
let code: String = listResponse[0] as! String
|
||||||
|
let message: String? = nilOrValue(listResponse[1])
|
||||||
|
let details: String? = nilOrValue(listResponse[2])
|
||||||
|
completion(.failure(PigeonError(code: code, message: message, details: details)))
|
||||||
|
} else {
|
||||||
|
completion(.success(()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func cancel(completion: @escaping (Result<Void, PigeonError>) -> Void) {
|
||||||
|
let channelName: String = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.cancel\(messageChannelSuffix)"
|
||||||
|
let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec)
|
||||||
|
channel.sendMessage(nil) { response in
|
||||||
|
guard let listResponse = response as? [Any?] else {
|
||||||
|
completion(.failure(createConnectionError(withChannelName: channelName)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if listResponse.count > 1 {
|
||||||
|
let code: String = listResponse[0] as! String
|
||||||
|
let message: String? = nilOrValue(listResponse[1])
|
||||||
|
let details: String? = nilOrValue(listResponse[2])
|
||||||
|
completion(.failure(PigeonError(code: code, message: message, details: details)))
|
||||||
|
} else {
|
||||||
|
completion(.success(()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,202 @@
|
|||||||
|
import BackgroundTasks
|
||||||
|
import Flutter
|
||||||
|
|
||||||
|
enum BackgroundTaskType { case localSync, refreshUpload, processingUpload }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* DEBUG: Testing Background Tasks in Xcode
|
||||||
|
*
|
||||||
|
* To test background task functionality during development:
|
||||||
|
* 1. Pause the application in Xcode debugger
|
||||||
|
* 2. In the debugger console, enter one of the following commands:
|
||||||
|
|
||||||
|
## For local sync (short-running sync):
|
||||||
|
|
||||||
|
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"app.alextran.immich.background.localSync"]
|
||||||
|
|
||||||
|
## For background refresh (short-running sync):
|
||||||
|
|
||||||
|
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"app.alextran.immich.background.refreshUpload"]
|
||||||
|
|
||||||
|
## For background processing (long-running upload):
|
||||||
|
|
||||||
|
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"app.alextran.immich.background.processingUpload"]
|
||||||
|
|
||||||
|
* To simulate task expiration (useful for testing expiration handlers):
|
||||||
|
|
||||||
|
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"app.alextran.immich.background.localSync"]
|
||||||
|
|
||||||
|
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"app.alextran.immich.background.refreshUpload"]
|
||||||
|
|
||||||
|
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"app.alextran.immich.background.processingUpload"]
|
||||||
|
|
||||||
|
* 3. Resume the application to see the background code execute
|
||||||
|
*
|
||||||
|
* NOTE: This must be tested on a physical device, not in the simulator.
|
||||||
|
* In testing, only the background processing task can be reliably simulated.
|
||||||
|
* These commands submit the respective task to BGTaskScheduler for immediate processing.
|
||||||
|
* Use the expiration commands to test how the app handles iOS terminating background tasks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
/// The background worker which creates a new Flutter VM, communicates with it
|
||||||
|
/// to run the backup job, and then finishes execution and calls back to its callback handler.
|
||||||
|
/// This class manages a separate Flutter engine instance for background execution,
|
||||||
|
/// independent of the main UI Flutter engine.
|
||||||
|
class BackgroundWorker: BackgroundWorkerBgHostApi {
|
||||||
|
private let taskType: BackgroundTaskType
|
||||||
|
/// The maximum number of seconds to run the task before timing out
|
||||||
|
private let maxSeconds: Int?
|
||||||
|
/// Callback function to invoke when the background task completes
|
||||||
|
private let completionHandler: (_ success: Bool) -> Void
|
||||||
|
|
||||||
|
/// The Flutter engine created specifically for background execution.
|
||||||
|
/// This is a separate instance from the main Flutter engine that handles the UI.
|
||||||
|
/// It operates in its own isolate and doesn't share memory with the main engine.
|
||||||
|
/// Must be properly started, registered, and torn down during background execution.
|
||||||
|
private let engine = FlutterEngine(name: "BackgroundImmich")
|
||||||
|
|
||||||
|
/// Used to call methods on the flutter side
|
||||||
|
private var flutterApi: BackgroundWorkerFlutterApi?
|
||||||
|
|
||||||
|
/// Flag to track whether the background task has completed to prevent duplicate completions
|
||||||
|
private var isComplete = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes a new background worker with the specified task type and execution constraints.
|
||||||
|
* Creates a new Flutter engine instance for background execution and sets up the necessary
|
||||||
|
* communication channels between native iOS and Flutter code.
|
||||||
|
*
|
||||||
|
* - Parameters:
|
||||||
|
* - taskType: The type of background task to execute (upload or sync task)
|
||||||
|
* - maxSeconds: Optional maximum execution time in seconds before the task is cancelled
|
||||||
|
* - completionHandler: Callback function invoked when the task completes, with success status
|
||||||
|
*/
|
||||||
|
init(taskType: BackgroundTaskType, maxSeconds: Int?, completionHandler: @escaping (_ success: Bool) -> Void) {
|
||||||
|
self.taskType = taskType
|
||||||
|
self.maxSeconds = maxSeconds
|
||||||
|
self.completionHandler = completionHandler
|
||||||
|
// Should be initialized only after the engine starts running
|
||||||
|
self.flutterApi = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the background Flutter engine and begins execution of the background task.
|
||||||
|
* Retrieves the callback handle from UserDefaults, looks up the Flutter callback,
|
||||||
|
* starts the engine, and sets up a timeout timer if specified.
|
||||||
|
*/
|
||||||
|
func run() {
|
||||||
|
// Retrieve the callback handle stored by the main Flutter app
|
||||||
|
// This handle points to the Flutter function that should be executed in the background
|
||||||
|
let callbackHandle = Int64(UserDefaults.standard.string(
|
||||||
|
forKey: BackgroundWorkerApiImpl.backgroundUploadCallbackHandleKey) ?? "0") ?? 0
|
||||||
|
|
||||||
|
if callbackHandle == 0 {
|
||||||
|
// Without a valid callback handle, we cannot start the Flutter background execution
|
||||||
|
complete(success: false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the callback handle to retrieve the actual Flutter callback information
|
||||||
|
guard let callback = FlutterCallbackCache.lookupCallbackInformation(callbackHandle) else {
|
||||||
|
// The callback handle is invalid or the callback was not found
|
||||||
|
complete(success: false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the Flutter engine with the specified callback as the entry point
|
||||||
|
let isRunning = engine.run(
|
||||||
|
withEntrypoint: callback.callbackName,
|
||||||
|
libraryURI: callback.callbackLibraryPath
|
||||||
|
)
|
||||||
|
|
||||||
|
// Verify that the Flutter engine started successfully
|
||||||
|
if !isRunning {
|
||||||
|
complete(success: false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register plugins in the new engine
|
||||||
|
GeneratedPluginRegistrant.register(with: engine)
|
||||||
|
// Register custom plugins
|
||||||
|
AppDelegate.registerPlugins(binaryMessenger: engine.binaryMessenger)
|
||||||
|
flutterApi = BackgroundWorkerFlutterApi(binaryMessenger: engine.binaryMessenger)
|
||||||
|
BackgroundWorkerBgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: self)
|
||||||
|
|
||||||
|
// Set up a timeout timer if maxSeconds was specified to prevent runaway background tasks
|
||||||
|
if maxSeconds != nil {
|
||||||
|
// Schedule a timer to cancel the task after the specified timeout period
|
||||||
|
Timer.scheduledTimer(withTimeInterval: TimeInterval(maxSeconds!), repeats: false) { _ in
|
||||||
|
self.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by the Flutter side when it has finished initialization and is ready to receive commands.
|
||||||
|
* Routes the appropriate task type (refresh or processing) to the corresponding Flutter method.
|
||||||
|
* This method acts as a bridge between the native iOS background task system and Flutter.
|
||||||
|
*/
|
||||||
|
func onInitialized() throws {
|
||||||
|
switch self.taskType {
|
||||||
|
case .refreshUpload, .processingUpload:
|
||||||
|
flutterApi?.onIosUpload(isRefresh: self.taskType == .refreshUpload,
|
||||||
|
maxSeconds: maxSeconds.map { Int64($0) }, completion: { result in
|
||||||
|
self.handleHostResult(result: result)
|
||||||
|
})
|
||||||
|
case .localSync:
|
||||||
|
flutterApi?.onLocalSync(maxSeconds: maxSeconds.map { Int64($0) }, completion: { result in
|
||||||
|
self.handleHostResult(result: result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancels the currently running background task, either due to timeout or external request.
|
||||||
|
* Sends a cancel signal to the Flutter side and sets up a fallback timer to ensure
|
||||||
|
* the completion handler is eventually called even if Flutter doesn't respond.
|
||||||
|
*/
|
||||||
|
func cancel() {
|
||||||
|
if isComplete {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isComplete = true
|
||||||
|
flutterApi?.cancel { result in
|
||||||
|
self.complete(success: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback safety mechanism: ensure completion is called within 2 seconds
|
||||||
|
// This prevents the background task from hanging indefinitely if Flutter doesn't respond
|
||||||
|
Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { _ in
|
||||||
|
self.complete(success: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the result from Flutter API calls and determines the success/failure status.
|
||||||
|
* Converts Flutter's Result type to a simple boolean success indicator for task completion.
|
||||||
|
*
|
||||||
|
* - Parameter result: The result returned from a Flutter API call
|
||||||
|
*/
|
||||||
|
private func handleHostResult(result: Result<Void, PigeonError>) {
|
||||||
|
switch result {
|
||||||
|
case .success(): self.complete(success: true)
|
||||||
|
case .failure(_): self.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleans up resources by destroying the Flutter engine context and invokes the completion handler.
|
||||||
|
* This method ensures that the background task is marked as complete, releases the Flutter engine,
|
||||||
|
* and notifies the caller of the task's success or failure. This is the final step in the
|
||||||
|
* background task lifecycle and should only be called once per task instance.
|
||||||
|
*
|
||||||
|
* - Parameter success: Indicates whether the background task completed successfully
|
||||||
|
*/
|
||||||
|
private func complete(success: Bool) {
|
||||||
|
isComplete = true
|
||||||
|
engine.destroyContext()
|
||||||
|
completionHandler(success)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,155 @@
|
|||||||
|
import BackgroundTasks
|
||||||
|
|
||||||
|
class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
|
||||||
|
func enableSyncWorker() throws {
|
||||||
|
BackgroundWorkerApiImpl.scheduleLocalSync()
|
||||||
|
print("BackgroundUploadImpl:enableSyncWorker Local Sync worker scheduled")
|
||||||
|
}
|
||||||
|
|
||||||
|
func enableUploadWorker(callbackHandle: Int64) throws {
|
||||||
|
BackgroundWorkerApiImpl.updateUploadEnabled(true)
|
||||||
|
// Store the callback handle for later use when starting background Flutter isolates
|
||||||
|
BackgroundWorkerApiImpl.updateUploadCallbackHandle(callbackHandle)
|
||||||
|
|
||||||
|
BackgroundWorkerApiImpl.scheduleRefreshUpload()
|
||||||
|
BackgroundWorkerApiImpl.scheduleProcessingUpload()
|
||||||
|
print("BackgroundUploadImpl:enableUploadWorker Scheduled background upload tasks")
|
||||||
|
}
|
||||||
|
|
||||||
|
func disableUploadWorker() throws {
|
||||||
|
BackgroundWorkerApiImpl.updateUploadEnabled(false)
|
||||||
|
BackgroundWorkerApiImpl.cancelUploadTasks()
|
||||||
|
print("BackgroundUploadImpl:disableUploadWorker Disabled background upload tasks")
|
||||||
|
}
|
||||||
|
|
||||||
|
public static let backgroundUploadEnabledKey = "immich:background:backup:enabled"
|
||||||
|
public static let backgroundUploadCallbackHandleKey = "immich:background:backup:callbackHandle"
|
||||||
|
|
||||||
|
private static let localSyncTaskID = "app.alextran.immich.background.localSync"
|
||||||
|
private static let refreshUploadTaskID = "app.alextran.immich.background.refreshUpload"
|
||||||
|
private static let processingUploadTaskID = "app.alextran.immich.background.processingUpload"
|
||||||
|
|
||||||
|
private static func updateUploadEnabled(_ isEnabled: Bool) {
|
||||||
|
return UserDefaults.standard.set(isEnabled, forKey: BackgroundWorkerApiImpl.backgroundUploadEnabledKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func updateUploadCallbackHandle(_ callbackHandle: Int64) {
|
||||||
|
return UserDefaults.standard.set(String(callbackHandle), forKey: BackgroundWorkerApiImpl.backgroundUploadCallbackHandleKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func cancelUploadTasks() {
|
||||||
|
BackgroundWorkerApiImpl.updateUploadEnabled(false)
|
||||||
|
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: refreshUploadTaskID);
|
||||||
|
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: processingUploadTaskID);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func registerBackgroundProcessing() {
|
||||||
|
BGTaskScheduler.shared.register(
|
||||||
|
forTaskWithIdentifier: processingUploadTaskID, using: nil) { task in
|
||||||
|
if task is BGProcessingTask {
|
||||||
|
handleBackgroundProcessing(task: task as! BGProcessingTask)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BGTaskScheduler.shared.register(
|
||||||
|
forTaskWithIdentifier: refreshUploadTaskID, using: nil) { task in
|
||||||
|
if task is BGAppRefreshTask {
|
||||||
|
handleBackgroundRefresh(task: task as! BGAppRefreshTask, taskType: .refreshUpload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BGTaskScheduler.shared.register(
|
||||||
|
forTaskWithIdentifier: localSyncTaskID, using: nil) { task in
|
||||||
|
if task is BGAppRefreshTask {
|
||||||
|
handleBackgroundRefresh(task: task as! BGAppRefreshTask, taskType: .localSync)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func scheduleLocalSync() {
|
||||||
|
let backgroundRefresh = BGAppRefreshTaskRequest(identifier: localSyncTaskID)
|
||||||
|
backgroundRefresh.earliestBeginDate = Date(timeIntervalSinceNow: 5 * 60) // 5 mins
|
||||||
|
|
||||||
|
do {
|
||||||
|
try BGTaskScheduler.shared.submit(backgroundRefresh)
|
||||||
|
} catch {
|
||||||
|
print("Could not schedule the local sync task \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func scheduleRefreshUpload() {
|
||||||
|
let backgroundRefresh = BGAppRefreshTaskRequest(identifier: refreshUploadTaskID)
|
||||||
|
backgroundRefresh.earliestBeginDate = Date(timeIntervalSinceNow: 5 * 60) // 5 mins
|
||||||
|
|
||||||
|
do {
|
||||||
|
try BGTaskScheduler.shared.submit(backgroundRefresh)
|
||||||
|
} catch {
|
||||||
|
print("Could not schedule the refresh upload task \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func scheduleProcessingUpload() {
|
||||||
|
let backgroundProcessing = BGProcessingTaskRequest(identifier: processingUploadTaskID)
|
||||||
|
|
||||||
|
backgroundProcessing.requiresNetworkConnectivity = true
|
||||||
|
backgroundProcessing.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 mins
|
||||||
|
|
||||||
|
do {
|
||||||
|
try BGTaskScheduler.shared.submit(backgroundProcessing)
|
||||||
|
} catch {
|
||||||
|
print("Could not schedule the processing upload task \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func handleBackgroundRefresh(task: BGAppRefreshTask, taskType: BackgroundTaskType) {
|
||||||
|
scheduleRefreshUpload()
|
||||||
|
// Restrict the refresh task to run only for a maximum of 20 seconds
|
||||||
|
runBackgroundWorker(task: task, taskType: taskType, maxSeconds: 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func handleBackgroundProcessing(task: BGProcessingTask) {
|
||||||
|
scheduleProcessingUpload()
|
||||||
|
// There are no restrictions for processing tasks. Although, the OS could signal expiration at any time
|
||||||
|
runBackgroundWorker(task: task, taskType: .processingUpload, maxSeconds: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes the background worker within the context of a background task.
|
||||||
|
* This method creates a BackgroundWorker, sets up task expiration handling,
|
||||||
|
* and manages the synchronization between the background task and the Flutter engine.
|
||||||
|
*
|
||||||
|
* - Parameters:
|
||||||
|
* - task: The iOS background task that provides the execution context
|
||||||
|
* - taskType: The type of background operation to perform (refresh or processing)
|
||||||
|
* - maxSeconds: Optional timeout for the operation in seconds
|
||||||
|
*/
|
||||||
|
private static func runBackgroundWorker(task: BGTask, taskType: BackgroundTaskType, maxSeconds: Int?) {
|
||||||
|
let semaphore = DispatchSemaphore(value: 0)
|
||||||
|
var isSuccess = true
|
||||||
|
|
||||||
|
let backgroundWorker = BackgroundWorker(taskType: taskType, maxSeconds: maxSeconds) { success in
|
||||||
|
isSuccess = success
|
||||||
|
semaphore.signal()
|
||||||
|
}
|
||||||
|
|
||||||
|
task.expirationHandler = {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
backgroundWorker.cancel()
|
||||||
|
}
|
||||||
|
isSuccess = false
|
||||||
|
|
||||||
|
// Schedule a timer to signal the semaphore after 2 seconds
|
||||||
|
Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { _ in
|
||||||
|
semaphore.signal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
backgroundWorker.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
semaphore.wait()
|
||||||
|
task.setTaskCompleted(success: isSuccess)
|
||||||
|
print("Background task completed with success: \(isSuccess)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,232 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:background_downloader/background_downloader.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/constants/constants.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart';
|
||||||
|
import 'package:immich_mobile/platform/background_worker_api.g.dart';
|
||||||
|
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/db.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
|
import 'package:immich_mobile/services/auth.service.dart';
|
||||||
|
import 'package:immich_mobile/services/localization.service.dart';
|
||||||
|
import 'package:immich_mobile/services/upload.service.dart';
|
||||||
|
import 'package:immich_mobile/utils/bootstrap.dart';
|
||||||
|
import 'package:immich_mobile/utils/http_ssl_options.dart';
|
||||||
|
import 'package:isar/isar.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
class BackgroundWorkerFgService {
|
||||||
|
final BackgroundWorkerFgHostApi _foregroundHostApi;
|
||||||
|
|
||||||
|
const BackgroundWorkerFgService(this._foregroundHostApi);
|
||||||
|
|
||||||
|
// TODO: Move this call to native side once old timeline is removed
|
||||||
|
Future<void> enableSyncService() => _foregroundHostApi.enableSyncWorker();
|
||||||
|
|
||||||
|
Future<void> enableUploadService() => _foregroundHostApi.enableUploadWorker(
|
||||||
|
PluginUtilities.getCallbackHandle(_backgroundSyncNativeEntrypoint)!.toRawHandle(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> disableUploadService() => _foregroundHostApi.disableUploadWorker();
|
||||||
|
}
|
||||||
|
|
||||||
|
class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||||
|
late final ProviderContainer _ref;
|
||||||
|
final Isar _isar;
|
||||||
|
final Drift _drift;
|
||||||
|
final DriftLogger _driftLogger;
|
||||||
|
final BackgroundWorkerBgHostApi _backgroundHostApi;
|
||||||
|
final Logger _logger = Logger('BackgroundUploadBgService');
|
||||||
|
|
||||||
|
bool _isCleanedUp = false;
|
||||||
|
|
||||||
|
BackgroundWorkerBgService({required Isar isar, required Drift drift, required DriftLogger driftLogger})
|
||||||
|
: _isar = isar,
|
||||||
|
_drift = drift,
|
||||||
|
_driftLogger = driftLogger,
|
||||||
|
_backgroundHostApi = BackgroundWorkerBgHostApi() {
|
||||||
|
_ref = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
dbProvider.overrideWithValue(isar),
|
||||||
|
isarProvider.overrideWithValue(isar),
|
||||||
|
driftProvider.overrideWith(driftOverride(drift)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
BackgroundWorkerFlutterApi.setUp(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _isBackupEnabled => _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
|
||||||
|
|
||||||
|
Future<void> init() async {
|
||||||
|
await loadTranslations();
|
||||||
|
HttpSSLOptions.apply(applyNative: false);
|
||||||
|
await _ref.read(authServiceProvider).setOpenApiServiceEndpoint();
|
||||||
|
|
||||||
|
// Initialize the file downloader
|
||||||
|
await FileDownloader().configure(
|
||||||
|
globalConfig: [
|
||||||
|
// maxConcurrent: 6, maxConcurrentByHost(server):6, maxConcurrentByGroup: 3
|
||||||
|
(Config.holdingQueue, (6, 6, 3)),
|
||||||
|
// On Android, if files are larger than 256MB, run in foreground service
|
||||||
|
(Config.runInForegroundIfFileLargerThan, 256),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
await FileDownloader().trackTasksInGroup(kDownloadGroupLivePhoto, markDownloadedComplete: false);
|
||||||
|
await FileDownloader().trackTasks();
|
||||||
|
configureFileDownloaderNotifications();
|
||||||
|
|
||||||
|
// Notify the host that the background upload service has been initialized and is ready to use
|
||||||
|
await _backgroundHostApi.onInitialized();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onLocalSync(int? maxSeconds) async {
|
||||||
|
_logger.info('Local background syncing started');
|
||||||
|
final sw = Stopwatch()..start();
|
||||||
|
|
||||||
|
final timeout = maxSeconds != null ? Duration(seconds: maxSeconds) : null;
|
||||||
|
await _syncAssets(hashTimeout: timeout, syncRemote: false);
|
||||||
|
|
||||||
|
sw.stop();
|
||||||
|
_logger.info("Local sync completed in ${sw.elapsed.inSeconds}s");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* We do the following on Android upload
|
||||||
|
* - Sync local assets
|
||||||
|
* - Hash local assets 3 / 6 minutes
|
||||||
|
* - Sync remote assets
|
||||||
|
* - Check and requeue upload tasks
|
||||||
|
*/
|
||||||
|
@override
|
||||||
|
Future<void> onAndroidUpload() async {
|
||||||
|
_logger.info('Android background processing started');
|
||||||
|
final sw = Stopwatch()..start();
|
||||||
|
|
||||||
|
await _syncAssets(hashTimeout: Duration(minutes: _isBackupEnabled ? 3 : 6));
|
||||||
|
await _handleBackup(processBulk: false);
|
||||||
|
|
||||||
|
await _cleanup();
|
||||||
|
|
||||||
|
sw.stop();
|
||||||
|
_logger.info("Android background processing completed in ${sw.elapsed.inSeconds}s");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* We do the following on background upload
|
||||||
|
* - Sync local assets
|
||||||
|
* - Hash local assets
|
||||||
|
* - Sync remote assets
|
||||||
|
* - Check and requeue upload tasks
|
||||||
|
*
|
||||||
|
* The native side will not send the maxSeconds value for processing tasks
|
||||||
|
*/
|
||||||
|
@override
|
||||||
|
Future<void> onIosUpload(bool isRefresh, int? maxSeconds) async {
|
||||||
|
_logger.info('iOS background upload started with maxSeconds: ${maxSeconds}s');
|
||||||
|
final sw = Stopwatch()..start();
|
||||||
|
|
||||||
|
final timeout = isRefresh ? const Duration(seconds: 5) : Duration(minutes: _isBackupEnabled ? 3 : 6);
|
||||||
|
await _syncAssets(hashTimeout: timeout);
|
||||||
|
|
||||||
|
final backupFuture = _handleBackup();
|
||||||
|
if (maxSeconds != null) {
|
||||||
|
await backupFuture.timeout(Duration(seconds: maxSeconds - 1), onTimeout: () {});
|
||||||
|
} else {
|
||||||
|
await backupFuture;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _cleanup();
|
||||||
|
|
||||||
|
sw.stop();
|
||||||
|
_logger.info("iOS background upload completed in ${sw.elapsed.inSeconds}s");
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> cancel() async {
|
||||||
|
_logger.warning("Background upload cancelled");
|
||||||
|
await _cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _cleanup() async {
|
||||||
|
if (_isCleanedUp) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isCleanedUp = true;
|
||||||
|
await _ref.read(backgroundSyncProvider).cancel();
|
||||||
|
await _ref.read(backgroundSyncProvider).cancelLocal();
|
||||||
|
await _isar.close();
|
||||||
|
await _drift.close();
|
||||||
|
await _driftLogger.close();
|
||||||
|
_ref.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleBackup({bool processBulk = true}) async {
|
||||||
|
if (!_isBackupEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final currentUser = _ref.read(currentUserProvider);
|
||||||
|
if (currentUser == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (processBulk) {
|
||||||
|
return _ref.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
final activeTask = await _ref.read(uploadServiceProvider).getActiveTasks(currentUser.id);
|
||||||
|
if (activeTask.isNotEmpty) {
|
||||||
|
await _ref.read(uploadServiceProvider).resumeBackup();
|
||||||
|
} else {
|
||||||
|
await _ref.read(uploadServiceProvider).startBackupSerial(currentUser.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _syncAssets({Duration? hashTimeout, bool syncRemote = true}) async {
|
||||||
|
final futures = <Future<void>>[];
|
||||||
|
|
||||||
|
final localSyncFuture = _ref.read(backgroundSyncProvider).syncLocal().then((_) async {
|
||||||
|
if (_isCleanedUp) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hashFuture = _ref.read(backgroundSyncProvider).hashAssets();
|
||||||
|
if (hashTimeout != null) {
|
||||||
|
hashFuture = hashFuture.timeout(
|
||||||
|
hashTimeout,
|
||||||
|
onTimeout: () {
|
||||||
|
// Consume cancellation errors as we want to continue processing
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hashFuture;
|
||||||
|
});
|
||||||
|
|
||||||
|
futures.add(localSyncFuture);
|
||||||
|
if (syncRemote) {
|
||||||
|
final remoteSyncFuture = _ref.read(backgroundSyncProvider).syncRemote();
|
||||||
|
futures.add(remoteSyncFuture);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Future.wait(futures);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@pragma('vm:entry-point')
|
||||||
|
Future<void> _backgroundSyncNativeEntrypoint() async {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
DartPluginRegistrant.ensureInitialized();
|
||||||
|
|
||||||
|
final (isar, drift, logDB) = await Bootstrap.initDB();
|
||||||
|
await Bootstrap.initDomain(isar, drift, logDB, shouldBufferLogs: false);
|
||||||
|
await BackgroundWorkerBgService(isar: isar, drift: drift, driftLogger: logDB).init();
|
||||||
|
}
|
||||||
@ -0,0 +1,296 @@
|
|||||||
|
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
|
||||||
|
// See also: https://pub.dev/packages/pigeon
|
||||||
|
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer;
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
PlatformException _createConnectionError(String channelName) {
|
||||||
|
return PlatformException(
|
||||||
|
code: 'channel-error',
|
||||||
|
message: 'Unable to establish connection on channel: "$channelName".',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty = false}) {
|
||||||
|
if (empty) {
|
||||||
|
return <Object?>[];
|
||||||
|
}
|
||||||
|
if (error == null) {
|
||||||
|
return <Object?>[result];
|
||||||
|
}
|
||||||
|
return <Object?>[error.code, error.message, error.details];
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PigeonCodec extends StandardMessageCodec {
|
||||||
|
const _PigeonCodec();
|
||||||
|
@override
|
||||||
|
void writeValue(WriteBuffer buffer, Object? value) {
|
||||||
|
if (value is int) {
|
||||||
|
buffer.putUint8(4);
|
||||||
|
buffer.putInt64(value);
|
||||||
|
} else {
|
||||||
|
super.writeValue(buffer, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Object? readValueOfType(int type, ReadBuffer buffer) {
|
||||||
|
switch (type) {
|
||||||
|
default:
|
||||||
|
return super.readValueOfType(type, buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BackgroundWorkerFgHostApi {
|
||||||
|
/// Constructor for [BackgroundWorkerFgHostApi]. The [binaryMessenger] named argument is
|
||||||
|
/// available for dependency injection. If it is left null, the default
|
||||||
|
/// BinaryMessenger will be used which routes to the host platform.
|
||||||
|
BackgroundWorkerFgHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||||
|
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||||
|
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||||
|
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||||
|
|
||||||
|
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||||
|
|
||||||
|
final String pigeonVar_messageChannelSuffix;
|
||||||
|
|
||||||
|
Future<void> enableSyncWorker() async {
|
||||||
|
final String pigeonVar_channelName =
|
||||||
|
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableSyncWorker$pigeonVar_messageChannelSuffix';
|
||||||
|
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
pigeonVar_channelName,
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
|
);
|
||||||
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||||
|
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
if (pigeonVar_replyList == null) {
|
||||||
|
throw _createConnectionError(pigeonVar_channelName);
|
||||||
|
} else if (pigeonVar_replyList.length > 1) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: pigeonVar_replyList[0]! as String,
|
||||||
|
message: pigeonVar_replyList[1] as String?,
|
||||||
|
details: pigeonVar_replyList[2],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> enableUploadWorker(int callbackHandle) async {
|
||||||
|
final String pigeonVar_channelName =
|
||||||
|
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableUploadWorker$pigeonVar_messageChannelSuffix';
|
||||||
|
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
pigeonVar_channelName,
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
|
);
|
||||||
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[callbackHandle]);
|
||||||
|
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
if (pigeonVar_replyList == null) {
|
||||||
|
throw _createConnectionError(pigeonVar_channelName);
|
||||||
|
} else if (pigeonVar_replyList.length > 1) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: pigeonVar_replyList[0]! as String,
|
||||||
|
message: pigeonVar_replyList[1] as String?,
|
||||||
|
details: pigeonVar_replyList[2],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> disableUploadWorker() async {
|
||||||
|
final String pigeonVar_channelName =
|
||||||
|
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disableUploadWorker$pigeonVar_messageChannelSuffix';
|
||||||
|
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
pigeonVar_channelName,
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
|
);
|
||||||
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||||
|
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
if (pigeonVar_replyList == null) {
|
||||||
|
throw _createConnectionError(pigeonVar_channelName);
|
||||||
|
} else if (pigeonVar_replyList.length > 1) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: pigeonVar_replyList[0]! as String,
|
||||||
|
message: pigeonVar_replyList[1] as String?,
|
||||||
|
details: pigeonVar_replyList[2],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BackgroundWorkerBgHostApi {
|
||||||
|
/// Constructor for [BackgroundWorkerBgHostApi]. The [binaryMessenger] named argument is
|
||||||
|
/// available for dependency injection. If it is left null, the default
|
||||||
|
/// BinaryMessenger will be used which routes to the host platform.
|
||||||
|
BackgroundWorkerBgHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||||
|
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||||
|
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||||
|
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||||
|
|
||||||
|
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||||
|
|
||||||
|
final String pigeonVar_messageChannelSuffix;
|
||||||
|
|
||||||
|
Future<void> onInitialized() async {
|
||||||
|
final String pigeonVar_channelName =
|
||||||
|
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerBgHostApi.onInitialized$pigeonVar_messageChannelSuffix';
|
||||||
|
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
pigeonVar_channelName,
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
|
);
|
||||||
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||||
|
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
if (pigeonVar_replyList == null) {
|
||||||
|
throw _createConnectionError(pigeonVar_channelName);
|
||||||
|
} else if (pigeonVar_replyList.length > 1) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: pigeonVar_replyList[0]! as String,
|
||||||
|
message: pigeonVar_replyList[1] as String?,
|
||||||
|
details: pigeonVar_replyList[2],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class BackgroundWorkerFlutterApi {
|
||||||
|
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||||
|
|
||||||
|
Future<void> onLocalSync(int? maxSeconds);
|
||||||
|
|
||||||
|
Future<void> onIosUpload(bool isRefresh, int? maxSeconds);
|
||||||
|
|
||||||
|
Future<void> onAndroidUpload();
|
||||||
|
|
||||||
|
Future<void> cancel();
|
||||||
|
|
||||||
|
static void setUp(
|
||||||
|
BackgroundWorkerFlutterApi? api, {
|
||||||
|
BinaryMessenger? binaryMessenger,
|
||||||
|
String messageChannelSuffix = '',
|
||||||
|
}) {
|
||||||
|
messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||||
|
{
|
||||||
|
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onLocalSync$messageChannelSuffix',
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: binaryMessenger,
|
||||||
|
);
|
||||||
|
if (api == null) {
|
||||||
|
pigeonVar_channel.setMessageHandler(null);
|
||||||
|
} else {
|
||||||
|
pigeonVar_channel.setMessageHandler((Object? message) async {
|
||||||
|
assert(
|
||||||
|
message != null,
|
||||||
|
'Argument for dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onLocalSync was null.',
|
||||||
|
);
|
||||||
|
final List<Object?> args = (message as List<Object?>?)!;
|
||||||
|
final int? arg_maxSeconds = (args[0] as int?);
|
||||||
|
try {
|
||||||
|
await api.onLocalSync(arg_maxSeconds);
|
||||||
|
return wrapResponse(empty: true);
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
return wrapResponse(error: e);
|
||||||
|
} catch (e) {
|
||||||
|
return wrapResponse(
|
||||||
|
error: PlatformException(code: 'error', message: e.toString()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onIosUpload$messageChannelSuffix',
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: binaryMessenger,
|
||||||
|
);
|
||||||
|
if (api == null) {
|
||||||
|
pigeonVar_channel.setMessageHandler(null);
|
||||||
|
} else {
|
||||||
|
pigeonVar_channel.setMessageHandler((Object? message) async {
|
||||||
|
assert(
|
||||||
|
message != null,
|
||||||
|
'Argument for dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onIosUpload was null.',
|
||||||
|
);
|
||||||
|
final List<Object?> args = (message as List<Object?>?)!;
|
||||||
|
final bool? arg_isRefresh = (args[0] as bool?);
|
||||||
|
assert(
|
||||||
|
arg_isRefresh != null,
|
||||||
|
'Argument for dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onIosUpload was null, expected non-null bool.',
|
||||||
|
);
|
||||||
|
final int? arg_maxSeconds = (args[1] as int?);
|
||||||
|
try {
|
||||||
|
await api.onIosUpload(arg_isRefresh!, arg_maxSeconds);
|
||||||
|
return wrapResponse(empty: true);
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
return wrapResponse(error: e);
|
||||||
|
} catch (e) {
|
||||||
|
return wrapResponse(
|
||||||
|
error: PlatformException(code: 'error', message: e.toString()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onAndroidUpload$messageChannelSuffix',
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: binaryMessenger,
|
||||||
|
);
|
||||||
|
if (api == null) {
|
||||||
|
pigeonVar_channel.setMessageHandler(null);
|
||||||
|
} else {
|
||||||
|
pigeonVar_channel.setMessageHandler((Object? message) async {
|
||||||
|
try {
|
||||||
|
await api.onAndroidUpload();
|
||||||
|
return wrapResponse(empty: true);
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
return wrapResponse(error: e);
|
||||||
|
} catch (e) {
|
||||||
|
return wrapResponse(
|
||||||
|
error: PlatformException(code: 'error', message: e.toString()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.cancel$messageChannelSuffix',
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: binaryMessenger,
|
||||||
|
);
|
||||||
|
if (api == null) {
|
||||||
|
pigeonVar_channel.setMessageHandler(null);
|
||||||
|
} else {
|
||||||
|
pigeonVar_channel.setMessageHandler((Object? message) async {
|
||||||
|
try {
|
||||||
|
await api.cancel();
|
||||||
|
return wrapResponse(empty: true);
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
return wrapResponse(error: e);
|
||||||
|
} catch (e) {
|
||||||
|
return wrapResponse(
|
||||||
|
error: PlatformException(code: 'error', message: e.toString()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
import 'package:pigeon/pigeon.dart';
|
||||||
|
|
||||||
|
@ConfigurePigeon(
|
||||||
|
PigeonOptions(
|
||||||
|
dartOut: 'lib/platform/background_worker_api.g.dart',
|
||||||
|
swiftOut: 'ios/Runner/Background/BackgroundWorker.g.swift',
|
||||||
|
swiftOptions: SwiftOptions(includeErrorClass: false),
|
||||||
|
kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt',
|
||||||
|
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.background'),
|
||||||
|
dartOptions: DartOptions(),
|
||||||
|
dartPackageName: 'immich_mobile',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@HostApi()
|
||||||
|
abstract class BackgroundWorkerFgHostApi {
|
||||||
|
void enableSyncWorker();
|
||||||
|
|
||||||
|
// Enables the background upload service with the given callback handle
|
||||||
|
void enableUploadWorker(int callbackHandle);
|
||||||
|
|
||||||
|
// Disables the background upload service
|
||||||
|
void disableUploadWorker();
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostApi()
|
||||||
|
abstract class BackgroundWorkerBgHostApi {
|
||||||
|
// Called from the background flutter engine when it has bootstrapped and established the
|
||||||
|
// required platform channels to notify the native side to start the background upload
|
||||||
|
void onInitialized();
|
||||||
|
}
|
||||||
|
|
||||||
|
@FlutterApi()
|
||||||
|
abstract class BackgroundWorkerFlutterApi {
|
||||||
|
// Android & iOS: Called when the local sync is triggered
|
||||||
|
@async
|
||||||
|
void onLocalSync(int? maxSeconds);
|
||||||
|
|
||||||
|
// iOS Only: Called when the iOS background upload is triggered
|
||||||
|
@async
|
||||||
|
void onIosUpload(bool isRefresh, int? maxSeconds);
|
||||||
|
|
||||||
|
// Android Only: Called when the Android background upload is triggered
|
||||||
|
@async
|
||||||
|
void onAndroidUpload();
|
||||||
|
|
||||||
|
@async
|
||||||
|
void cancel();
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue