From 3eb2bf0342387f30d11d1216a749a47e676b7fc8 Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Mon, 6 Oct 2025 11:41:34 +0300 Subject: [PATCH] optimize, refactor code remove redundant code and checking getTrashedAssetsForAlbum for iOS tests for hash trashed assets --- .../app/alextran/immich/sync/Messages.g.kt | 74 +------ .../alextran/immich/sync/MessagesImpl26.kt | 7 - .../alextran/immich/sync/MessagesImpl30.kt | 55 ----- .../alextran/immich/sync/MessagesImplBase.kt | 8 - mobile/ios/Podfile.lock | 62 +++--- mobile/ios/Runner/Sync/Messages.g.swift | 68 +------ mobile/ios/Runner/Sync/MessagesImpl.swift | 123 ++++++------ .../models/asset/trashed_asset.model.dart | 7 - mobile/lib/domain/services/hash.service.dart | 107 ++-------- .../domain/services/local_sync.service.dart | 101 ++++++++-- .../domain/services/sync_stream.service.dart | 2 +- .../domain/services/trash_sync.service.dart | 91 +-------- .../entities/trashed_local_asset.entity.dart | 3 - .../trashed_local_asset.entity.drift.dart | 71 +------ .../repositories/local_asset.repository.dart | 25 +-- .../trashed_local_asset.repository.dart | 189 +++++++++--------- mobile/lib/platform/native_sync_api.g.dart | 95 +-------- .../infrastructure/sync.provider.dart | 6 +- .../infrastructure/trash_sync.provider.dart | 7 +- .../sync_status_and_actions.dart | 2 +- mobile/pigeon/native_sync_api.dart | 24 +-- .../domain/services/hash_service_test.dart | 42 +++- mobile/test/fixtures/asset.stub.dart | 14 ++ .../test/infrastructure/repository.mock.dart | 3 + 24 files changed, 393 insertions(+), 793 deletions(-) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt index 7bba2508f9..fc3cb2b3a9 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt @@ -89,9 +89,7 @@ data class PlatformAsset ( val height: Long? = null, val durationInSeconds: Long, val orientation: Long, - val isFavorite: Boolean, - val isTrashed: Boolean? = null, - val volume: String? = null + val isFavorite: Boolean ) { companion object { @@ -106,9 +104,7 @@ data class PlatformAsset ( val durationInSeconds = pigeonVar_list[7] as Long val orientation = pigeonVar_list[8] as Long val isFavorite = pigeonVar_list[9] as Boolean - val isTrashed = pigeonVar_list[10] as Boolean? - val volume = pigeonVar_list[11] as String? - return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite, isTrashed, volume) + return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite) } } fun toList(): List { @@ -123,8 +119,6 @@ data class PlatformAsset ( durationInSeconds, orientation, isFavorite, - isTrashed, - volume, ) } override fun equals(other: Any?): Boolean { @@ -249,40 +243,6 @@ data class HashResult ( override fun hashCode(): Int = toList().hashCode() } - -/** Generated class from Pigeon that represents data sent in messages. */ -data class TrashedAssetParams ( - val id: String, - val type: Long, - val albumId: String? = null -) - { - companion object { - fun fromList(pigeonVar_list: List): TrashedAssetParams { - val id = pigeonVar_list[0] as String - val type = pigeonVar_list[1] as Long - val albumId = pigeonVar_list[2] as String? - return TrashedAssetParams(id, type, albumId) - } - } - fun toList(): List { - return listOf( - id, - type, - albumId, - ) - } - override fun equals(other: Any?): Boolean { - if (other !is TrashedAssetParams) { - return false - } - if (this === other) { - return true - } - return MessagesPigeonUtils.deepEquals(toList(), other.toList()) } - - override fun hashCode(): Int = toList().hashCode() -} private open class MessagesPigeonCodec : StandardMessageCodec() { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { return when (type) { @@ -306,11 +266,6 @@ private open class MessagesPigeonCodec : StandardMessageCodec() { HashResult.fromList(it) } } - 133.toByte() -> { - return (readValue(buffer) as? List)?.let { - TrashedAssetParams.fromList(it) - } - } else -> super.readValueOfType(type, buffer) } } @@ -332,10 +287,6 @@ private open class MessagesPigeonCodec : StandardMessageCodec() { stream.write(132) writeValue(stream, value.toList()) } - is TrashedAssetParams -> { - stream.write(133) - writeValue(stream, value.toList()) - } else -> super.writeValue(stream, value) } } @@ -355,7 +306,6 @@ interface NativeSyncApi { fun hashAssets(assetIds: List, allowNetworkAccess: Boolean, callback: (Result>) -> Unit) fun cancelHashing() fun getTrashedAssetsForAlbum(albumId: String): List - fun hashTrashedAssets(trashedAssets: List, callback: (Result>) -> Unit) companion object { /** The codec used by NativeSyncApi. */ @@ -553,26 +503,6 @@ interface NativeSyncApi { channel.setMessageHandler(null) } } - run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashTrashedAssets$separatedMessageChannelSuffix", codec, taskQueue) - if (api != null) { - channel.setMessageHandler { message, reply -> - val args = message as List - val trashedAssetsArg = args[0] as List - api.hashTrashedAssets(trashedAssetsArg) { result: Result> -> - val error = result.exceptionOrNull() - if (error != null) { - reply.reply(MessagesPigeonUtils.wrapError(error)) - } else { - val data = result.getOrNull() - reply.reply(MessagesPigeonUtils.wrapResult(data)) - } - } - } - } else { - channel.setMessageHandler(null) - } - } } } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl26.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl26.kt index 767ef8e026..1f42a55706 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl26.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl26.kt @@ -27,11 +27,4 @@ class NativeSyncApiImpl26(context: Context) : NativeSyncApiImplBase(context), Na ): List { throw IllegalStateException("Method not supported on this Android version.") } - - override fun hashTrashedAssets( - trashedAssets: List, - callback: (Result>) -> Unit - ) { - throw IllegalStateException("Method not supported on this Android version.") - } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl30.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl30.kt index 8f9052cf90..b43bf7ab72 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl30.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl30.kt @@ -130,59 +130,4 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na return trashed } - - override fun hashTrashedAssets( - trashedAssets: List, - callback: (Result>) -> Unit - ) { - if (trashedAssets.isEmpty()) { - callback(Result.success(emptyList())) - return - } - hashTask?.cancel() - hashTask = CoroutineScope(Dispatchers.IO).launch { - try { - val results = trashedAssets.map { assetParams -> - async { - hashSemaphore.withPermit { - ensureActive() - hashTrashedAsset(assetParams) - } - } - }.awaitAll() - - callback(Result.success(results)) - } catch (e: CancellationException) { - callback( - Result.failure( - FlutterError( - HASHING_CANCELLED_CODE, - "Hashing operation was cancelled", - null - ) - ) - ) - } catch (e: Exception) { - callback(Result.failure(e)) - } - } - } - - suspend fun hashTrashedAsset(assetParams: TrashedAssetParams): HashResult { - val id = assetParams.id.toLong() - val mediaType = assetParams.type.toInt() - val assetUri = contentUriForType(id, mediaType) - return hashAssetFromUri(assetParams.id, assetUri) - } - - private fun contentUriForType(id: Long, mediaType: Int): Uri { - val vol = MediaStore.VOLUME_EXTERNAL - val base = when (mediaType) { - MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE -> MediaStore.Images.Media.getContentUri(vol) - MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> MediaStore.Video.Media.getContentUri(vol) - MediaStore.Files.FileColumns.MEDIA_TYPE_AUDIO -> MediaStore.Audio.Media.getContentUri(vol) - else -> MediaStore.Files.getContentUri(vol) - } - return ContentUris.withAppendedId(base, id) - } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt index 5ed62c8930..d1882dc768 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt @@ -62,8 +62,6 @@ open class NativeSyncApiImplBase(context: Context) { // only available on Android 11 and above if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) { add(MediaStore.MediaColumns.IS_FAVORITE) - add(MediaStore.MediaColumns.IS_TRASHED) - add(MediaStore.MediaColumns.VOLUME_NAME) } }.toTypedArray() @@ -111,8 +109,6 @@ open class NativeSyncApiImplBase(context: Context) { val orientationColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.ORIENTATION) val favoriteColumn = c.getColumnIndex(MediaStore.MediaColumns.IS_FAVORITE) - val trashedColumn = c.getColumnIndex(MediaStore.MediaColumns.IS_TRASHED) - val volumeColumn = c.getColumnIndex(MediaStore.MediaColumns.VOLUME_NAME) while (c.moveToNext()) { val id = c.getLong(idColumn).toString() @@ -142,8 +138,6 @@ open class NativeSyncApiImplBase(context: Context) { val bucketId = c.getString(bucketIdColumn) val orientation = c.getInt(orientationColumn) val isFavorite = if (favoriteColumn == -1) false else c.getInt(favoriteColumn) != 0 - val isTrashed = if (trashedColumn == -1) null else c.getInt(trashedColumn) != 0 - val volume = if (volumeColumn == -1) null else c.getString(volumeColumn) val asset = PlatformAsset( id, name, @@ -155,8 +149,6 @@ open class NativeSyncApiImplBase(context: Context) { duration, orientation.toLong(), isFavorite, - isTrashed, - volume, ) yield(AssetResult.ValidAsset(asset, bucketId)) } diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 502fd9008f..13f081d66b 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -246,46 +246,46 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/wakelock_plus/ios" SPEC CHECKSUMS: - background_downloader: 50e91d979067b82081aba359d7d916b3ba5fadad - bonsoir_darwin: 29c7ccf356646118844721f36e1de4b61f6cbd0e - connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd - device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe + background_downloader: a05c77d32a0d70615b9c04577aa203535fc924ff + bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842 + connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d + device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342 DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 - file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be + file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100 - flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf - flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 - flutter_udid: f7c3884e6ec2951efe4f9de082257fc77c4d15e9 - flutter_web_auth_2: 5c8d9dcd7848b5a9efb086d24e7a9adcae979c80 - fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1 - geolocator_apple: 1560c3c875af2a412242c7a923e15d0d401966ff - home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f - image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a - integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e - isar_flutter_libs: bc909e72c3d756c2759f14c8776c13b5b0556e26 - local_auth_darwin: 553ce4f9b16d3fdfeafce9cf042e7c9f77c1c391 + flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086 + flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29 + flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12 + flutter_udid: b2417673f287ee62817a1de3d1643f47b9f508ab + flutter_web_auth_2: 06d500582775790a0d4c323222fcb6d7990f9603 + fluttertoast: 21eecd6935e7064cc1fcb733a4c5a428f3f24f0f + geolocator_apple: 9bcea1918ff7f0062d98345d238ae12718acfbc1 + home_widget: 0434835a4c9a75704264feff6be17ea40e0f0d57 + image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 + integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573 + isar_flutter_libs: fdf730ca925d05687f36d7f1d355e482529ed097 + local_auth_darwin: 66e40372f1c29f383a314c738c7446e2f7fdadc3 MapLibre: 69e572367f4ef6287e18246cfafc39c80cdcabcd - maplibre_gl: 3c924e44725147b03dda33430ad216005b40555f - native_video_player: b65c58951ede2f93d103a25366bdebca95081265 - network_info_plus: cf61925ab5205dce05a4f0895989afdb6aade5fc - package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 - path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d - photo_manager: d2fbcc0f2d82458700ee6256a15018210a81d413 + maplibre_gl: 753f55d763a81cbdba087d02af02d12206e6f94e + native_video_player: d12af78a1a4a8cf09775a5177d5b392def6fd23c + network_info_plus: 6613d9d7cdeb0e6f366ed4dbe4b3c51c52d567a9 + package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 + path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 + photo_manager: ff695c7a1dd5bc379974953a2b5c0a293f7c4c8a SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868 - share_handler_ios: e2244e990f826b2c8eaa291ac3831569438ba0fb + share_handler_ios: 6dd3a4ac5ca0d955274aec712ba0ecdcaf583e7c share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871 - share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a - shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 - sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f + shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 + sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983 - sqlite3_flutter_libs: f8fc13346870e73fe35ebf6dbb997fbcd156b241 + sqlite3_flutter_libs: cc304edcb8e1d8c595d1b08c7aeb46a47691d9db SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 - url_launcher_ios: 694010445543906933d732453a59da0a173ae33d - wakelock_plus: 04623e3f525556020ebd4034310f20fe7fda8b49 + url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe + wakelock_plus: 373cfe59b235a6dd5837d0fb88791d2f13a90d56 PODFILE CHECKSUM: 7ce312f2beab01395db96f6969d90a447279cf45 diff --git a/mobile/ios/Runner/Sync/Messages.g.swift b/mobile/ios/Runner/Sync/Messages.g.swift index 8306214110..ea0ba53bef 100644 --- a/mobile/ios/Runner/Sync/Messages.g.swift +++ b/mobile/ios/Runner/Sync/Messages.g.swift @@ -140,8 +140,6 @@ struct PlatformAsset: Hashable { var durationInSeconds: Int64 var orientation: Int64 var isFavorite: Bool - var isTrashed: Bool? = nil - var volume: String? = nil // swift-format-ignore: AlwaysUseLowerCamelCase @@ -156,8 +154,6 @@ struct PlatformAsset: Hashable { let durationInSeconds = pigeonVar_list[7] as! Int64 let orientation = pigeonVar_list[8] as! Int64 let isFavorite = pigeonVar_list[9] as! Bool - let isTrashed: Bool? = nilOrValue(pigeonVar_list[10]) - let volume: String? = nilOrValue(pigeonVar_list[11]) return PlatformAsset( id: id, @@ -169,9 +165,7 @@ struct PlatformAsset: Hashable { height: height, durationInSeconds: durationInSeconds, orientation: orientation, - isFavorite: isFavorite, - isTrashed: isTrashed, - volume: volume + isFavorite: isFavorite ) } func toList() -> [Any?] { @@ -186,8 +180,6 @@ struct PlatformAsset: Hashable { durationInSeconds, orientation, isFavorite, - isTrashed, - volume, ] } static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool { @@ -308,39 +300,6 @@ struct HashResult: Hashable { } } -/// Generated class from Pigeon that represents data sent in messages. -struct TrashedAssetParams: Hashable { - var id: String - var type: Int64 - var albumId: String? = nil - - - // swift-format-ignore: AlwaysUseLowerCamelCase - static func fromList(_ pigeonVar_list: [Any?]) -> TrashedAssetParams? { - let id = pigeonVar_list[0] as! String - let type = pigeonVar_list[1] as! Int64 - let albumId: String? = nilOrValue(pigeonVar_list[2]) - - return TrashedAssetParams( - id: id, - type: type, - albumId: albumId - ) - } - func toList() -> [Any?] { - return [ - id, - type, - albumId, - ] - } - static func == (lhs: TrashedAssetParams, rhs: TrashedAssetParams) -> Bool { - return deepEqualsMessages(lhs.toList(), rhs.toList()) } - func hash(into hasher: inout Hasher) { - deepHashMessages(value: toList(), hasher: &hasher) - } -} - private class MessagesPigeonCodecReader: FlutterStandardReader { override func readValue(ofType type: UInt8) -> Any? { switch type { @@ -352,8 +311,6 @@ private class MessagesPigeonCodecReader: FlutterStandardReader { return SyncDelta.fromList(self.readValue() as! [Any?]) case 132: return HashResult.fromList(self.readValue() as! [Any?]) - case 133: - return TrashedAssetParams.fromList(self.readValue() as! [Any?]) default: return super.readValue(ofType: type) } @@ -374,9 +331,6 @@ private class MessagesPigeonCodecWriter: FlutterStandardWriter { } else if let value = value as? HashResult { super.writeByte(132) super.writeValue(value.toList()) - } else if let value = value as? TrashedAssetParams { - super.writeByte(133) - super.writeValue(value.toList()) } else { super.writeValue(value) } @@ -411,7 +365,6 @@ protocol NativeSyncApi { func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void) func cancelHashing() throws func getTrashedAssetsForAlbum(albumId: String) throws -> [PlatformAsset] - func hashTrashedAssets(trashedAssets: [TrashedAssetParams], completion: @escaping (Result<[HashResult], Error>) -> Void) } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. @@ -599,24 +552,5 @@ class NativeSyncApiSetup { } else { getTrashedAssetsForAlbumChannel.setMessageHandler(nil) } - let hashTrashedAssetsChannel = taskQueue == nil - ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashTrashedAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashTrashedAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue) - if let api = api { - hashTrashedAssetsChannel.setMessageHandler { message, reply in - let args = message as! [Any?] - let trashedAssetsArg = args[0] as! [TrashedAssetParams] - api.hashTrashedAssets(trashedAssets: trashedAssetsArg) { result in - switch result { - case .success(let res): - reply(wrapResult(res)) - case .failure(let error): - reply(wrapError(error)) - } - } - } - } else { - hashTrashedAssetsChannel.setMessageHandler(nil) - } } } diff --git a/mobile/ios/Runner/Sync/MessagesImpl.swift b/mobile/ios/Runner/Sync/MessagesImpl.swift index bb23bae6b6..38849124b5 100644 --- a/mobile/ios/Runner/Sync/MessagesImpl.swift +++ b/mobile/ios/Runner/Sync/MessagesImpl.swift @@ -3,15 +3,15 @@ import CryptoKit struct AssetWrapper: Hashable, Equatable { let asset: PlatformAsset - + init(with asset: PlatformAsset) { self.asset = asset } - + func hash(into hasher: inout Hasher) { hasher.combine(self.asset.id) } - + static func == (lhs: AssetWrapper, rhs: AssetWrapper) -> Bool { return lhs.asset.id == rhs.asset.id } @@ -22,16 +22,16 @@ class NativeSyncApiImpl: NativeSyncApi { private let changeTokenKey = "immich:changeToken" private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum] private let recoveredAlbumSubType = 1000000219 - + private var hashTask: Task? private static let hashCancelledCode = "HASH_CANCELLED" private static let hashCancelled = Result<[HashResult], Error>.failure(PigeonError(code: hashCancelledCode, message: "Hashing cancelled", details: nil)) - - + + init(with defaults: UserDefaults = .standard) { self.defaults = defaults } - + @available(iOS 16, *) private func getChangeToken() -> PHPersistentChangeToken? { guard let data = defaults.data(forKey: changeTokenKey) else { @@ -39,7 +39,7 @@ class NativeSyncApiImpl: NativeSyncApi { } return try? NSKeyedUnarchiver.unarchivedObject(ofClass: PHPersistentChangeToken.self, from: data) } - + @available(iOS 16, *) private func saveChangeToken(token: PHPersistentChangeToken) -> Void { guard let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true) else { @@ -47,18 +47,18 @@ class NativeSyncApiImpl: NativeSyncApi { } defaults.set(data, forKey: changeTokenKey) } - + func clearSyncCheckpoint() -> Void { defaults.removeObject(forKey: changeTokenKey) } - + func checkpointSync() { guard #available(iOS 16, *) else { return } saveChangeToken(token: PHPhotoLibrary.shared().currentChangeToken) } - + func shouldFullSync() -> Bool { guard #available(iOS 16, *), PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized, @@ -66,34 +66,34 @@ class NativeSyncApiImpl: NativeSyncApi { // When we do not have access to photo library, older iOS version or No token available, fallback to full sync return true } - + guard let _ = try? PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken) else { // Cannot fetch persistent changes return true } - + return false } - + func getAlbums() throws -> [PlatformAlbum] { var albums: [PlatformAlbum] = [] - + albumTypes.forEach { type in let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil) for i in 0.. SyncDelta { + + func getMediaChanges(isTrashed: Bool) throws -> SyncDelta { guard #available(iOS 16, *) else { throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature requires iOS 16 or later.", details: nil) } - + guard PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized else { throw PigeonError(code: "NO_AUTH", message: "No photo library access", details: nil) } - + guard let storedToken = getChangeToken() else { // No token exists, definitely need a full sync print("MediaManager::getMediaChanges: No token found") throw PigeonError(code: "NO_TOKEN", message: "No stored change token", details: nil) } - + let currentToken = PHPhotoLibrary.shared().currentChangeToken if storedToken == currentToken { return SyncDelta(hasChanges: false, updates: [], deletes: [], assetAlbums: [:]) } - + do { let changes = try PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken) - + var updatedAssets: Set = [] var deletedAssets: Set = [] - + for change in changes { guard let details = try? change.changeDetails(for: PHObjectType.asset) else { continue } - + let updated = details.updatedLocalIdentifiers.union(details.insertedLocalIdentifiers) deletedAssets.formUnion(details.deletedLocalIdentifiers) - + if (updated.isEmpty) { continue } - + let options = PHFetchOptions() options.includeHiddenAssets = false let result = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: options) for i in 0..) -> [String: [String]] { guard !assets.isEmpty else { return [:] } - + var albumAssets: [String: [String]] = [:] - + for type in albumTypes { let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil) collections.enumerateObjects { (album, _, _) in @@ -197,13 +197,13 @@ class NativeSyncApiImpl: NativeSyncApi { } return albumAssets } - + func getAssetIdsForAlbum(albumId: String) throws -> [String] { let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil) guard let album = collections.firstObject else { return [] } - + var ids: [String] = [] let options = PHFetchOptions() options.includeHiddenAssets = false @@ -213,13 +213,13 @@ class NativeSyncApiImpl: NativeSyncApi { } return ids } - + func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64 { let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil) guard let album = collections.firstObject else { return 0 } - + let date = NSDate(timeIntervalSince1970: TimeInterval(timestamp)) let options = PHFetchOptions() options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date) @@ -227,32 +227,32 @@ class NativeSyncApiImpl: NativeSyncApi { let assets = PHAsset.fetchAssets(in: album, options: options) return Int64(assets.count) } - + func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] { let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil) guard let album = collections.firstObject else { return [] } - + let options = PHFetchOptions() options.includeHiddenAssets = false if(updatedTimeCond != nil) { let date = NSDate(timeIntervalSince1970: TimeInterval(updatedTimeCond!)) options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date) } - + let result = PHAsset.fetchAssets(in: album, options: options) if(result.count == 0) { return [] } - + var assets: [PlatformAsset] = [] result.enumerateObjects { (asset, _, _) in assets.append(asset.toPlatformAsset()) } return assets } - + func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void) { if let prevTask = hashTask { prevTask.cancel() @@ -270,11 +270,11 @@ class NativeSyncApiImpl: NativeSyncApi { missingAssetIds.remove(asset.localIdentifier) assets.append(asset) } - + if Task.isCancelled { return completion(Self.hashCancelled) } - + await withTaskGroup(of: HashResult?.self) { taskGroup in var results = [HashResult]() results.reserveCapacity(assets.count) @@ -287,28 +287,28 @@ class NativeSyncApiImpl: NativeSyncApi { return await self.hashAsset(asset, allowNetworkAccess: allowNetworkAccess) } } - + for await result in taskGroup { guard let result = result else { return completion(Self.hashCancelled) } results.append(result) } - + for missing in missingAssetIds { results.append(HashResult(assetId: missing, error: "Asset not found in library", hash: nil)) } - + completion(.success(results)) } } } - + func cancelHashing() { hashTask?.cancel() hashTask = nil } - + private func hashAsset(_ asset: PHAsset, allowNetworkAccess: Bool) async -> HashResult? { class RequestRef { var id: PHAssetResourceDataRequestID? @@ -318,21 +318,21 @@ class NativeSyncApiImpl: NativeSyncApi { if Task.isCancelled { return nil } - + guard let resource = asset.getResource() else { return HashResult(assetId: asset.localIdentifier, error: "Cannot get asset resource", hash: nil) } - + if Task.isCancelled { return nil } - + let options = PHAssetResourceRequestOptions() options.isNetworkAccessAllowed = allowNetworkAccess - + return await withCheckedContinuation { continuation in var hasher = Insecure.SHA1() - + requestRef.id = PHAssetResourceManager.default().requestData( for: resource, options: options, @@ -363,4 +363,9 @@ class NativeSyncApiImpl: NativeSyncApi { PHAssetResourceManager.default().cancelDataRequest(requestId) }) } + + func getTrashedAssetsForAlbum(albumId: String) throws ->[PlatformAsset] { +throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil) +} + } diff --git a/mobile/lib/domain/models/asset/trashed_asset.model.dart b/mobile/lib/domain/models/asset/trashed_asset.model.dart index 79e507dc06..44732b3b01 100644 --- a/mobile/lib/domain/models/asset/trashed_asset.model.dart +++ b/mobile/lib/domain/models/asset/trashed_asset.model.dart @@ -1,12 +1,10 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; class TrashedAsset extends LocalAsset { - final String? volume; final String albumId; const TrashedAsset({ required this.albumId, - this.volume, required super.id, super.remoteId, required super.name, @@ -38,7 +36,6 @@ class TrashedAsset extends LocalAsset { String? livePhotoVideoId, int? orientation, String? albumId, - String? volume, }) { return TrashedAsset( id: id ?? this.id, @@ -55,7 +52,6 @@ class TrashedAsset extends LocalAsset { livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId, orientation: orientation ?? this.orientation, albumId: albumId ?? this.albumId, - volume: volume ?? this.volume, ); } @@ -79,7 +75,6 @@ class TrashedAsset extends LocalAsset { livePhotoVideoId == other.livePhotoVideoId && orientation == other.orientation && // TrashedAsset extras - volume == other.volume && albumId == other.albumId; @override @@ -97,7 +92,6 @@ class TrashedAsset extends LocalAsset { isFavorite, livePhotoVideoId, orientation, - volume, albumId, ); @@ -118,7 +112,6 @@ class TrashedAsset extends LocalAsset { 'livePhotoVideoId: $livePhotoVideoId, ' 'orientation: $orientation, ' 'albumId: $albumId, ' - 'volume: $volume' ')'; } } diff --git a/mobile/lib/domain/services/hash.service.dart b/mobile/lib/domain/services/hash.service.dart index 4dcddf85d6..de72df1e1b 100644 --- a/mobile/lib/domain/services/hash.service.dart +++ b/mobile/lib/domain/services/hash.service.dart @@ -2,10 +2,10 @@ import 'package:flutter/services.dart'; import 'package:immich_mobile/constants/constants.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/asset/trashed_asset.model.dart'; -import 'package:immich_mobile/domain/services/trash_sync.service.dart'; +import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:logging/logging.dart'; @@ -15,24 +15,24 @@ class HashService { final int _batchSize; final DriftLocalAlbumRepository _localAlbumRepository; final DriftLocalAssetRepository _localAssetRepository; + final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository; final NativeSyncApi _nativeSyncApi; - final TrashSyncService _trashSyncService; final bool Function()? _cancelChecker; final _log = Logger('HashService'); HashService({ required DriftLocalAlbumRepository localAlbumRepository, required DriftLocalAssetRepository localAssetRepository, + required DriftTrashedLocalAssetRepository trashedLocalAssetRepository, required NativeSyncApi nativeSyncApi, - required TrashSyncService trashSyncService, bool Function()? cancelChecker, int? batchSize, }) : _localAlbumRepository = localAlbumRepository, _localAssetRepository = localAssetRepository, + _trashedLocalAssetRepository = trashedLocalAssetRepository, _cancelChecker = cancelChecker, _nativeSyncApi = nativeSyncApi, - _batchSize = batchSize ?? kBatchHashFileLimit, - _trashSyncService = trashSyncService; + _batchSize = batchSize ?? kBatchHashFileLimit; bool get isCancelled => _cancelChecker?.call() ?? false; @@ -54,6 +54,14 @@ class HashService { await _hashAssets(album, assetsToHash); } } + if (CurrentPlatform.isAndroid && localAlbums.isNotEmpty) { + final backupAlbumIds = localAlbums.map((e) => e.id); + final trashedToHash = await _trashedLocalAssetRepository.getAssetsToHash(backupAlbumIds); + if (trashedToHash.isNotEmpty) { + final pseudoAlbum = LocalAlbum(id: '-pseudoAlbum', name: 'Trash', updatedAt: DateTime.now()); + await _hashAssets(pseudoAlbum, trashedToHash.toList(), isTrashed: true); + } + } } on PlatformException catch (e) { if (e.code == _kHashCancelledCode) { _log.warning("Hashing cancelled by platform"); @@ -63,23 +71,6 @@ class HashService { _log.severe("Error during hashing", e, s); } - if (_trashSyncService.isAutoSyncMode) { - final backupAlbums = await _localAlbumRepository.getBackupAlbums(); - if (backupAlbums.isNotEmpty) { - final backupAlbumIds = backupAlbums.map((e) => e.id); - final trashedToHash = await _trashSyncService.getAssetsToHash(backupAlbumIds); - if (trashedToHash.isNotEmpty) { - for (final album in backupAlbums) { - if (isCancelled) { - _log.warning("Hashing cancelled. Stopped processing albums."); - break; - } - await _hashTrashedAssets(album, trashedToHash.where((e) => e.albumId == album.id)); - } - } - } - } - stopwatch.stop(); _log.info("Hashing took - ${stopwatch.elapsedMilliseconds}ms"); } @@ -87,7 +78,7 @@ class HashService { /// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB /// with hash for those that were successfully hashed. Hashes are looked up in a table /// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB. - Future _hashAssets(LocalAlbum album, List assetsToHash) async { + Future _hashAssets(LocalAlbum album, List assetsToHash, {bool isTrashed = false}) async { final toHash = {}; for (final asset in assetsToHash) { @@ -98,16 +89,16 @@ class HashService { toHash[asset.id] = asset; if (toHash.length == _batchSize) { - await _processBatch(album, toHash); + await _processBatch(album, toHash, isTrashed); toHash.clear(); } } - await _processBatch(album, toHash); + await _processBatch(album, toHash, isTrashed); } /// Processes a batch of assets. - Future _processBatch(LocalAlbum album, Map toHash) async { + Future _processBatch(LocalAlbum album, Map toHash, bool isTrashed) async { if (toHash.isEmpty) { return; } @@ -142,64 +133,10 @@ class HashService { } _log.fine("Hashed ${hashed.length}/${toHash.length} assets"); - - await _localAssetRepository.updateHashes(hashed); - } - - Future _hashTrashedAssets(LocalAlbum album, Iterable assetsToHash) async { - final toHash = []; - - for (final asset in assetsToHash) { - if (isCancelled) { - _log.warning("Hashing cancelled. Stopped processing assets."); - return; - } - - toHash.add(asset); - - if (toHash.length == _batchSize) { - await _processTrashedBatch(album, toHash); - toHash.clear(); - } + if (isTrashed) { + await _trashedLocalAssetRepository.updateHashes(hashed); + } else { + await _localAssetRepository.updateHashes(hashed); } - - await _processTrashedBatch(album, toHash); - } - - Future _processTrashedBatch(LocalAlbum album, List toHash) async { - if (toHash.isEmpty) { - return; - } - - _log.fine("Hashing ${toHash.length} trashed files"); - - final params = toHash.map((e) => TrashedAssetParams(id: e.id, type: e.type.index, albumId: album.id)).toList(); - final hashResults = await _nativeSyncApi.hashTrashedAssets(params); - - assert( - hashResults.length == toHash.length, - "Trashed Assets, Hashes length does not match toHash length: ${hashResults.length} != ${toHash.length}", - ); - final hashed = []; - - for (int i = 0; i < hashResults.length; i++) { - if (isCancelled) { - _log.warning("Hashing cancelled. Stopped processing batch."); - return; - } - - final hashResult = hashResults[i]; - final asset = toHash[i]; - if (hashResult.hash != null) { - hashed.add(asset.copyWith(checksum: hashResult.hash!)); - } else { - _log.warning( - "Failed to hash trashed asset with id: ${hashResult.assetId}, name: ${asset.name}, createdAt: ${asset.createdAt}, from album: ${album.name}. Error: ${hashResult.error ?? "unknown"}", - ); - } - } - - _log.fine("Hashed ${hashed.length}/${toHash.length} trashed assets"); - await _trashSyncService.updateChecksums(hashed); } } diff --git a/mobile/lib/domain/services/local_sync.service.dart b/mobile/lib/domain/services/local_sync.service.dart index 12c996f472..b522fa423f 100644 --- a/mobile/lib/domain/services/local_sync.service.dart +++ b/mobile/lib/domain/services/local_sync.service.dart @@ -4,10 +4,12 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.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/services/trash_sync.service.dart'; +import 'package:immich_mobile/domain/models/asset/trashed_asset.model.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart'; +import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; import 'package:immich_mobile/utils/datetime_helpers.dart'; import 'package:immich_mobile/utils/diff.dart'; import 'package:logging/logging.dart'; @@ -15,15 +17,18 @@ import 'package:logging/logging.dart'; class LocalSyncService { final DriftLocalAlbumRepository _localAlbumRepository; final NativeSyncApi _nativeSyncApi; - final TrashSyncService _trashSyncService; + final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository; + final LocalFilesManagerRepository _localFilesManager; final Logger _log = Logger("DeviceSyncService"); LocalSyncService({ required DriftLocalAlbumRepository localAlbumRepository, - required TrashSyncService trashSyncService, + required DriftTrashedLocalAssetRepository trashedLocalAssetRepository, + required LocalFilesManagerRepository localFilesManager, required NativeSyncApi nativeSyncApi, }) : _localAlbumRepository = localAlbumRepository, - _trashSyncService = trashSyncService, + _trashedLocalAssetRepository = trashedLocalAssetRepository, + _localFilesManager = localFilesManager, _nativeSyncApi = nativeSyncApi; Future sync({bool full = false}) async { @@ -34,6 +39,12 @@ class LocalSyncService { return await fullSync(); } + if (CurrentPlatform.isAndroid) { + final delta = await _nativeSyncApi.getMediaChanges(isTrashed: true); + _log.fine("Delta updated in trash: ${delta.updates.length - delta.updates.length}"); + await _applyTrashDelta(delta); + } + final delta = await _nativeSyncApi.getMediaChanges(); if (!delta.hasChanges) { _log.fine("No media changes detected. Skipping sync"); @@ -75,11 +86,6 @@ class LocalSyncService { await updateAlbum(dbAlbum, album); } } - if (_trashSyncService.isAutoSyncMode) { - final delta = await _nativeSyncApi.getMediaChanges(isTrashed: true); - _log.fine("Delta updated in trash: ${delta.updates.length - delta.updates.length}"); - await _trashSyncService.applyTrashDelta(delta); - } await _nativeSyncApi.checkpointSync(); } catch (e, s) { _log.severe("Error performing device sync", e, s); @@ -93,6 +99,10 @@ class LocalSyncService { try { final Stopwatch stopwatch = Stopwatch()..start(); + if (CurrentPlatform.isAndroid) { + await _syncDeviceTrashSnapshot(); + } + final deviceAlbums = await _nativeSyncApi.getAlbums(); final dbAlbums = await _localAlbumRepository.getAll(sortBy: {SortLocalAlbumsBy.id}); @@ -104,9 +114,6 @@ class LocalSyncService { onlyFirst: removeAlbum, onlySecond: addAlbum, ); - if (_trashSyncService.isAutoSyncMode) { - await _trashSyncService.syncDeviceTrashSnapshot(); - } await _nativeSyncApi.checkpointSync(); stopwatch.stop(); @@ -286,6 +293,53 @@ class LocalSyncService { bool _albumsEqual(LocalAlbum a, LocalAlbum b) { return a.name == b.name && a.assetCount == b.assetCount && a.updatedAt.isAtSameMomentAs(b.updatedAt); } + + Future _applyTrashDelta(SyncDelta delta) async { + final trashUpdates = delta.updates; + if (trashUpdates.isEmpty) { + return Future.value(); + } + final trashedAssets = []; + //todo try to reuse exist checksums from local assets table before they updated + for (final update in trashUpdates) { + final albums = delta.assetAlbums.cast>(); + for (final String id in albums[update.id]!.cast().nonNulls) { + trashedAssets.add(update.toTrashedAsset(id)); + } + } + _log.info("updateLocalTrashChanges trashedAssets: ${trashedAssets.map((e) => e.id)}"); + await _trashedLocalAssetRepository.saveTrashedAssets(trashedAssets); + await _applyRemoteRestoreToLocal(); + } + + Future _syncDeviceTrashSnapshot() async { + final backupAlbums = await _localAlbumRepository.getBackupAlbums(); + if (backupAlbums.isEmpty) { + _log.info("syncDeviceTrashSnapshot, No backup albums found"); + return; + } + for (final album in backupAlbums) { + _log.info("syncDeviceTrashSnapshot prepare, album: ${album.id}/${album.name}"); + final trashedPlatformAssets = await _nativeSyncApi.getTrashedAssetsForAlbum(album.id); + final trashedAssets = trashedPlatformAssets.toTrashedAssets(album.id); + await _trashedLocalAssetRepository.applyTrashSnapshot(trashedAssets, album.id); + } + await _applyRemoteRestoreToLocal(); + } + + Future _applyRemoteRestoreToLocal() async { + final remoteAssetsToRestore = await _trashedLocalAssetRepository.getToRestore(); + if (remoteAssetsToRestore.isNotEmpty) { + _log.info("remoteAssetsToRestore: $remoteAssetsToRestore"); + for (final asset in remoteAssetsToRestore) { + _log.info("Restoring from trash, localId: ${asset.id}, remoteId: ${asset.checksum}"); + await _localFilesManager.restoreFromTrashById(asset.id, asset.type.index); + } + await _trashedLocalAssetRepository.restoreLocalAssets(remoteAssetsToRestore.map((e) => e.id)); + } else { + _log.info("No remote assets found for restoration"); + } + } } extension on Iterable { @@ -320,3 +374,26 @@ extension on Iterable { ).toList(); } } + +extension on PlatformAsset { + TrashedAsset toTrashedAsset(String albumId) => TrashedAsset( + id: id, + name: name, + checksum: null, + type: AssetType.values.elementAtOrNull(type) ?? AssetType.other, + createdAt: tryFromSecondsSinceEpoch(createdAt) ?? DateTime.now(), + updatedAt: tryFromSecondsSinceEpoch(updatedAt) ?? DateTime.now(), + albumId: albumId, + width: width, + height: height, + durationInSeconds: durationInSeconds, + isFavorite: isFavorite, + orientation: orientation, + ); +} + +extension PlatformAssetsExtension on Iterable { + Iterable toTrashedAssets(String albumId) { + return map((e) => e.toTrashedAsset(albumId)); + } +} diff --git a/mobile/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart index b0f136a494..b4349de523 100644 --- a/mobile/lib/domain/services/sync_stream.service.dart +++ b/mobile/lib/domain/services/sync_stream.service.dart @@ -88,7 +88,7 @@ class SyncStreamService { return _syncStreamRepository.deletePartnerV1(data.cast()); case SyncEntityType.assetV1: final remoteSyncAssets = data.cast(); - if (_trashSyncService.isAutoSyncMode) { + if (_trashSyncService.isTrashSyncMode) { await _trashSyncService.handleRemoteTrashed( remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.checksum), ); diff --git a/mobile/lib/domain/services/trash_sync.service.dart b/mobile/lib/domain/services/trash_sync.service.dart index 37caad34c3..1fa7858ee8 100644 --- a/mobile/lib/domain/services/trash_sync.service.dart +++ b/mobile/lib/domain/services/trash_sync.service.dart @@ -1,23 +1,14 @@ -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/asset/trashed_asset.model.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; -import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; -import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/utils/datetime_helpers.dart'; import 'package:logging/logging.dart'; -typedef TrashSyncItem = ({String remoteId, String checksum, DateTime? deletedAt}); - class TrashSyncService { final AppSettingsService _appSettingsService; - final NativeSyncApi _nativeSyncApi; final DriftLocalAssetRepository _localAssetRepository; - final DriftLocalAlbumRepository _localAlbumRepository; final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository; final LocalFilesManagerRepository _localFilesManager; final StorageRepository _storageRepository; @@ -25,66 +16,25 @@ class TrashSyncService { TrashSyncService({ required AppSettingsService appSettingsService, - required NativeSyncApi nativeSyncApi, required DriftLocalAssetRepository localAssetRepository, - required DriftLocalAlbumRepository localAlbumRepository, required DriftTrashedLocalAssetRepository trashedLocalAssetRepository, required LocalFilesManagerRepository localFilesManager, required StorageRepository storageRepository, }) : _appSettingsService = appSettingsService, - _nativeSyncApi = nativeSyncApi, _localAssetRepository = localAssetRepository, - _localAlbumRepository = localAlbumRepository, _trashedLocalAssetRepository = trashedLocalAssetRepository, _localFilesManager = localFilesManager, _storageRepository = storageRepository; - bool get isAutoSyncMode => + bool get isTrashSyncMode => CurrentPlatform.isAndroid && _appSettingsService.getSetting(AppSettingsEnum.manageLocalMediaAndroid); - Future updateChecksums(Iterable assets) async => - _trashedLocalAssetRepository.updateChecksums(assets); - - Future> getAssetsToHash(Iterable albumIds) async => - _trashedLocalAssetRepository.getToHash(albumIds); - - Future syncDeviceTrashSnapshot() async { - final backupAlbums = await _localAlbumRepository.getBackupAlbums(); - if (backupAlbums.isEmpty) { - _logger.info("No backup albums found"); - return; - } - for (final album in backupAlbums) { - _logger.info("deviceTrashedAssets prepare, album: ${album.id}/${album.name}"); - final trashedPlatformAssets = await _nativeSyncApi.getTrashedAssetsForAlbum(album.id); - final trashedAssets = trashedPlatformAssets.toTrashedAssets(album.id); - await _trashedLocalAssetRepository.applyTrashSnapshot(trashedAssets, album.id); - } - await _applyRemoteRestoreToLocal(); - } - - Future applyTrashDelta(SyncDelta delta) async { - final trashUpdates = delta.updates; - if (trashUpdates.isEmpty) { - return Future.value(); - } - final trashedAssets = []; - for (final update in trashUpdates) { - final albums = delta.assetAlbums.cast>(); - for (final String id in albums[update.id]!.cast().nonNulls) { - trashedAssets.add(update.toTrashedAsset(id)); - } - } - _logger.info("updateLocalTrashChanges trashedAssets: ${trashedAssets.map((e) => e.id)}"); - await _trashedLocalAssetRepository.saveTrashedAssets(trashedAssets); - await _applyRemoteRestoreToLocal(); - } Future handleRemoteTrashed(Iterable checksums) async { if (checksums.isEmpty) { return Future.value(); } else { - final localAssetsToTrash = await _localAssetRepository.getBackupSelectedAssetsByAlbum(checksums); + final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(checksums); if (localAssetsToTrash.isNotEmpty) { final mediaUrls = await Future.wait( localAssetsToTrash.values @@ -101,42 +51,5 @@ class TrashSyncService { } } } - - Future _applyRemoteRestoreToLocal() async { - final remoteAssetsToRestore = await _trashedLocalAssetRepository.getToRestore(); - if (remoteAssetsToRestore.isNotEmpty) { - _logger.info("remoteAssetsToRestore: $remoteAssetsToRestore"); - for (final asset in remoteAssetsToRestore) { - _logger.info("Restoring from trash, localId: ${asset.id}, remoteId: ${asset.checksum}"); - await _localFilesManager.restoreFromTrashById(asset.id, asset.type.index); - } - await _trashedLocalAssetRepository.restoreLocalAssets(remoteAssetsToRestore.map((e) => e.id)); - } else { - _logger.info("No remote assets found for restoration"); - } - } } -extension on PlatformAsset { - TrashedAsset toTrashedAsset(String albumId) => TrashedAsset( - id: id, - name: name, - checksum: null, - type: AssetType.values.elementAtOrNull(type) ?? AssetType.other, - createdAt: tryFromSecondsSinceEpoch(createdAt) ?? DateTime.now(), - updatedAt: tryFromSecondsSinceEpoch(updatedAt) ?? DateTime.now(), - volume: volume, - albumId: albumId, - width: width, - height: height, - durationInSeconds: durationInSeconds, - isFavorite: isFavorite, - orientation: orientation, - ); -} - -extension PlatformAssetsExtension on Iterable { - Iterable toTrashedAssets(String albumId) { - return map((e) => e.toTrashedAsset(albumId)); - } -} diff --git a/mobile/lib/infrastructure/entities/trashed_local_asset.entity.dart b/mobile/lib/infrastructure/entities/trashed_local_asset.entity.dart index 94bca53121..10a4cefe66 100644 --- a/mobile/lib/infrastructure/entities/trashed_local_asset.entity.dart +++ b/mobile/lib/infrastructure/entities/trashed_local_asset.entity.dart @@ -12,8 +12,6 @@ class TrashedLocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntity TextColumn get albumId => text()(); - TextColumn get volume => text().nullable()(); - TextColumn get checksum => text().nullable()(); BoolColumn get isFavorite => boolean().withDefault(const Constant(false))(); @@ -28,7 +26,6 @@ extension TrashedLocalAssetEntityDataDomainExtension on TrashedLocalAssetEntityD TrashedAsset toDto() => TrashedAsset( id: id, name: name, - volume: volume, albumId: albumId, checksum: checksum, type: type, diff --git a/mobile/lib/infrastructure/entities/trashed_local_asset.entity.drift.dart b/mobile/lib/infrastructure/entities/trashed_local_asset.entity.drift.dart index eda44c256a..38df1218e0 100644 --- a/mobile/lib/infrastructure/entities/trashed_local_asset.entity.drift.dart +++ b/mobile/lib/infrastructure/entities/trashed_local_asset.entity.drift.dart @@ -1,12 +1,12 @@ // dart format width=80 // ignore_for_file: type=lint import 'package:drift/drift.dart' as i0; -import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart' - as i1; +import 'package:drift/src/runtime/query_builder/query_builder.dart' as i4; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' as i2; import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart' as i3; -import 'package:drift/src/runtime/query_builder/query_builder.dart' as i4; +import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart' + as i1; typedef $$TrashedLocalAssetEntityTableCreateCompanionBuilder = i1.TrashedLocalAssetEntityCompanion Function({ @@ -19,7 +19,6 @@ typedef $$TrashedLocalAssetEntityTableCreateCompanionBuilder = i0.Value durationInSeconds, required String id, required String albumId, - i0.Value volume, i0.Value checksum, i0.Value isFavorite, i0.Value orientation, @@ -35,7 +34,6 @@ typedef $$TrashedLocalAssetEntityTableUpdateCompanionBuilder = i0.Value durationInSeconds, i0.Value id, i0.Value albumId, - i0.Value volume, i0.Value checksum, i0.Value isFavorite, i0.Value orientation, @@ -97,11 +95,6 @@ class $$TrashedLocalAssetEntityTableFilterComposer builder: (column) => i0.ColumnFilters(column), ); - i0.ColumnFilters get volume => $composableBuilder( - column: $table.volume, - builder: (column) => i0.ColumnFilters(column), - ); - i0.ColumnFilters get checksum => $composableBuilder( column: $table.checksum, builder: (column) => i0.ColumnFilters(column), @@ -173,11 +166,6 @@ class $$TrashedLocalAssetEntityTableOrderingComposer builder: (column) => i0.ColumnOrderings(column), ); - i0.ColumnOrderings get volume => $composableBuilder( - column: $table.volume, - builder: (column) => i0.ColumnOrderings(column), - ); - i0.ColumnOrderings get checksum => $composableBuilder( column: $table.checksum, builder: (column) => i0.ColumnOrderings(column), @@ -233,9 +221,6 @@ class $$TrashedLocalAssetEntityTableAnnotationComposer i0.GeneratedColumn get albumId => $composableBuilder(column: $table.albumId, builder: (column) => column); - i0.GeneratedColumn get volume => - $composableBuilder(column: $table.volume, builder: (column) => column); - i0.GeneratedColumn get checksum => $composableBuilder(column: $table.checksum, builder: (column) => column); @@ -305,7 +290,6 @@ class $$TrashedLocalAssetEntityTableTableManager i0.Value durationInSeconds = const i0.Value.absent(), i0.Value id = const i0.Value.absent(), i0.Value albumId = const i0.Value.absent(), - i0.Value volume = const i0.Value.absent(), i0.Value checksum = const i0.Value.absent(), i0.Value isFavorite = const i0.Value.absent(), i0.Value orientation = const i0.Value.absent(), @@ -319,7 +303,6 @@ class $$TrashedLocalAssetEntityTableTableManager durationInSeconds: durationInSeconds, id: id, albumId: albumId, - volume: volume, checksum: checksum, isFavorite: isFavorite, orientation: orientation, @@ -335,7 +318,6 @@ class $$TrashedLocalAssetEntityTableTableManager i0.Value durationInSeconds = const i0.Value.absent(), required String id, required String albumId, - i0.Value volume = const i0.Value.absent(), i0.Value checksum = const i0.Value.absent(), i0.Value isFavorite = const i0.Value.absent(), i0.Value orientation = const i0.Value.absent(), @@ -349,7 +331,6 @@ class $$TrashedLocalAssetEntityTableTableManager durationInSeconds: durationInSeconds, id: id, albumId: albumId, - volume: volume, checksum: checksum, isFavorite: isFavorite, orientation: orientation, @@ -499,17 +480,6 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity type: i0.DriftSqlType.string, requiredDuringInsert: true, ); - static const i0.VerificationMeta _volumeMeta = const i0.VerificationMeta( - 'volume', - ); - @override - late final i0.GeneratedColumn volume = i0.GeneratedColumn( - 'volume', - aliasedName, - true, - type: i0.DriftSqlType.string, - requiredDuringInsert: false, - ); static const i0.VerificationMeta _checksumMeta = const i0.VerificationMeta( 'checksum', ); @@ -559,7 +529,6 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity durationInSeconds, id, albumId, - volume, checksum, isFavorite, orientation, @@ -630,12 +599,6 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity } else if (isInserting) { context.missing(_albumIdMeta); } - if (data.containsKey('volume')) { - context.handle( - _volumeMeta, - volume.isAcceptableOrUnknown(data['volume']!, _volumeMeta), - ); - } if (data.containsKey('checksum')) { context.handle( _checksumMeta, @@ -707,10 +670,6 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity i0.DriftSqlType.string, data['${effectivePrefix}album_id'], )!, - volume: attachedDatabase.typeMapping.read( - i0.DriftSqlType.string, - data['${effectivePrefix}volume'], - ), checksum: attachedDatabase.typeMapping.read( i0.DriftSqlType.string, data['${effectivePrefix}checksum'], @@ -750,7 +709,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass final int? durationInSeconds; final String id; final String albumId; - final String? volume; final String? checksum; final bool isFavorite; final int orientation; @@ -764,7 +722,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass this.durationInSeconds, required this.id, required this.albumId, - this.volume, this.checksum, required this.isFavorite, required this.orientation, @@ -791,9 +748,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass } map['id'] = i0.Variable(id); map['album_id'] = i0.Variable(albumId); - if (!nullToAbsent || volume != null) { - map['volume'] = i0.Variable(volume); - } if (!nullToAbsent || checksum != null) { map['checksum'] = i0.Variable(checksum); } @@ -819,7 +773,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass durationInSeconds: serializer.fromJson(json['durationInSeconds']), id: serializer.fromJson(json['id']), albumId: serializer.fromJson(json['albumId']), - volume: serializer.fromJson(json['volume']), checksum: serializer.fromJson(json['checksum']), isFavorite: serializer.fromJson(json['isFavorite']), orientation: serializer.fromJson(json['orientation']), @@ -840,7 +793,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass 'durationInSeconds': serializer.toJson(durationInSeconds), 'id': serializer.toJson(id), 'albumId': serializer.toJson(albumId), - 'volume': serializer.toJson(volume), 'checksum': serializer.toJson(checksum), 'isFavorite': serializer.toJson(isFavorite), 'orientation': serializer.toJson(orientation), @@ -857,7 +809,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass i0.Value durationInSeconds = const i0.Value.absent(), String? id, String? albumId, - i0.Value volume = const i0.Value.absent(), i0.Value checksum = const i0.Value.absent(), bool? isFavorite, int? orientation, @@ -873,7 +824,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass : this.durationInSeconds, id: id ?? this.id, albumId: albumId ?? this.albumId, - volume: volume.present ? volume.value : this.volume, checksum: checksum.present ? checksum.value : this.checksum, isFavorite: isFavorite ?? this.isFavorite, orientation: orientation ?? this.orientation, @@ -893,7 +843,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass : this.durationInSeconds, id: data.id.present ? data.id.value : this.id, albumId: data.albumId.present ? data.albumId.value : this.albumId, - volume: data.volume.present ? data.volume.value : this.volume, checksum: data.checksum.present ? data.checksum.value : this.checksum, isFavorite: data.isFavorite.present ? data.isFavorite.value @@ -916,7 +865,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass ..write('durationInSeconds: $durationInSeconds, ') ..write('id: $id, ') ..write('albumId: $albumId, ') - ..write('volume: $volume, ') ..write('checksum: $checksum, ') ..write('isFavorite: $isFavorite, ') ..write('orientation: $orientation') @@ -935,7 +883,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass durationInSeconds, id, albumId, - volume, checksum, isFavorite, orientation, @@ -953,7 +900,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass other.durationInSeconds == this.durationInSeconds && other.id == this.id && other.albumId == this.albumId && - other.volume == this.volume && other.checksum == this.checksum && other.isFavorite == this.isFavorite && other.orientation == this.orientation); @@ -970,7 +916,6 @@ class TrashedLocalAssetEntityCompanion final i0.Value durationInSeconds; final i0.Value id; final i0.Value albumId; - final i0.Value volume; final i0.Value checksum; final i0.Value isFavorite; final i0.Value orientation; @@ -984,7 +929,6 @@ class TrashedLocalAssetEntityCompanion this.durationInSeconds = const i0.Value.absent(), this.id = const i0.Value.absent(), this.albumId = const i0.Value.absent(), - this.volume = const i0.Value.absent(), this.checksum = const i0.Value.absent(), this.isFavorite = const i0.Value.absent(), this.orientation = const i0.Value.absent(), @@ -999,7 +943,6 @@ class TrashedLocalAssetEntityCompanion this.durationInSeconds = const i0.Value.absent(), required String id, required String albumId, - this.volume = const i0.Value.absent(), this.checksum = const i0.Value.absent(), this.isFavorite = const i0.Value.absent(), this.orientation = const i0.Value.absent(), @@ -1017,7 +960,6 @@ class TrashedLocalAssetEntityCompanion i0.Expression? durationInSeconds, i0.Expression? id, i0.Expression? albumId, - i0.Expression? volume, i0.Expression? checksum, i0.Expression? isFavorite, i0.Expression? orientation, @@ -1032,7 +974,6 @@ class TrashedLocalAssetEntityCompanion if (durationInSeconds != null) 'duration_in_seconds': durationInSeconds, if (id != null) 'id': id, if (albumId != null) 'album_id': albumId, - if (volume != null) 'volume': volume, if (checksum != null) 'checksum': checksum, if (isFavorite != null) 'is_favorite': isFavorite, if (orientation != null) 'orientation': orientation, @@ -1049,7 +990,6 @@ class TrashedLocalAssetEntityCompanion i0.Value? durationInSeconds, i0.Value? id, i0.Value? albumId, - i0.Value? volume, i0.Value? checksum, i0.Value? isFavorite, i0.Value? orientation, @@ -1064,7 +1004,6 @@ class TrashedLocalAssetEntityCompanion durationInSeconds: durationInSeconds ?? this.durationInSeconds, id: id ?? this.id, albumId: albumId ?? this.albumId, - volume: volume ?? this.volume, checksum: checksum ?? this.checksum, isFavorite: isFavorite ?? this.isFavorite, orientation: orientation ?? this.orientation, @@ -1103,9 +1042,6 @@ class TrashedLocalAssetEntityCompanion if (albumId.present) { map['album_id'] = i0.Variable(albumId.value); } - if (volume.present) { - map['volume'] = i0.Variable(volume.value); - } if (checksum.present) { map['checksum'] = i0.Variable(checksum.value); } @@ -1130,7 +1066,6 @@ class TrashedLocalAssetEntityCompanion ..write('durationInSeconds: $durationInSeconds, ') ..write('id: $id, ') ..write('albumId: $albumId, ') - ..write('volume: $volume, ') ..write('checksum: $checksum, ') ..write('isFavorite: $isFavorite, ') ..write('orientation: $orientation') diff --git a/mobile/lib/infrastructure/repositories/local_asset.repository.dart b/mobile/lib/infrastructure/repositories/local_asset.repository.dart index 3094b6660d..c8f45df6f9 100644 --- a/mobile/lib/infrastructure/repositories/local_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_asset.repository.dart @@ -100,26 +100,27 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository { return query.map((localAlbum) => localAlbum.toDto()).get(); } - Future>> getBackupSelectedAssetsByAlbum(Iterable checksums) async { + Future>> getAssetsFromBackupAlbums(Iterable checksums) async { if (checksums.isEmpty) { return {}; } - final lAlbumAsset = _db.localAlbumAssetEntity; - final lAlbum = _db.localAlbumEntity; - final lAsset = _db.localAssetEntity; - final result = >{}; - for (final slice in checksums.toSet().slices(800)) { - final rows = await (_db.select(lAlbumAsset).join([ - innerJoin(lAlbum, lAlbumAsset.albumId.equalsExp(lAlbum.id)), - innerJoin(lAsset, lAlbumAsset.assetId.equalsExp(lAsset.id)), - ])..where(lAlbum.backupSelection.equalsValue(BackupSelection.selected) & lAsset.checksum.isIn(slice))).get(); + for (final slice in checksums.toSet().slices(32000)) { + final rows = + await (_db.select(_db.localAlbumAssetEntity).join([ + innerJoin(_db.localAlbumEntity, _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id)), + innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)), + ])..where( + _db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) & + _db.localAssetEntity.checksum.isIn(slice), + )) + .get(); for (final row in rows) { - final albumId = row.readTable(lAlbumAsset).albumId; - final assetData = row.readTable(lAsset); + final albumId = row.readTable(_db.localAlbumAssetEntity).albumId; + final assetData = row.readTable(_db.localAssetEntity); final asset = assetData.toDto(); (result[albumId] ??= []).add(asset); } diff --git a/mobile/lib/infrastructure/repositories/trashed_local_asset.repository.dart b/mobile/lib/infrastructure/repositories/trashed_local_asset.repository.dart index 3ffc22fcab..47d8fe2042 100644 --- a/mobile/lib/infrastructure/repositories/trashed_local_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/trashed_local_asset.repository.dart @@ -14,41 +14,45 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository { const DriftTrashedLocalAssetRepository(this._db) : super(_db); - Future updateChecksums(Iterable assets) { - if (assets.isEmpty) { + Future updateHashes(Map hashes) { + if (hashes.isEmpty) { return Future.value(); } final now = DateTime.now(); return _db.batch((batch) async { - for (final asset in assets) { + for (final entry in hashes.entries) { batch.update( _db.trashedLocalAssetEntity, - TrashedLocalAssetEntityCompanion(checksum: Value(asset.checksum), updatedAt: Value(now)), - where: (e) => e.id.equals(asset.id), + TrashedLocalAssetEntityCompanion(checksum: Value(entry.value), updatedAt: Value(now)), + where: (e) => e.id.equals(entry.key), ); } }); } - Future> getToHash(Iterable albumIds) { + Future> getAssetsToHash(Iterable albumIds) { final query = _db.trashedLocalAssetEntity.select()..where((r) => r.albumId.isIn(albumIds) & r.checksum.isNull()); return query.map((row) => row.toDto()).get(); } Future> getToRestore() async { - final trashed = _db.trashedLocalAssetEntity; - final remote = _db.remoteAssetEntity; - final album = _db.localAlbumEntity; + final selectedAlbumIds = (_db.selectOnly(_db.localAlbumEntity) + ..addColumns([_db.localAlbumEntity.id]) + ..where(_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected))); - final selectedAlbumIds = (_db.selectOnly(album) - ..addColumns([album.id]) - ..where(album.backupSelection.equalsValue(BackupSelection.selected))); + final rows = + await (_db.select(_db.trashedLocalAssetEntity).join([ + innerJoin( + _db.remoteAssetEntity, + _db.remoteAssetEntity.checksum.equalsExp(_db.trashedLocalAssetEntity.checksum), + ), + ])..where( + _db.trashedLocalAssetEntity.albumId.isInQuery(selectedAlbumIds) & + _db.remoteAssetEntity.deletedAt.isNull(), + )) + .get(); - final rows = await (_db.select(trashed).join([ - innerJoin(remote, remote.checksum.equalsExp(trashed.checksum)), - ])..where(trashed.albumId.isInQuery(selectedAlbumIds) & remote.deletedAt.isNull())).get(); - - return rows.map((result) => result.readTable(trashed).toDto()); + return rows.map((result) => result.readTable(_db.trashedLocalAssetEntity).toDto()); } /// Applies resulted snapshot of trashed assets: @@ -61,41 +65,46 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository { } return _db.transaction(() async { - final table = _db.trashedLocalAssetEntity; + await _db.batch((batch) { + for (final asset in assets) { + final companion = TrashedLocalAssetEntityCompanion.insert( + id: asset.id, + albumId: albumId, + checksum: asset.checksum == null ? const Value.absent() : Value(asset.checksum), + name: asset.name, + type: asset.type, + createdAt: Value(asset.createdAt), + updatedAt: Value(asset.updatedAt), + width: Value(asset.width), + height: Value(asset.height), + durationInSeconds: Value(asset.durationInSeconds), + isFavorite: Value(asset.isFavorite), + orientation: Value(asset.orientation), + ); - final companions = assets.map( - (a) => TrashedLocalAssetEntityCompanion.insert( - id: a.id, - albumId: albumId, - volume: a.volume == null ? const Value.absent() : Value(a.volume), - checksum: a.checksum == null ? const Value.absent() : Value(a.checksum), - name: a.name, - type: a.type, - createdAt: Value(a.createdAt), - updatedAt: Value(a.updatedAt), - width: Value(a.width), - height: Value(a.height), - durationInSeconds: Value(a.durationInSeconds), - isFavorite: Value(a.isFavorite), - orientation: Value(a.orientation), - ), - ); - - for (final slice in companions.slices(400)) { - await _db.batch((b) { - b.insertAllOnConflictUpdate(table, slice); - }); - } + batch.insert<$TrashedLocalAssetEntityTable, TrashedLocalAssetEntityData>( + _db.trashedLocalAssetEntity, + companion, + onConflict: DoUpdate((_) => companion, where: (old) => old.updatedAt.isNotValue(asset.updatedAt)), + ); + } + }); final keepIds = assets.map((asset) => asset.id); - if (keepIds.length <= 900) { - await (_db.delete(table)..where((row) => row.id.isNotIn(keepIds))).go(); + + if (keepIds.length <= 32000) { + await (_db.delete(_db.trashedLocalAssetEntity)..where((row) => row.id.isNotIn(keepIds))).go(); } else { - final existingIds = await (_db.selectOnly(table)..addColumns([table.id])).map((r) => r.read(table.id)!).get(); - final toDelete = existingIds.where((id) => !keepIds.contains(id)); - for (final slice in toDelete.slices(400)) { - await (_db.delete(table)..where((row) => row.id.isIn(slice))).go(); - } + final keepIdsSet = keepIds.toSet(); + final existingIds = await (_db.selectOnly( + _db.trashedLocalAssetEntity, + )..addColumns([_db.trashedLocalAssetEntity.id])).map((r) => r.read(_db.trashedLocalAssetEntity.id)!).get(); + final idToDelete = existingIds.where((id) => !keepIdsSet.contains(id)); + await _db.batch((batch) { + for (final id in idToDelete) { + batch.deleteWhere(_db.trashedLocalAssetEntity, (row) => row.id.equals(id)); + } + }); } }); } @@ -104,42 +113,43 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository { if (trashUpdates.isEmpty) { return; } - final companions = trashUpdates.map( - (a) => TrashedLocalAssetEntityCompanion.insert( - id: a.id, - volume: a.volume == null ? const Value.absent() : Value(a.volume), - albumId: a.albumId, - name: a.name, - type: a.type, - checksum: a.checksum == null ? const Value.absent() : Value(a.checksum), - createdAt: Value(a.createdAt), - width: Value(a.width), - height: Value(a.height), - durationInSeconds: Value(a.durationInSeconds), - isFavorite: Value(a.isFavorite), - orientation: Value(a.orientation), - ), - ); - for (final slice in companions.slices(200)) { - await _db.batch((b) { - b.insertAllOnConflictUpdate(_db.trashedLocalAssetEntity, slice); - }); - } + await _db.batch((batch) { + for (final asset in trashUpdates) { + final companion = TrashedLocalAssetEntityCompanion.insert( + id: asset.id, + albumId: asset.albumId, + name: asset.name, + type: asset.type, + checksum: asset.checksum == null ? const Value.absent() : Value(asset.checksum), + createdAt: Value(asset.createdAt), + width: Value(asset.width), + height: Value(asset.height), + durationInSeconds: Value(asset.durationInSeconds), + isFavorite: Value(asset.isFavorite), + orientation: Value(asset.orientation), + ); + batch.insert<$TrashedLocalAssetEntityTable, TrashedLocalAssetEntityData>( + _db.trashedLocalAssetEntity, + companion, + onConflict: DoUpdate((_) => companion, where: (old) => old.updatedAt.isNotValue(asset.updatedAt)), + ); + } + }); } Stream watchCount() { - final t = _db.trashedLocalAssetEntity; - return (_db.selectOnly(t)..addColumns([t.id.count()])).watchSingle().map((row) => row.read(t.id.count()) ?? 0); + return (_db.selectOnly(_db.trashedLocalAssetEntity)..addColumns([_db.trashedLocalAssetEntity.id.count()])) + .watchSingle() + .map((row) => row.read(_db.trashedLocalAssetEntity.id.count()) ?? 0); } Stream watchHashedCount() { - final t = _db.trashedLocalAssetEntity; - return (_db.selectOnly(t) - ..addColumns([t.id.count()]) - ..where(t.checksum.isNotNull())) + return (_db.selectOnly(_db.trashedLocalAssetEntity) + ..addColumns([_db.trashedLocalAssetEntity.id.count()]) + ..where(_db.trashedLocalAssetEntity.checksum.isNotNull())) .watchSingle() - .map((row) => row.read(t.id.count()) ?? 0); + .map((row) => row.read(_db.trashedLocalAssetEntity.id.count()) ?? 0); } Future trashLocalAsset(Map> assetsByAlbums) async { @@ -150,14 +160,14 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository { final companions = []; final idToDelete = {}; - assetsByAlbums.forEach((albumId, assets) { - for (final asset in assets) { + for (final entry in assetsByAlbums.entries) { + for (final asset in entry.value) { idToDelete.add(asset.id); companions.add( TrashedLocalAssetEntityCompanion( id: Value(asset.id), name: Value(asset.name), - albumId: Value(albumId), + albumId: Value(entry.key), checksum: asset.checksum == null ? const Value.absent() : Value(asset.checksum), type: Value(asset.type), width: Value(asset.width), @@ -168,17 +178,18 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository { ), ); } - }); + } await _db.transaction(() async { - for (final slice in companions.slices(200)) { - await _db.batch((batch) { + await _db.batch((batch) { + for (final slice in companions.slices(32000)) { batch.insertAllOnConflictUpdate(_db.trashedLocalAssetEntity, slice); - }); - } - for (final slice in idToDelete.slices(800)) { - await (_db.delete(_db.localAssetEntity)..where((e) => e.id.isIn(slice))).go(); - } + } + + for (final id in idToDelete) { + batch.deleteWhere(_db.localAssetEntity, (row) => row.id.equals(id)); + } + }); }); } @@ -212,8 +223,8 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository { await _db.transaction(() async { await _db.batch((batch) { batch.insertAllOnConflictUpdate(_db.localAssetEntity, localAssets); - for (final slice in ids.slices(32000)) { - batch.deleteWhere(_db.trashedLocalAssetEntity, (tbl) => tbl.id.isIn(slice)); + for (final id in ids) { + batch.deleteWhere(_db.trashedLocalAssetEntity, (row) => row.id.equals(id)); } }); }); diff --git a/mobile/lib/platform/native_sync_api.g.dart b/mobile/lib/platform/native_sync_api.g.dart index 82af2484fe..bb2600b236 100644 --- a/mobile/lib/platform/native_sync_api.g.dart +++ b/mobile/lib/platform/native_sync_api.g.dart @@ -41,8 +41,6 @@ class PlatformAsset { required this.durationInSeconds, required this.orientation, required this.isFavorite, - this.isTrashed, - this.volume, }); String id; @@ -65,25 +63,8 @@ class PlatformAsset { bool isFavorite; - bool? isTrashed; - - String? volume; - List _toList() { - return [ - id, - name, - type, - createdAt, - updatedAt, - width, - height, - durationInSeconds, - orientation, - isFavorite, - isTrashed, - volume, - ]; + return [id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite]; } Object encode() { @@ -103,8 +84,6 @@ class PlatformAsset { durationInSeconds: result[7]! as int, orientation: result[8]! as int, isFavorite: result[9]! as bool, - isTrashed: result[10] as bool?, - volume: result[11] as String?, ); } @@ -265,45 +244,6 @@ class HashResult { int get hashCode => Object.hashAll(_toList()); } -class TrashedAssetParams { - TrashedAssetParams({required this.id, required this.type, this.albumId}); - - String id; - - int type; - - String? albumId; - - List _toList() { - return [id, type, albumId]; - } - - Object encode() { - return _toList(); - } - - static TrashedAssetParams decode(Object result) { - result as List; - return TrashedAssetParams(id: result[0]! as String, type: result[1]! as int, albumId: result[2] as String?); - } - - @override - // ignore: avoid_equals_and_hash_code_on_mutable_classes - bool operator ==(Object other) { - if (other is! TrashedAssetParams || 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 @@ -323,9 +263,6 @@ class _PigeonCodec extends StandardMessageCodec { } else if (value is HashResult) { buffer.putUint8(132); writeValue(buffer, value.encode()); - } else if (value is TrashedAssetParams) { - buffer.putUint8(133); - writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -342,8 +279,6 @@ class _PigeonCodec extends StandardMessageCodec { return SyncDelta.decode(readValue(buffer)!); case 132: return HashResult.decode(readValue(buffer)!); - case 133: - return TrashedAssetParams.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); } @@ -655,32 +590,4 @@ class NativeSyncApi { return (pigeonVar_replyList[0] as List?)!.cast(); } } - - Future> hashTrashedAssets(List trashedAssets) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashTrashedAssets$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send([trashedAssets]); - 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 if (pigeonVar_replyList[0] == null) { - throw PlatformException( - code: 'null-error', - message: 'Host platform returned null value for non-null return value.', - ); - } else { - return (pigeonVar_replyList[0] as List?)!.cast(); - } - } } diff --git a/mobile/lib/providers/infrastructure/sync.provider.dart b/mobile/lib/providers/infrastructure/sync.provider.dart index 5d0531a001..f783d47559 100644 --- a/mobile/lib/providers/infrastructure/sync.provider.dart +++ b/mobile/lib/providers/infrastructure/sync.provider.dart @@ -11,6 +11,7 @@ import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart'; +import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; final syncStreamServiceProvider = Provider( (ref) => SyncStreamService( @@ -28,7 +29,8 @@ final syncStreamRepositoryProvider = Provider((ref) => SyncStreamRepository(ref. final localSyncServiceProvider = Provider( (ref) => LocalSyncService( localAlbumRepository: ref.watch(localAlbumRepository), - trashSyncService: ref.watch(trashSyncServiceProvider), + trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository), + localFilesManager: ref.watch(localFilesManagerRepositoryProvider), nativeSyncApi: ref.watch(nativeSyncApiProvider), ), ); @@ -38,6 +40,6 @@ final hashServiceProvider = Provider( localAlbumRepository: ref.watch(localAlbumRepository), localAssetRepository: ref.watch(localAssetRepository), nativeSyncApi: ref.watch(nativeSyncApiProvider), - trashSyncService: ref.watch(trashSyncServiceProvider), + trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository), ), ); diff --git a/mobile/lib/providers/infrastructure/trash_sync.provider.dart b/mobile/lib/providers/infrastructure/trash_sync.provider.dart index a77202a36b..0fe3876c8a 100644 --- a/mobile/lib/providers/infrastructure/trash_sync.provider.dart +++ b/mobile/lib/providers/infrastructure/trash_sync.provider.dart @@ -2,21 +2,16 @@ import 'package:async/async.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/services/trash_sync.service.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; -import 'asset.provider.dart'; - typedef TrashedAssetsCount = ({int total, int hashed}); final trashSyncServiceProvider = Provider( (ref) => TrashSyncService( appSettingsService: ref.watch(appSettingsServiceProvider), - nativeSyncApi: ref.watch(nativeSyncApiProvider), localAssetRepository: ref.watch(localAssetRepository), - localAlbumRepository: ref.watch(localAlbumRepository), trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository), localFilesManager: ref.watch(localFilesManagerRepositoryProvider), storageRepository: ref.watch(storageRepositoryProvider), diff --git a/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart b/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart index 875bff6d09..76481004ec 100644 --- a/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart +++ b/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart @@ -353,7 +353,7 @@ class _SyncStatsCounts extends ConsumerWidget { ], ), ), - if (trashSyncService.isAutoSyncMode) ...[ + if (trashSyncService.isTrashSyncMode) ...[ _SectionHeaderText(text: "trash".t(context: context)), Consumer( builder: (context, ref, _) { diff --git a/mobile/pigeon/native_sync_api.dart b/mobile/pigeon/native_sync_api.dart index a7c860c3f5..2c6c725b39 100644 --- a/mobile/pigeon/native_sync_api.dart +++ b/mobile/pigeon/native_sync_api.dart @@ -14,8 +14,10 @@ import 'package:pigeon/pigeon.dart'; class PlatformAsset { final String id; final String name; + // Follows AssetType enum from base_asset.model.dart final int type; + // Seconds since epoch final int? createdAt; final int? updatedAt; @@ -24,8 +26,6 @@ class PlatformAsset { final int durationInSeconds; final int orientation; final bool isFavorite; - final bool? isTrashed; - final String? volume; const PlatformAsset({ required this.id, @@ -38,14 +38,13 @@ class PlatformAsset { this.durationInSeconds = 0, this.orientation = 0, this.isFavorite = false, - this.isTrashed, - this.volume, }); } class PlatformAlbum { final String id; final String name; + // Seconds since epoch final int? updatedAt; final bool isCloud; @@ -64,6 +63,7 @@ class SyncDelta { final bool hasChanges; final List updates; final List deletes; + // Asset -> Album mapping final Map> assetAlbums; @@ -83,18 +83,6 @@ class HashResult { const HashResult({required this.assetId, this.error, this.hash}); } -class TrashedAssetParams { - final String id; - final int type; - final String? albumId; - - const TrashedAssetParams({ - required this.id, - required this.type, - this.albumId, - }); -} - @HostApi() abstract class NativeSyncApi { bool shouldFullSync(); @@ -126,8 +114,4 @@ abstract class NativeSyncApi { @TaskQueue(type: TaskQueueType.serialBackgroundThread) List getTrashedAssetsForAlbum(String albumId); - - @async - @TaskQueue(type: TaskQueueType.serialBackgroundThread) - List hashTrashedAssets(List trashedAssets); } diff --git a/mobile/test/domain/services/hash_service_test.dart b/mobile/test/domain/services/hash_service_test.dart index 0912b8a52e..c09c16a7c9 100644 --- a/mobile/test/domain/services/hash_service_test.dart +++ b/mobile/test/domain/services/hash_service_test.dart @@ -14,19 +14,19 @@ void main() { late MockLocalAlbumRepository mockAlbumRepo; late MockLocalAssetRepository mockAssetRepo; late MockNativeSyncApi mockNativeApi; - late MockTrashSyncService mockTrashSyncService; + late MockTrashedLocalAssetRepository mockTrashedAssetRepo; setUp(() { mockAlbumRepo = MockLocalAlbumRepository(); mockAssetRepo = MockLocalAssetRepository(); mockNativeApi = MockNativeSyncApi(); - mockTrashSyncService = MockTrashSyncService(); + mockTrashedAssetRepo = MockTrashedLocalAssetRepository(); sut = HashService( localAlbumRepository: mockAlbumRepo, localAssetRepository: mockAssetRepo, nativeSyncApi: mockNativeApi, - trashSyncService: mockTrashSyncService, + trashedLocalAssetRepository: mockTrashedAssetRepo, ); registerFallbackValue(LocalAlbumStub.recent); @@ -34,7 +34,6 @@ void main() { registerFallbackValue({}); when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {}); - when(() => mockTrashSyncService.isAutoSyncMode).thenReturn(false); }); group('HashService hashAssets', () { @@ -118,7 +117,7 @@ void main() { localAssetRepository: mockAssetRepo, nativeSyncApi: mockNativeApi, batchSize: batchSize, - trashSyncService: mockTrashSyncService, + trashedLocalAssetRepository: mockTrashedAssetRepo, ); final album = LocalAlbumStub.recent; @@ -191,4 +190,37 @@ void main() { verify(() => mockNativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1); }); }); + + group('HashService hashAssets (trash sync mode)', () { + test('hashes trashed assets and writes to trashed repo', () async { + final album = LocalAlbumStub.recent; + final trashed1 = TrashedAssetStub.trashed1.copyWith(id: 't1'); + + when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]); + when(() => mockTrashedAssetRepo.getAssetsToHash([album.id])).thenAnswer((_) async => [trashed1]); + + when( + () => mockNativeApi.hashAssets([trashed1.id], allowNetworkAccess: false), + ).thenAnswer((_) async => [HashResult(assetId: trashed1.id, hash: 't1-hash')]); + + await sut.hashAssets(); + + verify(() => mockNativeApi.hashAssets([trashed1.id], allowNetworkAccess: false)).called(1); + verify(() => mockTrashedAssetRepo.updateHashes({'t1': 't1-hash'})).called(1); + verifyNever(() => mockAssetRepo.updateHashes(any())); + }); + + test('skips when trashed list is empty', () async { + final album = LocalAlbumStub.recent; + + when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]); + when(() => mockTrashedAssetRepo.getAssetsToHash([album.id])).thenAnswer((_) async => []); + + await sut.hashAssets(); + + verifyNever(() => mockNativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))); + verifyNever(() => mockTrashedAssetRepo.updateHashes(any())); + verifyNever(() => mockAssetRepo.updateHashes(any())); + }); + }); } diff --git a/mobile/test/fixtures/asset.stub.dart b/mobile/test/fixtures/asset.stub.dart index 8d92011999..27d67c7247 100644 --- a/mobile/test/fixtures/asset.stub.dart +++ b/mobile/test/fixtures/asset.stub.dart @@ -1,4 +1,5 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/asset/trashed_asset.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart' as old; @@ -74,3 +75,16 @@ abstract final class LocalAssetStub { updatedAt: DateTime(20021), ); } + +abstract final class TrashedAssetStub { + const TrashedAssetStub._(); + + static final trashed1 = TrashedAsset( + id: "t1", + name: "trashed1.jpg", + type: AssetType.image, + createdAt: DateTime(2025, 1, 1), + updatedAt: DateTime(2025, 1, 2), + albumId: "album1", + ); +} diff --git a/mobile/test/infrastructure/repository.mock.dart b/mobile/test/infrastructure/repository.mock.dart index 1b66451dda..3dbb4fe120 100644 --- a/mobile/test/infrastructure/repository.mock.dart +++ b/mobile/test/infrastructure/repository.mock.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/infrastructure/repositories/storage.repository.dar import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/user.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; @@ -30,6 +31,8 @@ class MockRemoteAlbumRepository extends Mock implements DriftRemoteAlbumReposito class MockLocalAssetRepository extends Mock implements DriftLocalAssetRepository {} +class MockTrashedLocalAssetRepository extends Mock implements DriftTrashedLocalAssetRepository {} + class MockStorageRepository extends Mock implements StorageRepository {} // API Repos