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 durationInSeconds: Long,
val orientation: Long,
val isFavorite: Boolean,
val isTrashed: Boolean? = null,
val volume: String? = null
val isFavorite: Boolean
)
{
companion object {
@ -106,9 +104,7 @@ data class PlatformAsset (
val durationInSeconds = pigeonVar_list[7] as Long
val orientation = pigeonVar_list[8] as Long
val isFavorite = pigeonVar_list[9] as Boolean
val isTrashed = pigeonVar_list[10] as Boolean?
val volume = pigeonVar_list[11] as String?
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite, isTrashed, volume)
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite)
}
}
fun toList(): List<Any?> {
@ -123,8 +119,6 @@ data class PlatformAsset (
durationInSeconds,
orientation,
isFavorite,
isTrashed,
volume,
)
}
override fun equals(other: Any?): Boolean {
@ -249,40 +243,6 @@ data class HashResult (
override fun hashCode(): Int = toList().hashCode()
}
/** Generated class from Pigeon that represents data sent in messages. */
data class TrashedAssetParams (
val id: String,
val type: Long,
val albumId: String? = null
)
{
companion object {
fun fromList(pigeonVar_list: List<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() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
@ -306,11 +266,6 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
HashResult.fromList(it)
}
}
133.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
TrashedAssetParams.fromList(it)
}
}
else -> super.readValueOfType(type, buffer)
}
}
@ -332,10 +287,6 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
stream.write(132)
writeValue(stream, value.toList())
}
is TrashedAssetParams -> {
stream.write(133)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)
}
}
@ -355,7 +306,6 @@ interface NativeSyncApi {
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
fun cancelHashing()
fun getTrashedAssetsForAlbum(albumId: String): List<PlatformAsset>
fun hashTrashedAssets(trashedAssets: List<TrashedAssetParams>, callback: (Result<List<HashResult>>) -> Unit)
companion object {
/** The codec used by NativeSyncApi. */
@ -553,26 +503,6 @@ interface NativeSyncApi {
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> {
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
}
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
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
add(MediaStore.MediaColumns.IS_FAVORITE)
add(MediaStore.MediaColumns.IS_TRASHED)
add(MediaStore.MediaColumns.VOLUME_NAME)
}
}.toTypedArray()
@ -111,8 +109,6 @@ open class NativeSyncApiImplBase(context: Context) {
val orientationColumn =
c.getColumnIndexOrThrow(MediaStore.MediaColumns.ORIENTATION)
val favoriteColumn = c.getColumnIndex(MediaStore.MediaColumns.IS_FAVORITE)
val trashedColumn = c.getColumnIndex(MediaStore.MediaColumns.IS_TRASHED)
val volumeColumn = c.getColumnIndex(MediaStore.MediaColumns.VOLUME_NAME)
while (c.moveToNext()) {
val id = c.getLong(idColumn).toString()
@ -142,8 +138,6 @@ open class NativeSyncApiImplBase(context: Context) {
val bucketId = c.getString(bucketIdColumn)
val orientation = c.getInt(orientationColumn)
val isFavorite = if (favoriteColumn == -1) false else c.getInt(favoriteColumn) != 0
val isTrashed = if (trashedColumn == -1) null else c.getInt(trashedColumn) != 0
val volume = if (volumeColumn == -1) null else c.getString(volumeColumn)
val asset = PlatformAsset(
id,
name,
@ -155,8 +149,6 @@ open class NativeSyncApiImplBase(context: Context) {
duration,
orientation.toLong(),
isFavorite,
isTrashed,
volume,
)
yield(AssetResult.ValidAsset(asset, bucketId))
}

View file

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

View file

@ -140,8 +140,6 @@ struct PlatformAsset: Hashable {
var durationInSeconds: Int64
var orientation: Int64
var isFavorite: Bool
var isTrashed: Bool? = nil
var volume: String? = nil
// swift-format-ignore: AlwaysUseLowerCamelCase
@ -156,8 +154,6 @@ struct PlatformAsset: Hashable {
let durationInSeconds = pigeonVar_list[7] as! Int64
let orientation = pigeonVar_list[8] as! Int64
let isFavorite = pigeonVar_list[9] as! Bool
let isTrashed: Bool? = nilOrValue(pigeonVar_list[10])
let volume: String? = nilOrValue(pigeonVar_list[11])
return PlatformAsset(
id: id,
@ -169,9 +165,7 @@ struct PlatformAsset: Hashable {
height: height,
durationInSeconds: durationInSeconds,
orientation: orientation,
isFavorite: isFavorite,
isTrashed: isTrashed,
volume: volume
isFavorite: isFavorite
)
}
func toList() -> [Any?] {
@ -186,8 +180,6 @@ struct PlatformAsset: Hashable {
durationInSeconds,
orientation,
isFavorite,
isTrashed,
volume,
]
}
static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool {
@ -308,39 +300,6 @@ struct HashResult: Hashable {
}
}
/// Generated class from Pigeon that represents data sent in messages.
struct TrashedAssetParams: Hashable {
var id: String
var type: Int64
var albumId: String? = nil
// swift-format-ignore: AlwaysUseLowerCamelCase
static func fromList(_ pigeonVar_list: [Any?]) -> TrashedAssetParams? {
let id = pigeonVar_list[0] as! String
let type = pigeonVar_list[1] as! Int64
let albumId: String? = nilOrValue(pigeonVar_list[2])
return TrashedAssetParams(
id: id,
type: type,
albumId: albumId
)
}
func toList() -> [Any?] {
return [
id,
type,
albumId,
]
}
static func == (lhs: TrashedAssetParams, rhs: TrashedAssetParams) -> Bool {
return deepEqualsMessages(lhs.toList(), rhs.toList()) }
func hash(into hasher: inout Hasher) {
deepHashMessages(value: toList(), hasher: &hasher)
}
}
private class MessagesPigeonCodecReader: FlutterStandardReader {
override func readValue(ofType type: UInt8) -> Any? {
switch type {
@ -352,8 +311,6 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
return SyncDelta.fromList(self.readValue() as! [Any?])
case 132:
return HashResult.fromList(self.readValue() as! [Any?])
case 133:
return TrashedAssetParams.fromList(self.readValue() as! [Any?])
default:
return super.readValue(ofType: type)
}
@ -374,9 +331,6 @@ private class MessagesPigeonCodecWriter: FlutterStandardWriter {
} else if let value = value as? HashResult {
super.writeByte(132)
super.writeValue(value.toList())
} else if let value = value as? TrashedAssetParams {
super.writeByte(133)
super.writeValue(value.toList())
} else {
super.writeValue(value)
}
@ -411,7 +365,6 @@ protocol NativeSyncApi {
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
func cancelHashing() throws
func getTrashedAssetsForAlbum(albumId: String) throws -> [PlatformAsset]
func hashTrashedAssets(trashedAssets: [TrashedAssetParams], completion: @escaping (Result<[HashResult], Error>) -> Void)
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@ -599,24 +552,5 @@ class NativeSyncApiSetup {
} else {
getTrashedAssetsForAlbumChannel.setMessageHandler(nil)
}
let hashTrashedAssetsChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashTrashedAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashTrashedAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
hashTrashedAssetsChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let trashedAssetsArg = args[0] as! [TrashedAssetParams]
api.hashTrashedAssets(trashedAssets: trashedAssetsArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
hashTrashedAssetsChannel.setMessageHandler(nil)
}
}
}

View file

@ -112,7 +112,7 @@ class NativeSyncApiImpl: NativeSyncApi {
return albums.sorted { $0.id < $1.id }
}
func getMediaChanges() throws -> SyncDelta {
func getMediaChanges(isTrashed: Bool) throws -> SyncDelta {
guard #available(iOS 16, *) else {
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature requires iOS 16 or later.", details: nil)
}
@ -363,4 +363,9 @@ class NativeSyncApiImpl: NativeSyncApi {
PHAssetResourceManager.default().cancelDataRequest(requestId)
})
}
func getTrashedAssetsForAlbum(albumId: String) throws ->[PlatformAsset] {
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)
}
}

View file

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

View file

@ -2,10 +2,10 @@ import 'package:flutter/services.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset/trashed_asset.model.dart';
import 'package:immich_mobile/domain/services/trash_sync.service.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:logging/logging.dart';
@ -15,24 +15,24 @@ class HashService {
final int _batchSize;
final DriftLocalAlbumRepository _localAlbumRepository;
final DriftLocalAssetRepository _localAssetRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final NativeSyncApi _nativeSyncApi;
final TrashSyncService _trashSyncService;
final bool Function()? _cancelChecker;
final _log = Logger('HashService');
HashService({
required DriftLocalAlbumRepository localAlbumRepository,
required DriftLocalAssetRepository localAssetRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required NativeSyncApi nativeSyncApi,
required TrashSyncService trashSyncService,
bool Function()? cancelChecker,
int? batchSize,
}) : _localAlbumRepository = localAlbumRepository,
_localAssetRepository = localAssetRepository,
_trashedLocalAssetRepository = trashedLocalAssetRepository,
_cancelChecker = cancelChecker,
_nativeSyncApi = nativeSyncApi,
_batchSize = batchSize ?? kBatchHashFileLimit,
_trashSyncService = trashSyncService;
_batchSize = batchSize ?? kBatchHashFileLimit;
bool get isCancelled => _cancelChecker?.call() ?? false;
@ -54,6 +54,14 @@ class HashService {
await _hashAssets(album, assetsToHash);
}
}
if (CurrentPlatform.isAndroid && localAlbums.isNotEmpty) {
final backupAlbumIds = localAlbums.map((e) => e.id);
final trashedToHash = await _trashedLocalAssetRepository.getAssetsToHash(backupAlbumIds);
if (trashedToHash.isNotEmpty) {
final pseudoAlbum = LocalAlbum(id: '-pseudoAlbum', name: 'Trash', updatedAt: DateTime.now());
await _hashAssets(pseudoAlbum, trashedToHash.toList(), isTrashed: true);
}
}
} on PlatformException catch (e) {
if (e.code == _kHashCancelledCode) {
_log.warning("Hashing cancelled by platform");
@ -63,23 +71,6 @@ class HashService {
_log.severe("Error during hashing", e, s);
}
if (_trashSyncService.isAutoSyncMode) {
final backupAlbums = await _localAlbumRepository.getBackupAlbums();
if (backupAlbums.isNotEmpty) {
final backupAlbumIds = backupAlbums.map((e) => e.id);
final trashedToHash = await _trashSyncService.getAssetsToHash(backupAlbumIds);
if (trashedToHash.isNotEmpty) {
for (final album in backupAlbums) {
if (isCancelled) {
_log.warning("Hashing cancelled. Stopped processing albums.");
break;
}
await _hashTrashedAssets(album, trashedToHash.where((e) => e.albumId == album.id));
}
}
}
}
stopwatch.stop();
_log.info("Hashing took - ${stopwatch.elapsedMilliseconds}ms");
}
@ -87,7 +78,7 @@ class HashService {
/// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB
/// with hash for those that were successfully hashed. Hashes are looked up in a table
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
Future<void> _hashAssets(LocalAlbum album, List<LocalAsset> assetsToHash) async {
Future<void> _hashAssets(LocalAlbum album, List<LocalAsset> assetsToHash, {bool isTrashed = false}) async {
final toHash = <String, LocalAsset>{};
for (final asset in assetsToHash) {
@ -98,16 +89,16 @@ class HashService {
toHash[asset.id] = asset;
if (toHash.length == _batchSize) {
await _processBatch(album, toHash);
await _processBatch(album, toHash, isTrashed);
toHash.clear();
}
}
await _processBatch(album, toHash);
await _processBatch(album, toHash, isTrashed);
}
/// Processes a batch of assets.
Future<void> _processBatch(LocalAlbum album, Map<String, LocalAsset> toHash) async {
Future<void> _processBatch(LocalAlbum album, Map<String, LocalAsset> toHash, bool isTrashed) async {
if (toHash.isEmpty) {
return;
}
@ -142,64 +133,10 @@ class HashService {
}
_log.fine("Hashed ${hashed.length}/${toHash.length} assets");
await _localAssetRepository.updateHashes(hashed);
}
Future<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();
}
if (isTrashed) {
await _trashedLocalAssetRepository.updateHashes(hashed);
} else {
await _localAssetRepository.updateHashes(hashed);
}
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:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/trash_sync.service.dart';
import 'package:immich_mobile/domain/models/asset/trashed_asset.model.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/utils/datetime_helpers.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:logging/logging.dart';
@ -15,15 +17,18 @@ import 'package:logging/logging.dart';
class LocalSyncService {
final DriftLocalAlbumRepository _localAlbumRepository;
final NativeSyncApi _nativeSyncApi;
final TrashSyncService _trashSyncService;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final LocalFilesManagerRepository _localFilesManager;
final Logger _log = Logger("DeviceSyncService");
LocalSyncService({
required DriftLocalAlbumRepository localAlbumRepository,
required TrashSyncService trashSyncService,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required LocalFilesManagerRepository localFilesManager,
required NativeSyncApi nativeSyncApi,
}) : _localAlbumRepository = localAlbumRepository,
_trashSyncService = trashSyncService,
_trashedLocalAssetRepository = trashedLocalAssetRepository,
_localFilesManager = localFilesManager,
_nativeSyncApi = nativeSyncApi;
Future<void> sync({bool full = false}) async {
@ -34,6 +39,12 @@ class LocalSyncService {
return await fullSync();
}
if (CurrentPlatform.isAndroid) {
final delta = await _nativeSyncApi.getMediaChanges(isTrashed: true);
_log.fine("Delta updated in trash: ${delta.updates.length - delta.updates.length}");
await _applyTrashDelta(delta);
}
final delta = await _nativeSyncApi.getMediaChanges();
if (!delta.hasChanges) {
_log.fine("No media changes detected. Skipping sync");
@ -75,11 +86,6 @@ class LocalSyncService {
await updateAlbum(dbAlbum, album);
}
}
if (_trashSyncService.isAutoSyncMode) {
final delta = await _nativeSyncApi.getMediaChanges(isTrashed: true);
_log.fine("Delta updated in trash: ${delta.updates.length - delta.updates.length}");
await _trashSyncService.applyTrashDelta(delta);
}
await _nativeSyncApi.checkpointSync();
} catch (e, s) {
_log.severe("Error performing device sync", e, s);
@ -93,6 +99,10 @@ class LocalSyncService {
try {
final Stopwatch stopwatch = Stopwatch()..start();
if (CurrentPlatform.isAndroid) {
await _syncDeviceTrashSnapshot();
}
final deviceAlbums = await _nativeSyncApi.getAlbums();
final dbAlbums = await _localAlbumRepository.getAll(sortBy: {SortLocalAlbumsBy.id});
@ -104,9 +114,6 @@ class LocalSyncService {
onlyFirst: removeAlbum,
onlySecond: addAlbum,
);
if (_trashSyncService.isAutoSyncMode) {
await _trashSyncService.syncDeviceTrashSnapshot();
}
await _nativeSyncApi.checkpointSync();
stopwatch.stop();
@ -286,6 +293,53 @@ class LocalSyncService {
bool _albumsEqual(LocalAlbum a, LocalAlbum b) {
return a.name == b.name && a.assetCount == b.assetCount && a.updatedAt.isAtSameMomentAs(b.updatedAt);
}
Future<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> {
@ -320,3 +374,26 @@ extension on Iterable<PlatformAsset> {
).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());
case SyncEntityType.assetV1:
final remoteSyncAssets = data.cast<SyncAssetV1>();
if (_trashSyncService.isAutoSyncMode) {
if (_trashSyncService.isTrashSyncMode) {
await _trashSyncService.handleRemoteTrashed(
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/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/datetime_helpers.dart';
import 'package:logging/logging.dart';
typedef TrashSyncItem = ({String remoteId, String checksum, DateTime? deletedAt});
class TrashSyncService {
final AppSettingsService _appSettingsService;
final NativeSyncApi _nativeSyncApi;
final DriftLocalAssetRepository _localAssetRepository;
final DriftLocalAlbumRepository _localAlbumRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final LocalFilesManagerRepository _localFilesManager;
final StorageRepository _storageRepository;
@ -25,66 +16,25 @@ class TrashSyncService {
TrashSyncService({
required AppSettingsService appSettingsService,
required NativeSyncApi nativeSyncApi,
required DriftLocalAssetRepository localAssetRepository,
required DriftLocalAlbumRepository localAlbumRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required LocalFilesManagerRepository localFilesManager,
required StorageRepository storageRepository,
}) : _appSettingsService = appSettingsService,
_nativeSyncApi = nativeSyncApi,
_localAssetRepository = localAssetRepository,
_localAlbumRepository = localAlbumRepository,
_trashedLocalAssetRepository = trashedLocalAssetRepository,
_localFilesManager = localFilesManager,
_storageRepository = storageRepository;
bool get isAutoSyncMode =>
bool get isTrashSyncMode =>
CurrentPlatform.isAndroid && _appSettingsService.getSetting<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 {
if (checksums.isEmpty) {
return Future.value();
} else {
final localAssetsToTrash = await _localAssetRepository.getBackupSelectedAssetsByAlbum(checksums);
final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(checksums);
if (localAssetsToTrash.isNotEmpty) {
final mediaUrls = await Future.wait(
localAssetsToTrash.values
@ -101,42 +51,5 @@ class TrashSyncService {
}
}
}
Future<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 volume => text().nullable()();
TextColumn get checksum => text().nullable()();
BoolColumn get isFavorite => boolean().withDefault(const Constant(false))();
@ -28,7 +26,6 @@ extension TrashedLocalAssetEntityDataDomainExtension on TrashedLocalAssetEntityD
TrashedAsset toDto() => TrashedAsset(
id: id,
name: name,
volume: volume,
albumId: albumId,
checksum: checksum,
type: type,

View file

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

View file

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

View file

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

View file

@ -41,8 +41,6 @@ class PlatformAsset {
required this.durationInSeconds,
required this.orientation,
required this.isFavorite,
this.isTrashed,
this.volume,
});
String id;
@ -65,25 +63,8 @@ class PlatformAsset {
bool isFavorite;
bool? isTrashed;
String? volume;
List<Object?> _toList() {
return <Object?>[
id,
name,
type,
createdAt,
updatedAt,
width,
height,
durationInSeconds,
orientation,
isFavorite,
isTrashed,
volume,
];
return <Object?>[id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite];
}
Object encode() {
@ -103,8 +84,6 @@ class PlatformAsset {
durationInSeconds: result[7]! as int,
orientation: result[8]! as int,
isFavorite: result[9]! as bool,
isTrashed: result[10] as bool?,
volume: result[11] as String?,
);
}
@ -265,45 +244,6 @@ class HashResult {
int get hashCode => Object.hashAll(_toList());
}
class TrashedAssetParams {
TrashedAssetParams({required this.id, required this.type, this.albumId});
String id;
int type;
String? albumId;
List<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 {
const _PigeonCodec();
@override
@ -323,9 +263,6 @@ class _PigeonCodec extends StandardMessageCodec {
} else if (value is HashResult) {
buffer.putUint8(132);
writeValue(buffer, value.encode());
} else if (value is TrashedAssetParams) {
buffer.putUint8(133);
writeValue(buffer, value.encode());
} else {
super.writeValue(buffer, value);
}
@ -342,8 +279,6 @@ class _PigeonCodec extends StandardMessageCodec {
return SyncDelta.decode(readValue(buffer)!);
case 132:
return HashResult.decode(readValue(buffer)!);
case 133:
return TrashedAssetParams.decode(readValue(buffer)!);
default:
return super.readValueOfType(type, buffer);
}
@ -655,32 +590,4 @@ class NativeSyncApi {
return (pigeonVar_replyList[0] as List<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/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
final syncStreamServiceProvider = Provider(
(ref) => SyncStreamService(
@ -28,7 +29,8 @@ final syncStreamRepositoryProvider = Provider((ref) => SyncStreamRepository(ref.
final localSyncServiceProvider = Provider(
(ref) => LocalSyncService(
localAlbumRepository: ref.watch(localAlbumRepository),
trashSyncService: ref.watch(trashSyncServiceProvider),
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
localFilesManager: ref.watch(localFilesManagerRepositoryProvider),
nativeSyncApi: ref.watch(nativeSyncApiProvider),
),
);
@ -38,6 +40,6 @@ final hashServiceProvider = Provider(
localAlbumRepository: ref.watch(localAlbumRepository),
localAssetRepository: ref.watch(localAssetRepository),
nativeSyncApi: ref.watch(nativeSyncApiProvider),
trashSyncService: ref.watch(trashSyncServiceProvider),
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
),
);

View file

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

View file

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

View file

@ -14,8 +14,10 @@ import 'package:pigeon/pigeon.dart';
class PlatformAsset {
final String id;
final String name;
// Follows AssetType enum from base_asset.model.dart
final int type;
// Seconds since epoch
final int? createdAt;
final int? updatedAt;
@ -24,8 +26,6 @@ class PlatformAsset {
final int durationInSeconds;
final int orientation;
final bool isFavorite;
final bool? isTrashed;
final String? volume;
const PlatformAsset({
required this.id,
@ -38,14 +38,13 @@ class PlatformAsset {
this.durationInSeconds = 0,
this.orientation = 0,
this.isFavorite = false,
this.isTrashed,
this.volume,
});
}
class PlatformAlbum {
final String id;
final String name;
// Seconds since epoch
final int? updatedAt;
final bool isCloud;
@ -64,6 +63,7 @@ class SyncDelta {
final bool hasChanges;
final List<PlatformAsset> updates;
final List<String> deletes;
// Asset -> Album mapping
final Map<String, List<String>> assetAlbums;
@ -83,18 +83,6 @@ class HashResult {
const HashResult({required this.assetId, this.error, this.hash});
}
class TrashedAssetParams {
final String id;
final int type;
final String? albumId;
const TrashedAssetParams({
required this.id,
required this.type,
this.albumId,
});
}
@HostApi()
abstract class NativeSyncApi {
bool shouldFullSync();
@ -126,8 +114,4 @@ abstract class NativeSyncApi {
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
List<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 MockLocalAssetRepository mockAssetRepo;
late MockNativeSyncApi mockNativeApi;
late MockTrashSyncService mockTrashSyncService;
late MockTrashedLocalAssetRepository mockTrashedAssetRepo;
setUp(() {
mockAlbumRepo = MockLocalAlbumRepository();
mockAssetRepo = MockLocalAssetRepository();
mockNativeApi = MockNativeSyncApi();
mockTrashSyncService = MockTrashSyncService();
mockTrashedAssetRepo = MockTrashedLocalAssetRepository();
sut = HashService(
localAlbumRepository: mockAlbumRepo,
localAssetRepository: mockAssetRepo,
nativeSyncApi: mockNativeApi,
trashSyncService: mockTrashSyncService,
trashedLocalAssetRepository: mockTrashedAssetRepo,
);
registerFallbackValue(LocalAlbumStub.recent);
@ -34,7 +34,6 @@ void main() {
registerFallbackValue(<String, String>{});
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
when(() => mockTrashSyncService.isAutoSyncMode).thenReturn(false);
});
group('HashService hashAssets', () {
@ -118,7 +117,7 @@ void main() {
localAssetRepository: mockAssetRepo,
nativeSyncApi: mockNativeApi,
batchSize: batchSize,
trashSyncService: mockTrashSyncService,
trashedLocalAssetRepository: mockTrashedAssetRepo,
);
final album = LocalAlbumStub.recent;
@ -191,4 +190,37 @@ void main() {
verify(() => mockNativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
});
});
group('HashService hashAssets (trash sync mode)', () {
test('hashes trashed assets and writes to trashed repo', () async {
final album = LocalAlbumStub.recent;
final trashed1 = TrashedAssetStub.trashed1.copyWith(id: 't1');
when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mockTrashedAssetRepo.getAssetsToHash([album.id])).thenAnswer((_) async => [trashed1]);
when(
() => mockNativeApi.hashAssets([trashed1.id], allowNetworkAccess: false),
).thenAnswer((_) async => [HashResult(assetId: trashed1.id, hash: 't1-hash')]);
await sut.hashAssets();
verify(() => mockNativeApi.hashAssets([trashed1.id], allowNetworkAccess: false)).called(1);
verify(() => mockTrashedAssetRepo.updateHashes({'t1': 't1-hash'})).called(1);
verifyNever(() => mockAssetRepo.updateHashes(any()));
});
test('skips when trashed list is empty', () async {
final album = LocalAlbumStub.recent;
when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mockTrashedAssetRepo.getAssetsToHash([album.id])).thenAnswer((_) async => []);
await sut.hashAssets();
verifyNever(() => mockNativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
verifyNever(() => mockTrashedAssetRepo.updateHashes(any()));
verifyNever(() => mockAssetRepo.updateHashes(any()));
});
});
}

View file

@ -1,4 +1,5 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset/trashed_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart' as old;
@ -74,3 +75,16 @@ abstract final class LocalAssetStub {
updatedAt: DateTime(20021),
);
}
abstract final class TrashedAssetStub {
const TrashedAssetStub._();
static final trashed1 = TrashedAsset(
id: "t1",
name: "trashed1.jpg",
type: AssetType.image,
createdAt: DateTime(2025, 1, 1),
updatedAt: DateTime(2025, 1, 2),
albumId: "album1",
);
}

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/sync_api.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
@ -30,6 +31,8 @@ class MockRemoteAlbumRepository extends Mock implements DriftRemoteAlbumReposito
class MockLocalAssetRepository extends Mock implements DriftLocalAssetRepository {}
class MockTrashedLocalAssetRepository extends Mock implements DriftTrashedLocalAssetRepository {}
class MockStorageRepository extends Mock implements StorageRepository {}
// API Repos