Include trashed items in getMediaChanges

Process trashed items delta during incremental sync
This commit is contained in:
Peter Ombodi 2025-09-19 17:55:20 +03:00
parent 5ddb6cd2e1
commit 55fe480cc1
9 changed files with 110 additions and 36 deletions

View file

@ -90,6 +90,7 @@ data class PlatformAsset (
val durationInSeconds: Long, val durationInSeconds: Long,
val orientation: Long, val orientation: Long,
val isFavorite: Boolean, val isFavorite: Boolean,
val isTrashed: Boolean,
val size: Long? = null val size: Long? = null
) )
{ {
@ -105,8 +106,9 @@ 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 size = pigeonVar_list[10] as Long? val isTrashed = pigeonVar_list[10] as Boolean
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite, size) val size = pigeonVar_list[11] as Long?
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite, isTrashed, size)
} }
} }
fun toList(): List<Any?> { fun toList(): List<Any?> {
@ -121,6 +123,7 @@ data class PlatformAsset (
durationInSeconds, durationInSeconds,
orientation, orientation,
isFavorite, isFavorite,
isTrashed,
size, size,
) )
} }

View file

@ -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( override fun getTrashedAssetsForAlbum(
albumId: String, albumId: String,
updatedTimeCond: Long? updatedTimeCond: Long?
@ -155,14 +153,22 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na
storedGen.toString() storedGen.toString()
) )
getAssets(getCursor(volume, selection, selectionArgs)).forEach { val uri = MediaStore.Files.getContentUri(volume)
when (it) { val queryArgs = Bundle().apply {
is AssetResult.ValidAsset -> { putString(ContentResolver.QUERY_ARG_SQL_SELECTION, selection)
changed.add(it.asset) putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, selectionArgs)
assetAlbums[it.asset.id] = listOf(it.albumId) 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)
}
} }
} }
} }

View file

@ -61,6 +61,8 @@ open class NativeSyncApiImplBase(context: Context) {
// IS_FAVORITE is only available on Android 11 and above // IS_FAVORITE is 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)
// IS_TRASHED available on Android 11+
add(MediaStore.MediaColumns.IS_TRASHED)
} }
add(MediaStore.MediaColumns.SIZE) add(MediaStore.MediaColumns.SIZE)
}.toTypedArray() }.toTypedArray()
@ -99,6 +101,7 @@ 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 sizeColumn = c.getColumnIndex(MediaStore.MediaColumns.SIZE) val sizeColumn = c.getColumnIndex(MediaStore.MediaColumns.SIZE)
while (c.moveToNext()) { while (c.moveToNext()) {
@ -129,6 +132,7 @@ 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) false else c.getInt(trashedColumn) != 0
val size = c.getLong(sizeColumn) val size = c.getLong(sizeColumn)
val asset = PlatformAsset( val asset = PlatformAsset(
id, id,
@ -141,6 +145,7 @@ open class NativeSyncApiImplBase(context: Context) {
duration, duration,
orientation.toLong(), orientation.toLong(),
isFavorite, isFavorite,
isTrashed,
size size
) )
yield(AssetResult.ValidAsset(asset, bucketId)) yield(AssetResult.ValidAsset(asset, bucketId))

View file

@ -140,6 +140,7 @@ struct PlatformAsset: Hashable {
var durationInSeconds: Int64 var durationInSeconds: Int64
var orientation: Int64 var orientation: Int64
var isFavorite: Bool var isFavorite: Bool
var isTrashed: Bool
var size: Int64? = nil var size: Int64? = nil
@ -155,7 +156,8 @@ 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 size: Int64? = nilOrValue(pigeonVar_list[10]) let isTrashed = pigeonVar_list[10] as! Bool
let size: Int64? = nilOrValue(pigeonVar_list[11])
return PlatformAsset( return PlatformAsset(
id: id, id: id,
@ -168,6 +170,7 @@ struct PlatformAsset: Hashable {
durationInSeconds: durationInSeconds, durationInSeconds: durationInSeconds,
orientation: orientation, orientation: orientation,
isFavorite: isFavorite, isFavorite: isFavorite,
isTrashed: isTrashed,
size: size size: size
) )
} }
@ -183,6 +186,7 @@ struct PlatformAsset: Hashable {
durationInSeconds, durationInSeconds,
orientation, orientation,
isFavorite, isFavorite,
isTrashed,
size, size,
] ]
} }

