optimize, refactor code

remove redundant code and checking
getTrashedAssetsForAlbum for iOS
tests for hash trashed assets
This commit is contained in:
Peter Ombodi 2025-10-06 11:41:34 +03:00
parent 3839e72028
commit 3eb2bf0342
24 changed files with 393 additions and 793 deletions

View file

@ -89,9 +89,7 @@ data class PlatformAsset (
val height: Long? = null, val height: Long? = null,
val durationInSeconds: Long, val durationInSeconds: Long,
val orientation: Long, val orientation: Long,
val isFavorite: Boolean, val isFavorite: Boolean
val isTrashed: Boolean? = null,
val volume: String? = null
) )
{ {
companion object { companion object {
@ -106,9 +104,7 @@ data class PlatformAsset (
val durationInSeconds = pigeonVar_list[7] as Long val durationInSeconds = pigeonVar_list[7] as Long
val orientation = pigeonVar_list[8] as Long val orientation = pigeonVar_list[8] as Long
val isFavorite = pigeonVar_list[9] as Boolean val isFavorite = pigeonVar_list[9] as Boolean
val isTrashed = pigeonVar_list[10] as Boolean? return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite)
val volume = pigeonVar_list[11] as String?
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite, isTrashed, volume)
} }
} }
fun toList(): List<Any?> { fun toList(): List<Any?> {
@ -123,8 +119,6 @@ data class PlatformAsset (
durationInSeconds, durationInSeconds,
orientation, orientation,
isFavorite, isFavorite,
isTrashed,
volume,
) )
} }
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
@ -249,40 +243,6 @@ data class HashResult (
override fun hashCode(): Int = toList().hashCode() 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<Any?>): 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<Any?> {
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() { private open class MessagesPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) { return when (type) {
@ -306,11 +266,6 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
HashResult.fromList(it) HashResult.fromList(it)
} }
} }
133.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
TrashedAssetParams.fromList(it)
}
}
else -> super.readValueOfType(type, buffer) else -> super.readValueOfType(type, buffer)
} }
} }
@ -332,10 +287,6 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
stream.write(132) stream.write(132)
writeValue(stream, value.toList()) writeValue(stream, value.toList())
} }
is TrashedAssetParams -> {
stream.write(133)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value) else -> super.writeValue(stream, value)
} }
} }
@ -355,7 +306,6 @@ interface NativeSyncApi {
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit) fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
fun cancelHashing() fun cancelHashing()
fun getTrashedAssetsForAlbum(albumId: String): List<PlatformAsset> fun getTrashedAssetsForAlbum(albumId: String): List<PlatformAsset>
fun hashTrashedAssets(trashedAssets: List<TrashedAssetParams>, callback: (Result<List<HashResult>>) -> Unit)
companion object { companion object {
/** The codec used by NativeSyncApi. */ /** The codec used by NativeSyncApi. */
@ -553,26 +503,6 @@ interface NativeSyncApi {
channel.setMessageHandler(null) channel.setMessageHandler(null)
} }
} }
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashTrashedAssets$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val trashedAssetsArg = args[0] as List<TrashedAssetParams>
api.hashTrashedAssets(trashedAssetsArg) { result: Result<List<HashResult>> ->
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)
}
}
} }
} }
} }

View file

@ -27,11 +27,4 @@ class NativeSyncApiImpl26(context: Context) : NativeSyncApiImplBase(context), Na
): List<PlatformAsset> { ): List<PlatformAsset> {
throw IllegalStateException("Method not supported on this Android version.") throw IllegalStateException("Method not supported on this Android version.")
} }
override fun hashTrashedAssets(
trashedAssets: List<TrashedAssetParams>,
callback: (Result<List<HashResult>>) -> Unit
) {
throw IllegalStateException("Method not supported on this Android version.")
}
} }

View file

@ -130,59 +130,4 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na
return trashed return trashed
} }
override fun hashTrashedAssets(
trashedAssets: List<TrashedAssetParams>,
callback: (Result<List<HashResult>>) -> 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)
}
} }

View file

