Compare commits

..

5 Commits

Author SHA1 Message Date
Santo Shakil
6e00cb0244 fix(mobile): prevent background cleanup deadlock 2026-07-24 02:11:05 +06:00
Santo Shakil
c17aaee230 Merge remote-tracking branch 'origin/main' into fix/24545-sync-retry-cx
# Conflicts:
#	mobile/lib/domain/services/background_worker.service.dart
2026-07-24 00:02:30 +06:00
Santo Shakil
b2ab8b37f9 rearm background backup through a network constrained relay when connectivity returns 2026-07-23 22:05:50 +06:00
Santo Shakil
7f46885d3a Merge remote-tracking branch 'origin/main' into fix/24545-sync-retry-cx 2026-07-23 15:46:01 +06:00
Santo Shakil
bba46d5c62 retry background backup when the network comes back 2026-07-21 00:03:58 +06:00
15 changed files with 999 additions and 306 deletions

View File

@@ -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")
}

View File

@@ -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()
}
}
}

View File

@@ -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)))

View File

@@ -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)
}

View File

@@ -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")
}

View File

@@ -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"
}
},
{

View File

@@ -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))
}
}
}

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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();

View File

@@ -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);

View File

@@ -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);
}
});
}
});
}