View file

@ -4,8 +4,8 @@ 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/extensions/platform_extensions.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/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/platform/native_sync_api.g.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/utils/datetime_helpers.dart'; import 'package:immich_mobile/utils/datetime_helpers.dart';
@ -40,13 +40,14 @@ class LocalSyncService {
return; 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}"); _log.fine("Delta deleted: ${delta.deletes.length}");
final deviceAlbums = await _nativeSyncApi.getAlbums(); final deviceAlbums = await _nativeSyncApi.getAlbums();
await _localAlbumRepository.updateAll(deviceAlbums.toLocalAlbums()); await _localAlbumRepository.updateAll(deviceAlbums.toLocalAlbums());
await _localAlbumRepository.processDelta( await _localAlbumRepository.processDelta(
updates: delta.updates.toLocalAssets(), updates: updates.toLocalAssets(),
deletes: delta.deletes, deletes: delta.deletes,
assetAlbums: delta.assetAlbums, assetAlbums: delta.assetAlbums,
); );
@ -76,8 +77,8 @@ class LocalSyncService {
} }
} }
if (_trashSyncService.isAutoSyncMode) { if (_trashSyncService.isAutoSyncMode) {
// On Android we need to sync trashed assets _log.fine("Delta updated trashed: ${delta.updates.length - updates.length}");
await _trashSyncService.updateLocalTrashFromDevice(); await _trashSyncService.applyTrashDelta(delta);
} }
await _nativeSyncApi.checkpointSync(); await _nativeSyncApi.checkpointSync();
} catch (e, s) { } catch (e, s) {
@ -104,7 +105,7 @@ class LocalSyncService {
onlySecond: addAlbum, onlySecond: addAlbum,
); );
if (_trashSyncService.isAutoSyncMode) { if (_trashSyncService.isAutoSyncMode) {
await _trashSyncService.updateLocalTrashFromDevice(); await _trashSyncService.syncDeviceTrashSnapshot();
} }
await _nativeSyncApi.checkpointSync(); await _nativeSyncApi.checkpointSync();

View file