@ -62,8 +62,6 @@ open class NativeSyncApiImplBase(context: Context) {
// only available on Android 11 and above // only available on Android 11 and above
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) { if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
add(MediaStore.MediaColumns.IS_FAVORITE) add(MediaStore.MediaColumns.IS_FAVORITE)
add(MediaStore.MediaColumns.IS_TRASHED)
add(MediaStore.MediaColumns.VOLUME_NAME)
} }
}.toTypedArray() }.toTypedArray()
@ -111,8 +109,6 @@ open class NativeSyncApiImplBase(context: Context) {
val orientationColumn = val orientationColumn =
c.getColumnIndexOrThrow(MediaStore.MediaColumns.ORIENTATION) c.getColumnIndexOrThrow(MediaStore.MediaColumns.ORIENTATION)
val favoriteColumn = c.getColumnIndex(MediaStore.MediaColumns.IS_FAVORITE) 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()) { while (c.moveToNext()) {
val id = c.getLong(idColumn).toString() val id = c.getLong(idColumn).toString()
@ -142,8 +138,6 @@ open class NativeSyncApiImplBase(context: Context) {
val bucketId = c.getString(bucketIdColumn) val bucketId = c.getString(bucketIdColumn)
val orientation = c.getInt(orientationColumn) val orientation = c.getInt(orientationColumn)
val isFavorite = if (favoriteColumn == -1) false else c.getInt(favoriteColumn) != 0 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( val asset = PlatformAsset(
id, id,
name, name,
@ -155,8 +149,6 @@ open class NativeSyncApiImplBase(context: Context) {
duration, duration,
orientation.toLong(), orientation.toLong(),
isFavorite, isFavorite,
isTrashed,
volume,
) )
yield(AssetResult.ValidAsset(asset, bucketId)) yield(AssetResult.ValidAsset(asset, bucketId))
} }

View file

@ -246,46 +246,46 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/wakelock_plus/ios" :path: ".symlinks/plugins/wakelock_plus/ios"
SPEC CHECKSUMS: SPEC CHECKSUMS:
background_downloader: 50e91d979067b82081aba359d7d916b3ba5fadad background_downloader: a05c77d32a0d70615b9c04577aa203535fc924ff
bonsoir_darwin: 29c7ccf356646118844721f36e1de4b61f6cbd0e bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100 flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
flutter_udid: f7c3884e6ec2951efe4f9de082257fc77c4d15e9 flutter_udid: b2417673f287ee62817a1de3d1643f47b9f508ab
flutter_web_auth_2: 5c8d9dcd7848b5a9efb086d24e7a9adcae979c80 flutter_web_auth_2: 06d500582775790a0d4c323222fcb6d7990f9603
fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1 fluttertoast: 21eecd6935e7064cc1fcb733a4c5a428f3f24f0f
geolocator_apple: 1560c3c875af2a412242c7a923e15d0d401966ff geolocator_apple: 9bcea1918ff7f0062d98345d238ae12718acfbc1
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f home_widget: 0434835a4c9a75704264feff6be17ea40e0f0d57
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
isar_flutter_libs: bc909e72c3d756c2759f14c8776c13b5b0556e26 isar_flutter_libs: fdf730ca925d05687f36d7f1d355e482529ed097
local_auth_darwin: 553ce4f9b16d3fdfeafce9cf042e7c9f77c1c391 local_auth_darwin: 66e40372f1c29f383a314c738c7446e2f7fdadc3
MapLibre: 69e572367f4ef6287e18246cfafc39c80cdcabcd MapLibre: 69e572367f4ef6287e18246cfafc39c80cdcabcd
maplibre_gl: 3c924e44725147b03dda33430ad216005b40555f maplibre_gl: 753f55d763a81cbdba087d02af02d12206e6f94e
native_video_player: b65c58951ede2f93d103a25366bdebca95081265 native_video_player: d12af78a1a4a8cf09775a5177d5b392def6fd23c
network_info_plus: cf61925ab5205dce05a4f0895989afdb6aade5fc network_info_plus: 6613d9d7cdeb0e6f366ed4dbe4b3c51c52d567a9
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
photo_manager: d2fbcc0f2d82458700ee6256a15018210a81d413 photo_manager: ff695c7a1dd5bc379974953a2b5c0a293f7c4c8a
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868 SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868
share_handler_ios: e2244e990f826b2c8eaa291ac3831569438ba0fb share_handler_ios: 6dd3a4ac5ca0d955274aec712ba0ecdcaf583e7c
share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871 share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983 sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983
sqlite3_flutter_libs: f8fc13346870e73fe35ebf6dbb997fbcd156b241 sqlite3_flutter_libs: cc304edcb8e1d8c595d1b08c7aeb46a47691d9db
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
wakelock_plus: 04623e3f525556020ebd4034310f20fe7fda8b49 wakelock_plus: 373cfe59b235a6dd5837d0fb88791d2f13a90d56
PODFILE CHECKSUM: 7ce312f2beab01395db96f6969d90a447279cf45 PODFILE CHECKSUM: 7ce312f2beab01395db96f6969d90a447279cf45

View file

@ -140,8 +140,6 @@ struct PlatformAsset: Hashable {
var durationInSeconds: Int64 var durationInSeconds: Int64
var orientation: Int64 var orientation: Int64
var isFavorite: Bool var isFavorite: Bool
var isTrashed: Bool? = nil
var volume: String? = nil
// swift-format-ignore: AlwaysUseLowerCamelCase // swift-format-ignore: AlwaysUseLowerCamelCase
@ -156,8 +154,6 @@ struct PlatformAsset: Hashable {
let durationInSeconds = pigeonVar_list[7] as! Int64 let durationInSeconds = pigeonVar_list[7] as! Int64
let orientation = pigeonVar_list[8] as! Int64 let orientation = pigeonVar_list[8] as! Int64
let isFavorite = pigeonVar_list[9] as! Bool let isFavorite = pigeonVar_list[9] as! Bool
let isTrashed: Bool? = nilOrValue(pigeonVar_list[10])
let volume: String? = nilOrValue(pigeonVar_list[11])
return PlatformAsset( return PlatformAsset(
id: id, id: id,
@ -169,9 +165,7 @@ struct PlatformAsset: Hashable {
height: height, height: height,
durationInSeconds: durationInSeconds, durationInSeconds: durationInSeconds,
orientation: orientation, orientation: orientation,
isFavorite: isFavorite, isFavorite: isFavorite
isTrashed: isTrashed,
volume: volume
) )
} }
func toList() -> [Any?] { func toList() -> [Any?] {
@ -186,8 +180,6 @@ struct PlatformAsset: Hashable {
durationInSeconds, durationInSeconds,
orientation, orientation,
isFavorite, isFavorite,
isTrashed,
volume,
] ]
} }
static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool { 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 { private class MessagesPigeonCodecReader: FlutterStandardReader {
override func readValue(ofType type: UInt8) -> Any? { override func readValue(ofType type: UInt8) -> Any? {
switch type { switch type {
@ -352,8 +311,6 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
return SyncDelta.fromList(self.readValue() as! [Any?]) return SyncDelta.fromList(self.readValue() as! [Any?])
case 132: case 132:
return HashResult.fromList(self.readValue() as! [Any?]) return HashResult.fromList(self.readValue() as! [Any?])
case 133:
return TrashedAssetParams.fromList(self.readValue() as! [Any?])
default: default:
return super.readValue(ofType: type) return super.readValue(ofType: type)
} }
@ -374,9 +331,6 @@ private class MessagesPigeonCodecWriter: FlutterStandardWriter {
} else if let value = value as? HashResult { } else if let value = value as? HashResult {
super.writeByte(132) super.writeByte(132)
super.writeValue(value.toList()) super.writeValue(value.toList())
} else if let value = value as? TrashedAssetParams {
super.writeByte(133)
super.writeValue(value.toList())
} else { } else {
super.writeValue(value) super.writeValue(value)
} }
@ -411,7 +365,6 @@ protocol NativeSyncApi {
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void) func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
func cancelHashing() throws func cancelHashing() throws
func getTrashedAssetsForAlbum(albumId: String) throws -> [PlatformAsset] 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`. /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@ -599,24 +552,5 @@ class NativeSyncApiSetup {
} else { } else {
getTrashedAssetsForAlbumChannel.setMessageHandler(nil) 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)
}
} }
} }

View file

@ -3,15 +3,15 @@ import CryptoKit
struct AssetWrapper: Hashable, Equatable { struct AssetWrapper: Hashable, Equatable {
let asset: PlatformAsset let asset: PlatformAsset
init(with asset: PlatformAsset) { init(with asset: PlatformAsset) {
self.asset = asset self.asset = asset
} }
func hash(into hasher: inout Hasher) { func hash(into hasher: inout Hasher) {
hasher.combine(self.asset.id) hasher.combine(self.asset.id)
} }
static func == (lhs: AssetWrapper, rhs: AssetWrapper) -> Bool { static func == (lhs: AssetWrapper, rhs: AssetWrapper) -> Bool {
return lhs.asset.id == rhs.asset.id return lhs.asset.id == rhs.asset.id
} }
@ -22,16 +22,16 @@ class NativeSyncApiImpl: NativeSyncApi {
private let changeTokenKey = "immich:changeToken" private let changeTokenKey = "immich:changeToken"
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum] private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
private let recoveredAlbumSubType = 1000000219 private let recoveredAlbumSubType = 1000000219
private var hashTask: Task<Void, Error>? private var hashTask: Task<Void, Error>?
private static let hashCancelledCode = "HASH_CANCELLED" private static let hashCancelledCode = "HASH_CANCELLED"
private static let hashCancelled = Result<[HashResult], Error>.failure(PigeonError(code: hashCancelledCode, message: "Hashing cancelled", details: nil)) private static let hashCancelled = Result<[HashResult], Error>.failure(PigeonError(code: hashCancelledCode, message: "Hashing cancelled", details: nil))
init(with defaults: UserDefaults = .standard) { init(with defaults: UserDefaults = .standard) {
self.defaults = defaults self.defaults = defaults
} }
@available(iOS 16, *) @available(iOS 16, *)
private func getChangeToken() -> PHPersistentChangeToken? { private func getChangeToken() -> PHPersistentChangeToken? {
guard let data = defaults.data(forKey: changeTokenKey) else { guard let data = defaults.data(forKey: changeTokenKey) else {
@ -39,7 +39,7 @@ class NativeSyncApiImpl: NativeSyncApi {
} }
return try? NSKeyedUnarchiver.unarchivedObject(ofClass: PHPersistentChangeToken.self, from: data) return try? NSKeyedUnarchiver.unarchivedObject(ofClass: PHPersistentChangeToken.self, from: data)
} }
@available(iOS 16, *) @available(iOS 16, *)
private func saveChangeToken(token: PHPersistentChangeToken) -> Void { private func saveChangeToken(token: PHPersistentChangeToken) -> Void {
guard let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true) else { guard let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true) else {
@ -47,18 +47,18 @@ class NativeSyncApiImpl: NativeSyncApi {
} }
defaults.set(data, forKey: changeTokenKey) defaults.set(data, forKey: changeTokenKey)
} }
func clearSyncCheckpoint() -> Void { func clearSyncCheckpoint() -> Void {
defaults.removeObject(forKey: changeTokenKey) defaults.removeObject(forKey: changeTokenKey)
} }
func checkpointSync() { func checkpointSync() {
guard #available(iOS 16, *) else { guard #available(iOS 16, *) else {
return return
} }
saveChangeToken(token: PHPhotoLibrary.shared().currentChangeToken) saveChangeToken(token: PHPhotoLibrary.shared().currentChangeToken)
} }
func shouldFullSync() -> Bool { func shouldFullSync() -> Bool {
guard #available(iOS 16, *), guard #available(iOS 16, *),
PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized, 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 // When we do not have access to photo library, older iOS version or No token available, fallback to full sync
return true return true
} }
guard let _ = try? PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken) else { guard let _ = try? PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken) else {
// Cannot fetch persistent changes // Cannot fetch persistent changes
return true return true
} }
return false return false
} }
func getAlbums() throws -> [PlatformAlbum] { func getAlbums() throws -> [PlatformAlbum] {
var albums: [PlatformAlbum] = [] var albums: [PlatformAlbum] = []
albumTypes.forEach { type in albumTypes.forEach { type in
let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil) let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
for i in 0..<collections.count { for i in 0..<collections.count {
let album = collections.object(at: i) let album = collections.object(at: i)
// Ignore recovered album // Ignore recovered album
if(album.assetCollectionSubtype.rawValue == self.recoveredAlbumSubType) { if(album.assetCollectionSubtype.rawValue == self.recoveredAlbumSubType) {
continue; continue;
} }
let options = PHFetchOptions() let options = PHFetchOptions()
options.sortDescriptors = [NSSortDescriptor(key: "modificationDate", ascending: false)] options.sortDescriptors = [NSSortDescriptor(key: "modificationDate", ascending: false)]
options.includeHiddenAssets = false options.includeHiddenAssets = false
let assets = PHAsset.fetchAssets(in: album, options: options) let assets = PHAsset.fetchAssets(in: album, options: options)
let isCloud = album.assetCollectionSubtype == .albumCloudShared || album.assetCollectionSubtype == .albumMyPhotoStream let isCloud = album.assetCollectionSubtype == .albumCloudShared || album.assetCollectionSubtype == .albumMyPhotoStream
var domainAlbum = PlatformAlbum( var domainAlbum = PlatformAlbum(
id: album.localIdentifier, id: album.localIdentifier,
name: album.localizedTitle!, name: album.localizedTitle!,
@ -101,57 +101,57 @@ class NativeSyncApiImpl: NativeSyncApi {
isCloud: isCloud, isCloud: isCloud,
assetCount: Int64(assets.count) assetCount: Int64(assets.count)
) )
if let firstAsset = assets.firstObject { if let firstAsset = assets.firstObject {
domainAlbum.updatedAt = firstAsset.modificationDate.map { Int64($0.timeIntervalSince1970) } domainAlbum.updatedAt = firstAsset.modificationDate.map { Int64($0.timeIntervalSince1970) }
} }
albums.append(domainAlbum) albums.append(domainAlbum)
} }
} }
return albums.sorted { $0.id < $1.id } return albums.sorted { $0.id < $1.id }
} }
func getMediaChanges() throws -> SyncDelta { func getMediaChanges(isTrashed: Bool) throws -> SyncDelta {
guard #available(iOS 16, *) else { guard #available(iOS 16, *) else {
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature requires iOS 16 or later.", details: nil) throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature requires iOS 16 or later.", details: nil)
} }
guard PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized else { guard PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized else {
throw PigeonError(code: "NO_AUTH", message: "No photo library access", details: nil) throw PigeonError(code: "NO_AUTH", message: "No photo library access", details: nil)
} }
guard let storedToken = getChangeToken() else { guard let storedToken = getChangeToken() else {
// No token exists, definitely need a full sync // No token exists, definitely need a full sync
print("MediaManager::getMediaChanges: No token found") print("MediaManager::getMediaChanges: No token found")
throw PigeonError(code: "NO_TOKEN", message: "No stored change token", details: nil) throw PigeonError(code: "NO_TOKEN", message: "No stored change token", details: nil)
} }
let currentToken = PHPhotoLibrary.shared().currentChangeToken let currentToken = PHPhotoLibrary.shared().currentChangeToken
if storedToken == currentToken { if storedToken == currentToken {
return SyncDelta(hasChanges: false, updates: [], deletes: [], assetAlbums: [:]) return SyncDelta(hasChanges: false, updates: [], deletes: [], assetAlbums: [:])
} }
do { do {
let changes = try PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken) let changes = try PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken)
var updatedAssets: Set<AssetWrapper> = [] var updatedAssets: Set<AssetWrapper> = []
var deletedAssets: Set<String> = [] var deletedAssets: Set<String> = []
for change in changes { for change in changes {
guard let details = try? change.changeDetails(for: PHObjectType.asset) else { continue } guard let details = try? change.changeDetails(for: PHObjectType.asset) else { continue }
let updated = details.updatedLocalIdentifiers.union(details.insertedLocalIdentifiers) let updated = details.updatedLocalIdentifiers.union(details.insertedLocalIdentifiers)
deletedAssets.formUnion(details.deletedLocalIdentifiers) deletedAssets.formUnion(details.deletedLocalIdentifiers)
if (updated.isEmpty) { continue } if (updated.isEmpty) { continue }
let options = PHFetchOptions() let options = PHFetchOptions()
options.includeHiddenAssets = false options.includeHiddenAssets = false
let result = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: options) let result = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: options)
for i in 0..<result.count { for i in 0..<result.count {
let asset = result.object(at: i) let asset = result.object(at: i)
// Asset wrapper only uses the id for comparison. Multiple change can contain the same asset, skip duplicate changes // Asset wrapper only uses the id for comparison. Multiple change can contain the same asset, skip duplicate changes
let predicate = PlatformAsset( let predicate = PlatformAsset(
id: asset.localIdentifier, id: asset.localIdentifier,
@ -164,25 +164,25 @@ class NativeSyncApiImpl: NativeSyncApi {
if (updatedAssets.contains(AssetWrapper(with: predicate))) { if (updatedAssets.contains(AssetWrapper(with: predicate))) {
continue continue
} }
let domainAsset = AssetWrapper(with: asset.toPlatformAsset()) let domainAsset = AssetWrapper(with: asset.toPlatformAsset())
updatedAssets.insert(domainAsset) updatedAssets.insert(domainAsset)
} }
} }
let updates = Array(updatedAssets.map { $0.asset }) let updates = Array(updatedAssets.map { $0.asset })
return SyncDelta(hasChanges: true, updates: updates, deletes: Array(deletedAssets), assetAlbums: buildAssetAlbumsMap(assets: updates)) return SyncDelta(hasChanges: true, updates: updates, deletes: Array(deletedAssets), assetAlbums: buildAssetAlbumsMap(assets: updates))
} }
} }
private func buildAssetAlbumsMap(assets: Array<PlatformAsset>) -> [String: [String]] { private func buildAssetAlbumsMap(assets: Array<PlatformAsset>) -> [String: [String]] {
guard !assets.isEmpty else { guard !assets.isEmpty else {
return [:] return [:]
} }
var albumAssets: [String: [String]] = [:] var albumAssets: [String: [String]] = [:]
for type in albumTypes { for type in albumTypes {
let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil) let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
collections.enumerateObjects { (album, _, _) in collections.enumerateObjects { (album, _, _) in
@ -197,13 +197,13 @@ class NativeSyncApiImpl: NativeSyncApi {
} }
return albumAssets return albumAssets
} }
func getAssetIdsForAlbum(albumId: String) throws -> [String] { func getAssetIdsForAlbum(albumId: String) throws -> [String] {
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil) let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
guard let album = collections.firstObject else { guard let album = collections.firstObject else {
return [] return []
} }
var ids: [String] = [] var ids: [String] = []
let options = PHFetchOptions() let options = PHFetchOptions()
options.includeHiddenAssets = false options.includeHiddenAssets = false
@ -213,13 +213,13 @@ class NativeSyncApiImpl: NativeSyncApi {
} }
return ids return ids
} }
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64 { func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64 {
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil) let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
guard let album = collections.firstObject else { guard let album = collections.firstObject else {
return 0 return 0
} }
let date = NSDate(timeIntervalSince1970: TimeInterval(timestamp)) let date = NSDate(timeIntervalSince1970: TimeInterval(timestamp))
let options = PHFetchOptions() let options = PHFetchOptions()
options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date) options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
@ -227,32 +227,32 @@ class NativeSyncApiImpl: NativeSyncApi {
let assets = PHAsset.fetchAssets(in: album, options: options) let assets = PHAsset.fetchAssets(in: album, options: options)
return Int64(assets.count) return Int64(assets.count)
} }
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] { func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] {
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil) let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
guard let album = collections.firstObject else { guard let album = collections.firstObject else {
return [] return []
} }
let options = PHFetchOptions() let options = PHFetchOptions()
options.includeHiddenAssets = false options.includeHiddenAssets = false
if(updatedTimeCond != nil) { if(updatedTimeCond != nil) {
let date = NSDate(timeIntervalSince1970: TimeInterval(updatedTimeCond!)) let date = NSDate(timeIntervalSince1970: TimeInterval(updatedTimeCond!))
options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date) options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
} }
let result = PHAsset.fetchAssets(in: album, options: options) let result = PHAsset.fetchAssets(in: album, options: options)
if(result.count == 0) { if(result.count == 0) {
return [] return []
} }
var assets: [PlatformAsset] = [] var assets: [PlatformAsset] = []
result.enumerateObjects { (asset, _, _) in result.enumerateObjects { (asset, _, _) in
assets.append(asset.toPlatformAsset()) assets.append(asset.toPlatformAsset())
} }
return assets return assets
} }
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void) { func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void) {
if let prevTask = hashTask { if let prevTask = hashTask {
prevTask.cancel() prevTask.cancel()
@ -270,11 +270,11 @@ class NativeSyncApiImpl: NativeSyncApi {
missingAssetIds.remove(asset.localIdentifier) missingAssetIds.remove(asset.localIdentifier)
assets.append(asset) assets.append(asset)
} }
if Task.isCancelled { if Task.isCancelled {
return completion(Self.hashCancelled) return completion(Self.hashCancelled)
} }
await withTaskGroup(of: HashResult?.self) { taskGroup in await withTaskGroup(of: HashResult?.self) { taskGroup in
var results = [HashResult]() var results = [HashResult]()
results.reserveCapacity(assets.count) results.reserveCapacity(assets.count)
@ -287,28 +287,28 @@ class NativeSyncApiImpl: NativeSyncApi {
return await self.hashAsset(asset, allowNetworkAccess: allowNetworkAccess) return await self.hashAsset(asset, allowNetworkAccess: allowNetworkAccess)
} }
} }
for await result in taskGroup { for await result in taskGroup {
guard let result = result else { guard let result = result else {
return completion(Self.hashCancelled) return completion(Self.hashCancelled)
} }
results.append(result) results.append(result)
} }
for missing in missingAssetIds { for missing in missingAssetIds {
results.append(HashResult(assetId: missing, error: "Asset not found in library", hash: nil)) results.append(HashResult(assetId: missing, error: "Asset not found in library", hash: nil))
} }
completion(.success(results)) completion(.success(results))
} }
} }
} }
func cancelHashing() { func cancelHashing() {
hashTask?.cancel() hashTask?.cancel()
hashTask = nil hashTask = nil
} }
private func hashAsset(_ asset: PHAsset, allowNetworkAccess: Bool) async -> HashResult? { private func hashAsset(_ asset: PHAsset, allowNetworkAccess: Bool) async -> HashResult? {
class RequestRef { class RequestRef {
var id: PHAssetResourceDataRequestID? var id: PHAssetResourceDataRequestID?
@ -318,21 +318,21 @@ class NativeSyncApiImpl: NativeSyncApi {
if Task.isCancelled { if Task.isCancelled {
return nil return nil
} }
guard let resource = asset.getResource() else { guard let resource = asset.getResource() else {
return HashResult(assetId: asset.localIdentifier, error: "Cannot get asset resource", hash: nil) return HashResult(assetId: asset.localIdentifier, error: "Cannot get asset resource", hash: nil)
} }
if Task.isCancelled { if Task.isCancelled {
return nil return nil
} }
let options = PHAssetResourceRequestOptions() let options = PHAssetResourceRequestOptions()
options.isNetworkAccessAllowed = allowNetworkAccess options.isNetworkAccessAllowed = allowNetworkAccess
return await withCheckedContinuation { continuation in return await withCheckedContinuation { continuation in
var hasher = Insecure.SHA1() var hasher = Insecure.SHA1()
requestRef.id = PHAssetResourceManager.default().requestData( requestRef.id = PHAssetResourceManager.default().requestData(
for: resource, for: resource,
options: options, options: options,
@ -363,4 +363,9 @@ class NativeSyncApiImpl: NativeSyncApi {
PHAssetResourceManager.default().cancelDataRequest(requestId) PHAssetResourceManager.default().cancelDataRequest(requestId)
}) })
} }
func getTrashedAssetsForAlbum(albumId: String) throws ->[PlatformAsset] {
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)
}
} }

View file

@ -1,12 +1,10 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
class TrashedAsset extends LocalAsset { class TrashedAsset extends LocalAsset {
final String? volume;
final String albumId; final String albumId;
const TrashedAsset({ const TrashedAsset({
required this.albumId, required this.albumId,
this.volume,
required super.id, required super.id,
super.remoteId, super.remoteId,
required super.name, required super.name,
@ -38,7 +36,6 @@ class TrashedAsset extends LocalAsset {
String? livePhotoVideoId, String? livePhotoVideoId,
int? orientation, int? orientation,
String? albumId, String? albumId,
String? volume,
}) { }) {
return TrashedAsset( return TrashedAsset(
id: id ?? this.id, id: id ?? this.id,
@ -55,7 +52,6 @@ class TrashedAsset extends LocalAsset {
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId, livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
orientation: orientation ?? this.orientation, orientation: orientation ?? this.orientation,
albumId: albumId ?? this.albumId, albumId: albumId ?? this.albumId,
volume: volume ?? this.volume,
); );
} }
@ -79,7 +75,6 @@ class TrashedAsset extends LocalAsset {
livePhotoVideoId == other.livePhotoVideoId && livePhotoVideoId == other.livePhotoVideoId &&
orientation == other.orientation && orientation == other.orientation &&
// TrashedAsset extras // TrashedAsset extras
volume == other.volume &&
albumId == other.albumId; albumId == other.albumId;
@override @override
@ -97,7 +92,6 @@ class TrashedAsset extends LocalAsset {
isFavorite, isFavorite,
livePhotoVideoId, livePhotoVideoId,
orientation, orientation,
volume,
albumId, albumId,
); );
@ -118,7 +112,6 @@ class TrashedAsset extends LocalAsset {
'livePhotoVideoId: $livePhotoVideoId, ' 'livePhotoVideoId: $livePhotoVideoId, '
'orientation: $orientation, ' 'orientation: $orientation, '
'albumId: $albumId, ' 'albumId: $albumId, '
'volume: $volume'
')'; ')';
} }
} }

View file

@ -2,10 +2,10 @@ import 'package:flutter/services.dart';
import 'package:immich_mobile/constants/constants.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/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.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/extensions/platform_extensions.dart';
import 'package:immich_mobile/domain/services/trash_sync.service.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.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/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:immich_mobile/platform/native_sync_api.g.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@ -15,24 +15,24 @@ class HashService {
final int _batchSize; final int _batchSize;
final DriftLocalAlbumRepository _localAlbumRepository; final DriftLocalAlbumRepository _localAlbumRepository;
final DriftLocalAssetRepository _localAssetRepository; final DriftLocalAssetRepository _localAssetRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final NativeSyncApi _nativeSyncApi; final NativeSyncApi _nativeSyncApi;
final TrashSyncService _trashSyncService;
final bool Function()? _cancelChecker; final bool Function()? _cancelChecker;
final _log = Logger('HashService'); final _log = Logger('HashService');
HashService({ HashService({
required DriftLocalAlbumRepository localAlbumRepository, required DriftLocalAlbumRepository localAlbumRepository,
required DriftLocalAssetRepository localAssetRepository, required DriftLocalAssetRepository localAssetRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required NativeSyncApi nativeSyncApi, required NativeSyncApi nativeSyncApi,
required TrashSyncService trashSyncService,
bool Function()? cancelChecker, bool Function()? cancelChecker,
int? batchSize, int? batchSize,
}) : _localAlbumRepository = localAlbumRepository, }) : _localAlbumRepository = localAlbumRepository,
_localAssetRepository = localAssetRepository, _localAssetRepository = localAssetRepository,
_trashedLocalAssetRepository = trashedLocalAssetRepository,
_cancelChecker = cancelChecker, _cancelChecker = cancelChecker,
_nativeSyncApi = nativeSyncApi, _nativeSyncApi = nativeSyncApi,
_batchSize = batchSize ?? kBatchHashFileLimit, _batchSize = batchSize ?? kBatchHashFileLimit;
_trashSyncService = trashSyncService;
bool get isCancelled => _cancelChecker?.call() ?? false; bool get isCancelled => _cancelChecker?.call() ?? false;
@ -54,6 +54,14 @@ class HashService {
await _hashAssets(album, assetsToHash); 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) { } on PlatformException catch (e) {
if (e.code == _kHashCancelledCode) { if (e.code == _kHashCancelledCode) {
_log.warning("Hashing cancelled by platform"); _log.warning("Hashing cancelled by platform");
@ -63,23 +71,6 @@ class HashService {
_log.severe("Error during hashing", e, s); _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(); stopwatch.stop();
_log.info("Hashing took - ${stopwatch.elapsedMilliseconds}ms"); _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 /// 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 /// 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. /// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
Future<void> _hashAssets(LocalAlbum album, List<LocalAsset> assetsToHash) async { Future<void> _hashAssets(LocalAlbum album, List<LocalAsset> assetsToHash, {bool isTrashed = false}) async {
final toHash = <String, LocalAsset>{}; final toHash = <String, LocalAsset>{};
for (final asset in assetsToHash) { for (final asset in assetsToHash) {
@ -98,16 +89,16 @@ class HashService {
toHash[asset.id] = asset; toHash[asset.id] = asset;
if (toHash.length == _batchSize) { if (toHash.length == _batchSize) {
await _processBatch(album, toHash); await _processBatch(album, toHash, isTrashed);
toHash.clear(); toHash.clear();
} }
} }
await _processBatch(album, toHash); await _processBatch(album, toHash, isTrashed);
} }
/// Processes a batch of assets. /// Processes a batch of assets.
Future<void> _processBatch(LocalAlbum album, Map<String, LocalAsset> toHash) async { Future<void> _processBatch(LocalAlbum album, Map<String, LocalAsset> toHash, bool isTrashed) async {
if (toHash.isEmpty) { if (toHash.isEmpty) {
return; return;
} }
@ -142,64 +133,10 @@ class HashService {
} }
_log.fine("Hashed ${hashed.length}/${toHash.length} assets"); _log.fine("Hashed ${hashed.length}/${toHash.length} assets");
if (isTrashed) {
await _localAssetRepository.updateHashes(hashed); await _trashedLocalAssetRepository.updateHashes(hashed);
} } else {
await _localAssetRepository.updateHashes(hashed);
Future<void> _hashTrashedAssets(LocalAlbum album, Iterable<TrashedAsset> assetsToHash) async {
final toHash = <TrashedAsset>[];
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();
}
} }
await _processTrashedBatch(album, toHash);
}
Future<void> _processTrashedBatch(LocalAlbum album, List<TrashedAsset> 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 = <TrashedAsset>[];
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);
} }
} }

View file

@ -4,10 +4,12 @@ import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.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/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/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.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/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/datetime_helpers.dart';
import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/diff.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@ -15,15 +17,18 @@ import 'package:logging/logging.dart';
class LocalSyncService { class LocalSyncService {
final DriftLocalAlbumRepository _localAlbumRepository; final DriftLocalAlbumRepository _localAlbumRepository;
final NativeSyncApi _nativeSyncApi; final NativeSyncApi _nativeSyncApi;
final TrashSyncService _trashSyncService; final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final LocalFilesManagerRepository _localFilesManager;
final Logger _log = Logger("DeviceSyncService"); final Logger _log = Logger("DeviceSyncService");
LocalSyncService({ LocalSyncService({
required DriftLocalAlbumRepository localAlbumRepository, required DriftLocalAlbumRepository localAlbumRepository,
required TrashSyncService trashSyncService, required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required LocalFilesManagerRepository localFilesManager,
required NativeSyncApi nativeSyncApi, required NativeSyncApi nativeSyncApi,
}) : _localAlbumRepository = localAlbumRepository, }) : _localAlbumRepository = localAlbumRepository,
_trashSyncService = trashSyncService, _trashedLocalAssetRepository = trashedLocalAssetRepository,
_localFilesManager = localFilesManager,
_nativeSyncApi = nativeSyncApi; _nativeSyncApi = nativeSyncApi;
Future<void> sync({bool full = false}) async { Future<void> sync({bool full = false}) async {
@ -34,6 +39,12 @@ class LocalSyncService {
return await fullSync(); 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(); final delta = await _nativeSyncApi.getMediaChanges();
if (!delta.hasChanges) { if (!delta.hasChanges) {
_log.fine("No media changes detected. Skipping sync"); _log.fine("No media changes detected. Skipping sync");
@ -75,11 +86,6 @@ class LocalSyncService {
await updateAlbum(dbAlbum, album); 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(); await _nativeSyncApi.checkpointSync();
} catch (e, s) { } catch (e, s) {
_log.severe("Error performing device sync", e, s); _log.severe("Error performing device sync", e, s);
@ -93,6 +99,10 @@ class LocalSyncService {
try { try {
final Stopwatch stopwatch = Stopwatch()..start(); final Stopwatch stopwatch = Stopwatch()..start();
if (CurrentPlatform.isAndroid) {
await _syncDeviceTrashSnapshot();
}
final deviceAlbums = await _nativeSyncApi.getAlbums(); final deviceAlbums = await _nativeSyncApi.getAlbums();
final dbAlbums = await _localAlbumRepository.getAll(sortBy: {SortLocalAlbumsBy.id}); final dbAlbums = await _localAlbumRepository.getAll(sortBy: {SortLocalAlbumsBy.id});
@ -104,9 +114,6 @@ class LocalSyncService {
onlyFirst: removeAlbum, onlyFirst: removeAlbum,
onlySecond: addAlbum, onlySecond: addAlbum,
); );
if (_trashSyncService.isAutoSyncMode) {
await _trashSyncService.syncDeviceTrashSnapshot();
}
await _nativeSyncApi.checkpointSync(); await _nativeSyncApi.checkpointSync();
stopwatch.stop(); stopwatch.stop();
@ -286,6 +293,53 @@ class LocalSyncService {
bool _albumsEqual(LocalAlbum a, LocalAlbum b) { bool _albumsEqual(LocalAlbum a, LocalAlbum b) {
return a.name == b.name && a.assetCount == b.assetCount && a.updatedAt.isAtSameMomentAs(b.updatedAt); return a.name == b.name && a.assetCount == b.assetCount && a.updatedAt.isAtSameMomentAs(b.updatedAt);
} }
Future<void> _applyTrashDelta(SyncDelta delta) async {
final trashUpdates = delta.updates;
if (trashUpdates.isEmpty) {
return Future.value();
}
final trashedAssets = <TrashedAsset>[];
//todo try to reuse exist checksums from local assets table before they updated
for (final update in trashUpdates) {
final albums = delta.assetAlbums.cast<String, List<Object?>>();
for (final String id in albums[update.id]!.cast<String?>().nonNulls) {
trashedAssets.add(update.toTrashedAsset(id));
}
}
_log.info("updateLocalTrashChanges trashedAssets: ${trashedAssets.map((e) => e.id)}");
await _trashedLocalAssetRepository.saveTrashedAssets(trashedAssets);
await _applyRemoteRestoreToLocal();
}
Future<void> _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<void> _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<PlatformAlbum> { extension on Iterable<PlatformAlbum> {
@ -320,3 +374,26 @@ extension on Iterable<PlatformAsset> {
).toList(); ).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<PlatformAsset> {
Iterable<TrashedAsset> toTrashedAssets(String albumId) {
return map((e) => e.toTrashedAsset(albumId));
}
}

View file

@ -88,7 +88,7 @@ class SyncStreamService {
return _syncStreamRepository.deletePartnerV1(data.cast()); return _syncStreamRepository.deletePartnerV1(data.cast());
case SyncEntityType.assetV1: case SyncEntityType.assetV1:
final remoteSyncAssets = data.cast<SyncAssetV1>(); final remoteSyncAssets = data.cast<SyncAssetV1>();
if (_trashSyncService.isAutoSyncMode) { if (_trashSyncService.isTrashSyncMode) {
await _trashSyncService.handleRemoteTrashed( await _trashSyncService.handleRemoteTrashed(
remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.checksum), remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.checksum),
); );

View file

@ -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/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/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.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/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/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/datetime_helpers.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
typedef TrashSyncItem = ({String remoteId, String checksum, DateTime? deletedAt});
class TrashSyncService { class TrashSyncService {
final AppSettingsService _appSettingsService; final AppSettingsService _appSettingsService;
final NativeSyncApi _nativeSyncApi;
final DriftLocalAssetRepository _localAssetRepository; final DriftLocalAssetRepository _localAssetRepository;
final DriftLocalAlbumRepository _localAlbumRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository; final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final LocalFilesManagerRepository _localFilesManager; final LocalFilesManagerRepository _localFilesManager;
final StorageRepository _storageRepository; final StorageRepository _storageRepository;
@ -25,66 +16,25 @@ class TrashSyncService {
TrashSyncService({ TrashSyncService({
required AppSettingsService appSettingsService, required AppSettingsService appSettingsService,
required NativeSyncApi nativeSyncApi,
required DriftLocalAssetRepository localAssetRepository, required DriftLocalAssetRepository localAssetRepository,
required DriftLocalAlbumRepository localAlbumRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository, required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required LocalFilesManagerRepository localFilesManager, required LocalFilesManagerRepository localFilesManager,
required StorageRepository storageRepository, required StorageRepository storageRepository,
}) : _appSettingsService = appSettingsService, }) : _appSettingsService = appSettingsService,
_nativeSyncApi = nativeSyncApi,
_localAssetRepository = localAssetRepository, _localAssetRepository = localAssetRepository,
_localAlbumRepository = localAlbumRepository,
_trashedLocalAssetRepository = trashedLocalAssetRepository, _trashedLocalAssetRepository = trashedLocalAssetRepository,
_localFilesManager = localFilesManager, _localFilesManager = localFilesManager,
_storageRepository = storageRepository; _storageRepository = storageRepository;
bool get isAutoSyncMode => bool get isTrashSyncMode =>
CurrentPlatform.isAndroid && _appSettingsService.getSetting<bool>(AppSettingsEnum.manageLocalMediaAndroid); CurrentPlatform.isAndroid && _appSettingsService.getSetting<bool>(AppSettingsEnum.manageLocalMediaAndroid);
Future<void> updateChecksums(Iterable<TrashedAsset> assets) async =>
_trashedLocalAssetRepository.updateChecksums(assets);
Future<Iterable<TrashedAsset>> getAssetsToHash(Iterable<String> albumIds) async =>
_trashedLocalAssetRepository.getToHash(albumIds);
Future<void> 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<void> applyTrashDelta(SyncDelta delta) async {
final trashUpdates = delta.updates;
if (trashUpdates.isEmpty) {
return Future.value();
}
final trashedAssets = <TrashedAsset>[];
for (final update in trashUpdates) {
final albums = delta.assetAlbums.cast<String, List<Object?>>();
for (final String id in albums[update.id]!.cast<String?>().nonNulls) {
trashedAssets.add(update.toTrashedAsset(id));
}
}
_logger.info("updateLocalTrashChanges trashedAssets: ${trashedAssets.map((e) => e.id)}");
await _trashedLocalAssetRepository.saveTrashedAssets(trashedAssets);
await _applyRemoteRestoreToLocal();
}
Future<void> handleRemoteTrashed(Iterable<String> checksums) async { Future<void> handleRemoteTrashed(Iterable<String> checksums) async {
if (checksums.isEmpty) { if (checksums.isEmpty) {
return Future.value(); return Future.value();
} else { } else {
final localAssetsToTrash = await _localAssetRepository.getBackupSelectedAssetsByAlbum(checksums); final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(checksums);
if (localAssetsToTrash.isNotEmpty) { if (localAssetsToTrash.isNotEmpty) {
final mediaUrls = await Future.wait( final mediaUrls = await Future.wait(
localAssetsToTrash.values localAssetsToTrash.values
@ -101,42 +51,5 @@ class TrashSyncService {
} }
} }
} }
Future<void> _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<PlatformAsset> {
Iterable<TrashedAsset> toTrashedAssets(String albumId) {
return map((e) => e.toTrashedAsset(albumId));
}
}

View file

@ -12,8 +12,6 @@ class TrashedLocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntity
TextColumn get albumId => text()(); TextColumn get albumId => text()();
TextColumn get volume => text().nullable()();
TextColumn get checksum => text().nullable()(); TextColumn get checksum => text().nullable()();
BoolColumn get isFavorite => boolean().withDefault(const Constant(false))(); BoolColumn get isFavorite => boolean().withDefault(const Constant(false))();
@ -28,7 +26,6 @@ extension TrashedLocalAssetEntityDataDomainExtension on TrashedLocalAssetEntityD
TrashedAsset toDto() => TrashedAsset( TrashedAsset toDto() => TrashedAsset(
id: id, id: id,
name: name, name: name,
volume: volume,
albumId: albumId, albumId: albumId,
checksum: checksum, checksum: checksum,
type: type, type: type,

View file

@ -1,12 +1,12 @@
// dart format width=80 // dart format width=80
// ignore_for_file: type=lint // ignore_for_file: type=lint
import 'package:drift/drift.dart' as i0; import 'package:drift/drift.dart' as i0;
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart' import 'package:drift/src/runtime/query_builder/query_builder.dart' as i4;
as i1;
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' as i2; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' as i2;
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart' import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart'
as i3; 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 = typedef $$TrashedLocalAssetEntityTableCreateCompanionBuilder =
i1.TrashedLocalAssetEntityCompanion Function({ i1.TrashedLocalAssetEntityCompanion Function({
@ -19,7 +19,6 @@ typedef $$TrashedLocalAssetEntityTableCreateCompanionBuilder =
i0.Value<int?> durationInSeconds, i0.Value<int?> durationInSeconds,
required String id, required String id,
required String albumId, required String albumId,
i0.Value<String?> volume,
i0.Value<String?> checksum, i0.Value<String?> checksum,
i0.Value<bool> isFavorite, i0.Value<bool> isFavorite,
i0.Value<int> orientation, i0.Value<int> orientation,
@ -35,7 +34,6 @@ typedef $$TrashedLocalAssetEntityTableUpdateCompanionBuilder =
i0.Value<int?> durationInSeconds, i0.Value<int?> durationInSeconds,
i0.Value<String> id, i0.Value<String> id,
i0.Value<String> albumId, i0.Value<String> albumId,
i0.Value<String?> volume,
i0.Value<String?> checksum, i0.Value<String?> checksum,
i0.Value<bool> isFavorite, i0.Value<bool> isFavorite,
i0.Value<int> orientation, i0.Value<int> orientation,
@ -97,11 +95,6 @@ class $$TrashedLocalAssetEntityTableFilterComposer
builder: (column) => i0.ColumnFilters(column), builder: (column) => i0.ColumnFilters(column),
); );
i0.ColumnFilters<String> get volume => $composableBuilder(
column: $table.volume,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<String> get checksum => $composableBuilder( i0.ColumnFilters<String> get checksum => $composableBuilder(
column: $table.checksum, column: $table.checksum,
builder: (column) => i0.ColumnFilters(column), builder: (column) => i0.ColumnFilters(column),
@ -173,11 +166,6 @@ class $$TrashedLocalAssetEntityTableOrderingComposer
builder: (column) => i0.ColumnOrderings(column), builder: (column) => i0.ColumnOrderings(column),
); );
i0.ColumnOrderings<String> get volume => $composableBuilder(
column: $table.volume,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<String> get checksum => $composableBuilder( i0.ColumnOrderings<String> get checksum => $composableBuilder(
column: $table.checksum, column: $table.checksum,
builder: (column) => i0.ColumnOrderings(column), builder: (column) => i0.ColumnOrderings(column),
@ -233,9 +221,6 @@ class $$TrashedLocalAssetEntityTableAnnotationComposer
i0.GeneratedColumn<String> get albumId => i0.GeneratedColumn<String> get albumId =>
$composableBuilder(column: $table.albumId, builder: (column) => column); $composableBuilder(column: $table.albumId, builder: (column) => column);
i0.GeneratedColumn<String> get volume =>
$composableBuilder(column: $table.volume, builder: (column) => column);
i0.GeneratedColumn<String> get checksum => i0.GeneratedColumn<String> get checksum =>
$composableBuilder(column: $table.checksum, builder: (column) => column); $composableBuilder(column: $table.checksum, builder: (column) => column);
@ -305,7 +290,6 @@ class $$TrashedLocalAssetEntityTableTableManager
i0.Value<int?> durationInSeconds = const i0.Value.absent(), i0.Value<int?> durationInSeconds = const i0.Value.absent(),
i0.Value<String> id = const i0.Value.absent(), i0.Value<String> id = const i0.Value.absent(),
i0.Value<String> albumId = const i0.Value.absent(), i0.Value<String> albumId = const i0.Value.absent(),
i0.Value<String?> volume = const i0.Value.absent(),
i0.Value<String?> checksum = const i0.Value.absent(), i0.Value<String?> checksum = const i0.Value.absent(),
i0.Value<bool> isFavorite = const i0.Value.absent(), i0.Value<bool> isFavorite = const i0.Value.absent(),
i0.Value<int> orientation = const i0.Value.absent(), i0.Value<int> orientation = const i0.Value.absent(),
@ -319,7 +303,6 @@ class $$TrashedLocalAssetEntityTableTableManager
durationInSeconds: durationInSeconds, durationInSeconds: durationInSeconds,
id: id, id: id,
albumId: albumId, albumId: albumId,
volume: volume,
checksum: checksum, checksum: checksum,
isFavorite: isFavorite, isFavorite: isFavorite,
orientation: orientation, orientation: orientation,
@ -335,7 +318,6 @@ class $$TrashedLocalAssetEntityTableTableManager
i0.Value<int?> durationInSeconds = const i0.Value.absent(), i0.Value<int?> durationInSeconds = const i0.Value.absent(),
required String id, required String id,
required String albumId, required String albumId,
i0.Value<String?> volume = const i0.Value.absent(),
i0.Value<String?> checksum = const i0.Value.absent(), i0.Value<String?> checksum = const i0.Value.absent(),
i0.Value<bool> isFavorite = const i0.Value.absent(), i0.Value<bool> isFavorite = const i0.Value.absent(),
i0.Value<int> orientation = const i0.Value.absent(), i0.Value<int> orientation = const i0.Value.absent(),
@ -349,7 +331,6 @@ class $$TrashedLocalAssetEntityTableTableManager
durationInSeconds: durationInSeconds, durationInSeconds: durationInSeconds,
id: id, id: id,
albumId: albumId, albumId: albumId,
volume: volume,
checksum: checksum, checksum: checksum,
isFavorite: isFavorite, isFavorite: isFavorite,
orientation: orientation, orientation: orientation,
@ -499,17 +480,6 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity
type: i0.DriftSqlType.string, type: i0.DriftSqlType.string,
requiredDuringInsert: true, requiredDuringInsert: true,
); );
static const i0.VerificationMeta _volumeMeta = const i0.VerificationMeta(
'volume',
);
@override
late final i0.GeneratedColumn<String> volume = i0.GeneratedColumn<String>(
'volume',
aliasedName,
true,
type: i0.DriftSqlType.string,
requiredDuringInsert: false,
);
static const i0.VerificationMeta _checksumMeta = const i0.VerificationMeta( static const i0.VerificationMeta _checksumMeta = const i0.VerificationMeta(
'checksum', 'checksum',
); );
@ -559,7 +529,6 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity
durationInSeconds, durationInSeconds,
id, id,
albumId, albumId,
volume,
checksum, checksum,
isFavorite, isFavorite,
orientation, orientation,
@ -630,12 +599,6 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity
} else if (isInserting) { } else if (isInserting) {
context.missing(_albumIdMeta); context.missing(_albumIdMeta);
} }
if (data.containsKey('volume')) {
context.handle(
_volumeMeta,
volume.isAcceptableOrUnknown(data['volume']!, _volumeMeta),
);
}
if (data.containsKey('checksum')) { if (data.containsKey('checksum')) {
context.handle( context.handle(
_checksumMeta, _checksumMeta,
@ -707,10 +670,6 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity
i0.DriftSqlType.string, i0.DriftSqlType.string,
data['${effectivePrefix}album_id'], data['${effectivePrefix}album_id'],
)!, )!,
volume: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}volume'],
),
checksum: attachedDatabase.typeMapping.read( checksum: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string, i0.DriftSqlType.string,
data['${effectivePrefix}checksum'], data['${effectivePrefix}checksum'],
@ -750,7 +709,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass
final int? durationInSeconds; final int? durationInSeconds;
final String id; final String id;
final String albumId; final String albumId;
final String? volume;
final String? checksum; final String? checksum;
final bool isFavorite; final bool isFavorite;
final int orientation; final int orientation;
@ -764,7 +722,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass
this.durationInSeconds, this.durationInSeconds,
required this.id, required this.id,
required this.albumId, required this.albumId,
this.volume,
this.checksum, this.checksum,
required this.isFavorite, required this.isFavorite,
required this.orientation, required this.orientation,
@ -791,9 +748,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass
} }
map['id'] = i0.Variable<String>(id); map['id'] = i0.Variable<String>(id);
map['album_id'] = i0.Variable<String>(albumId); map['album_id'] = i0.Variable<String>(albumId);
if (!nullToAbsent || volume != null) {
map['volume'] = i0.Variable<String>(volume);
}
if (!nullToAbsent || checksum != null) { if (!nullToAbsent || checksum != null) {
map['checksum'] = i0.Variable<String>(checksum); map['checksum'] = i0.Variable<String>(checksum);
} }
@ -819,7 +773,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass
durationInSeconds: serializer.fromJson<int?>(json['durationInSeconds']), durationInSeconds: serializer.fromJson<int?>(json['durationInSeconds']),
id: serializer.fromJson<String>(json['id']), id: serializer.fromJson<String>(json['id']),
albumId: serializer.fromJson<String>(json['albumId']), albumId: serializer.fromJson<String>(json['albumId']),
volume: serializer.fromJson<String?>(json['volume']),
checksum: serializer.fromJson<String?>(json['checksum']), checksum: serializer.fromJson<String?>(json['checksum']),
isFavorite: serializer.fromJson<bool>(json['isFavorite']), isFavorite: serializer.fromJson<bool>(json['isFavorite']),
orientation: serializer.fromJson<int>(json['orientation']), orientation: serializer.fromJson<int>(json['orientation']),
@ -840,7 +793,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass
'durationInSeconds': serializer.toJson<int?>(durationInSeconds), 'durationInSeconds': serializer.toJson<int?>(durationInSeconds),
'id': serializer.toJson<String>(id), 'id': serializer.toJson<String>(id),
'albumId': serializer.toJson<String>(albumId), 'albumId': serializer.toJson<String>(albumId),
'volume': serializer.toJson<String?>(volume),
'checksum': serializer.toJson<String?>(checksum), 'checksum': serializer.toJson<String?>(checksum),
'isFavorite': serializer.toJson<bool>(isFavorite), 'isFavorite': serializer.toJson<bool>(isFavorite),
'orientation': serializer.toJson<int>(orientation), 'orientation': serializer.toJson<int>(orientation),
@ -857,7 +809,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass
i0.Value<int?> durationInSeconds = const i0.Value.absent(), i0.Value<int?> durationInSeconds = const i0.Value.absent(),
String? id, String? id,
String? albumId, String? albumId,
i0.Value<String?> volume = const i0.Value.absent(),
i0.Value<String?> checksum = const i0.Value.absent(), i0.Value<String?> checksum = const i0.Value.absent(),
bool? isFavorite, bool? isFavorite,
int? orientation, int? orientation,
@ -873,7 +824,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass
: this.durationInSeconds, : this.durationInSeconds,
id: id ?? this.id, id: id ?? this.id,
albumId: albumId ?? this.albumId, albumId: albumId ?? this.albumId,
volume: volume.present ? volume.value : this.volume,
checksum: checksum.present ? checksum.value : this.checksum, checksum: checksum.present ? checksum.value : this.checksum,
isFavorite: isFavorite ?? this.isFavorite, isFavorite: isFavorite ?? this.isFavorite,
orientation: orientation ?? this.orientation, orientation: orientation ?? this.orientation,
@ -893,7 +843,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass
: this.durationInSeconds, : this.durationInSeconds,
id: data.id.present ? data.id.value : this.id, id: data.id.present ? data.id.value : this.id,
albumId: data.albumId.present ? data.albumId.value : this.albumId, 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, checksum: data.checksum.present ? data.checksum.value : this.checksum,
isFavorite: data.isFavorite.present isFavorite: data.isFavorite.present
? data.isFavorite.value ? data.isFavorite.value
@ -916,7 +865,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass
..write('durationInSeconds: $durationInSeconds, ') ..write('durationInSeconds: $durationInSeconds, ')
..write('id: $id, ') ..write('id: $id, ')
..write('albumId: $albumId, ') ..write('albumId: $albumId, ')
..write('volume: $volume, ')
..write('checksum: $checksum, ') ..write('checksum: $checksum, ')
..write('isFavorite: $isFavorite, ') ..write('isFavorite: $isFavorite, ')
..write('orientation: $orientation') ..write('orientation: $orientation')
@ -935,7 +883,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass
durationInSeconds, durationInSeconds,
id, id,
albumId, albumId,
volume,
checksum, checksum,
isFavorite, isFavorite,
orientation, orientation,
@ -953,7 +900,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass
other.durationInSeconds == this.durationInSeconds && other.durationInSeconds == this.durationInSeconds &&
other.id == this.id && other.id == this.id &&
other.albumId == this.albumId && other.albumId == this.albumId &&
other.volume == this.volume &&
other.checksum == this.checksum && other.checksum == this.checksum &&
other.isFavorite == this.isFavorite && other.isFavorite == this.isFavorite &&
other.orientation == this.orientation); other.orientation == this.orientation);
@ -970,7 +916,6 @@ class TrashedLocalAssetEntityCompanion
final i0.Value<int?> durationInSeconds; final i0.Value<int?> durationInSeconds;
final i0.Value<String> id; final i0.Value<String> id;
final i0.Value<String> albumId; final i0.Value<String> albumId;
final i0.Value<String?> volume;
final i0.Value<String?> checksum; final i0.Value<String?> checksum;
final i0.Value<bool> isFavorite; final i0.Value<bool> isFavorite;
final i0.Value<int> orientation; final i0.Value<int> orientation;
@ -984,7 +929,6 @@ class TrashedLocalAssetEntityCompanion
this.durationInSeconds = const i0.Value.absent(), this.durationInSeconds = const i0.Value.absent(),
this.id = const i0.Value.absent(), this.id = const i0.Value.absent(),
this.albumId = const i0.Value.absent(), this.albumId = const i0.Value.absent(),
this.volume = const i0.Value.absent(),
this.checksum = const i0.Value.absent(), this.checksum = const i0.Value.absent(),
this.isFavorite = const i0.Value.absent(), this.isFavorite = const i0.Value.absent(),
this.orientation = const i0.Value.absent(), this.orientation = const i0.Value.absent(),
@ -999,7 +943,6 @@ class TrashedLocalAssetEntityCompanion
this.durationInSeconds = const i0.Value.absent(), this.durationInSeconds = const i0.Value.absent(),
required String id, required String id,
required String albumId, required String albumId,
this.volume = const i0.Value.absent(),
this.checksum = const i0.Value.absent(), this.checksum = const i0.Value.absent(),
this.isFavorite = const i0.Value.absent(), this.isFavorite = const i0.Value.absent(),
this.orientation = const i0.Value.absent(), this.orientation = const i0.Value.absent(),
@ -1017,7 +960,6 @@ class TrashedLocalAssetEntityCompanion
i0.Expression<int>? durationInSeconds, i0.Expression<int>? durationInSeconds,
i0.Expression<String>? id, i0.Expression<String>? id,
i0.Expression<String>? albumId, i0.Expression<String>? albumId,
i0.Expression<String>? volume,
i0.Expression<String>? checksum, i0.Expression<String>? checksum,
i0.Expression<bool>? isFavorite, i0.Expression<bool>? isFavorite,
i0.Expression<int>? orientation, i0.Expression<int>? orientation,
@ -1032,7 +974,6 @@ class TrashedLocalAssetEntityCompanion
if (durationInSeconds != null) 'duration_in_seconds': durationInSeconds, if (durationInSeconds != null) 'duration_in_seconds': durationInSeconds,
if (id != null) 'id': id, if (id != null) 'id': id,
if (albumId != null) 'album_id': albumId, if (albumId != null) 'album_id': albumId,
if (volume != null) 'volume': volume,
if (checksum != null) 'checksum': checksum, if (checksum != null) 'checksum': checksum,
if (isFavorite != null) 'is_favorite': isFavorite, if (isFavorite != null) 'is_favorite': isFavorite,
if (orientation != null) 'orientation': orientation, if (orientation != null) 'orientation': orientation,
@ -1049,7 +990,6 @@ class TrashedLocalAssetEntityCompanion
i0.Value<int?>? durationInSeconds, i0.Value<int?>? durationInSeconds,
i0.Value<String>? id, i0.Value<String>? id,
i0.Value<String>? albumId, i0.Value<String>? albumId,
i0.Value<String?>? volume,
i0.Value<String?>? checksum, i0.Value<String?>? checksum,
i0.Value<bool>? isFavorite, i0.Value<bool>? isFavorite,
i0.Value<int>? orientation, i0.Value<int>? orientation,
@ -1064,7 +1004,6 @@ class TrashedLocalAssetEntityCompanion
durationInSeconds: durationInSeconds ?? this.durationInSeconds, durationInSeconds: durationInSeconds ?? this.durationInSeconds,
id: id ?? this.id, id: id ?? this.id,
albumId: albumId ?? this.albumId, albumId: albumId ?? this.albumId,
volume: volume ?? this.volume,
checksum: checksum ?? this.checksum, checksum: checksum ?? this.checksum,
isFavorite: isFavorite ?? this.isFavorite, isFavorite: isFavorite ?? this.isFavorite,
orientation: orientation ?? this.orientation, orientation: orientation ?? this.orientation,
@ -1103,9 +1042,6 @@ class TrashedLocalAssetEntityCompanion
if (albumId.present) { if (albumId.present) {
map['album_id'] = i0.Variable<String>(albumId.value); map['album_id'] = i0.Variable<String>(albumId.value);
} }
if (volume.present) {
map['volume'] = i0.Variable<String>(volume.value);
}
if (checksum.present) { if (checksum.present) {
map['checksum'] = i0.Variable<String>(checksum.value); map['checksum'] = i0.Variable<String>(checksum.value);
} }
@ -1130,7 +1066,6 @@ class TrashedLocalAssetEntityCompanion
..write('durationInSeconds: $durationInSeconds, ') ..write('durationInSeconds: $durationInSeconds, ')
..write('id: $id, ') ..write('id: $id, ')
..write('albumId: $albumId, ') ..write('albumId: $albumId, ')
..write('volume: $volume, ')
..write('checksum: $checksum, ') ..write('checksum: $checksum, ')
..write('isFavorite: $isFavorite, ') ..write('isFavorite: $isFavorite, ')
..write('orientation: $orientation') ..write('orientation: $orientation')

View file

@ -100,26 +100,27 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
return query.map((localAlbum) => localAlbum.toDto()).get(); return query.map((localAlbum) => localAlbum.toDto()).get();
} }
Future<Map<AlbumId, List<LocalAsset>>> getBackupSelectedAssetsByAlbum(Iterable<String> checksums) async { Future<Map<AlbumId, List<LocalAsset>>> getAssetsFromBackupAlbums(Iterable<String> checksums) async {
if (checksums.isEmpty) { if (checksums.isEmpty) {
return {}; return {};
} }
final lAlbumAsset = _db.localAlbumAssetEntity;
final lAlbum = _db.localAlbumEntity;
final lAsset = _db.localAssetEntity;
final result = <String, List<LocalAsset>>{}; final result = <String, List<LocalAsset>>{};
for (final slice in checksums.toSet().slices(800)) { for (final slice in checksums.toSet().slices(32000)) {
final rows = await (_db.select(lAlbumAsset).join([ final rows =
innerJoin(lAlbum, lAlbumAsset.albumId.equalsExp(lAlbum.id)), await (_db.select(_db.localAlbumAssetEntity).join([
innerJoin(lAsset, lAlbumAsset.assetId.equalsExp(lAsset.id)), innerJoin(_db.localAlbumEntity, _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id)),
])..where(lAlbum.backupSelection.equalsValue(BackupSelection.selected) & lAsset.checksum.isIn(slice))).get(); 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) { for (final row in rows) {
final albumId = row.readTable(lAlbumAsset).albumId; final albumId = row.readTable(_db.localAlbumAssetEntity).albumId;
final assetData = row.readTable(lAsset); final assetData = row.readTable(_db.localAssetEntity);
final asset = assetData.toDto(); final asset = assetData.toDto();
(result[albumId] ??= <LocalAsset>[]).add(asset); (result[albumId] ??= <LocalAsset>[]).add(asset);
} }

View file

@ -14,41 +14,45 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
const DriftTrashedLocalAssetRepository(this._db) : super(_db); const DriftTrashedLocalAssetRepository(this._db) : super(_db);
Future<void> updateChecksums(Iterable<TrashedAsset> assets) { Future<void> updateHashes(Map<String, String> hashes) {
if (assets.isEmpty) { if (hashes.isEmpty) {
return Future.value(); return Future.value();
} }
final now = DateTime.now(); final now = DateTime.now();
return _db.batch((batch) async { return _db.batch((batch) async {
for (final asset in assets) { for (final entry in hashes.entries) {
batch.update( batch.update(
_db.trashedLocalAssetEntity, _db.trashedLocalAssetEntity,
TrashedLocalAssetEntityCompanion(checksum: Value(asset.checksum), updatedAt: Value(now)), TrashedLocalAssetEntityCompanion(checksum: Value(entry.value), updatedAt: Value(now)),
where: (e) => e.id.equals(asset.id), where: (e) => e.id.equals(entry.key),
); );
} }
}); });
} }
Future<Iterable<TrashedAsset>> getToHash(Iterable<String> albumIds) { Future<Iterable<TrashedAsset>> getAssetsToHash(Iterable<String> albumIds) {
final query = _db.trashedLocalAssetEntity.select()..where((r) => r.albumId.isIn(albumIds) & r.checksum.isNull()); final query = _db.trashedLocalAssetEntity.select()..where((r) => r.albumId.isIn(albumIds) & r.checksum.isNull());
return query.map((row) => row.toDto()).get(); return query.map((row) => row.toDto()).get();
} }
Future<Iterable<TrashedAsset>> getToRestore() async { Future<Iterable<TrashedAsset>> getToRestore() async {
final trashed = _db.trashedLocalAssetEntity; final selectedAlbumIds = (_db.selectOnly(_db.localAlbumEntity)
final remote = _db.remoteAssetEntity; ..addColumns([_db.localAlbumEntity.id])
final album = _db.localAlbumEntity; ..where(_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected)));
final selectedAlbumIds = (_db.selectOnly(album) final rows =
..addColumns([album.id]) await (_db.select(_db.trashedLocalAssetEntity).join([
..where(album.backupSelection.equalsValue(BackupSelection.selected))); 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([ return rows.map((result) => result.readTable(_db.trashedLocalAssetEntity).toDto());
innerJoin(remote, remote.checksum.equalsExp(trashed.checksum)),
])..where(trashed.albumId.isInQuery(selectedAlbumIds) & remote.deletedAt.isNull())).get();
return rows.map((result) => result.readTable(trashed).toDto());
} }
/// Applies resulted snapshot of trashed assets: /// Applies resulted snapshot of trashed assets:
@ -61,41 +65,46 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
} }
return _db.transaction(() async { 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( batch.insert<$TrashedLocalAssetEntityTable, TrashedLocalAssetEntityData>(
(a) => TrashedLocalAssetEntityCompanion.insert( _db.trashedLocalAssetEntity,
id: a.id, companion,
albumId: albumId, onConflict: DoUpdate((_) => companion, where: (old) => old.updatedAt.isNotValue(asset.updatedAt)),
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);
});
}
final keepIds = assets.map((asset) => asset.id); 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 { } else {
final existingIds = await (_db.selectOnly(table)..addColumns([table.id])).map((r) => r.read(table.id)!).get(); final keepIdsSet = keepIds.toSet();
final toDelete = existingIds.where((id) => !keepIds.contains(id)); final existingIds = await (_db.selectOnly(
for (final slice in toDelete.slices(400)) { _db.trashedLocalAssetEntity,
await (_db.delete(table)..where((row) => row.id.isIn(slice))).go(); )..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) { if (trashUpdates.isEmpty) {
return; 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((batch) {
await _db.batch((b) { for (final asset in trashUpdates) {
b.insertAllOnConflictUpdate(_db.trashedLocalAssetEntity, slice); 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<int> watchCount() { Stream<int> watchCount() {
final t = _db.trashedLocalAssetEntity; return (_db.selectOnly(_db.trashedLocalAssetEntity)..addColumns([_db.trashedLocalAssetEntity.id.count()]))
return (_db.selectOnly(t)..addColumns([t.id.count()])).watchSingle().map((row) => row.read<int>(t.id.count()) ?? 0); .watchSingle()
.map((row) => row.read<int>(_db.trashedLocalAssetEntity.id.count()) ?? 0);
} }
Stream<int> watchHashedCount() { Stream<int> watchHashedCount() {
final t = _db.trashedLocalAssetEntity; return (_db.selectOnly(_db.trashedLocalAssetEntity)
return (_db.selectOnly(t) ..addColumns([_db.trashedLocalAssetEntity.id.count()])
..addColumns([t.id.count()]) ..where(_db.trashedLocalAssetEntity.checksum.isNotNull()))
..where(t.checksum.isNotNull()))
.watchSingle() .watchSingle()
.map((row) => row.read<int>(t.id.count()) ?? 0); .map((row) => row.read<int>(_db.trashedLocalAssetEntity.id.count()) ?? 0);
} }
Future<void> trashLocalAsset(Map<AlbumId, List<LocalAsset>> assetsByAlbums) async { Future<void> trashLocalAsset(Map<AlbumId, List<LocalAsset>> assetsByAlbums) async {
@ -150,14 +160,14 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
final companions = <TrashedLocalAssetEntityCompanion>[]; final companions = <TrashedLocalAssetEntityCompanion>[];
final idToDelete = <String>{}; final idToDelete = <String>{};
assetsByAlbums.forEach((albumId, assets) { for (final entry in assetsByAlbums.entries) {
for (final asset in assets) { for (final asset in entry.value) {
idToDelete.add(asset.id); idToDelete.add(asset.id);
companions.add( companions.add(
TrashedLocalAssetEntityCompanion( TrashedLocalAssetEntityCompanion(
id: Value(asset.id), id: Value(asset.id),
name: Value(asset.name), name: Value(asset.name),
albumId: Value(albumId), albumId: Value(entry.key),
checksum: asset.checksum == null ? const Value.absent() : Value(asset.checksum), checksum: asset.checksum == null ? const Value.absent() : Value(asset.checksum),
type: Value(asset.type), type: Value(asset.type),
width: Value(asset.width), width: Value(asset.width),
@ -168,17 +178,18 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
), ),
); );
} }
}); }
await _db.transaction(() async { 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); batch.insertAllOnConflictUpdate(_db.trashedLocalAssetEntity, slice);
}); }
}
for (final slice in idToDelete.slices(800)) { for (final id in idToDelete) {
await (_db.delete(_db.localAssetEntity)..where((e) => e.id.isIn(slice))).go(); batch.deleteWhere(_db.localAssetEntity, (row) => row.id.equals(id));
} }
});
}); });
} }
@ -212,8 +223,8 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
await _db.transaction(() async { await _db.transaction(() async {
await _db.batch((batch) { await _db.batch((batch) {
batch.insertAllOnConflictUpdate(_db.localAssetEntity, localAssets); batch.insertAllOnConflictUpdate(_db.localAssetEntity, localAssets);
for (final slice in ids.slices(32000)) { for (final id in ids) {
batch.deleteWhere(_db.trashedLocalAssetEntity, (tbl) => tbl.id.isIn(slice)); batch.deleteWhere(_db.trashedLocalAssetEntity, (row) => row.id.equals(id));
} }
}); });
}); });

View file

@ -41,8 +41,6 @@ class PlatformAsset {
required this.durationInSeconds, required this.durationInSeconds,
required this.orientation, required this.orientation,
required this.isFavorite, required this.isFavorite,
this.isTrashed,
this.volume,
}); });
String id; String id;
@ -65,25 +63,8 @@ class PlatformAsset {
bool isFavorite; bool isFavorite;
bool? isTrashed;
String? volume;
List<Object?> _toList() { List<Object?> _toList() {
return <Object?>[ return <Object?>[id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite];
id,
name,
type,
createdAt,
updatedAt,
width,
height,
durationInSeconds,
orientation,
isFavorite,
isTrashed,
volume,
];
} }
Object encode() { Object encode() {
@ -103,8 +84,6 @@ class PlatformAsset {
durationInSeconds: result[7]! as int, durationInSeconds: result[7]! as int,
orientation: result[8]! as int, orientation: result[8]! as int,
isFavorite: result[9]! as bool, 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()); int get hashCode => Object.hashAll(_toList());
} }
class TrashedAssetParams {
TrashedAssetParams({required this.id, required this.type, this.albumId});
String id;
int type;
String? albumId;
List<Object?> _toList() {
return <Object?>[id, type, albumId];
}
Object encode() {
return _toList();
}
static TrashedAssetParams decode(Object result) {
result as List<Object?>;
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 { class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec(); const _PigeonCodec();
@override @override
@ -323,9 +263,6 @@ class _PigeonCodec extends StandardMessageCodec {
} else if (value is HashResult) { } else if (value is HashResult) {
buffer.putUint8(132); buffer.putUint8(132);
writeValue(buffer, value.encode()); writeValue(buffer, value.encode());
} else if (value is TrashedAssetParams) {
buffer.putUint8(133);
writeValue(buffer, value.encode());
} else { } else {
super.writeValue(buffer, value); super.writeValue(buffer, value);
} }
@ -342,8 +279,6 @@ class _PigeonCodec extends StandardMessageCodec {
return SyncDelta.decode(readValue(buffer)!); return SyncDelta.decode(readValue(buffer)!);
case 132: case 132:
return HashResult.decode(readValue(buffer)!); return HashResult.decode(readValue(buffer)!);
case 133:
return TrashedAssetParams.decode(readValue(buffer)!);
default: default:
return super.readValueOfType(type, buffer); return super.readValueOfType(type, buffer);
} }
@ -655,32 +590,4 @@ class NativeSyncApi {
return (pigeonVar_replyList[0] as List<Object?>?)!.cast<PlatformAsset>(); return (pigeonVar_replyList[0] as List<Object?>?)!.cast<PlatformAsset>();
} }
} }
Future<List<HashResult>> hashTrashedAssets(List<TrashedAssetParams> trashedAssets) async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashTrashedAssets$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[trashedAssets]);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else 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<Object?>?)!.cast<HashResult>();
}
}
} }

View file

@ -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/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.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/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
final syncStreamServiceProvider = Provider( final syncStreamServiceProvider = Provider(
(ref) => SyncStreamService( (ref) => SyncStreamService(
@ -28,7 +29,8 @@ final syncStreamRepositoryProvider = Provider((ref) => SyncStreamRepository(ref.
final localSyncServiceProvider = Provider( final localSyncServiceProvider = Provider(
(ref) => LocalSyncService( (ref) => LocalSyncService(
localAlbumRepository: ref.watch(localAlbumRepository), localAlbumRepository: ref.watch(localAlbumRepository),
trashSyncService: ref.watch(trashSyncServiceProvider), trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
localFilesManager: ref.watch(localFilesManagerRepositoryProvider),
nativeSyncApi: ref.watch(nativeSyncApiProvider), nativeSyncApi: ref.watch(nativeSyncApiProvider),
), ),
); );
@ -38,6 +40,6 @@ final hashServiceProvider = Provider(
localAlbumRepository: ref.watch(localAlbumRepository), localAlbumRepository: ref.watch(localAlbumRepository),
localAssetRepository: ref.watch(localAssetRepository), localAssetRepository: ref.watch(localAssetRepository),
nativeSyncApi: ref.watch(nativeSyncApiProvider), nativeSyncApi: ref.watch(nativeSyncApiProvider),
trashSyncService: ref.watch(trashSyncServiceProvider), trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
), ),
); );

View file

@ -2,21 +2,16 @@ import 'package:async/async.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/trash_sync.service.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/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'asset.provider.dart';
typedef TrashedAssetsCount = ({int total, int hashed}); typedef TrashedAssetsCount = ({int total, int hashed});
final trashSyncServiceProvider = Provider( final trashSyncServiceProvider = Provider(
(ref) => TrashSyncService( (ref) => TrashSyncService(
appSettingsService: ref.watch(appSettingsServiceProvider), appSettingsService: ref.watch(appSettingsServiceProvider),
nativeSyncApi: ref.watch(nativeSyncApiProvider),
localAssetRepository: ref.watch(localAssetRepository), localAssetRepository: ref.watch(localAssetRepository),
localAlbumRepository: ref.watch(localAlbumRepository),
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository), trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
localFilesManager: ref.watch(localFilesManagerRepositoryProvider), localFilesManager: ref.watch(localFilesManagerRepositoryProvider),
storageRepository: ref.watch(storageRepositoryProvider), storageRepository: ref.watch(storageRepositoryProvider),

View file

@ -353,7 +353,7 @@ class _SyncStatsCounts extends ConsumerWidget {
], ],
), ),
), ),
if (trashSyncService.isAutoSyncMode) ...[ if (trashSyncService.isTrashSyncMode) ...[
_SectionHeaderText(text: "trash".t(context: context)), _SectionHeaderText(text: "trash".t(context: context)),
Consumer( Consumer(
builder: (context, ref, _) { builder: (context, ref, _) {

View file

@ -14,8 +14,10 @@ import 'package:pigeon/pigeon.dart';
class PlatformAsset { class PlatformAsset {
final String id; final String id;
final String name; final String name;
// Follows AssetType enum from base_asset.model.dart // Follows AssetType enum from base_asset.model.dart
final int type; final int type;
// Seconds since epoch // Seconds since epoch
final int? createdAt; final int? createdAt;
final int? updatedAt; final int? updatedAt;
@ -24,8 +26,6 @@ class PlatformAsset {
final int durationInSeconds; final int durationInSeconds;
final int orientation; final int orientation;
final bool isFavorite; final bool isFavorite;
final bool? isTrashed;
final String? volume;
const PlatformAsset({ const PlatformAsset({
required this.id, required this.id,
@ -38,14 +38,13 @@ class PlatformAsset {
this.durationInSeconds = 0, this.durationInSeconds = 0,
this.orientation = 0, this.orientation = 0,
this.isFavorite = false, this.isFavorite = false,
this.isTrashed,
this.volume,
}); });
} }
class PlatformAlbum { class PlatformAlbum {
final String id; final String id;
final String name; final String name;
// Seconds since epoch // Seconds since epoch
final int? updatedAt; final int? updatedAt;
final bool isCloud; final bool isCloud;
@ -64,6 +63,7 @@ class SyncDelta {
final bool hasChanges; final bool hasChanges;
final List<PlatformAsset> updates; final List<PlatformAsset> updates;
final List<String> deletes; final List<String> deletes;
// Asset -> Album mapping // Asset -> Album mapping
final Map<String, List<String>> assetAlbums; final Map<String, List<String>> assetAlbums;
@ -83,18 +83,6 @@ class HashResult {
const HashResult({required this.assetId, this.error, this.hash}); 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() @HostApi()
abstract class NativeSyncApi { abstract class NativeSyncApi {
bool shouldFullSync(); bool shouldFullSync();
@ -126,8 +114,4 @@ abstract class NativeSyncApi {
@TaskQueue(type: TaskQueueType.serialBackgroundThread) @TaskQueue(type: TaskQueueType.serialBackgroundThread)
List<PlatformAsset> getTrashedAssetsForAlbum(String albumId); List<PlatformAsset> getTrashedAssetsForAlbum(String albumId);
@async
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
List<HashResult> hashTrashedAssets(List<TrashedAssetParams> trashedAssets);
} }

View file

@ -14,19 +14,19 @@ void main() {
late MockLocalAlbumRepository mockAlbumRepo; late MockLocalAlbumRepository mockAlbumRepo;
late MockLocalAssetRepository mockAssetRepo; late MockLocalAssetRepository mockAssetRepo;
late MockNativeSyncApi mockNativeApi; late MockNativeSyncApi mockNativeApi;
late MockTrashSyncService mockTrashSyncService; late MockTrashedLocalAssetRepository mockTrashedAssetRepo;
setUp(() { setUp(() {
mockAlbumRepo = MockLocalAlbumRepository(); mockAlbumRepo = MockLocalAlbumRepository();
mockAssetRepo = MockLocalAssetRepository(); mockAssetRepo = MockLocalAssetRepository();
mockNativeApi = MockNativeSyncApi(); mockNativeApi = MockNativeSyncApi();
mockTrashSyncService = MockTrashSyncService(); mockTrashedAssetRepo = MockTrashedLocalAssetRepository();
sut = HashService( sut = HashService(
localAlbumRepository: mockAlbumRepo, localAlbumRepository: mockAlbumRepo,
localAssetRepository: mockAssetRepo, localAssetRepository: mockAssetRepo,
nativeSyncApi: mockNativeApi, nativeSyncApi: mockNativeApi,
trashSyncService: mockTrashSyncService, trashedLocalAssetRepository: mockTrashedAssetRepo,
); );
registerFallbackValue(LocalAlbumStub.recent); registerFallbackValue(LocalAlbumStub.recent);
@ -34,7 +34,6 @@ void main() {
registerFallbackValue(<String, String>{}); registerFallbackValue(<String, String>{});
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {}); when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
when(() => mockTrashSyncService.isAutoSyncMode).thenReturn(false);
}); });
group('HashService hashAssets', () { group('HashService hashAssets', () {
@ -118,7 +117,7 @@ void main() {
localAssetRepository: mockAssetRepo, localAssetRepository: mockAssetRepo,
nativeSyncApi: mockNativeApi, nativeSyncApi: mockNativeApi,
batchSize: batchSize, batchSize: batchSize,
trashSyncService: mockTrashSyncService, trashedLocalAssetRepository: mockTrashedAssetRepo,
); );
final album = LocalAlbumStub.recent; final album = LocalAlbumStub.recent;
@ -191,4 +190,37 @@ void main() {
verify(() => mockNativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1); 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()));
});
});
} }

View file

@ -1,4 +1,5 @@
import 'package:immich_mobile/domain/models/asset/base_asset.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/models/exif.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart' as old; import 'package:immich_mobile/entities/asset.entity.dart' as old;
@ -74,3 +75,16 @@ abstract final class LocalAssetStub {
updatedAt: DateTime(20021), 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",
);
}

View file

@ -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/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.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/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.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
import 'package:immich_mobile/repositories/drift_album_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 MockLocalAssetRepository extends Mock implements DriftLocalAssetRepository {}
class MockTrashedLocalAssetRepository extends Mock implements DriftTrashedLocalAssetRepository {}
class MockStorageRepository extends Mock implements StorageRepository {} class MockStorageRepository extends Mock implements StorageRepository {}
// API Repos // API Repos