From 55fe480cc1ad59c1df632e8755363aea2f72ef61 Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Fri, 19 Sep 2025 17:55:20 +0300 Subject: [PATCH] Include trashed items in getMediaChanges Process trashed items delta during incremental sync --- .../app/alextran/immich/sync/Messages.g.kt | 7 ++- .../alextran/immich/sync/MessagesImpl30.kt | 24 +++++---- .../alextran/immich/sync/MessagesImplBase.kt | 5 ++ mobile/ios/Runner/Sync/Messages.g.swift | 6 ++- .../domain/services/local_sync.service.dart | 13 ++--- .../domain/services/trash_sync.service.dart | 54 +++++++++++++------ .../trashed_local_asset.repository.dart | 28 +++++++++- mobile/lib/platform/native_sync_api.g.dart | 7 ++- mobile/pigeon/native_sync_api.dart | 2 + 9 files changed, 110 insertions(+), 36 deletions(-) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt index aea177e86e..27eb2f4aa8 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt @@ -90,6 +90,7 @@ data class PlatformAsset ( val durationInSeconds: Long, val orientation: Long, val isFavorite: Boolean, + val isTrashed: Boolean, val size: Long? = null ) { @@ -105,8 +106,9 @@ 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 size = pigeonVar_list[10] as Long? - return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite, size) + val isTrashed = pigeonVar_list[10] as Boolean + val size = pigeonVar_list[11] as Long? + return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite, isTrashed, size) } } fun toList(): List { @@ -121,6 +123,7 @@ data class PlatformAsset ( durationInSeconds, orientation, isFavorite, + isTrashed, size, ) } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl30.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl30.kt index f0e1225fe3..2bead8a697 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl30.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl30.kt @@ -45,8 +45,6 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na } } - @RequiresExtension(extension = Build.VERSION_CODES.R, version = 1) - @RequiresApi(Build.VERSION_CODES.R) override fun getTrashedAssetsForAlbum( albumId: String, updatedTimeCond: Long? @@ -155,14 +153,22 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na storedGen.toString() ) - getAssets(getCursor(volume, selection, selectionArgs)).forEach { - when (it) { - is AssetResult.ValidAsset -> { - changed.add(it.asset) - assetAlbums[it.asset.id] = listOf(it.albumId) - } + val uri = MediaStore.Files.getContentUri(volume) + val queryArgs = Bundle().apply { + putString(ContentResolver.QUERY_ARG_SQL_SELECTION, selection) + putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, selectionArgs) + putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_INCLUDE) + } - is AssetResult.InvalidAsset -> deleted.add(it.assetId) + ctx.contentResolver.query(uri, ASSET_PROJECTION, queryArgs, null).use { cursor -> + getAssets(cursor).forEach { + when (it) { + is AssetResult.ValidAsset -> { + changed.add(it.asset) + assetAlbums[it.asset.id] = listOf(it.albumId) + } + is AssetResult.InvalidAsset -> deleted.add(it.assetId) + } } } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt index 7fc51fced6..ed2b80c7f8 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt @@ -61,6 +61,8 @@ open class NativeSyncApiImplBase(context: Context) { // IS_FAVORITE is 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) + // IS_TRASHED available on Android 11+ + add(MediaStore.MediaColumns.IS_TRASHED) } add(MediaStore.MediaColumns.SIZE) }.toTypedArray() @@ -99,6 +101,7 @@ 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 sizeColumn = c.getColumnIndex(MediaStore.MediaColumns.SIZE) while (c.moveToNext()) { @@ -129,6 +132,7 @@ 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) false else c.getInt(trashedColumn) != 0 val size = c.getLong(sizeColumn) val asset = PlatformAsset( id, @@ -141,6 +145,7 @@ open class NativeSyncApiImplBase(context: Context) { duration, orientation.toLong(), isFavorite, + isTrashed, size ) yield(AssetResult.ValidAsset(asset, bucketId)) diff --git a/mobile/ios/Runner/Sync/Messages.g.swift b/mobile/ios/Runner/Sync/Messages.g.swift index 8834c74611..977a3ade7c 100644 --- a/mobile/ios/Runner/Sync/Messages.g.swift +++ b/mobile/ios/Runner/Sync/Messages.g.swift @@ -140,6 +140,7 @@ struct PlatformAsset: Hashable { var durationInSeconds: Int64 var orientation: Int64 var isFavorite: Bool + var isTrashed: Bool var size: Int64? = nil @@ -155,7 +156,8 @@ 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 size: Int64? = nilOrValue(pigeonVar_list[10]) + let isTrashed = pigeonVar_list[10] as! Bool + let size: Int64? = nilOrValue(pigeonVar_list[11]) return PlatformAsset( id: id, @@ -168,6 +170,7 @@ struct PlatformAsset: Hashable { durationInSeconds: durationInSeconds, orientation: orientation, isFavorite: isFavorite, + isTrashed: isTrashed, size: size ) } @@ -183,6 +186,7 @@ struct PlatformAsset: Hashable { durationInSeconds, orientation, isFavorite, + isTrashed, size, ] } diff --git a/mobile/lib/domain/services/local_sync.service.dart b/mobile/lib/domain/services/local_sync.service.dart index 2e0d60dfed..266aa9f13d 100644 --- a/mobile/lib/domain/services/local_sync.service.dart +++ b/mobile/lib/domain/services/local_sync.service.dart @@ -4,8 +4,8 @@ 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/extensions/platform_extensions.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/platform/native_sync_api.g.dart'; import 'package:immich_mobile/utils/datetime_helpers.dart'; @@ -40,13 +40,14 @@ class LocalSyncService { return; } - _log.fine("Delta updated: ${delta.updates.length}"); + final updates = delta.updates.where((e) => !e.isTrashed); + _log.fine("Delta updated assets: ${updates.length}"); _log.fine("Delta deleted: ${delta.deletes.length}"); final deviceAlbums = await _nativeSyncApi.getAlbums(); await _localAlbumRepository.updateAll(deviceAlbums.toLocalAlbums()); await _localAlbumRepository.processDelta( - updates: delta.updates.toLocalAssets(), + updates: updates.toLocalAssets(), deletes: delta.deletes, assetAlbums: delta.assetAlbums, ); @@ -76,8 +77,8 @@ class LocalSyncService { } } if (_trashSyncService.isAutoSyncMode) { - // On Android we need to sync trashed assets - await _trashSyncService.updateLocalTrashFromDevice(); + _log.fine("Delta updated trashed: ${delta.updates.length - updates.length}"); + await _trashSyncService.applyTrashDelta(delta); } await _nativeSyncApi.checkpointSync(); } catch (e, s) { @@ -104,7 +105,7 @@ class LocalSyncService { onlySecond: addAlbum, ); if (_trashSyncService.isAutoSyncMode) { - await _trashSyncService.updateLocalTrashFromDevice(); + await _trashSyncService.syncDeviceTrashSnapshot(); } await _nativeSyncApi.checkpointSync(); diff --git a/mobile/lib/domain/services/trash_sync.service.dart b/mobile/lib/domain/services/trash_sync.service.dart index 7945693424..368d1071c4 100644 --- a/mobile/lib/domain/services/trash_sync.service.dart +++ b/mobile/lib/domain/services/trash_sync.service.dart @@ -48,7 +48,7 @@ class TrashSyncService { Future> getAssetsToHash(String albumId) async => _trashedLocalAssetRepository.getToHash(albumId); - Future updateLocalTrashFromDevice() async { + Future syncDeviceTrashSnapshot() async { final backupAlbums = await _localAlbumRepository.getBackupAlbums(); if (backupAlbums.isEmpty) { _logger.info("No backup albums found"); @@ -63,6 +63,24 @@ class TrashSyncService { await applyRemoteRestoreToLocal(); } + Future applyTrashDelta(SyncDelta delta) async { + final trashUpdates = delta.updates.where((e) => e.isTrashed); + if (trashUpdates.isEmpty) { + return Future.value(); + } + final trashedAssets = []; + for (final update in trashUpdates) { + final albums = delta.assetAlbums.cast>(); + for (final String id in albums[update.id]!.cast().nonNulls) { + trashedAssets.add(update.toTrashedAsset(id)); + } + } + _logger.info("updateLocalTrashChanges trashedAssets: ${trashedAssets.map((e) => e.id)}"); + await _trashedLocalAssetRepository.insertTrashDelta(trashedAssets); + // todo find for more suitable place + await applyRemoteRestoreToLocal(); + } + Future handleRemoteChanges(Iterable checksums) async { if (checksums.isEmpty) { return Future.value(); @@ -93,6 +111,10 @@ class TrashSyncService { _logger.info("Restoring from trash, localId: ${asset.id}, remoteId: ${asset.checksum}"); await _localFilesManager.restoreFromTrashById(asset.id, asset.type.index); } + // todo 19/09/2025 + // 1. keeping full mirror of local asset table struct + size into trashedLocalAssetEntity could help to restore assets here + // 2. now when hash calculating doing without taking into account size of files, size field may be redundant + // todo It`s necessary? could cause race with deletion in applyTrashSnapshot? 18/09/2025 await _trashedLocalAssetRepository.delete(remoteAssetsToRestore.map((e) => e.id)); } else { @@ -101,19 +123,21 @@ class TrashSyncService { } } -extension on Iterable { - List toTrashedAssets(String albumId) { - return map( - (e) => TrashedAsset( - id: e.id, - name: e.name, - checksum: null, - type: AssetType.values.elementAtOrNull(e.type) ?? AssetType.other, - createdAt: tryFromSecondsSinceEpoch(e.createdAt) ?? DateTime.now(), - updatedAt: tryFromSecondsSinceEpoch(e.updatedAt) ?? DateTime.now(), - size: e.size, - albumId: albumId, - ), - ).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(), + size: size, + albumId: albumId, + ); +} + +extension PlatformAssetsExtension on Iterable { + Iterable toTrashedAssets(String albumId) { + return map((e) => e.toTrashedAsset(albumId)); } } diff --git a/mobile/lib/infrastructure/repositories/trashed_local_asset.repository.dart b/mobile/lib/infrastructure/repositories/trashed_local_asset.repository.dart index b99d7f75ce..e44d85d7a4 100644 --- a/mobile/lib/infrastructure/repositories/trashed_local_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/trashed_local_asset.repository.dart @@ -15,12 +15,12 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository { if (assets.isEmpty) { return Future.value(); } - + final now = DateTime.now(); return _db.batch((batch) async { for (final asset in assets) { batch.update( _db.trashedLocalAssetEntity, - TrashedLocalAssetEntityCompanion(checksum: Value(asset.checksum)), + TrashedLocalAssetEntityCompanion(checksum: Value(asset.checksum), updatedAt: Value(now)), where: (e) => e.id.equals(asset.id), ); } @@ -95,6 +95,30 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository { }); } + Future insertTrashDelta(Iterable trashUpdates) async { + if (trashUpdates.isEmpty) { + return; + } + final companions = trashUpdates + .map( + (a) => TrashedLocalAssetEntityCompanion.insert( + id: a.id, + albumId: a.albumId, + name: a.name, + type: a.type, + checksum: a.checksum == null ? const Value.absent() : Value(a.checksum), + size: a.size == null ? const Value.absent() : Value(a.size), + createdAt: Value(a.createdAt), + ), + ); + + for (final slice in companions.slices(200)) { + await _db.batch((b) { + b.insertAllOnConflictUpdate(_db.trashedLocalAssetEntity, slice); + }); + } + } + Stream watchCount() { final t = _db.trashedLocalAssetEntity; return (_db.selectOnly(t)..addColumns([t.id.count()])).watchSingle().map((row) => row.read(t.id.count()) ?? 0); diff --git a/mobile/lib/platform/native_sync_api.g.dart b/mobile/lib/platform/native_sync_api.g.dart index 5b390663cb..5a815c22c7 100644 --- a/mobile/lib/platform/native_sync_api.g.dart +++ b/mobile/lib/platform/native_sync_api.g.dart @@ -41,6 +41,7 @@ class PlatformAsset { required this.durationInSeconds, required this.orientation, required this.isFavorite, + required this.isTrashed, this.size, }); @@ -64,6 +65,8 @@ class PlatformAsset { bool isFavorite; + bool isTrashed; + int? size; List _toList() { @@ -78,6 +81,7 @@ class PlatformAsset { durationInSeconds, orientation, isFavorite, + isTrashed, size, ]; } @@ -99,7 +103,8 @@ class PlatformAsset { durationInSeconds: result[7]! as int, orientation: result[8]! as int, isFavorite: result[9]! as bool, - size: result[10] as int?, + isTrashed: result[10]! as bool, + size: result[11] as int?, ); } diff --git a/mobile/pigeon/native_sync_api.dart b/mobile/pigeon/native_sync_api.dart index 292b22dc9b..fb94665fb9 100644 --- a/mobile/pigeon/native_sync_api.dart +++ b/mobile/pigeon/native_sync_api.dart @@ -24,6 +24,7 @@ class PlatformAsset { final int durationInSeconds; final int orientation; final bool isFavorite; + final bool isTrashed; final int? size; const PlatformAsset({ @@ -37,6 +38,7 @@ class PlatformAsset { this.durationInSeconds = 0, this.orientation = 0, this.isFavorite = false, + this.isTrashed = false, this.size, }); }