@ -48,7 +48,7 @@ class TrashSyncService {
Future<Iterable<TrashedAsset>> getAssetsToHash(String albumId) async => Future<Iterable<TrashedAsset>> getAssetsToHash(String albumId) async =>
_trashedLocalAssetRepository.getToHash(albumId); _trashedLocalAssetRepository.getToHash(albumId);
Future<void> updateLocalTrashFromDevice() async { Future<void> syncDeviceTrashSnapshot() async {
final backupAlbums = await _localAlbumRepository.getBackupAlbums(); final backupAlbums = await _localAlbumRepository.getBackupAlbums();
if (backupAlbums.isEmpty) { if (backupAlbums.isEmpty) {
_logger.info("No backup albums found"); _logger.info("No backup albums found");
@ -63,6 +63,24 @@ class TrashSyncService {
await applyRemoteRestoreToLocal(); await applyRemoteRestoreToLocal();
} }
Future<void> applyTrashDelta(SyncDelta delta) async {
final trashUpdates = delta.updates.where((e) => e.isTrashed);
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.insertTrashDelta(trashedAssets);
// todo find for more suitable place
await applyRemoteRestoreToLocal();
}
Future<void> handleRemoteChanges(Iterable<String> checksums) async { Future<void> handleRemoteChanges(Iterable<String> checksums) async {
if (checksums.isEmpty) { if (checksums.isEmpty) {
return Future.value(); return Future.value();
@ -93,6 +111,10 @@ class TrashSyncService {
_logger.info("Restoring from trash, localId: ${asset.id}, remoteId: ${asset.checksum}"); _logger.info("Restoring from trash, localId: ${asset.id}, remoteId: ${asset.checksum}");
await _localFilesManager.restoreFromTrashById(asset.id, asset.type.index); 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 // todo It`s necessary? could cause race with deletion in applyTrashSnapshot? 18/09/2025
await _trashedLocalAssetRepository.delete(remoteAssetsToRestore.map((e) => e.id)); await _trashedLocalAssetRepository.delete(remoteAssetsToRestore.map((e) => e.id));
} else { } else {
@ -101,19 +123,21 @@ class TrashSyncService {
} }
} }
extension on Iterable<PlatformAsset> { extension on PlatformAsset {
List<TrashedAsset> toTrashedAssets(String albumId) { TrashedAsset toTrashedAsset(String albumId) => TrashedAsset(
return map( id: id,
(e) => TrashedAsset( name: name,
id: e.id, checksum: null,
name: e.name, type: AssetType.values.elementAtOrNull(type) ?? AssetType.other,
checksum: null, createdAt: tryFromSecondsSinceEpoch(createdAt) ?? DateTime.now(),
type: AssetType.values.elementAtOrNull(e.type) ?? AssetType.other, updatedAt: tryFromSecondsSinceEpoch(updatedAt) ?? DateTime.now(),
createdAt: tryFromSecondsSinceEpoch(e.createdAt) ?? DateTime.now(), size: size,
updatedAt: tryFromSecondsSinceEpoch(e.updatedAt) ?? DateTime.now(), albumId: albumId,
size: e.size, );
albumId: albumId, }
),
).toList(); extension PlatformAssetsExtension on Iterable<PlatformAsset> {
Iterable<TrashedAsset> toTrashedAssets(String albumId) {
return map((e) => e.toTrashedAsset(albumId));
} }
} }

View file

@ -15,12 +15,12 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
if (assets.isEmpty) { if (assets.isEmpty) {
return Future.value(); return Future.value();
} }
final now = DateTime.now();
return _db.batch((batch) async { return _db.batch((batch) async {
for (final asset in assets) { for (final asset in assets) {
batch.update( batch.update(
_db.trashedLocalAssetEntity, _db.trashedLocalAssetEntity,
TrashedLocalAssetEntityCompanion(checksum: Value(asset.checksum)), TrashedLocalAssetEntityCompanion(checksum: Value(asset.checksum), updatedAt: Value(now)),
where: (e) => e.id.equals(asset.id), where: (e) => e.id.equals(asset.id),
); );
} }
@ -95,6 +95,30 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
}); });
} }
Future<void> insertTrashDelta(Iterable<TrashedAsset> 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<int> watchCount() { Stream<int> watchCount() {
final t = _db.trashedLocalAssetEntity; 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(t)..addColumns([t.id.count()])).watchSingle().map((row) => row.read<int>(t.id.count()) ?? 0);

View file

@ -41,6 +41,7 @@ class PlatformAsset {
required this.durationInSeconds, required this.durationInSeconds,
required this.orientation, required this.orientation,
required this.isFavorite, required this.isFavorite,
required this.isTrashed,
this.size, this.size,
}); });
@ -64,6 +65,8 @@ class PlatformAsset {
bool isFavorite; bool isFavorite;
bool isTrashed;
int? size; int? size;
List<Object?> _toList() { List<Object?> _toList() {
@ -78,6 +81,7 @@ class PlatformAsset {
durationInSeconds, durationInSeconds,
orientation, orientation,
isFavorite, isFavorite,
isTrashed,
size, size,
]; ];
} }
@ -99,7 +103,8 @@ 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,
size: result[10] as int?, isTrashed: result[10]! as bool,
size: result[11] as int?,
); );
} }

View file

@ -24,6 +24,7 @@ class PlatformAsset {
final int durationInSeconds; final int durationInSeconds;
final int orientation; final int orientation;
final bool isFavorite; final bool isFavorite;
final bool isTrashed;
final int? size; final int? size;
const PlatformAsset({ const PlatformAsset({
@ -37,6 +38,7 @@ class PlatformAsset {
this.durationInSeconds = 0, this.durationInSeconds = 0,
this.orientation = 0, this.orientation = 0,
this.isFavorite = false, this.isFavorite = false,
this.isTrashed = false,
this.size, this.size,
}); });
} }