mirror of
https://github.com/immich-app/immich.git
synced 2026-07-24 14:02:48 +03:00
Compare commits
5 Commits
make-sure-
...
fix/24545-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e00cb0244 | ||
|
|
c17aaee230 | ||
|
|
b2ab8b37f9 | ||
|
|
7f46885d3a | ||
|
|
bba46d5c62 |
@@ -38,6 +38,7 @@ class BackgroundEngineLock(context: Context) : BackgroundWorkerLockApi, ImmichPl
|
||||
|
||||
override fun unlock() {
|
||||
BackgroundWorkerPreferences(ctx).setLocked(false)
|
||||
BackgroundWorkerApiImpl.rearmRetryWorker(ctx)
|
||||
Log.i(TAG, "Background worker is unlocked")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package app.alextran.immich.background
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.Worker
|
||||
import androidx.work.WorkerParameters
|
||||
|
||||
private const val TAG = "BackgroundRetryWorker"
|
||||
|
||||
class BackgroundRetryWorker(context: Context, params: WorkerParameters) : Worker(context, params) {
|
||||
private val ctx = context.applicationContext
|
||||
|
||||
override fun doWork(): Result {
|
||||
return try {
|
||||
BackgroundWorkerApiImpl.enqueueBackgroundWorker(ctx, widenRetryTarget = false)
|
||||
Result.retry()
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "Failed to enqueue background worker", error)
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,6 +195,19 @@ class FlutterError (
|
||||
val details: Any? = null
|
||||
) : RuntimeException()
|
||||
|
||||
enum class BackgroundWorkerResult(val raw: Int) {
|
||||
NONE(0),
|
||||
CONNECTED(1),
|
||||
UNMETERED(2),
|
||||
UNCHANGED(3);
|
||||
|
||||
companion object {
|
||||
fun ofRaw(raw: Int): BackgroundWorkerResult? {
|
||||
return values().firstOrNull { it.raw == raw }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class BackgroundWorkerSettings (
|
||||
val requiresCharging: Boolean,
|
||||
@@ -236,6 +249,11 @@ private open class BackgroundWorkerPigeonCodec : StandardMessageCodec() {
|
||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||
return when (type) {
|
||||
129.toByte() -> {
|
||||
return (readValue(buffer) as Long?)?.let {
|
||||
BackgroundWorkerResult.ofRaw(it.toInt())
|
||||
}
|
||||
}
|
||||
130.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
BackgroundWorkerSettings.fromList(it)
|
||||
}
|
||||
@@ -245,8 +263,12 @@ private open class BackgroundWorkerPigeonCodec : StandardMessageCodec() {
|
||||
}
|
||||
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
||||
when (value) {
|
||||
is BackgroundWorkerSettings -> {
|
||||
is BackgroundWorkerResult -> {
|
||||
stream.write(129)
|
||||
writeValue(stream, value.raw.toLong())
|
||||
}
|
||||
is BackgroundWorkerSettings -> {
|
||||
stream.write(130)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
else -> super.writeValue(stream, value)
|
||||
@@ -416,7 +438,7 @@ class BackgroundWorkerFlutterApi(private val binaryMessenger: BinaryMessenger, p
|
||||
}
|
||||
}
|
||||
}
|
||||
fun onAndroidUpload(maxMinutesArg: Long?, callback: (Result<Unit>) -> Unit)
|
||||
fun onAndroidUpload(maxMinutesArg: Long?, callback: (Result<BackgroundWorkerResult>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onAndroidUpload$separatedMessageChannelSuffix"
|
||||
@@ -425,8 +447,11 @@ class BackgroundWorkerFlutterApi(private val binaryMessenger: BinaryMessenger, p
|
||||
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 if (it[0] == null) {
|
||||
callback(Result.failure(FlutterError("null-error", "Flutter api returned null value for non-null return value.", "")))
|
||||
} else {
|
||||
callback(Result.success(Unit))
|
||||
val output = it[0] as BackgroundWorkerResult
|
||||
callback(Result.success(output))
|
||||
}
|
||||
} else {
|
||||
callback(Result.failure(BackgroundWorkerPigeonUtils.createConnectionError(channelName)))
|
||||
|
||||
@@ -51,6 +51,10 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
|
||||
/// Flag to track whether the background task has completed to prevent duplicate completions
|
||||
private var isComplete = false
|
||||
|
||||
private var isStopping = false
|
||||
|
||||
private var retryEpoch = 0L
|
||||
|
||||
private val notificationManager =
|
||||
ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
@@ -59,6 +63,16 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
|
||||
companion object {
|
||||
private const val NOTIFICATION_CHANNEL_ID = "immich::background_worker::notif"
|
||||
private const val NOTIFICATION_ID = 100
|
||||
private val workerLock = Any()
|
||||
private var activeWorker: BackgroundWorker? = null
|
||||
|
||||
fun isRunning(): Boolean = synchronized(workerLock) {
|
||||
activeWorker != null
|
||||
}
|
||||
|
||||
fun hasEngine(): Boolean = synchronized(workerLock) {
|
||||
activeWorker?.engine != null
|
||||
}
|
||||
}
|
||||
|
||||
override fun startWork(): ListenableFuture<Result> {
|
||||
@@ -67,6 +81,33 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
|
||||
return Futures.immediateFuture(Result.success())
|
||||
}
|
||||
|
||||
synchronized(workerLock) {
|
||||
activeWorker = this
|
||||
}
|
||||
|
||||
val widenRetryTarget = inputData.getBoolean(BackgroundWorkerApiImpl.WIDEN_RETRY_TARGET_KEY, true)
|
||||
BackgroundWorkerApiImpl.prepareRetryWorker(ctx, widenRetryTarget) { result ->
|
||||
synchronized(this) {
|
||||
if (isStopped || isStopping || isComplete) {
|
||||
return@prepareRetryWorker
|
||||
}
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
retryEpoch = it
|
||||
startFlutter()
|
||||
},
|
||||
onFailure = {
|
||||
Log.w(TAG, "Failed to prepare background retry worker", it)
|
||||
complete(Result.failure())
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return completionHandler
|
||||
}
|
||||
|
||||
private fun startFlutter() {
|
||||
Log.i(TAG, "Starting background upload worker")
|
||||
|
||||
if (!loader.initialized()) {
|
||||
@@ -87,28 +128,48 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
|
||||
return@ensureInitializationCompleteAsync
|
||||
}
|
||||
|
||||
engine = FlutterEngine(ctx)
|
||||
FlutterEngineCache.getInstance().put(BackgroundWorkerApiImpl.ENGINE_CACHE_KEY, engine!!)
|
||||
val engine = FlutterEngine(ctx)
|
||||
if (!cacheEngine(engine)) {
|
||||
engine.destroy()
|
||||
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.executeDartEntrypoint(
|
||||
DartExecutor.DartEntrypoint(
|
||||
loader.findAppBundlePath(),
|
||||
"package:immich_mobile/domain/services/background_worker.service.dart",
|
||||
"backgroundSyncNativeEntrypoint"
|
||||
synchronized(this) {
|
||||
if (isStopped || isStopping || isComplete) {
|
||||
return@ensureInitializationCompleteAsync
|
||||
}
|
||||
// Register custom plugins
|
||||
MainActivity.registerPlugins(ctx, engine)
|
||||
flutterApi =
|
||||
BackgroundWorkerFlutterApi(binaryMessenger = engine.dartExecutor.binaryMessenger)
|
||||
BackgroundWorkerBgHostApi.setUp(
|
||||
binaryMessenger = engine.dartExecutor.binaryMessenger,
|
||||
api = this
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return completionHandler
|
||||
engine.dartExecutor.executeDartEntrypoint(
|
||||
DartExecutor.DartEntrypoint(
|
||||
loader.findAppBundlePath(),
|
||||
"package:immich_mobile/domain/services/background_worker.service.dart",
|
||||
"backgroundSyncNativeEntrypoint"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cacheEngine(engine: FlutterEngine): Boolean = synchronized(this) {
|
||||
if (isStopped || isStopping || isComplete) {
|
||||
return@synchronized false
|
||||
}
|
||||
synchronized(workerLock) {
|
||||
if (activeWorker !== this) {
|
||||
return@synchronized false
|
||||
}
|
||||
this.engine = engine
|
||||
FlutterEngineCache.getInstance().put(BackgroundWorkerApiImpl.ENGINE_CACHE_KEY, engine)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,19 +241,44 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
|
||||
* This is also called when the worker has been explicitly cancelled or replaced
|
||||
*/
|
||||
override fun onStopped() {
|
||||
synchronized(this) {
|
||||
isStopping = true
|
||||
}
|
||||
Log.d(TAG, "About to stop BackupWorker")
|
||||
close()
|
||||
}
|
||||
|
||||
private fun handleHostResult(result: kotlin.Result<Unit>) {
|
||||
if (isComplete) {
|
||||
return
|
||||
}
|
||||
private fun handleHostResult(result: kotlin.Result<BackgroundWorkerResult>) {
|
||||
synchronized(this) {
|
||||
if (isStopped || isStopping || isComplete) {
|
||||
return
|
||||
}
|
||||
|
||||
result.fold(
|
||||
onSuccess = { _ -> complete(Result.success()) },
|
||||
onFailure = { _ -> onStopped() }
|
||||
)
|
||||
result.fold(
|
||||
onSuccess = { updateRetryWorker(it) },
|
||||
onFailure = {
|
||||
Log.w(TAG, "Background upload failed", it)
|
||||
complete(Result.failure())
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateRetryWorker(result: BackgroundWorkerResult) {
|
||||
BackgroundWorkerApiImpl.updateRetryWorker(ctx, retryEpoch, result) callback@{
|
||||
synchronized(this) {
|
||||
if (isStopped || isStopping || isComplete) {
|
||||
return@callback
|
||||
}
|
||||
it.fold(
|
||||
onSuccess = { complete(Result.success()) },
|
||||
onFailure = { error ->
|
||||
Log.w(TAG, "Failed to update background retry worker", error)
|
||||
complete(Result.failure())
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -203,17 +289,28 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
|
||||
*
|
||||
* - Parameter success: Indicates whether the background task completed successfully
|
||||
*/
|
||||
@Synchronized
|
||||
private fun complete(success: Result) {
|
||||
if (isComplete) {
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "About to complete BackupWorker with result: $success")
|
||||
isComplete = true
|
||||
val engine = engine
|
||||
if (engine != null) {
|
||||
MainActivity.cancelPlugins(engine!!)
|
||||
MainActivity.cancelPlugins(engine)
|
||||
}
|
||||
engine?.destroy()
|
||||
engine = null
|
||||
this.engine = null
|
||||
flutterApi = null
|
||||
notificationManager.cancel(NOTIFICATION_ID)
|
||||
FlutterEngineCache.getInstance().remove(BackgroundWorkerApiImpl.ENGINE_CACHE_KEY)
|
||||
synchronized(workerLock) {
|
||||
if (activeWorker === this) {
|
||||
activeWorker = null
|
||||
notificationManager.cancel(NOTIFICATION_ID)
|
||||
FlutterEngineCache.getInstance().remove(BackgroundWorkerApiImpl.ENGINE_CACHE_KEY)
|
||||
}
|
||||
}
|
||||
waitForForegroundPromotion()
|
||||
completionHandler.set(success)
|
||||
}
|
||||
|
||||
@@ -3,18 +3,29 @@ package app.alextran.immich.background
|
||||
import android.content.Context
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.Data
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.Operation
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import io.flutter.embedding.engine.FlutterEngineCache
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
private const val TAG = "BackgroundWorkerApiImpl"
|
||||
|
||||
internal enum class BackgroundRetryTarget(val networkType: NetworkType, val tag: String) {
|
||||
CONNECTED(NetworkType.CONNECTED, "immich/BackgroundRetryWorkerV1/connected"),
|
||||
UNMETERED(NetworkType.UNMETERED, "immich/BackgroundRetryWorkerV1/unmetered"),
|
||||
}
|
||||
|
||||
class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
|
||||
private val ctx: Context = context.applicationContext
|
||||
|
||||
@@ -39,15 +50,28 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
|
||||
cancelUniqueWork(BACKGROUND_WORKER_NAME)
|
||||
cancelUniqueWork(PERIODIC_WORKER_NAME)
|
||||
}
|
||||
retryExecutor.execute {
|
||||
retryEpoch.incrementAndGet()
|
||||
try {
|
||||
val manager = WorkManager.getInstance(ctx)
|
||||
manager.cancelUniqueWork(RETRY_WORKER_NAME).result.get()
|
||||
manager.cancelUniqueWork(BACKGROUND_WORKER_NAME).result.get()
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "Failed to cancel background workers", error)
|
||||
}
|
||||
}
|
||||
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"
|
||||
private const val RETRY_WORKER_NAME = "immich/BackgroundRetryWorkerV1"
|
||||
private const val PERIODIC_WORKER_NAME = "immich/PeriodicBackgroundWorkerV1"
|
||||
const val WIDEN_RETRY_TARGET_KEY = "immich::background_worker::widen_retry_target"
|
||||
const val ENGINE_CACHE_KEY = "immich::background_worker::engine"
|
||||
|
||||
private val retryExecutor = Executors.newSingleThreadExecutor()
|
||||
private val retryEpoch = AtomicLong()
|
||||
|
||||
fun enqueueMediaObserver(ctx: Context) {
|
||||
val settings = BackgroundWorkerPreferences(ctx).getSettings()
|
||||
@@ -94,27 +118,173 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
|
||||
Log.i(TAG, "Enqueued periodic background worker with name: $PERIODIC_WORKER_NAME")
|
||||
}
|
||||
|
||||
fun enqueueBackgroundWorker(ctx: Context) {
|
||||
val constraints = Constraints.Builder().setRequiresBatteryNotLow(true).build()
|
||||
fun enqueueBackgroundWorker(ctx: Context, widenRetryTarget: Boolean = true): Operation {
|
||||
if (widenRetryTarget) {
|
||||
try {
|
||||
retryExecutor.submit {
|
||||
retryEpoch.incrementAndGet()
|
||||
val backgroundEngines = if (BackgroundWorker.hasEngine()) 1 else 0
|
||||
val isForeground =
|
||||
BackgroundWorkerPreferences(ctx).isLocked() &&
|
||||
BackgroundEngineLock.connectEngines > backgroundEngines
|
||||
if (!isForeground) {
|
||||
val current = queryRetryTarget(ctx)
|
||||
if (current == BackgroundRetryTarget.UNMETERED ||
|
||||
current == null && isBackgroundWorkRunning(ctx)
|
||||
) {
|
||||
enqueueRetryWorker(ctx, BackgroundRetryTarget.CONNECTED).result.get()
|
||||
}
|
||||
}
|
||||
}.get()
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "Failed to prepare background retry worker", error)
|
||||
}
|
||||
}
|
||||
|
||||
val settings = BackgroundWorkerPreferences(ctx).getSettings()
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiresBatteryNotLow(true)
|
||||
.setRequiresCharging(settings.requiresCharging)
|
||||
.build()
|
||||
val input = Data.Builder()
|
||||
.putBoolean(WIDEN_RETRY_TARGET_KEY, widenRetryTarget)
|
||||
.build()
|
||||
val work = OneTimeWorkRequestBuilder<BackgroundWorker>()
|
||||
.setConstraints(constraints)
|
||||
.setInputData(input)
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
|
||||
.build()
|
||||
WorkManager.getInstance(ctx)
|
||||
val operation = WorkManager.getInstance(ctx)
|
||||
.enqueueUniqueWork(BACKGROUND_WORKER_NAME, ExistingWorkPolicy.KEEP, work)
|
||||
|
||||
Log.i(TAG, "Enqueued background worker with name: $BACKGROUND_WORKER_NAME")
|
||||
return operation
|
||||
}
|
||||
|
||||
fun isBackgroundWorkerRunning(): Boolean {
|
||||
// Easier to check if the engine is cached as we always cache the engine when starting the worker
|
||||
// and remove it when the worker is finished
|
||||
return FlutterEngineCache.getInstance().get(ENGINE_CACHE_KEY) != null
|
||||
internal fun prepareRetryWorker(
|
||||
ctx: Context,
|
||||
widenRetryTarget: Boolean,
|
||||
callback: (kotlin.Result<Long>) -> Unit,
|
||||
) {
|
||||
retryExecutor.execute {
|
||||
val result = try {
|
||||
prepareRetryTarget(ctx, widenRetryTarget)
|
||||
kotlin.Result.success(retryEpoch.get())
|
||||
} catch (error: Exception) {
|
||||
kotlin.Result.failure(error)
|
||||
}
|
||||
ContextCompat.getMainExecutor(ctx).execute {
|
||||
callback(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun updateRetryWorker(
|
||||
ctx: Context,
|
||||
epoch: Long,
|
||||
result: BackgroundWorkerResult,
|
||||
callback: (kotlin.Result<Unit>) -> Unit,
|
||||
) {
|
||||
retryExecutor.execute {
|
||||
val update = try {
|
||||
if (epoch == retryEpoch.get()) {
|
||||
val current = queryRetryTarget(ctx)
|
||||
val operation = when (result) {
|
||||
BackgroundWorkerResult.NONE ->
|
||||
WorkManager.getInstance(ctx).cancelUniqueWork(RETRY_WORKER_NAME)
|
||||
BackgroundWorkerResult.CONNECTED -> {
|
||||
if (current == BackgroundRetryTarget.CONNECTED) null
|
||||
else enqueueRetryWorker(ctx, BackgroundRetryTarget.CONNECTED)
|
||||
}
|
||||
BackgroundWorkerResult.UNMETERED -> {
|
||||
if (current == BackgroundRetryTarget.UNMETERED) null
|
||||
else enqueueRetryWorker(ctx, BackgroundRetryTarget.UNMETERED)
|
||||
}
|
||||
BackgroundWorkerResult.UNCHANGED -> null
|
||||
}
|
||||
operation?.result?.get()
|
||||
}
|
||||
kotlin.Result.success(Unit)
|
||||
} catch (error: Exception) {
|
||||
kotlin.Result.failure(error)
|
||||
}
|
||||
ContextCompat.getMainExecutor(ctx).execute {
|
||||
callback(update)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun rearmRetryWorker(ctx: Context) {
|
||||
retryExecutor.execute {
|
||||
try {
|
||||
val target = queryRetryTarget(ctx)
|
||||
if (target != null) {
|
||||
enqueueRetryWorker(ctx, target).result.get()
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "Failed to rearm background retry worker", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isBackgroundWorkRunning(ctx: Context): Boolean {
|
||||
return WorkManager.getInstance(ctx)
|
||||
.getWorkInfosForUniqueWork(BACKGROUND_WORKER_NAME)
|
||||
.get()
|
||||
.any { it.state == WorkInfo.State.RUNNING }
|
||||
}
|
||||
|
||||
private fun prepareRetryTarget(
|
||||
ctx: Context,
|
||||
widenRetryTarget: Boolean,
|
||||
): BackgroundRetryTarget {
|
||||
val current = queryRetryTarget(ctx)
|
||||
val target =
|
||||
if (widenRetryTarget && current == BackgroundRetryTarget.UNMETERED) {
|
||||
BackgroundRetryTarget.CONNECTED
|
||||
} else {
|
||||
current ?: BackgroundRetryTarget.CONNECTED
|
||||
}
|
||||
if (target != current) {
|
||||
enqueueRetryWorker(ctx, target).result.get()
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
private fun queryRetryTarget(ctx: Context): BackgroundRetryTarget? {
|
||||
val info = WorkManager.getInstance(ctx)
|
||||
.getWorkInfosForUniqueWork(RETRY_WORKER_NAME)
|
||||
.get()
|
||||
.firstOrNull {
|
||||
it.state == WorkInfo.State.ENQUEUED || it.state == WorkInfo.State.RUNNING
|
||||
}
|
||||
return info?.let { workInfo ->
|
||||
BackgroundRetryTarget.entries.firstOrNull { it.tag in workInfo.tags }
|
||||
}
|
||||
}
|
||||
|
||||
private fun enqueueRetryWorker(ctx: Context, target: BackgroundRetryTarget): Operation {
|
||||
val settings = BackgroundWorkerPreferences(ctx).getSettings()
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(target.networkType)
|
||||
.setRequiresCharging(settings.requiresCharging)
|
||||
.build()
|
||||
val work = OneTimeWorkRequestBuilder<BackgroundRetryWorker>()
|
||||
.setConstraints(constraints)
|
||||
.addTag(target.tag)
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
|
||||
.build()
|
||||
val operation = WorkManager.getInstance(ctx)
|
||||
.enqueueUniqueWork(RETRY_WORKER_NAME, ExistingWorkPolicy.REPLACE, work)
|
||||
|
||||
Log.i(TAG, "Enqueued background retry worker with target: $target")
|
||||
return operation
|
||||
}
|
||||
|
||||
fun isBackgroundWorkerRunning(): Boolean = BackgroundWorker.isRunning()
|
||||
|
||||
fun cancelBackgroundWorker(ctx: Context) {
|
||||
WorkManager.getInstance(ctx).cancelUniqueWork(BACKGROUND_WORKER_NAME)
|
||||
FlutterEngineCache.getInstance().remove(ENGINE_CACHE_KEY)
|
||||
|
||||
Log.i(TAG, "Cancelled background upload task")
|
||||
}
|
||||
|
||||
@@ -140,8 +140,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-structured-queries",
|
||||
"state" : {
|
||||
"revision" : "dafddd843f10630aa6b5be47c1b6a59cc4ab6586",
|
||||
"version" : "0.34.0"
|
||||
"revision" : "8da8818fccd9959bd683934ddc62cf45bb65b3c8",
|
||||
"version" : "0.31.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -160,6 +160,13 @@ func deepHashBackgroundWorker(value: Any?, hasher: inout Hasher) {
|
||||
}
|
||||
|
||||
|
||||
enum BackgroundWorkerResult: Int {
|
||||
case none = 0
|
||||
case connected = 1
|
||||
case unmetered = 2
|
||||
case unchanged = 3
|
||||
}
|
||||
|
||||
/// Generated class from Pigeon that represents data sent in messages.
|
||||
struct BackgroundWorkerSettings: Hashable {
|
||||
var requiresCharging: Bool
|
||||
@@ -200,6 +207,12 @@ private class BackgroundWorkerPigeonCodecReader: FlutterStandardReader {
|
||||
override func readValue(ofType type: UInt8) -> Any? {
|
||||
switch type {
|
||||
case 129:
|
||||
let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
|
||||
if let enumResultAsInt = enumResultAsInt {
|
||||
return BackgroundWorkerResult(rawValue: enumResultAsInt)
|
||||
}
|
||||
return nil
|
||||
case 130:
|
||||
return BackgroundWorkerSettings.fromList(self.readValue() as! [Any?])
|
||||
default:
|
||||
return super.readValue(ofType: type)
|
||||
@@ -209,8 +222,11 @@ private class BackgroundWorkerPigeonCodecReader: FlutterStandardReader {
|
||||
|
||||
private class BackgroundWorkerPigeonCodecWriter: FlutterStandardWriter {
|
||||
override func writeValue(_ value: Any) {
|
||||
if let value = value as? BackgroundWorkerSettings {
|
||||
if let value = value as? BackgroundWorkerResult {
|
||||
super.writeByte(129)
|
||||
super.writeValue(value.rawValue)
|
||||
} else if let value = value as? BackgroundWorkerSettings {
|
||||
super.writeByte(130)
|
||||
super.writeValue(value.toList())
|
||||
} else {
|
||||
super.writeValue(value)
|
||||
@@ -348,7 +364,7 @@ class BackgroundWorkerBgHostApiSetup {
|
||||
/// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift.
|
||||
protocol BackgroundWorkerFlutterApiProtocol {
|
||||
func onIosUpload(isRefresh isRefreshArg: Bool, maxSeconds maxSecondsArg: Int64?, completion: @escaping (Result<Void, PigeonError>) -> Void)
|
||||
func onAndroidUpload(maxMinutes maxMinutesArg: Int64?, completion: @escaping (Result<Void, PigeonError>) -> Void)
|
||||
func onAndroidUpload(maxMinutes maxMinutesArg: Int64?, completion: @escaping (Result<BackgroundWorkerResult, PigeonError>) -> Void)
|
||||
func cancel(completion: @escaping (Result<Void, PigeonError>) -> Void)
|
||||
}
|
||||
class BackgroundWorkerFlutterApi: BackgroundWorkerFlutterApiProtocol {
|
||||
@@ -379,7 +395,7 @@ class BackgroundWorkerFlutterApi: BackgroundWorkerFlutterApiProtocol {
|
||||
}
|
||||
}
|
||||
}
|
||||
func onAndroidUpload(maxMinutes maxMinutesArg: Int64?, completion: @escaping (Result<Void, PigeonError>) -> Void) {
|
||||
func onAndroidUpload(maxMinutes maxMinutesArg: Int64?, completion: @escaping (Result<BackgroundWorkerResult, PigeonError>) -> Void) {
|
||||
let channelName: String = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onAndroidUpload\(messageChannelSuffix)"
|
||||
let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec)
|
||||
channel.sendMessage([maxMinutesArg] as [Any?]) { response in
|
||||
@@ -392,8 +408,11 @@ class BackgroundWorkerFlutterApi: BackgroundWorkerFlutterApiProtocol {
|
||||
let message: String? = nilOrValue(listResponse[1])
|
||||
let details: String? = nilOrValue(listResponse[2])
|
||||
completion(.failure(PigeonError(code: code, message: message, details: details)))
|
||||
} else if listResponse[0] == nil {
|
||||
completion(.failure(PigeonError(code: "null-error", message: "Flutter api returned null value for non-null return value.", details: "")))
|
||||
} else {
|
||||
completion(.success(()))
|
||||
let result = listResponse[0] as! BackgroundWorkerResult
|
||||
completion(.success(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,13 +83,7 @@ struct ImmichMemoryProvider: TimelineProvider {
|
||||
return
|
||||
}
|
||||
|
||||
let memories: [MemoryResult]
|
||||
do {
|
||||
memories = try await api.fetchMemory(for: Date.now)
|
||||
} catch {
|
||||
completion(ImageEntry.handleError(for: cacheKey))
|
||||
return
|
||||
}
|
||||
let memories = try await api.fetchMemory(for: Date.now)
|
||||
|
||||
await withTaskGroup(of: ImageEntry?.self) { group in
|
||||
var totalAssets = 0
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
@@ -69,6 +68,13 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
late LocalSyncService _localSyncService;
|
||||
late SyncStreamService _remoteSyncService;
|
||||
late HashService _hashService;
|
||||
Future<bool>? _localSyncTask;
|
||||
Future<bool>? _remoteSyncTask;
|
||||
Future<void>? _hashTask;
|
||||
Future<void>? _optimizeTask;
|
||||
Future<List<dynamic>>? _iosTasks;
|
||||
Future<BackgroundWorkerResult>? _androidBackupTask;
|
||||
Future<void>? _cleanupTask;
|
||||
|
||||
bool _isCleanedUp = false;
|
||||
|
||||
@@ -140,10 +146,14 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onAndroidUpload(int? maxMinutes) async {
|
||||
Future<BackgroundWorkerResult> onAndroidUpload(int? maxMinutes) async {
|
||||
final hashTimeout = Duration(minutes: _isBackupEnabled ? 3 : 6);
|
||||
final backupTimeout = maxMinutes != null ? Duration(minutes: maxMinutes - 1) : null;
|
||||
await _optimizeDB();
|
||||
_optimizeTask = _optimizeDB();
|
||||
await _optimizeTask;
|
||||
if (_isCleanedUp) {
|
||||
return BackgroundWorkerResult.unchanged;
|
||||
}
|
||||
return _backgroundLoop(
|
||||
hashTimeout: hashTimeout,
|
||||
backupTimeout: backupTimeout,
|
||||
@@ -160,7 +170,11 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
|
||||
// Only for Background Processing tasks
|
||||
if (maxSeconds == null) {
|
||||
await _optimizeDB();
|
||||
_optimizeTask = _optimizeDB();
|
||||
await _optimizeTask;
|
||||
if (_isCleanedUp) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Run sync local, sync remote, hash and backup concurrently so the bg
|
||||
@@ -169,12 +183,11 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
// hash and handle_backup read drift state and tolerate stale reads
|
||||
// (server-side dedup catches the rare race). The single budget caps the
|
||||
// whole batch; no phase needs its own timeout.
|
||||
final all = Future.wait<dynamic>([
|
||||
_localSyncService.sync(),
|
||||
_remoteSyncService.sync(),
|
||||
_hashService.hashAssets(),
|
||||
_handleBackup(),
|
||||
]);
|
||||
_localSyncTask = _runLocalSync();
|
||||
_remoteSyncTask = _runRemoteSync();
|
||||
_hashTask = _runHash();
|
||||
final all = Future.wait<dynamic>([_localSyncTask!, _remoteSyncTask!, _hashTask!, _handleIosBackup()]);
|
||||
_iosTasks = all;
|
||||
if (budget != null) {
|
||||
await all.timeout(
|
||||
budget,
|
||||
@@ -198,7 +211,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _backgroundLoop({
|
||||
Future<BackgroundWorkerResult> _backgroundLoop({
|
||||
required Duration hashTimeout,
|
||||
required Duration? backupTimeout,
|
||||
required String debugLabel,
|
||||
@@ -208,12 +221,14 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
);
|
||||
final sw = Stopwatch()..start();
|
||||
try {
|
||||
if (!await _syncAssets(hashTimeout: hashTimeout)) {
|
||||
_logger.warning("Remote sync did not complete successfully, skipping backup");
|
||||
return;
|
||||
}
|
||||
final sync = await _syncAssets(hashTimeout: hashTimeout);
|
||||
|
||||
final backupFuture = _handleBackup();
|
||||
final backupFuture = _handleAndroidBackup(localSyncCompleted: sync.local, remoteSyncCompleted: sync.remote)
|
||||
.catchError((Object error, StackTrace stack) {
|
||||
_logger.severe("Failed to complete $debugLabel", error, stack);
|
||||
return BackgroundWorkerResult.unchanged;
|
||||
});
|
||||
_androidBackupTask = backupFuture;
|
||||
Timer? cancelTimer;
|
||||
if (backupTimeout != null) {
|
||||
cancelTimer = Timer(backupTimeout, () {
|
||||
@@ -224,12 +239,13 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
});
|
||||
}
|
||||
try {
|
||||
await backupFuture;
|
||||
return await backupFuture;
|
||||
} finally {
|
||||
cancelTimer?.cancel();
|
||||
}
|
||||
} catch (error, stack) {
|
||||
_logger.severe("Failed to complete $debugLabel", error, stack);
|
||||
return BackgroundWorkerResult.unchanged;
|
||||
} finally {
|
||||
sw.stop();
|
||||
_logger.info("$debugLabel completed in ${sw.elapsed.inSeconds}s");
|
||||
@@ -255,10 +271,34 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cleanup() async {
|
||||
await runZonedGuarded(_handleCleanup, (error, stack) {
|
||||
dPrint(() => "Error during background worker cleanup: $error, $stack");
|
||||
});
|
||||
Future<bool> _runLocalSync() async {
|
||||
try {
|
||||
return await _localSyncService.sync();
|
||||
} catch (error, stack) {
|
||||
_logger.warning("Local sync failed", error, stack);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _runRemoteSync() async {
|
||||
try {
|
||||
return await _remoteSyncService.sync();
|
||||
} catch (error, stack) {
|
||||
_logger.warning("Remote sync failed", error, stack);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _runHash() async {
|
||||
try {
|
||||
await _hashService.hashAssets();
|
||||
} catch (error, stack) {
|
||||
_logger.warning("Hashing failed", error, stack);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cleanup() {
|
||||
return _cleanupTask ??= _handleCleanup();
|
||||
}
|
||||
|
||||
Future<void> _handleCleanup() async {
|
||||
@@ -278,6 +318,18 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
|
||||
// Workers share one sqlite connection, so DB teardown must wait until every worker has stopped using it.
|
||||
await Future.wait([if (nativeSyncApi != null) nativeSyncApi.cancelHashing()]);
|
||||
final directTasks = <Future<dynamic>>[if (_optimizeTask != null) _optimizeTask!];
|
||||
if (_iosTasks != null) {
|
||||
directTasks.add(_iosTasks!);
|
||||
} else {
|
||||
directTasks.addAll([
|
||||
if (_localSyncTask != null) _localSyncTask!,
|
||||
if (_remoteSyncTask != null) _remoteSyncTask!,
|
||||
if (_hashTask != null) _hashTask!,
|
||||
if (_androidBackupTask != null) _androidBackupTask!,
|
||||
]);
|
||||
}
|
||||
await Future.wait(directTasks);
|
||||
await workerManagerPatch.dispose().catchError((_) async {});
|
||||
await Future.wait([LogService.I.dispose(), Store.dispose()]);
|
||||
await _drift.close();
|
||||
@@ -290,50 +342,67 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleBackup() async {
|
||||
await runZonedGuarded(
|
||||
() async {
|
||||
if (_isCleanedUp) {
|
||||
return;
|
||||
}
|
||||
Future<void> _handleIosBackup() async {
|
||||
try {
|
||||
if (_isCleanedUp) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_isBackupEnabled) {
|
||||
_logger.info("Backup is disabled. Skipping backup routine");
|
||||
return;
|
||||
}
|
||||
if (!_isBackupEnabled) {
|
||||
_logger.info("Backup is disabled. Skipping backup routine");
|
||||
return;
|
||||
}
|
||||
|
||||
final currentUser = _ref?.read(currentUserProvider);
|
||||
if (currentUser == null) {
|
||||
_logger.warning("No current user found. Skipping backup from background");
|
||||
return;
|
||||
}
|
||||
final currentUser = _ref?.read(currentUserProvider);
|
||||
if (currentUser == null) {
|
||||
_logger.warning("No current user found. Skipping backup from background");
|
||||
return;
|
||||
}
|
||||
|
||||
if (Platform.isIOS) {
|
||||
return _ref?.read(driftBackupProvider.notifier).startBackupWithURLSession(currentUser.id);
|
||||
}
|
||||
await _ref?.read(driftBackupProvider.notifier).startBackupWithURLSession(currentUser.id);
|
||||
} catch (error, stack) {
|
||||
dPrint(() => "Error during iOS background backup: $error, $stack");
|
||||
}
|
||||
}
|
||||
|
||||
return _ref
|
||||
?.read(foregroundUploadServiceProvider)
|
||||
.uploadCandidates(currentUser.id, _cancellationToken, useSequentialUpload: true);
|
||||
},
|
||||
(error, stack) {
|
||||
dPrint(() => "Error in backup zone $error, $stack");
|
||||
},
|
||||
Future<BackgroundWorkerResult> _handleAndroidBackup({
|
||||
required bool localSyncCompleted,
|
||||
required bool remoteSyncCompleted,
|
||||
}) async {
|
||||
if (_isCleanedUp) {
|
||||
return BackgroundWorkerResult.unchanged;
|
||||
}
|
||||
|
||||
final currentUser = _ref?.read(currentUserProvider);
|
||||
final uploadService = _ref?.read(foregroundUploadServiceProvider);
|
||||
if (uploadService == null) {
|
||||
return BackgroundWorkerResult.unchanged;
|
||||
}
|
||||
|
||||
return uploadService.uploadCandidatesInBackground(
|
||||
currentUser?.id,
|
||||
_cancellationToken,
|
||||
backupEnabled: _isBackupEnabled,
|
||||
localSyncCompleted: localSyncCompleted,
|
||||
remoteSyncCompleted: remoteSyncCompleted,
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _syncAssets({Duration? hashTimeout}) async {
|
||||
await _localSyncService.sync();
|
||||
Future<({bool local, bool remote})> _syncAssets({Duration? hashTimeout}) async {
|
||||
_localSyncTask = _runLocalSync();
|
||||
final localSyncCompleted = await _localSyncTask!;
|
||||
if (_isCleanedUp) {
|
||||
return false;
|
||||
return (local: false, remote: false);
|
||||
}
|
||||
|
||||
final isSuccess = await _remoteSyncService.sync();
|
||||
_remoteSyncTask = _runRemoteSync();
|
||||
final isSuccess = await _remoteSyncTask!;
|
||||
if (_isCleanedUp) {
|
||||
return isSuccess;
|
||||
return (local: localSyncCompleted, remote: isSuccess);
|
||||
}
|
||||
|
||||
var hashFuture = _hashService.hashAssets();
|
||||
_hashTask = _runHash();
|
||||
var hashFuture = _hashTask!;
|
||||
if (hashTimeout != null) {
|
||||
hashFuture = hashFuture.timeout(
|
||||
hashTimeout,
|
||||
@@ -344,7 +413,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
}
|
||||
|
||||
await hashFuture;
|
||||
return isSuccess;
|
||||
return (local: localSyncCompleted, remote: isSuccess);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ class LocalSyncService {
|
||||
final IPermissionRepository _permissionRepository;
|
||||
final Completer<void>? _cancellation;
|
||||
final Logger _log = Logger("DeviceSyncService");
|
||||
bool _syncFailed = false;
|
||||
|
||||
LocalSyncService({
|
||||
required this._localAlbumRepository,
|
||||
@@ -45,7 +46,8 @@ class LocalSyncService {
|
||||
|
||||
bool get _isCancelled => _cancellation?.isCompleted ?? false;
|
||||
|
||||
Future<void> sync({bool full = false}) async {
|
||||
Future<bool> sync({bool full = false}) async {
|
||||
_syncFailed = false;
|
||||
final Stopwatch stopwatch = Stopwatch()..start();
|
||||
try {
|
||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
@@ -70,7 +72,7 @@ class LocalSyncService {
|
||||
final delta = await _nativeSyncApi.getMediaChanges();
|
||||
if (!delta.hasChanges) {
|
||||
_log.fine("No media changes detected. Skipping sync");
|
||||
return;
|
||||
return !_isCancelled;
|
||||
}
|
||||
|
||||
_log.fine("Delta updated: ${delta.updates.length}");
|
||||
@@ -92,7 +94,7 @@ class LocalSyncService {
|
||||
for (final album in dbAlbums) {
|
||||
if (_isCancelled) {
|
||||
_log.warning("Local sync cancelled. Stopped processing albums.");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
final deviceIds = await _nativeSyncApi.getAssetIdsForAlbum(album.id);
|
||||
await _localAlbumRepository.syncDeletes(album.id, deviceIds);
|
||||
@@ -106,7 +108,7 @@ class LocalSyncService {
|
||||
for (final album in cloudAlbums) {
|
||||
if (_isCancelled) {
|
||||
_log.warning("Local sync cancelled. Stopped processing cloud albums.");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
final dbAlbum = dbAlbums.firstWhereOrNull((a) => a.id == album.id);
|
||||
if (dbAlbum == null) {
|
||||
@@ -118,22 +120,29 @@ class LocalSyncService {
|
||||
|
||||
await _mapIosCloudIds(newAssets);
|
||||
}
|
||||
if (_syncFailed || _isCancelled) {
|
||||
return false;
|
||||
}
|
||||
await _nativeSyncApi.checkpointSync();
|
||||
return true;
|
||||
} on PlatformException catch (e, s) {
|
||||
if (e.code == _kSyncCancelledCode) {
|
||||
_log.warning("Local sync cancelled");
|
||||
} else {
|
||||
_log.severe("Error performing device sync", e, s);
|
||||
}
|
||||
return false;
|
||||
} catch (e, s) {
|
||||
_log.severe("Error performing device sync", e, s);
|
||||
return false;
|
||||
} finally {
|
||||
stopwatch.stop();
|
||||
_log.info("Device sync took - ${stopwatch.elapsedMilliseconds}ms");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fullSync() async {
|
||||
Future<bool> fullSync() async {
|
||||
_syncFailed = false;
|
||||
try {
|
||||
final Stopwatch stopwatch = Stopwatch()..start();
|
||||
|
||||
@@ -149,22 +158,29 @@ class LocalSyncService {
|
||||
onlySecond: addAlbum,
|
||||
);
|
||||
|
||||
if (_syncFailed || _isCancelled) {
|
||||
return false;
|
||||
}
|
||||
await _nativeSyncApi.checkpointSync();
|
||||
stopwatch.stop();
|
||||
_log.info("Full device sync took - ${stopwatch.elapsedMilliseconds}ms");
|
||||
return true;
|
||||
} on PlatformException catch (e, s) {
|
||||
if (e.code == _kSyncCancelledCode) {
|
||||
_log.warning("Full device sync cancelled");
|
||||
} else {
|
||||
_log.severe("Error performing full device sync", e, s);
|
||||
}
|
||||
return false;
|
||||
} catch (e, s) {
|
||||
_log.severe("Error performing full device sync", e, s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> addAlbum(LocalAlbum album) async {
|
||||
if (_isCancelled) {
|
||||
_syncFailed = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -178,6 +194,7 @@ class LocalSyncService {
|
||||
await _mapIosCloudIds(assets);
|
||||
_log.fine("Successfully added device album ${album.name}");
|
||||
} catch (e, s) {
|
||||
_syncFailed = true;
|
||||
_log.warning("Error while adding device album", e, s);
|
||||
}
|
||||
}
|
||||
@@ -188,6 +205,7 @@ class LocalSyncService {
|
||||
// Asset deletion is handled in the repository
|
||||
await _localAlbumRepository.delete(a.id);
|
||||
} catch (e, s) {
|
||||
_syncFailed = true;
|
||||
_log.warning("Error while removing device album", e, s);
|
||||
}
|
||||
}
|
||||
@@ -195,6 +213,7 @@ class LocalSyncService {
|
||||
// The deviceAlbum is ignored since we are going to refresh it anyways
|
||||
FutureOr<bool> updateAlbum(LocalAlbum dbAlbum, LocalAlbum deviceAlbum) async {
|
||||
if (_isCancelled) {
|
||||
_syncFailed = true;
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
@@ -216,6 +235,7 @@ class LocalSyncService {
|
||||
// Slower path - full sync
|
||||
return await fullDiff(dbAlbum, deviceAlbum);
|
||||
} catch (e, s) {
|
||||
_syncFailed = true;
|
||||
_log.warning("Error while diff device album", e, s);
|
||||
}
|
||||
return true;
|
||||
@@ -260,6 +280,7 @@ class LocalSyncService {
|
||||
await _mapIosCloudIds(newAssets);
|
||||
return true;
|
||||
} catch (e, s) {
|
||||
_syncFailed = true;
|
||||
_log.warning("Error on fast syncing local album: ${dbAlbum.name}", e, s);
|
||||
}
|
||||
return false;
|
||||
@@ -331,6 +352,7 @@ class LocalSyncService {
|
||||
|
||||
return true;
|
||||
} catch (e, s) {
|
||||
_syncFailed = true;
|
||||
_log.warning("Error on full syncing local album: ${dbAlbum.name}", e, s);
|
||||
}
|
||||
return true;
|
||||
|
||||
16
mobile/lib/platform/background_worker_api.g.dart
generated
16
mobile/lib/platform/background_worker_api.g.dart
generated
@@ -96,6 +96,8 @@ int _deepHash(Object? value) {
|
||||
return value.hashCode;
|
||||
}
|
||||
|
||||
enum BackgroundWorkerResult { none, connected, unmetered, unchanged }
|
||||
|
||||
class BackgroundWorkerSettings {
|
||||
BackgroundWorkerSettings({required this.requiresCharging, required this.minimumDelaySeconds});
|
||||
|
||||
@@ -141,8 +143,11 @@ class _PigeonCodec extends StandardMessageCodec {
|
||||
if (value is int) {
|
||||
buffer.putUint8(4);
|
||||
buffer.putInt64(value);
|
||||
} else if (value is BackgroundWorkerSettings) {
|
||||
} else if (value is BackgroundWorkerResult) {
|
||||
buffer.putUint8(129);
|
||||
writeValue(buffer, value.index);
|
||||
} else if (value is BackgroundWorkerSettings) {
|
||||
buffer.putUint8(130);
|
||||
writeValue(buffer, value.encode());
|
||||
} else {
|
||||
super.writeValue(buffer, value);
|
||||
@@ -153,6 +158,9 @@ class _PigeonCodec extends StandardMessageCodec {
|
||||
Object? readValueOfType(int type, ReadBuffer buffer) {
|
||||
switch (type) {
|
||||
case 129:
|
||||
final value = readValue(buffer) as int?;
|
||||
return value == null ? null : BackgroundWorkerResult.values[value];
|
||||
case 130:
|
||||
return BackgroundWorkerSettings.decode(readValue(buffer)!);
|
||||
default:
|
||||
return super.readValueOfType(type, buffer);
|
||||
@@ -277,7 +285,7 @@ abstract class BackgroundWorkerFlutterApi {
|
||||
|
||||
Future<void> onIosUpload(bool isRefresh, int? maxSeconds);
|
||||
|
||||
Future<void> onAndroidUpload(int? maxMinutes);
|
||||
Future<BackgroundWorkerResult> onAndroidUpload(int? maxMinutes);
|
||||
|
||||
Future<void> cancel();
|
||||
|
||||
@@ -326,8 +334,8 @@ abstract class BackgroundWorkerFlutterApi {
|
||||
final List<Object?> args = message! as List<Object?>;
|
||||
final int? arg_maxMinutes = args[0] as int?;
|
||||
try {
|
||||
await api.onAndroidUpload(arg_maxMinutes);
|
||||
return wrapResponse(empty: true);
|
||||
final BackgroundWorkerResult output = await api.onAndroidUpload(arg_maxMinutes);
|
||||
return wrapResponse(result: output);
|
||||
} on PlatformException catch (e) {
|
||||
return wrapResponse(error: e);
|
||||
} catch (e) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_api.g.dart';
|
||||
import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||
@@ -24,6 +25,8 @@ import 'package:openapi/api.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
|
||||
|
||||
enum _AssetUploadResult { complete, pending, failed, offline }
|
||||
|
||||
/// Callbacks for upload progress and status updates
|
||||
class UploadCallbacks {
|
||||
final void Function(String id, String filename, int bytes, int totalBytes)? onProgress;
|
||||
@@ -80,7 +83,6 @@ class ForegroundUploadService {
|
||||
String userId,
|
||||
Completer<void> cancelToken, {
|
||||
UploadCallbacks callbacks = const UploadCallbacks(),
|
||||
bool useSequentialUpload = false,
|
||||
}) async {
|
||||
final candidates = await _backupRepository.getCandidates(userId);
|
||||
if (candidates.isEmpty) {
|
||||
@@ -91,44 +93,121 @@ class ForegroundUploadService {
|
||||
final hasWifi = networkCapabilities.isUnmetered;
|
||||
_logger.info('Network capabilities: $networkCapabilities, hasWifi/isUnmetered: $hasWifi');
|
||||
|
||||
if (useSequentialUpload) {
|
||||
await _uploadSequentially(items: candidates, cancelToken: cancelToken, hasWifi: hasWifi, callbacks: callbacks);
|
||||
} else {
|
||||
await _executeWithWorkerPool<LocalAsset>(
|
||||
items: candidates,
|
||||
cancelToken: cancelToken,
|
||||
shouldSkip: (asset) {
|
||||
final requireWifi = _shouldRequireWiFi(asset);
|
||||
return requireWifi && !hasWifi;
|
||||
},
|
||||
processItem: (asset) => uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
|
||||
);
|
||||
await _executeWithWorkerPool<LocalAsset>(
|
||||
items: candidates,
|
||||
cancelToken: cancelToken,
|
||||
shouldSkip: (asset) {
|
||||
final requireWifi = _shouldRequireWiFi(asset);
|
||||
return requireWifi && !hasWifi;
|
||||
},
|
||||
processItem: (asset) => uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
|
||||
);
|
||||
}
|
||||
|
||||
Future<BackgroundWorkerResult> uploadCandidatesInBackground(
|
||||
String? userId,
|
||||
Completer<void> cancelToken, {
|
||||
required bool backupEnabled,
|
||||
required bool localSyncCompleted,
|
||||
required bool remoteSyncCompleted,
|
||||
}) async {
|
||||
if (!localSyncCompleted) {
|
||||
return BackgroundWorkerResult.unchanged;
|
||||
}
|
||||
if (!backupEnabled) {
|
||||
_logger.info("Backup is disabled. Skipping backup routine");
|
||||
return BackgroundWorkerResult.none;
|
||||
}
|
||||
if (userId == null) {
|
||||
_logger.warning("No current user found. Skipping backup from background");
|
||||
return BackgroundWorkerResult.none;
|
||||
}
|
||||
|
||||
try {
|
||||
final remaining = await _backupRepository.getCandidates(userId, onlyHashed: false);
|
||||
if (remaining.isEmpty) {
|
||||
return BackgroundWorkerResult.none;
|
||||
}
|
||||
|
||||
var capabilities = await _connectivityApi.getCapabilities();
|
||||
if (!remoteSyncCompleted) {
|
||||
if (capabilities.isUnmetered) {
|
||||
return BackgroundWorkerResult.connected;
|
||||
}
|
||||
return _classifyRemaining(remaining);
|
||||
}
|
||||
|
||||
if (capabilities.isEmpty) {
|
||||
return _classifyRemaining(remaining);
|
||||
}
|
||||
|
||||
await _storageRepository.clearCache();
|
||||
shouldAbortUpload = false;
|
||||
var needsConnected = false;
|
||||
|
||||
for (final asset in remaining.toList()) {
|
||||
if (shouldAbortUpload || cancelToken.isCompleted) {
|
||||
break;
|
||||
}
|
||||
if (asset.checksum == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final requiresUnmetered = _shouldRequireWiFi(asset);
|
||||
if (requiresUnmetered) {
|
||||
capabilities = await _connectivityApi.getCapabilities();
|
||||
if (!capabilities.isUnmetered) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
final result = await _uploadSingleAsset(
|
||||
asset,
|
||||
cancelToken,
|
||||
callbacks: const UploadCallbacks(),
|
||||
checkNetwork: true,
|
||||
);
|
||||
if (result == _AssetUploadResult.complete) {
|
||||
remaining.remove(asset);
|
||||
continue;
|
||||
}
|
||||
if (shouldAbortUpload || cancelToken.isCompleted) {
|
||||
break;
|
||||
}
|
||||
if (result == _AssetUploadResult.offline) {
|
||||
break;
|
||||
}
|
||||
if (result == _AssetUploadResult.pending) {
|
||||
continue;
|
||||
}
|
||||
|
||||
capabilities = await _connectivityApi.getCapabilities();
|
||||
if (capabilities.isUnmetered) {
|
||||
needsConnected = true;
|
||||
continue;
|
||||
}
|
||||
if (capabilities.isEmpty) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (needsConnected) {
|
||||
return BackgroundWorkerResult.connected;
|
||||
}
|
||||
return _classifyRemaining(remaining);
|
||||
} catch (error, stackTrace) {
|
||||
_logger.severe(() => "Error classifying background upload: $error", stackTrace);
|
||||
return BackgroundWorkerResult.unchanged;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sequential upload - used for background isolate where concurrent HTTP clients may cause issues
|
||||
Future<void> _uploadSequentially({
|
||||
required List<LocalAsset> items,
|
||||
required Completer<void> cancelToken,
|
||||
required bool hasWifi,
|
||||
required UploadCallbacks callbacks,
|
||||
}) async {
|
||||
await _storageRepository.clearCache();
|
||||
shouldAbortUpload = false;
|
||||
|
||||
for (final asset in items) {
|
||||
if (shouldAbortUpload || cancelToken.isCompleted) {
|
||||
break;
|
||||
}
|
||||
|
||||
final requireWifi = _shouldRequireWiFi(asset);
|
||||
if (requireWifi && !hasWifi) {
|
||||
_logger.warning('Skipping upload for ${asset.id} because it requires WiFi');
|
||||
continue;
|
||||
}
|
||||
|
||||
await uploadSingleAsset(asset, cancelToken, callbacks: callbacks);
|
||||
BackgroundWorkerResult _classifyRemaining(List<LocalAsset> remaining) {
|
||||
if (remaining.isEmpty) {
|
||||
return BackgroundWorkerResult.none;
|
||||
}
|
||||
return remaining.any((asset) => !_shouldRequireWiFi(asset))
|
||||
? BackgroundWorkerResult.connected
|
||||
: BackgroundWorkerResult.unmetered;
|
||||
}
|
||||
|
||||
/// Manually upload picked local assets
|
||||
@@ -239,9 +318,20 @@ class ForegroundUploadService {
|
||||
LocalAsset asset,
|
||||
Completer<void>? cancelToken, {
|
||||
required UploadCallbacks callbacks,
|
||||
}) async {
|
||||
await _uploadSingleAsset(asset, cancelToken, callbacks: callbacks);
|
||||
}
|
||||
|
||||
Future<_AssetUploadResult> _uploadSingleAsset(
|
||||
LocalAsset asset,
|
||||
Completer<void>? cancelToken, {
|
||||
required UploadCallbacks callbacks,
|
||||
bool checkNetwork = false,
|
||||
}) async {
|
||||
File? file;
|
||||
File? livePhotoFile;
|
||||
var livePhotoFailed = false;
|
||||
var livePhotoUploadFailed = false;
|
||||
|
||||
try {
|
||||
final entity = await _storageRepository.getAssetEntityForAsset(asset);
|
||||
@@ -250,7 +340,7 @@ class ForegroundUploadService {
|
||||
asset.localId!,
|
||||
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
|
||||
);
|
||||
return;
|
||||
return _AssetUploadResult.complete;
|
||||
}
|
||||
|
||||
final isAvailableLocally = await _storageRepository.isAssetAvailableLocally(asset.id);
|
||||
@@ -287,7 +377,7 @@ class ForegroundUploadService {
|
||||
asset.localId!,
|
||||
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
|
||||
);
|
||||
return;
|
||||
return _AssetUploadResult.complete;
|
||||
}
|
||||
|
||||
// For live photos, get the motion video file
|
||||
@@ -306,7 +396,10 @@ class ForegroundUploadService {
|
||||
if (file == null) {
|
||||
_logger.warning("Failed to obtain file from iCloud for asset ${asset.id} - ${asset.name}");
|
||||
callbacks.onError?.call(asset.localId!, "asset_not_found_on_icloud".t());
|
||||
return;
|
||||
return _AssetUploadResult.complete;
|
||||
}
|
||||
if (entity.isLivePhoto && livePhotoFile == null) {
|
||||
livePhotoFailed = true;
|
||||
}
|
||||
|
||||
final fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
|
||||
@@ -345,6 +438,22 @@ class ForegroundUploadService {
|
||||
|
||||
if (livePhotoResult.isSuccess && livePhotoResult.remoteAssetId != null) {
|
||||
livePhotoVideoId = livePhotoResult.remoteAssetId;
|
||||
} else if (livePhotoResult.isCancelled && checkNetwork) {
|
||||
shouldAbortUpload = true;
|
||||
return _AssetUploadResult.pending;
|
||||
} else {
|
||||
livePhotoFailed = true;
|
||||
livePhotoUploadFailed = true;
|
||||
}
|
||||
|
||||
if (checkNetwork) {
|
||||
final capabilities = await _connectivityApi.getCapabilities();
|
||||
if (capabilities.isEmpty) {
|
||||
return _AssetUploadResult.offline;
|
||||
}
|
||||
if (_shouldRequireWiFi(asset) && !capabilities.isUnmetered) {
|
||||
return _AssetUploadResult.pending;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,6 +491,10 @@ class ForegroundUploadService {
|
||||
|
||||
if (result.isSuccess && result.remoteAssetId != null) {
|
||||
callbacks.onSuccess?.call(asset.localId!, result.remoteAssetId!);
|
||||
if (livePhotoUploadFailed) {
|
||||
return _AssetUploadResult.failed;
|
||||
}
|
||||
return livePhotoFailed ? _AssetUploadResult.pending : _AssetUploadResult.complete;
|
||||
} else if (result.isCancelled) {
|
||||
_logger.warning(() => "Backup was cancelled by the user");
|
||||
shouldAbortUpload = true;
|
||||
@@ -397,9 +510,11 @@ class ForegroundUploadService {
|
||||
shouldAbortUpload = true;
|
||||
}
|
||||
}
|
||||
return _AssetUploadResult.failed;
|
||||
} catch (error, stackTrace) {
|
||||
_logger.severe(() => "Error backup asset: ${error.toString()}", stackTrace);
|
||||
callbacks.onError?.call(asset.localId!, error.toString());
|
||||
return _AssetUploadResult.pending;
|
||||
} finally {
|
||||
if (Platform.isIOS) {
|
||||
try {
|
||||
|
||||
@@ -18,6 +18,8 @@ class BackgroundWorkerSettings {
|
||||
const BackgroundWorkerSettings({required this.requiresCharging, required this.minimumDelaySeconds});
|
||||
}
|
||||
|
||||
enum BackgroundWorkerResult { none, connected, unmetered, unchanged }
|
||||
|
||||
@HostApi()
|
||||
abstract class BackgroundWorkerFgHostApi {
|
||||
void enable();
|
||||
@@ -47,7 +49,7 @@ abstract class BackgroundWorkerFlutterApi {
|
||||
|
||||
// Android Only: Called when the Android background upload is triggered
|
||||
@async
|
||||
void onAndroidUpload(int? maxMinutes);
|
||||
BackgroundWorkerResult onAndroidUpload(int? maxMinutes);
|
||||
|
||||
@async
|
||||
void cancel();
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/local_sync.service.dart';
|
||||
@@ -34,6 +37,13 @@ void main() {
|
||||
setUpAll(() async {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
registerFallbackValue(
|
||||
LocalAlbum(id: 'fallback', name: 'Fallback', updatedAt: DateTime.fromMillisecondsSinceEpoch(0)),
|
||||
);
|
||||
registerFallbackValue(<LocalAlbum>[]);
|
||||
registerFallbackValue(<LocalAsset>[]);
|
||||
registerFallbackValue(<String>[]);
|
||||
registerFallbackValue(<String, List<String>>{});
|
||||
|
||||
db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
|
||||
await StoreService.init(storeRepository: DriftStoreRepository(db));
|
||||
@@ -81,6 +91,83 @@ void main() {
|
||||
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => false);
|
||||
});
|
||||
|
||||
group('LocalSyncService - result', () {
|
||||
test('returns true when there are no media changes', () async {
|
||||
expect(await sut.sync(), isTrue);
|
||||
});
|
||||
|
||||
test('returns false when the media change scan fails', () async {
|
||||
when(() => mockNativeSyncApi.getMediaChanges()).thenThrow(Exception('scan failed'));
|
||||
|
||||
expect(await sut.sync(), isFalse);
|
||||
});
|
||||
|
||||
test('returns false when cancelled during a no-change scan', () async {
|
||||
final cancellation = Completer<void>();
|
||||
when(() => mockNativeSyncApi.cancelSync()).thenAnswer((_) async {});
|
||||
when(() => mockNativeSyncApi.getMediaChanges()).thenAnswer((_) async {
|
||||
cancellation.complete();
|
||||
return SyncDelta(hasChanges: false, updates: const [], deletes: const [], assetAlbums: const {});
|
||||
});
|
||||
sut = LocalSyncService(
|
||||
localAlbumRepository: mockLocalAlbumRepository,
|
||||
localAssetRepository: mockLocalAssetRepository,
|
||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepository,
|
||||
assetMediaRepository: mockAssetMediaRepository,
|
||||
permissionRepository: mockPermissionRepository,
|
||||
nativeSyncApi: mockNativeSyncApi,
|
||||
cancellation: cancellation,
|
||||
);
|
||||
|
||||
expect(await sut.sync(), isFalse);
|
||||
});
|
||||
|
||||
test('returns false when a full sync album fails', () async {
|
||||
final album = PlatformAlbum(id: 'album', name: 'Album', isCloud: false, assetCount: 1);
|
||||
when(() => mockNativeSyncApi.getAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mockLocalAlbumRepository.getAll(sortBy: {SortLocalAlbumsBy.id})).thenAnswer((_) async => []);
|
||||
when(() => mockNativeSyncApi.getAssetsForAlbum('album')).thenThrow(Exception('album failed'));
|
||||
when(() => mockNativeSyncApi.checkpointSync()).thenAnswer((_) async {});
|
||||
|
||||
expect(await sut.fullSync(), isFalse);
|
||||
verifyNever(() => mockNativeSyncApi.checkpointSync());
|
||||
});
|
||||
|
||||
test('does not checkpoint after a delta sync album fails', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
|
||||
final updatedAt = DateTime.utc(2026);
|
||||
final album = PlatformAlbum(
|
||||
id: 'album',
|
||||
name: 'Device album',
|
||||
isCloud: true,
|
||||
assetCount: 0,
|
||||
updatedAt: updatedAt.millisecondsSinceEpoch ~/ 1000,
|
||||
);
|
||||
final dbAlbum = LocalAlbum(id: 'album', name: 'Database album', updatedAt: updatedAt, isIosSharedAlbum: true);
|
||||
when(() => mockNativeSyncApi.getMediaChanges()).thenAnswer(
|
||||
(_) async => SyncDelta(hasChanges: true, updates: const [], deletes: const [], assetAlbums: const {}),
|
||||
);
|
||||
when(() => mockNativeSyncApi.getAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mockLocalAlbumRepository.updateAll(any())).thenAnswer((_) async {});
|
||||
when(
|
||||
() => mockLocalAlbumRepository.processDelta(
|
||||
updates: any(named: 'updates'),
|
||||
deletes: any(named: 'deletes'),
|
||||
assetAlbums: any(named: 'assetAlbums'),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
when(() => mockLocalAlbumRepository.getAll()).thenAnswer((_) async => [dbAlbum]);
|
||||
when(
|
||||
() => mockLocalAlbumRepository.upsert(any(), toDelete: any(named: 'toDelete')),
|
||||
).thenThrow(Exception('album failed'));
|
||||
when(() => mockNativeSyncApi.checkpointSync()).thenAnswer((_) async {});
|
||||
|
||||
expect(await sut.sync(), isFalse);
|
||||
verifyNever(() => mockNativeSyncApi.checkpointSync());
|
||||
});
|
||||
});
|
||||
|
||||
group('LocalSyncService - syncTrashedAssets gating', () {
|
||||
test('invokes syncTrashedAssets when Android flag enabled and permission granted', () async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart' hide isNull, isNotNull;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_api.g.dart';
|
||||
import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
||||
import 'package:immich_mobile/repositories/upload.repository.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
@@ -20,181 +24,239 @@ import '../infrastructure/repository.mock.dart';
|
||||
import '../mocks/asset_entity.mock.dart';
|
||||
import '../repository.mocks.dart';
|
||||
|
||||
class _Case {
|
||||
final String name;
|
||||
final List<LocalAsset> assets;
|
||||
final List<List<NetworkCapability>> capabilities;
|
||||
final BackgroundWorkerResult result;
|
||||
final int uploads;
|
||||
final bool photos, videos, local, remote, throws;
|
||||
final List<UploadResult> uploadResults;
|
||||
|
||||
const _Case(
|
||||
this.name,
|
||||
this.assets,
|
||||
this.capabilities,
|
||||
this.result,
|
||||
this.uploads, {
|
||||
this.photos = true,
|
||||
this.videos = true,
|
||||
this.local = true,
|
||||
this.remote = true,
|
||||
this.throws = false,
|
||||
this.uploadResults = const [],
|
||||
});
|
||||
}
|
||||
|
||||
void main() {
|
||||
late ForegroundUploadService sut;
|
||||
late MockUploadRepository mockUploadRepository;
|
||||
late MockStorageRepository mockStorageRepository;
|
||||
late MockDriftBackupRepository mockBackupRepository;
|
||||
late MockConnectivityApi mockConnectivityApi;
|
||||
late MockAssetMediaRepository mockAssetMediaRepository;
|
||||
late Drift db;
|
||||
late MockUploadRepository uploads;
|
||||
late MockStorageRepository storage;
|
||||
late MockDriftBackupRepository backup;
|
||||
late MockConnectivityApi connectivity;
|
||||
late MockAssetMediaRepository media;
|
||||
late List<Map<String, String>> fields;
|
||||
late List<String> names;
|
||||
late int uploadCount;
|
||||
|
||||
setUpAll(() async {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
const MethodChannel('plugins.flutter.io/path_provider'),
|
||||
(MethodCall methodCall) async => 'test',
|
||||
(_) async => 'test',
|
||||
);
|
||||
db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
|
||||
final db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
|
||||
await StoreService.init(storeRepository: DriftStoreRepository(db));
|
||||
await SettingsRepository.ensureInitialized(db);
|
||||
|
||||
await Store.put(StoreKey.serverEndpoint, 'http://demo.immich.app');
|
||||
await Store.put(StoreKey.deviceId, 'device-id');
|
||||
|
||||
registerFallbackValue(File('file'));
|
||||
registerFallbackValue(<String, String>{});
|
||||
});
|
||||
|
||||
final photo = LocalAssetStub.image1.copyWith(checksum: 'photo');
|
||||
final photo2 = LocalAssetStub.image2.copyWith(checksum: 'photo2');
|
||||
final unhashedPhoto = LocalAssetStub.image2;
|
||||
final unhashedVideo = LocalAsset(
|
||||
id: 'unhashed-video',
|
||||
name: 'video.mp4',
|
||||
type: AssetType.video,
|
||||
createdAt: DateTime(2025),
|
||||
updatedAt: DateTime(2025, 2),
|
||||
playbackStyle: AssetPlaybackStyle.video,
|
||||
isEdited: false,
|
||||
);
|
||||
final video = unhashedVideo.copyWith(id: 'video', checksum: 'video');
|
||||
final live = photo.copyWith(playbackStyle: AssetPlaybackStyle.livePhoto);
|
||||
|
||||
void stubUploads(List<UploadResult> results) {
|
||||
var index = 0;
|
||||
when(
|
||||
() => uploads.uploadFile(
|
||||
file: any(named: 'file'),
|
||||
originalFileName: any(named: 'originalFileName'),
|
||||
fields: any(named: 'fields'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
onProgress: any(named: 'onProgress'),
|
||||
logContext: any(named: 'logContext'),
|
||||
),
|
||||
).thenAnswer((invocation) async {
|
||||
uploadCount++;
|
||||
names.add(invocation.namedArguments[#originalFileName] as String);
|
||||
fields.add(Map.of(invocation.namedArguments[#fields] as Map<String, String>));
|
||||
return index < results.length ? results[index++] : UploadResult.success(remoteAssetId: 'remote-$uploadCount');
|
||||
});
|
||||
}
|
||||
|
||||
void stubAsset(LocalAsset asset, {File? file, String? originalName}) {
|
||||
final entity = MockAssetEntity();
|
||||
final isLive = asset.playbackStyle == AssetPlaybackStyle.livePhoto;
|
||||
when(() => entity.isLivePhoto).thenReturn(isLive);
|
||||
when(() => storage.getAssetEntityForAsset(asset)).thenAnswer((_) async => entity);
|
||||
when(() => storage.isAssetAvailableLocally(asset.id)).thenAnswer((_) async => true);
|
||||
when(
|
||||
() => storage.getFileForAsset(asset.id),
|
||||
).thenAnswer((_) async => file ?? File('/${asset.id}${asset.isVideo ? '.mp4' : '.jpg'}'));
|
||||
when(() => media.getOriginalFilename(asset.id)).thenAnswer((_) async => originalName ?? asset.name);
|
||||
if (isLive) {
|
||||
when(() => storage.getMotionFileForAsset(asset)).thenAnswer((_) async => File('/motion.mov'));
|
||||
}
|
||||
}
|
||||
|
||||
void stubCapabilities(List<List<NetworkCapability>> values) {
|
||||
var index = 0;
|
||||
when(() => connectivity.getCapabilities()).thenAnswer((_) async {
|
||||
final value = values[index < values.length ? index : values.length - 1];
|
||||
index++;
|
||||
return value;
|
||||
});
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
mockUploadRepository = MockUploadRepository();
|
||||
mockStorageRepository = MockStorageRepository();
|
||||
mockBackupRepository = MockDriftBackupRepository();
|
||||
mockConnectivityApi = MockConnectivityApi();
|
||||
mockAssetMediaRepository = MockAssetMediaRepository();
|
||||
|
||||
sut = ForegroundUploadService(
|
||||
mockUploadRepository,
|
||||
mockStorageRepository,
|
||||
mockBackupRepository,
|
||||
mockConnectivityApi,
|
||||
mockAssetMediaRepository,
|
||||
);
|
||||
uploads = MockUploadRepository();
|
||||
storage = MockStorageRepository();
|
||||
backup = MockDriftBackupRepository();
|
||||
connectivity = MockConnectivityApi();
|
||||
media = MockAssetMediaRepository();
|
||||
sut = ForegroundUploadService(uploads, storage, backup, connectivity, media);
|
||||
fields = [];
|
||||
names = [];
|
||||
uploadCount = 0;
|
||||
when(storage.clearCache).thenAnswer((_) async {});
|
||||
stubUploads(const []);
|
||||
});
|
||||
|
||||
List<Map<String, String>> captureFields() {
|
||||
final captured = <Map<String, String>>[];
|
||||
when(
|
||||
() => mockUploadRepository.uploadFile(
|
||||
file: any(named: 'file'),
|
||||
originalFileName: any(named: 'originalFileName'),
|
||||
fields: any(named: 'fields'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
onProgress: any(named: 'onProgress'),
|
||||
logContext: any(named: 'logContext'),
|
||||
),
|
||||
).thenAnswer((invocation) async {
|
||||
final fields = invocation.namedArguments[#fields] as Map<String, String>;
|
||||
captured.add(Map.of(fields));
|
||||
return UploadResult.success(remoteAssetId: 'remote-${captured.length}');
|
||||
});
|
||||
return captured;
|
||||
}
|
||||
|
||||
List<String> captureOriginalFileNames() {
|
||||
final captured = <String>[];
|
||||
when(
|
||||
() => mockUploadRepository.uploadFile(
|
||||
file: any(named: 'file'),
|
||||
originalFileName: any(named: 'originalFileName'),
|
||||
fields: any(named: 'fields'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
onProgress: any(named: 'onProgress'),
|
||||
logContext: any(named: 'logContext'),
|
||||
),
|
||||
).thenAnswer((invocation) async {
|
||||
captured.add(invocation.namedArguments[#originalFileName] as String);
|
||||
return UploadResult.success(remoteAssetId: 'remote-${captured.length}');
|
||||
});
|
||||
return captured;
|
||||
}
|
||||
|
||||
group('uploadSingleAsset', () {
|
||||
test('should upload the motion part hidden and keep the still image visible', () async {
|
||||
final asset = LocalAssetStub.image1;
|
||||
final mockEntity = MockAssetEntity();
|
||||
final stillFile = File('/path/to/still.heic');
|
||||
final videoFile = File('/path/to/motion.mov');
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(true);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.isAssetAvailableLocally(asset.id)).thenAnswer((_) async => true);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => stillFile);
|
||||
when(() => mockStorageRepository.getMotionFileForAsset(asset)).thenAnswer((_) async => videoFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'live.heic');
|
||||
|
||||
final captured = captureFields();
|
||||
|
||||
await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks());
|
||||
|
||||
expect(captured, hasLength(2));
|
||||
expect(captured[0]['visibility'], equals('hidden'));
|
||||
expect(captured[0].containsKey('livePhotoVideoId'), isFalse);
|
||||
expect(captured[1].containsKey('visibility'), isFalse);
|
||||
expect(captured[1]['livePhotoVideoId'], equals('remote-1'));
|
||||
test('uploads live motion hidden and keeps the still visible', () async {
|
||||
stubAsset(live, file: File('/still.heic'), originalName: 'live.heic');
|
||||
await sut.uploadSingleAsset(live, null, callbacks: const UploadCallbacks());
|
||||
expect(fields, hasLength(2));
|
||||
expect(fields.first['visibility'], 'hidden');
|
||||
expect(fields.last['visibility'], isNull);
|
||||
expect(fields.last['livePhotoVideoId'], 'remote-1');
|
||||
});
|
||||
|
||||
test('should not set visibility for a regular photo', () async {
|
||||
final asset = LocalAssetStub.image1;
|
||||
final mockEntity = MockAssetEntity();
|
||||
final stillFile = File('/path/to/photo.jpg');
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.isAssetAvailableLocally(asset.id)).thenAnswer((_) async => true);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => stillFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'photo.jpg');
|
||||
|
||||
final captured = captureFields();
|
||||
|
||||
await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks());
|
||||
|
||||
expect(captured, hasLength(1));
|
||||
expect(captured[0].containsKey('visibility'), isFalse);
|
||||
test('does not set visibility for a regular photo', () async {
|
||||
stubAsset(photo);
|
||||
await sut.uploadSingleAsset(photo, null, callbacks: const UploadCallbacks());
|
||||
expect(fields.single['visibility'], isNull);
|
||||
});
|
||||
|
||||
test('corrects the extension when iOS returns a rendered file for a .dng asset', () async {
|
||||
final asset = LocalAssetStub.image1;
|
||||
final mockEntity = MockAssetEntity();
|
||||
final stillFile = File('/path/to/IMG_6499.jpg');
|
||||
final filenameCases = [
|
||||
(File('/IMG_6499.jpg'), 'IMG_6499.dng', 'IMG_6499.jpg'),
|
||||
(File('/IMG_5210.dng'), 'IMG_5210.dng', 'IMG_5210.dng'),
|
||||
(File('/DJI_0001'), 'DJI_0001', 'DJI_0001.jpg'),
|
||||
];
|
||||
for (final row in filenameCases) {
|
||||
test('uses ${row.$3}', () async {
|
||||
stubAsset(photo, file: row.$1, originalName: row.$2);
|
||||
await sut.uploadSingleAsset(photo, null, callbacks: const UploadCallbacks());
|
||||
expect(names.single, row.$3);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.isAssetAvailableLocally(asset.id)).thenAnswer((_) async => true);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => stillFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'IMG_6499.dng');
|
||||
group('background matrix', () {
|
||||
const offline = <NetworkCapability>[];
|
||||
const metered = [NetworkCapability.cellular];
|
||||
const unmetered = [NetworkCapability.wifi, NetworkCapability.unmetered];
|
||||
const none = BackgroundWorkerResult.none;
|
||||
const connected = BackgroundWorkerResult.connected;
|
||||
const wifi = BackgroundWorkerResult.unmetered;
|
||||
const unchanged = BackgroundWorkerResult.unchanged;
|
||||
final off = [offline];
|
||||
final met = [metered];
|
||||
final un = [unmetered];
|
||||
final unToMet = [unmetered, metered];
|
||||
final metToUn = [metered, unmetered];
|
||||
final p = [photo];
|
||||
final v = [video];
|
||||
final pv = [photo, video];
|
||||
final vp = [video, photo];
|
||||
final vpp = [video, photo, photo2];
|
||||
final lv = [live, video];
|
||||
final liveCost = [unmetered, ...unToMet];
|
||||
final quota = [UploadResult.error(errorMessage: 'Quota has been exceeded!')];
|
||||
final failed = [UploadResult.error(errorMessage: 'network')];
|
||||
final recovered = [...failed, UploadResult.success(remoteAssetId: 'remote-2')];
|
||||
final cancelled = [UploadResult.cancelled()];
|
||||
final matrix = [
|
||||
_Case('authoritative empty', [], met, none, 0),
|
||||
_Case('remote fail offline allowed', p, off, connected, 0, remote: false),
|
||||
_Case('remote fail offline gated', v, off, wifi, 0, videos: false, remote: false),
|
||||
_Case('offline mixed gated first', vp, off, connected, 0, videos: false),
|
||||
_Case('metered allowed complete', p, met, none, 1),
|
||||
_Case('metered gated only', v, met, wifi, 0, videos: false),
|
||||
_Case('metered mixed allowed first', pv, met, wifi, 1, videos: false),
|
||||
_Case('metered mixed early stop gated first', vpp, met, connected, 1, videos: false, uploadResults: quota),
|
||||
_Case('unmetered complete', vp, un, none, 2, photos: false, videos: false),
|
||||
_Case('apparent unmetered failure', vp, un, connected, 2, videos: false, uploadResults: recovered),
|
||||
_Case('cancelled allowed remains', pv, met, connected, 1, videos: false, uploadResults: cancelled),
|
||||
_Case('classifier exception both sources', p, met, unchanged, 0, throws: true),
|
||||
_Case('local sync error both sources', v, met, unchanged, 0, local: false),
|
||||
_Case('hash timeout unhashed allowed', [photo, unhashedPhoto], met, connected, 1),
|
||||
_Case('hash timeout unhashed gated', [unhashedVideo], met, wifi, 0, videos: false),
|
||||
_Case('hash timeout authoritative empty', [], off, none, 0),
|
||||
_Case('unmetered to metered gated only', v, unToMet, wifi, 0, videos: false),
|
||||
_Case('unmetered to metered mixed', vp, unToMet, wifi, 1, videos: false),
|
||||
_Case('live photo part cost change', lv, liveCost, wifi, 2, photos: false, uploadResults: recovered),
|
||||
_Case('metered to unmetered later gated', pv, metToUn, none, 2, videos: false),
|
||||
];
|
||||
|
||||
final names = captureOriginalFileNames();
|
||||
for (final row in matrix) {
|
||||
test(row.name, () async {
|
||||
await SettingsRepository.instance.write(.backupUseCellularForPhotos, row.photos);
|
||||
await SettingsRepository.instance.write(.backupUseCellularForVideos, row.videos);
|
||||
Future<List<LocalAsset>> query() => backup.getCandidates('user', onlyHashed: false);
|
||||
if (row.throws) {
|
||||
when(query).thenThrow(StateError('scan'));
|
||||
} else {
|
||||
when(query).thenAnswer((_) async => row.assets.toList());
|
||||
}
|
||||
for (final asset in row.assets) {
|
||||
stubAsset(asset);
|
||||
}
|
||||
stubCapabilities(row.capabilities);
|
||||
if (row.uploadResults.isNotEmpty) {
|
||||
stubUploads(row.uploadResults);
|
||||
}
|
||||
|
||||
await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks());
|
||||
final result = await sut.uploadCandidatesInBackground(
|
||||
'user',
|
||||
Completer<void>(),
|
||||
backupEnabled: true,
|
||||
localSyncCompleted: row.local,
|
||||
remoteSyncCompleted: row.remote,
|
||||
);
|
||||
|
||||
expect(names, equals(['IMG_6499.jpg']));
|
||||
});
|
||||
|
||||
test('keeps the .dng extension for a genuine RAW original', () async {
|
||||
final asset = LocalAssetStub.image1;
|
||||
final mockEntity = MockAssetEntity();
|
||||
final stillFile = File('/path/to/IMG_5210.dng');
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.isAssetAvailableLocally(asset.id)).thenAnswer((_) async => true);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => stillFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'IMG_5210.dng');
|
||||
|
||||
final names = captureOriginalFileNames();
|
||||
|
||||
await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks());
|
||||
|
||||
expect(names, equals(['IMG_5210.dng']));
|
||||
});
|
||||
|
||||
test('borrows the extension from the asset name for an extensionless name (DJI/Fusion)', () async {
|
||||
final asset = LocalAssetStub.image1;
|
||||
final mockEntity = MockAssetEntity();
|
||||
final stillFile = File('/path/to/DJI_0001');
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.isAssetAvailableLocally(asset.id)).thenAnswer((_) async => true);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => stillFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'DJI_0001');
|
||||
|
||||
final names = captureOriginalFileNames();
|
||||
|
||||
await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks());
|
||||
|
||||
expect(names, equals(['DJI_0001.jpg']));
|
||||
});
|
||||
expect(result, row.result);
|
||||
expect(uploadCount, row.uploads);
|
||||
if (!row.local) {
|
||||
verifyNever(query);
|
||||
} else {
|
||||
verify(query).called(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user