diff --git a/i18n/en.json b/i18n/en.json index fef3243ec0..aa1999adcb 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -533,6 +533,7 @@ "background_backup_running_error": "Background backup is currently running, cannot start manual backup", "background_location_permission": "Background location permission", "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", + "background_options": "Background Options", "backup": "Backup", "backup_album_selection_page_albums_device": "Albums on device ({count})", "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", @@ -540,6 +541,7 @@ "backup_album_selection_page_select_albums": "Select albums", "backup_album_selection_page_selection_info": "Selection Info", "backup_album_selection_page_total_assets": "Total unique assets", + "backup_albums_sync": "Backup albums synchronization", "backup_all": "All", "backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…", "backup_background_service_connection_failed_message": "Failed to connect to the server. Retrying…", @@ -656,6 +658,8 @@ "change_pin_code": "Change PIN code", "change_your_password": "Change your password", "changed_visibility_successfully": "Changed visibility successfully", + "charging": "Charging", + "charging_requirement_mobile_backup": "Background backup requires the device to be charging", "check_corrupt_asset_backup": "Check for corrupt asset backups", "check_corrupt_asset_backup_button": "Perform check", "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", @@ -1351,6 +1355,7 @@ "name_or_nickname": "Name or nickname", "network_requirement_photos_upload": "Use cellular data to backup photos", "network_requirement_videos_upload": "Use cellular data to backup videos", + "network_requirements": "Network Requirements", "network_requirements_updated": "Network requirements changed, resetting backup queue", "networking_settings": "Networking", "networking_subtitle": "Manage the server endpoint settings", diff --git a/mise.toml b/mise.toml index ed287b178d..dd9afad5a8 100644 --- a/mise.toml +++ b/mise.toml @@ -1,6 +1,6 @@ [tools] node = "22.19.0" -flutter = "3.35.3" +flutter = "3.35.4" pnpm = "10.14.0" dart = "3.8.2" diff --git a/mobile/.fvmrc b/mobile/.fvmrc index d8a11548fd..a4d5f6d9b7 100644 --- a/mobile/.fvmrc +++ b/mobile/.fvmrc @@ -1,3 +1,3 @@ { - "flutter": "3.35.3" + "flutter": "3.35.4" } \ No newline at end of file diff --git a/mobile/.vscode/settings.json b/mobile/.vscode/settings.json index 0c2f2c29cc..9c6057e582 100644 --- a/mobile/.vscode/settings.json +++ b/mobile/.vscode/settings.json @@ -1,5 +1,5 @@ { - "dart.flutterSdkPath": ".fvm/versions/3.35.3", + "dart.flutterSdkPath": ".fvm/versions/3.35.4", "dart.lineLength": 120, "[dart]": { "editor.rulers": [120] diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/ImmichApp.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/ImmichApp.kt index 4237643233..5a3b0e1f3d 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/ImmichApp.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/ImmichApp.kt @@ -3,6 +3,7 @@ package app.alextran.immich import android.app.Application import androidx.work.Configuration import androidx.work.WorkManager +import app.alextran.immich.background.BackgroundWorkerApiImpl class ImmichApp : Application() { override fun onCreate() { @@ -14,6 +15,8 @@ class ImmichApp : Application() { // Thus, the BackupWorker is not started. If the system kills the process after each initialization // (because of low memory etc.), the backup is never performed. // As a workaround, we also run a backup check when initializing the application + ContentObserverWorker.startBackupWorker(context = this, delayMilliseconds = 0) + BackgroundWorkerApiImpl.enqueueBackgroundWorker(this) } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt index d395cc2243..4e811c8dfc 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt @@ -3,6 +3,7 @@ package app.alextran.immich import android.content.Context import android.os.Build import android.os.ext.SdkExtensions +import app.alextran.immich.background.BackgroundEngineLock import app.alextran.immich.background.BackgroundWorkerApiImpl import app.alextran.immich.background.BackgroundWorkerFgHostApi import app.alextran.immich.connectivity.ConnectivityApi @@ -25,6 +26,7 @@ class MainActivity : FlutterFragmentActivity() { fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) { flutterEngine.plugins.add(BackgroundServicePlugin()) flutterEngine.plugins.add(HttpSSLOptionsPlugin()) + flutterEngine.plugins.add(BackgroundEngineLock()) val messenger = flutterEngine.dartExecutor.binaryMessenger val nativeSyncApiImpl = diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundEngineLock.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundEngineLock.kt new file mode 100644 index 0000000000..6d6f45a708 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundEngineLock.kt @@ -0,0 +1,33 @@ +package app.alextran.immich.background + +import android.util.Log +import androidx.work.WorkManager +import io.flutter.embedding.engine.FlutterEngineCache +import io.flutter.embedding.engine.plugins.FlutterPlugin +import java.util.concurrent.atomic.AtomicInteger + +private const val TAG = "BackgroundEngineLock" + +class BackgroundEngineLock : FlutterPlugin { + companion object { + const val ENGINE_CACHE_KEY = "immich::background_worker::engine" + var engineCount = AtomicInteger(0) + } + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + // work manager task is running while the main app is opened, cancel the worker + if (engineCount.incrementAndGet() > 1 && FlutterEngineCache.getInstance() + .get(ENGINE_CACHE_KEY) != null + ) { + WorkManager.getInstance(binding.applicationContext) + .cancelUniqueWork(BackgroundWorkerApiImpl.BACKGROUND_WORKER_NAME) + FlutterEngineCache.getInstance().remove(ENGINE_CACHE_KEY) + } + Log.i(TAG, "Flutter engine attached. Attached Engines count: $engineCount") + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + engineCount.decrementAndGet() + Log.i(TAG, "Flutter engine detached. Attached Engines count: $engineCount") + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt index 4d9e2c0caf..052395c172 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt @@ -37,6 +37,36 @@ private object BackgroundWorkerPigeonUtils { ) } } + fun deepEquals(a: Any?, b: Any?): Boolean { + if (a is ByteArray && b is ByteArray) { + return a.contentEquals(b) + } + if (a is IntArray && b is IntArray) { + return a.contentEquals(b) + } + if (a is LongArray && b is LongArray) { + return a.contentEquals(b) + } + if (a is DoubleArray && b is DoubleArray) { + return a.contentEquals(b) + } + if (a is Array<*> && b is Array<*>) { + return a.size == b.size && + a.indices.all{ deepEquals(a[it], b[it]) } + } + if (a is List<*> && b is List<*>) { + return a.size == b.size && + a.indices.all{ deepEquals(a[it], b[it]) } + } + if (a is Map<*, *> && b is Map<*, *>) { + return a.size == b.size && a.all { + (b as Map).containsKey(it.key) && + deepEquals(it.value, b[it.key]) + } + } + return a == b + } + } /** @@ -50,18 +80,63 @@ class FlutterError ( override val message: String? = null, val details: Any? = null ) : Throwable() + +/** Generated class from Pigeon that represents data sent in messages. */ +data class BackgroundWorkerSettings ( + val requiresCharging: Boolean, + val minimumDelaySeconds: Long +) + { + companion object { + fun fromList(pigeonVar_list: List): BackgroundWorkerSettings { + val requiresCharging = pigeonVar_list[0] as Boolean + val minimumDelaySeconds = pigeonVar_list[1] as Long + return BackgroundWorkerSettings(requiresCharging, minimumDelaySeconds) + } + } + fun toList(): List { + return listOf( + requiresCharging, + minimumDelaySeconds, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is BackgroundWorkerSettings) { + return false + } + if (this === other) { + return true + } + return BackgroundWorkerPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} private open class BackgroundWorkerPigeonCodec : StandardMessageCodec() { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { - return super.readValueOfType(type, buffer) + return when (type) { + 129.toByte() -> { + return (readValue(buffer) as? List)?.let { + BackgroundWorkerSettings.fromList(it) + } + } + else -> super.readValueOfType(type, buffer) + } } override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { - super.writeValue(stream, value) + when (value) { + is BackgroundWorkerSettings -> { + stream.write(129) + writeValue(stream, value.toList()) + } + else -> super.writeValue(stream, value) + } } } /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ interface BackgroundWorkerFgHostApi { fun enable() + fun configure(settings: BackgroundWorkerSettings) fun disable() companion object { @@ -89,6 +164,24 @@ interface BackgroundWorkerFgHostApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.configure$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val settingsArg = args[0] as BackgroundWorkerSettings + val wrapped: List = try { + api.configure(settingsArg) + listOf(null) + } catch (exception: Throwable) { + BackgroundWorkerPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } run { val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable$separatedMessageChannelSuffix", codec) if (api != null) { diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt index 33eb60dc82..7d30319af4 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt @@ -19,6 +19,7 @@ import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.SettableFuture import io.flutter.FlutterInjector import io.flutter.embedding.engine.FlutterEngine +import io.flutter.embedding.engine.FlutterEngineCache import io.flutter.embedding.engine.dart.DartExecutor import io.flutter.embedding.engine.loader.FlutterLoader import java.util.concurrent.TimeUnit @@ -75,6 +76,9 @@ class BackgroundWorker(context: Context, params: WorkerParameters) : loader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) { engine = FlutterEngine(ctx) + FlutterEngineCache.getInstance().remove(BackgroundEngineLock.ENGINE_CACHE_KEY); + FlutterEngineCache.getInstance() + .put(BackgroundEngineLock.ENGINE_CACHE_KEY, engine!!) // Register custom plugins MainActivity.registerPlugins(ctx, engine!!) @@ -188,6 +192,7 @@ class BackgroundWorker(context: Context, params: WorkerParameters) : isComplete = true engine?.destroy() engine = null + FlutterEngineCache.getInstance().remove(BackgroundEngineLock.ENGINE_CACHE_KEY); flutterApi = null notificationManager.cancel(NOTIFICATION_ID) waitForForegroundPromotion() diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerApiImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerApiImpl.kt index 4c2d98be71..259e3244bc 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerApiImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerApiImpl.kt @@ -1,6 +1,7 @@ package app.alextran.immich.background import android.content.Context +import android.content.SharedPreferences import android.provider.MediaStore import android.util.Log import androidx.work.BackoffPolicy @@ -10,7 +11,7 @@ import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager import java.util.concurrent.TimeUnit -private const val TAG = "BackgroundUploadImpl" +private const val TAG = "BackgroundWorkerApiImpl" class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi { private val ctx: Context = context.applicationContext @@ -19,25 +20,34 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi { enqueueMediaObserver(ctx) } + override fun configure(settings: BackgroundWorkerSettings) { + BackgroundWorkerPreferences(ctx).updateSettings(settings) + enqueueMediaObserver(ctx) + } + override fun disable() { - WorkManager.getInstance(ctx).cancelUniqueWork(OBSERVER_WORKER_NAME) - WorkManager.getInstance(ctx).cancelUniqueWork(BACKGROUND_WORKER_NAME) + WorkManager.getInstance(ctx).apply { + cancelUniqueWork(OBSERVER_WORKER_NAME) + cancelUniqueWork(BACKGROUND_WORKER_NAME) + } Log.i(TAG, "Cancelled background upload tasks") } companion object { - private const val BACKGROUND_WORKER_NAME = "immich/BackgroundWorkerV1" + const val BACKGROUND_WORKER_NAME = "immich/BackgroundWorkerV1" private const val OBSERVER_WORKER_NAME = "immich/MediaObserverV1" fun enqueueMediaObserver(ctx: Context) { - val constraints = Constraints.Builder() - .addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true) - .addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true) - .addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true) - .addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true) - .setTriggerContentUpdateDelay(30, TimeUnit.SECONDS) - .setTriggerContentMaxDelay(3, TimeUnit.MINUTES) - .build() + val settings = BackgroundWorkerPreferences(ctx).getSettings() + val constraints = Constraints.Builder().apply { + addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true) + addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true) + addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true) + addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true) + setTriggerContentUpdateDelay(settings.minimumDelaySeconds, TimeUnit.SECONDS) + setTriggerContentMaxDelay(settings.minimumDelaySeconds * 10, TimeUnit.MINUTES) + setRequiresCharging(settings.requiresCharging) + }.build() val work = OneTimeWorkRequest.Builder(MediaObserver::class.java) .setConstraints(constraints) @@ -45,7 +55,10 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi { WorkManager.getInstance(ctx) .enqueueUniqueWork(OBSERVER_WORKER_NAME, ExistingWorkPolicy.REPLACE, work) - Log.i(TAG, "Enqueued media observer worker with name: $OBSERVER_WORKER_NAME") + Log.i( + TAG, + "Enqueued media observer worker with name: $OBSERVER_WORKER_NAME and settings: $settings" + ) } fun enqueueBackgroundWorker(ctx: Context) { @@ -56,9 +69,39 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi { .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES) .build() WorkManager.getInstance(ctx) - .enqueueUniqueWork(BACKGROUND_WORKER_NAME, ExistingWorkPolicy.REPLACE, work) + .enqueueUniqueWork(BACKGROUND_WORKER_NAME, ExistingWorkPolicy.KEEP, work) Log.i(TAG, "Enqueued background worker with name: $BACKGROUND_WORKER_NAME") } } } + +private class BackgroundWorkerPreferences(private val ctx: Context) { + companion object { + private const val SHARED_PREF_NAME = "Immich::BackgroundWorker" + private const val SHARED_PREF_MIN_DELAY_KEY = "BackgroundWorker::minDelaySeconds" + private const val SHARED_PREF_REQUIRE_CHARGING_KEY = "BackgroundWorker::requireCharging" + + private const val DEFAULT_MIN_DELAY_SECONDS = 30L + private const val DEFAULT_REQUIRE_CHARGING = false + } + + private val sp: SharedPreferences by lazy { + ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) + } + + fun updateSettings(settings: BackgroundWorkerSettings) { + sp.edit().apply { + putLong(SHARED_PREF_MIN_DELAY_KEY, settings.minimumDelaySeconds) + putBoolean(SHARED_PREF_REQUIRE_CHARGING_KEY, settings.requiresCharging) + apply() + } + } + + fun getSettings(): BackgroundWorkerSettings { + return BackgroundWorkerSettings( + minimumDelaySeconds = sp.getLong(SHARED_PREF_MIN_DELAY_KEY, DEFAULT_MIN_DELAY_SECONDS), + requiresCharging = sp.getBoolean(SHARED_PREF_REQUIRE_CHARGING_KEY, DEFAULT_REQUIRE_CHARGING), + ) + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbnailsImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbnailsImpl.kt index 1b1716f55c..1ccd742d67 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbnailsImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbnailsImpl.kt @@ -8,7 +8,6 @@ import android.net.Uri import android.os.Build import android.os.CancellationSignal import android.os.OperationCanceledException -import android.provider.MediaStore import android.provider.MediaStore.Images import android.provider.MediaStore.Video import android.util.Size @@ -19,7 +18,6 @@ import com.bumptech.glide.Glide import com.bumptech.glide.Priority import com.bumptech.glide.load.DecodeFormat import java.util.Base64 -import java.util.HashMap import java.util.concurrent.CancellationException import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Future @@ -202,8 +200,10 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi { val source = ImageDecoder.createSource(resolver, uri) signal.throwIfCanceled() ImageDecoder.decodeBitmap(source) { decoder, info, _ -> - val sampleSize = max(1, min(info.size.width / targetWidth, info.size.height / targetHeight)) - decoder.setTargetSampleSize(sampleSize) + if (targetWidth > 0 && targetHeight > 0) { + val sample = max(1, min(info.size.width / targetWidth, info.size.height / targetHeight)) + decoder.setTargetSampleSize(sample) + } decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB)) } diff --git a/mobile/ios/Runner/Background/BackgroundWorker.g.swift b/mobile/ios/Runner/Background/BackgroundWorker.g.swift index 45a6402fe8..ece5cd5f64 100644 --- a/mobile/ios/Runner/Background/BackgroundWorker.g.swift +++ b/mobile/ios/Runner/Background/BackgroundWorker.g.swift @@ -50,11 +50,119 @@ private func nilOrValue(_ value: Any?) -> T? { return value as! T? } +func deepEqualsBackgroundWorker(_ lhs: Any?, _ rhs: Any?) -> Bool { + let cleanLhs = nilOrValue(lhs) as Any? + let cleanRhs = nilOrValue(rhs) as Any? + switch (cleanLhs, cleanRhs) { + case (nil, nil): + return true + + case (nil, _), (_, nil): + return false + + case is (Void, Void): + return true + + case let (cleanLhsHashable, cleanRhsHashable) as (AnyHashable, AnyHashable): + return cleanLhsHashable == cleanRhsHashable + + case let (cleanLhsArray, cleanRhsArray) as ([Any?], [Any?]): + guard cleanLhsArray.count == cleanRhsArray.count else { return false } + for (index, element) in cleanLhsArray.enumerated() { + if !deepEqualsBackgroundWorker(element, cleanRhsArray[index]) { + return false + } + } + return true + + case let (cleanLhsDictionary, cleanRhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]): + guard cleanLhsDictionary.count == cleanRhsDictionary.count else { return false } + for (key, cleanLhsValue) in cleanLhsDictionary { + guard cleanRhsDictionary.index(forKey: key) != nil else { return false } + if !deepEqualsBackgroundWorker(cleanLhsValue, cleanRhsDictionary[key]!) { + return false + } + } + return true + + default: + // Any other type shouldn't be able to be used with pigeon. File an issue if you find this to be untrue. + return false + } +} + +func deepHashBackgroundWorker(value: Any?, hasher: inout Hasher) { + if let valueList = value as? [AnyHashable] { + for item in valueList { deepHashBackgroundWorker(value: item, hasher: &hasher) } + return + } + + if let valueDict = value as? [AnyHashable: AnyHashable] { + for key in valueDict.keys { + hasher.combine(key) + deepHashBackgroundWorker(value: valueDict[key]!, hasher: &hasher) + } + return + } + + if let hashableValue = value as? AnyHashable { + hasher.combine(hashableValue.hashValue) + } + + return hasher.combine(String(describing: value)) +} + + + +/// Generated class from Pigeon that represents data sent in messages. +struct BackgroundWorkerSettings: Hashable { + var requiresCharging: Bool + var minimumDelaySeconds: Int64 + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> BackgroundWorkerSettings? { + let requiresCharging = pigeonVar_list[0] as! Bool + let minimumDelaySeconds = pigeonVar_list[1] as! Int64 + + return BackgroundWorkerSettings( + requiresCharging: requiresCharging, + minimumDelaySeconds: minimumDelaySeconds + ) + } + func toList() -> [Any?] { + return [ + requiresCharging, + minimumDelaySeconds, + ] + } + static func == (lhs: BackgroundWorkerSettings, rhs: BackgroundWorkerSettings) -> Bool { + return deepEqualsBackgroundWorker(lhs.toList(), rhs.toList()) } + func hash(into hasher: inout Hasher) { + deepHashBackgroundWorker(value: toList(), hasher: &hasher) + } +} private class BackgroundWorkerPigeonCodecReader: FlutterStandardReader { + override func readValue(ofType type: UInt8) -> Any? { + switch type { + case 129: + return BackgroundWorkerSettings.fromList(self.readValue() as! [Any?]) + default: + return super.readValue(ofType: type) + } + } } private class BackgroundWorkerPigeonCodecWriter: FlutterStandardWriter { + override func writeValue(_ value: Any) { + if let value = value as? BackgroundWorkerSettings { + super.writeByte(129) + super.writeValue(value.toList()) + } else { + super.writeValue(value) + } + } } private class BackgroundWorkerPigeonCodecReaderWriter: FlutterStandardReaderWriter { @@ -74,6 +182,7 @@ class BackgroundWorkerPigeonCodec: FlutterStandardMessageCodec, @unchecked Senda /// Generated protocol from Pigeon that represents a handler of messages from Flutter. protocol BackgroundWorkerFgHostApi { func enable() throws + func configure(settings: BackgroundWorkerSettings) throws func disable() throws } @@ -96,6 +205,21 @@ class BackgroundWorkerFgHostApiSetup { } else { enableChannel.setMessageHandler(nil) } + let configureChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.configure\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + configureChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let settingsArg = args[0] as! BackgroundWorkerSettings + do { + try api.configure(settings: settingsArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + configureChannel.setMessageHandler(nil) + } let disableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) if let api = api { disableChannel.setMessageHandler { _, reply in diff --git a/mobile/ios/Runner/Background/BackgroundWorkerApiImpl.swift b/mobile/ios/Runner/Background/BackgroundWorkerApiImpl.swift index 184b797133..ca2453404c 100644 --- a/mobile/ios/Runner/Background/BackgroundWorkerApiImpl.swift +++ b/mobile/ios/Runner/Background/BackgroundWorkerApiImpl.swift @@ -8,6 +8,10 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi { print("BackgroundUploadImpl:enbale Background worker scheduled") } + func configure(settings: BackgroundWorkerSettings) throws { + // Android only + } + func disable() throws { BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.refreshTaskID); BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.processingTaskID); diff --git a/mobile/ios/Runner/Images/ThumbnailsImpl.swift b/mobile/ios/Runner/Images/ThumbnailsImpl.swift index d1ea2cc0e0..452ca62377 100644 --- a/mobile/ios/Runner/Images/ThumbnailsImpl.swift +++ b/mobile/ios/Runner/Images/ThumbnailsImpl.swift @@ -105,7 +105,7 @@ class ThumbnailApiImpl: ThumbnailApi { var image: UIImage? Self.imageManager.requestImage( for: asset, - targetSize: CGSize(width: Double(width), height: Double(height)), + targetSize: width > 0 && height > 0 ? CGSize(width: Double(width), height: Double(height)) : PHImageManagerMaximumSize, contentMode: .aspectFill, options: Self.requestOptions, resultHandler: { (_image, info) -> Void in diff --git a/mobile/lib/domain/services/background_worker.service.dart b/mobile/lib/domain/services/background_worker.service.dart index 90e2ddff4c..d57fe507d9 100644 --- a/mobile/lib/domain/services/background_worker.service.dart +++ b/mobile/lib/domain/services/background_worker.service.dart @@ -43,6 +43,17 @@ class BackgroundWorkerFgService { // TODO: Move this call to native side once old timeline is removed Future enable() => _foregroundHostApi.enable(); + Future configure({int? minimumDelaySeconds, bool? requireCharging}) => _foregroundHostApi.configure( + BackgroundWorkerSettings( + minimumDelaySeconds: + minimumDelaySeconds ?? + Store.get(AppSettingsEnum.backupTriggerDelay.storeKey, AppSettingsEnum.backupTriggerDelay.defaultValue), + requiresCharging: + requireCharging ?? + Store.get(AppSettingsEnum.backupRequireCharging.storeKey, AppSettingsEnum.backupRequireCharging.defaultValue), + ), + ); + Future disable() => _foregroundHostApi.disable(); } diff --git a/mobile/lib/pages/backup/drift_backup.page.dart b/mobile/lib/pages/backup/drift_backup.page.dart index dc452d49d3..bf9ad43f9c 100644 --- a/mobile/lib/pages/backup/drift_backup.page.dart +++ b/mobile/lib/pages/backup/drift_backup.page.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -11,13 +9,11 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.widget.dart'; -import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/backup/backup_album.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/widgets/backup/backup_info_card.dart'; @RoutePage() @@ -29,8 +25,6 @@ class DriftBackupPage extends ConsumerStatefulWidget { } class _DriftBackupPageState extends ConsumerState { - Timer? _countPoller; - @override void initState() { super.initState(); @@ -39,42 +33,9 @@ class _DriftBackupPageState extends ConsumerState { return; } - if (ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup)) { - _startCountPolling(); - } - ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id); } - void _startCountPolling() { - _countPoller?.cancel(); - _countPoller = Timer.periodic(const Duration(seconds: 5), (timer) async { - if (!mounted) { - timer.cancel(); - return; - } - - final currentUser = ref.read(currentUserProvider); - if (currentUser == null) { - timer.cancel(); - return; - } - - await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id); - }); - } - - void _stopCountPolling() { - _countPoller?.cancel(); - _countPoller = null; - } - - @override - void dispose() { - _stopCountPolling(); - super.dispose(); - } - @override Widget build(BuildContext context) { final selectedAlbum = ref @@ -94,12 +55,10 @@ class _DriftBackupPageState extends ConsumerState { await backgroundManager.syncRemote(); await backupNotifier.getBackupStatus(currentUser.id); await backupNotifier.startBackup(currentUser.id); - _startCountPolling(); } Future stopBackup() async { await backupNotifier.cancel(); - _stopCountPolling(); } return Scaffold( diff --git a/mobile/lib/platform/background_worker_api.g.dart b/mobile/lib/platform/background_worker_api.g.dart index 9398b0a15b..af7c78fd4b 100644 --- a/mobile/lib/platform/background_worker_api.g.dart +++ b/mobile/lib/platform/background_worker_api.g.dart @@ -25,6 +25,57 @@ List wrapResponse({Object? result, PlatformException? error, bool empty return [error.code, error.message, error.details]; } +bool _deepEquals(Object? a, Object? b) { + if (a is List && b is List) { + return a.length == b.length && a.indexed.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); + } + if (a is Map && b is Map) { + return a.length == b.length && + a.entries.every( + (MapEntry entry) => + (b as Map).containsKey(entry.key) && _deepEquals(entry.value, b[entry.key]), + ); + } + return a == b; +} + +class BackgroundWorkerSettings { + BackgroundWorkerSettings({required this.requiresCharging, required this.minimumDelaySeconds}); + + bool requiresCharging; + + int minimumDelaySeconds; + + List _toList() { + return [requiresCharging, minimumDelaySeconds]; + } + + Object encode() { + return _toList(); + } + + static BackgroundWorkerSettings decode(Object result) { + result as List; + return BackgroundWorkerSettings(requiresCharging: result[0]! as bool, minimumDelaySeconds: result[1]! as int); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! BackgroundWorkerSettings || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @override @@ -32,6 +83,9 @@ class _PigeonCodec extends StandardMessageCodec { if (value is int) { buffer.putUint8(4); buffer.putInt64(value); + } else if (value is BackgroundWorkerSettings) { + buffer.putUint8(129); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -40,6 +94,8 @@ class _PigeonCodec extends StandardMessageCodec { @override Object? readValueOfType(int type, ReadBuffer buffer) { switch (type) { + case 129: + return BackgroundWorkerSettings.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); } @@ -82,6 +138,29 @@ class BackgroundWorkerFgHostApi { } } + Future configure(BackgroundWorkerSettings settings) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.configure$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([settings]); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + Future disable() async { final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable$pigeonVar_messageChannelSuffix'; diff --git a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart index e30ce6e40d..73ebf60067 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart @@ -87,7 +87,7 @@ class _GeneralBottomSheetState extends ConsumerState { return BaseBottomSheet( controller: sheetController, - initialChildSize: 0.45, + initialChildSize: widget.minChildSize ?? 0.15, minChildSize: widget.minChildSize, maxChildSize: 0.85, shouldCloseOnMinExtent: false, diff --git a/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart index 15b049928b..1dcc52f349 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart @@ -84,7 +84,8 @@ class _RemoteAlbumBottomSheetState extends ConsumerState return BaseBottomSheet( controller: sheetController, - initialChildSize: 0.45, + initialChildSize: 0.22, + minChildSize: 0.22, maxChildSize: 0.85, shouldCloseOnMinExtent: false, actions: [ diff --git a/mobile/lib/presentation/widgets/images/local_image_provider.dart b/mobile/lib/presentation/widgets/images/local_image_provider.dart index 223d095432..f90961ea5a 100644 --- a/mobile/lib/presentation/widgets/images/local_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/local_image_provider.dart @@ -4,6 +4,8 @@ import 'dart:ui'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.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/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/loaders/image_request.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart'; @@ -88,13 +90,26 @@ class LocalFullImageProvider extends CancellableImageProvider { children: [ timeline, if (!isSelectionMode && isMultiSelectEnabled) ...[ - const Positioned(top: 60, left: 25, child: _MultiSelectStatusButton()), + Positioned( + top: MediaQuery.paddingOf(context).top, + left: 25, + child: const SizedBox( + height: kToolbarHeight, + child: Center(child: _MultiSelectStatusButton()), + ), + ), if (widget.bottomSheet != null) widget.bottomSheet!, ], ], diff --git a/mobile/lib/providers/backup/drift_backup.provider.dart b/mobile/lib/providers/backup/drift_backup.provider.dart index 1de7f75135..40ec7b1077 100644 --- a/mobile/lib/providers/backup/drift_backup.provider.dart +++ b/mobile/lib/providers/backup/drift_backup.provider.dart @@ -12,8 +12,8 @@ import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/services/upload.service.dart'; -import 'package:immich_mobile/utils/debug_print.dart'; import 'package:logging/logging.dart'; +import 'package:immich_mobile/utils/debug_print.dart'; class EnqueueStatus { final int enqueueCount; @@ -234,6 +234,10 @@ class DriftBackupNotifier extends StateNotifier { switch (update.status) { case TaskStatus.complete: + if (update.task.group == kBackupGroup) { + state = state.copyWith(backupCount: state.backupCount + 1, remainderCount: state.remainderCount - 1); + } + // Remove the completed task from the upload items if (state.uploadItems.containsKey(taskId)) { Future.delayed(const Duration(milliseconds: 1000), () { diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index d39795b116..8d47c6b5fa 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -52,6 +52,9 @@ enum AppSettingsEnum { useCellularForUploadPhotos(StoreKey.useWifiForUploadPhotos, null, false), readonlyModeEnabled(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false), albumGridView(StoreKey.albumGridView, "albumGridView", false); + backupRequireCharging(StoreKey.backupRequireCharging, null, false), + backupTriggerDelay(StoreKey.backupTriggerDelay, null, 30), + readonlyModeEnabled(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false); const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue); diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 4dfc0398bd..090aeeeaa7 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -102,7 +102,7 @@ enum ActionButtonType { context.asset.hasRemote, ActionButtonType.deleteLocal => !context.isInLockedView && // - context.asset.storage == AssetState.local, + context.asset.hasLocal, ActionButtonType.upload => !context.isInLockedView && // context.asset.storage == AssetState.local, diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index 653c3f4347..f4f0564425 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -65,7 +65,7 @@ Future migrateDatabaseIfNeeded(Isar db, Drift drift) async { // Handle migration only for this version // TODO: remove when old timeline is removed final needBetaMigration = Store.tryGet(StoreKey.needBetaMigration); - if (version == 15 && needBetaMigration == null) { + if (version >= 15 && needBetaMigration == null) { // Check both databases directly instead of relying on cache final isBeta = Store.tryGet(StoreKey.betaTimeline); @@ -73,7 +73,7 @@ Future migrateDatabaseIfNeeded(Isar db, Drift drift) async { // For new installations, no migration needed // For existing installations, only migrate if beta timeline is not enabled (null or false) - if (isNewInstallation || isBeta == true) { + if (isNewInstallation || isBeta == true || (version > 15 && isBeta == null)) { await Store.put(StoreKey.needBetaMigration, false); await Store.put(StoreKey.betaTimeline, true); } else { diff --git a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart index f9768d575e..f75dd6e803 100644 --- a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart @@ -75,86 +75,79 @@ class _MesmerizingSliverAppBarState extends ConsumerState const SizedBox(height: 120), - _ => const SizedBox(height: 452), - }, - ); - } else { - return SliverAppBar( - expandedHeight: 400.0, - floating: false, - pinned: true, - snap: false, - elevation: 0, - leading: IconButton( - icon: Icon( - Platform.isIOS ? Icons.arrow_back_ios_new_rounded : Icons.arrow_back, - color: actionIconColor, - shadows: actionIconShadows, - ), - onPressed: () => context.navigateTo(const TabShellRoute(children: [DriftAlbumsRoute()])), - ), - actions: [ - if (widget.onToggleAlbumOrder != null) - IconButton( - icon: Icon(Icons.swap_vert_rounded, color: actionIconColor, shadows: actionIconShadows), - onPressed: widget.onToggleAlbumOrder, - ), - if (currentAlbum.isActivityEnabled && currentAlbum.isShared) - IconButton( - icon: Icon(Icons.chat_outlined, color: actionIconColor, shadows: actionIconShadows), - onPressed: widget.onActivity, - ), - if (widget.onShowOptions != null) - IconButton( - icon: Icon(Icons.more_vert, color: actionIconColor, shadows: actionIconShadows), - onPressed: widget.onShowOptions, - ), - ], - title: Builder( - builder: (context) { - final settings = context.dependOnInheritedWidgetOfExactType(); - final scrollProgress = _calculateScrollProgress(settings); - - return AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - child: scrollProgress > 0.95 - ? Text( - currentAlbum.name, - style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w600, fontSize: 18), - ) - : null, - ); - }, - ), - flexibleSpace: Builder( - builder: (context) { - final settings = context.dependOnInheritedWidgetOfExactType(); - final scrollProgress = _calculateScrollProgress(settings); - - // Update scroll progress for the leading button - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && _scrollProgress != scrollProgress) { - setState(() { - _scrollProgress = scrollProgress; - }); - } - }); - - return FlexibleSpaceBar( - background: _ExpandedBackground( - scrollProgress: scrollProgress, - icon: widget.icon, - onEditTitle: widget.onEditTitle, + return SliverAppBar( + expandedHeight: 400.0, + floating: false, + pinned: true, + snap: false, + elevation: 0, + leading: isMultiSelectEnabled + ? const SizedBox.shrink() + : IconButton( + icon: Icon( + Platform.isIOS ? Icons.arrow_back_ios_new_rounded : Icons.arrow_back, + color: actionIconColor, + shadows: actionIconShadows, ), - ); - }, - ), - ); - } + onPressed: () => context.navigateTo(const TabShellRoute(children: [DriftAlbumsRoute()])), + ), + actions: [ + if (widget.onToggleAlbumOrder != null) + IconButton( + icon: Icon(Icons.swap_vert_rounded, color: actionIconColor, shadows: actionIconShadows), + onPressed: widget.onToggleAlbumOrder, + ), + if (currentAlbum.isActivityEnabled && currentAlbum.isShared) + IconButton( + icon: Icon(Icons.chat_outlined, color: actionIconColor, shadows: actionIconShadows), + onPressed: widget.onActivity, + ), + if (widget.onShowOptions != null) + IconButton( + icon: Icon(Icons.more_vert, color: actionIconColor, shadows: actionIconShadows), + onPressed: widget.onShowOptions, + ), + ], + title: Builder( + builder: (context) { + final settings = context.dependOnInheritedWidgetOfExactType(); + final scrollProgress = _calculateScrollProgress(settings); + + return AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: scrollProgress > 0.95 + ? Text( + currentAlbum.name, + style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w600, fontSize: 18), + ) + : null, + ); + }, + ), + flexibleSpace: Builder( + builder: (context) { + final settings = context.dependOnInheritedWidgetOfExactType(); + final scrollProgress = _calculateScrollProgress(settings); + + // Update scroll progress for the leading button + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _scrollProgress != scrollProgress) { + setState(() { + _scrollProgress = scrollProgress; + }); + } + }); + + return FlexibleSpaceBar( + background: _ExpandedBackground( + scrollProgress: scrollProgress, + icon: widget.icon, + onEditTitle: widget.onEditTitle, + ), + ); + }, + ), + ); } } diff --git a/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart b/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart index ac9866d4da..743d38fc48 100644 --- a/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart +++ b/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart @@ -1,14 +1,19 @@ +import 'dart:async'; + +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/services/sync_linked_album.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; -import 'package:immich_mobile/domain/services/sync_linked_album.service.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/backup/backup_album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; @@ -18,12 +23,40 @@ class DriftBackupSettings extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - return const SettingsSubPageScaffold( + return SettingsSubPageScaffold( settings: [ - _UseWifiForUploadVideosButton(), - _UseWifiForUploadPhotosButton(), - Divider(indent: 16, endIndent: 16), - _AlbumSyncActionButton(), + Padding( + padding: const EdgeInsets.only(left: 16.0), + child: Text( + "network_requirements".t(context: context).toUpperCase(), + style: context.textTheme.labelSmall?.copyWith(color: context.colorScheme.onSurface.withValues(alpha: 0.7)), + ), + ), + const _UseWifiForUploadVideosButton(), + const _UseWifiForUploadPhotosButton(), + if (CurrentPlatform.isAndroid) ...[ + const Divider(), + Padding( + padding: const EdgeInsets.only(left: 16.0), + child: Text( + "background_options".t(context: context).toUpperCase(), + style: context.textTheme.labelSmall?.copyWith( + color: context.colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + ), + const _BackupOnlyWhenChargingButton(), + const _BackupDelaySlider(), + ], + const Divider(), + Padding( + padding: const EdgeInsets.only(left: 16.0), + child: Text( + "backup_albums_sync".t(context: context).toUpperCase(), + style: context.textTheme.labelSmall?.copyWith(color: context.colorScheme.onSurface.withValues(alpha: 0.7)), + ), + ), + const _AlbumSyncActionButton(), ], ); } @@ -151,30 +184,59 @@ class _AlbumSyncActionButtonState extends ConsumerState<_AlbumSyncActionButton> } } -class _UseWifiForUploadVideosButton extends ConsumerWidget { - const _UseWifiForUploadVideosButton(); +class _SettingsSwitchTile extends ConsumerStatefulWidget { + final AppSettingsEnum appSettingsEnum; + final String titleKey; + final String subtitleKey; + final void Function(bool?)? onChanged; + + const _SettingsSwitchTile({ + required this.appSettingsEnum, + required this.titleKey, + required this.subtitleKey, + this.onChanged, + }); @override - Widget build(BuildContext context, WidgetRef ref) { - final valueStream = Store.watch(StoreKey.useWifiForUploadVideos); + ConsumerState createState() => _SettingsSwitchTileState(); +} +class _SettingsSwitchTileState extends ConsumerState<_SettingsSwitchTile> { + late final Stream valueStream; + late final StreamSubscription subscription; + + @override + void initState() { + super.initState(); + valueStream = Store.watch(widget.appSettingsEnum.storeKey).asBroadcastStream(); + subscription = valueStream.listen((value) { + widget.onChanged?.call(value); + }); + } + + @override + void dispose() { + subscription.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { return ListTile( title: Text( - "videos".t(context: context), + widget.titleKey.t(context: context), style: context.textTheme.titleMedium?.copyWith(color: context.primaryColor), ), - subtitle: Text("network_requirement_videos_upload".t(context: context), style: context.textTheme.labelLarge), + subtitle: Text(widget.subtitleKey.t(context: context), style: context.textTheme.labelLarge), trailing: StreamBuilder( stream: valueStream, - initialData: Store.tryGet(StoreKey.useWifiForUploadVideos) ?? false, + initialData: Store.tryGet(widget.appSettingsEnum.storeKey) ?? widget.appSettingsEnum.defaultValue, builder: (context, snapshot) { final value = snapshot.data ?? false; return Switch( value: value, onChanged: (bool newValue) async { - await ref - .read(appSettingsServiceProvider) - .setSetting(AppSettingsEnum.useCellularForUploadVideos, newValue); + await ref.read(appSettingsServiceProvider).setSetting(widget.appSettingsEnum, newValue); }, ); }, @@ -183,34 +245,135 @@ class _UseWifiForUploadVideosButton extends ConsumerWidget { } } +class _UseWifiForUploadVideosButton extends ConsumerWidget { + const _UseWifiForUploadVideosButton(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return const _SettingsSwitchTile( + appSettingsEnum: AppSettingsEnum.useCellularForUploadVideos, + titleKey: "videos", + subtitleKey: "network_requirement_videos_upload", + ); + } +} + class _UseWifiForUploadPhotosButton extends ConsumerWidget { const _UseWifiForUploadPhotosButton(); @override Widget build(BuildContext context, WidgetRef ref) { - final valueStream = Store.watch(StoreKey.useWifiForUploadPhotos); - - return ListTile( - title: Text( - "photos".t(context: context), - style: context.textTheme.titleMedium?.copyWith(color: context.primaryColor), - ), - subtitle: Text("network_requirement_photos_upload".t(context: context), style: context.textTheme.labelLarge), - trailing: StreamBuilder( - stream: valueStream, - initialData: Store.tryGet(StoreKey.useWifiForUploadPhotos) ?? false, - builder: (context, snapshot) { - final value = snapshot.data ?? false; - return Switch( - value: value, - onChanged: (bool newValue) async { - await ref - .read(appSettingsServiceProvider) - .setSetting(AppSettingsEnum.useCellularForUploadPhotos, newValue); - }, - ); - }, - ), + return const _SettingsSwitchTile( + appSettingsEnum: AppSettingsEnum.useCellularForUploadPhotos, + titleKey: "photos", + subtitleKey: "network_requirement_photos_upload", + ); + } +} + +class _BackupOnlyWhenChargingButton extends ConsumerWidget { + const _BackupOnlyWhenChargingButton(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return _SettingsSwitchTile( + appSettingsEnum: AppSettingsEnum.backupRequireCharging, + titleKey: "charging", + subtitleKey: "charging_requirement_mobile_backup", + onChanged: (value) { + ref.read(backgroundWorkerFgServiceProvider).configure(requireCharging: value ?? false); + }, + ); + } +} + +class _BackupDelaySlider extends ConsumerStatefulWidget { + const _BackupDelaySlider(); + + @override + ConsumerState<_BackupDelaySlider> createState() => _BackupDelaySliderState(); +} + +class _BackupDelaySliderState extends ConsumerState<_BackupDelaySlider> { + late final Stream valueStream; + late final StreamSubscription subscription; + late int currentValue; + + static int backupDelayToSliderValue(int ms) => switch (ms) { + 5 => 0, + 30 => 1, + 120 => 2, + _ => 3, + }; + + static int backupDelayToSeconds(int v) => switch (v) { + 0 => 5, + 1 => 30, + 2 => 120, + _ => 600, + }; + + static String formatBackupDelaySliderValue(int v) => switch (v) { + 0 => 'setting_notifications_notify_seconds'.tr(namedArgs: {'count': '5'}), + 1 => 'setting_notifications_notify_seconds'.tr(namedArgs: {'count': '30'}), + 2 => 'setting_notifications_notify_minutes'.tr(namedArgs: {'count': '2'}), + _ => 'setting_notifications_notify_minutes'.tr(namedArgs: {'count': '10'}), + }; + + @override + void initState() { + super.initState(); + final initialValue = + Store.tryGet(AppSettingsEnum.backupTriggerDelay.storeKey) ?? AppSettingsEnum.backupTriggerDelay.defaultValue; + currentValue = backupDelayToSliderValue(initialValue); + + valueStream = Store.watch(AppSettingsEnum.backupTriggerDelay.storeKey).asBroadcastStream(); + subscription = valueStream.listen((value) { + if (mounted && value != null) { + setState(() { + currentValue = backupDelayToSliderValue(value); + }); + } + }); + } + + @override + void dispose() { + subscription.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 16.0, top: 8.0), + child: Text( + 'backup_controller_page_background_delay'.tr( + namedArgs: {'duration': formatBackupDelaySliderValue(currentValue)}, + ), + style: context.textTheme.titleMedium?.copyWith(color: context.primaryColor), + ), + ), + Slider( + value: currentValue.toDouble(), + onChanged: (double v) { + setState(() { + currentValue = v.toInt(); + }); + }, + onChangeEnd: (double v) async { + final milliseconds = backupDelayToSeconds(v.toInt()); + await ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.backupTriggerDelay, milliseconds); + }, + max: 3.0, + min: 0.0, + divisions: 3, + label: formatBackupDelaySliderValue(currentValue), + ), + ], ); } } diff --git a/mobile/pigeon/background_worker_api.dart b/mobile/pigeon/background_worker_api.dart index 8aa0f5f5ee..6f6c781de2 100644 --- a/mobile/pigeon/background_worker_api.dart +++ b/mobile/pigeon/background_worker_api.dart @@ -11,10 +11,19 @@ import 'package:pigeon/pigeon.dart'; dartPackageName: 'immich_mobile', ), ) +class BackgroundWorkerSettings { + final bool requiresCharging; + final int minimumDelaySeconds; + + const BackgroundWorkerSettings({required this.requiresCharging, required this.minimumDelaySeconds}); +} + @HostApi() abstract class BackgroundWorkerFgHostApi { void enable(); + void configure(BackgroundWorkerSettings settings); + void disable(); } diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 92e2a56172..d0dc8e64d3 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -2171,4 +2171,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.8.0 <4.0.0" - flutter: ">=3.35.3" + flutter: ">=3.35.4" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index bd8817ce06..91f937125f 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -6,7 +6,7 @@ version: 1.142.1+3015 environment: sdk: '>=3.8.0 <4.0.0' - flutter: 3.35.3 + flutter: 3.35.4 isar_version: &isar_version 3.1.8 diff --git a/mobile/test/utils/action_button_utils_test.dart b/mobile/test/utils/action_button_utils_test.dart index 497246e2a1..f8c51173d7 100644 --- a/mobile/test/utils/action_button_utils_test.dart +++ b/mobile/test/utils/action_button_utils_test.dart @@ -502,6 +502,21 @@ void main() { expect(ActionButtonType.deleteLocal.shouldShow(context), isFalse); }); + + test('should show when asset is merged', () { + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.deleteLocal.shouldShow(context), isTrue); + }); }); group('upload button', () { diff --git a/server/Dockerfile.dev b/server/Dockerfile.dev index 644d605f48..86624998f6 100644 --- a/server/Dockerfile.dev +++ b/server/Dockerfile.dev @@ -56,7 +56,7 @@ RUN if [ "$(dpkg --print-architecture)" = "arm64" ]; then \ # Flutter SDK # https://flutter.dev/docs/development/tools/sdk/releases?tab=linux ENV FLUTTER_CHANNEL="stable" -ENV FLUTTER_VERSION="3.35.3" +ENV FLUTTER_VERSION="3.35.4" ENV FLUTTER_HOME=/flutter ENV PATH=${PATH}:${FLUTTER_HOME}/bin diff --git a/web/src/lib/components/ServerAboutItem.svelte b/web/src/lib/components/ServerAboutItem.svelte new file mode 100644 index 0000000000..9e169a9839 --- /dev/null +++ b/web/src/lib/components/ServerAboutItem.svelte @@ -0,0 +1,24 @@ + + +
+ + + {#if versionHref} + {version} + {:else} + {version} + {/if} + +
diff --git a/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte b/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte index 43cc6e016b..854cf3fdd4 100644 --- a/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte +++ b/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte @@ -163,7 +163,7 @@ {#if config.storageTemplate.enabled}
-

{$t('variables')}

+

{$t('variables')}

{#await getSupportDateTimeFormat()} @@ -180,7 +180,7 @@
-

{$t('template')}

+

{$t('template')}

{$t('preview')}

@@ -192,7 +192,7 @@ values={{ length: parsedTemplate().length + $user.id.length + 'UPLOAD_LOCATION'.length, limit: 260 }} > {#snippet children({ message })} - {message} + {message} {/snippet}

@@ -200,7 +200,7 @@

{#snippet children({ message })} - {message} + {message} {/snippet}

@@ -214,10 +214,7 @@
{#if templateOptions} -