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 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<Any?> {
@ -121,6 +123,7 @@ data class PlatformAsset (
durationInSeconds,
orientation,
isFavorite,
isTrashed,
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(
albumId: String,
updatedTimeCond: Long?
@ -155,17 +153,25 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na
storedGen.toString()
)
getAssets(getCursor(volume, selection, selectionArgs)).forEach {
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)
}
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)
}
}
}
}
// Unmounted volumes are handled in dart when the album is removed
return SyncDelta(hasChanges, changed, deleted, assetAlbums)
}

View file

@ -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))

View file

@ -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,
]
}

View file

@ -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();

View file

@ -48,7 +48,7 @@ class TrashSyncService {
Future<Iterable<TrashedAsset>> getAssetsToHash(String albumId) async =>
_trashedLocalAssetRepository.getToHash(albumId);
Future<void> updateLocalTrashFromDevice() async {
Future<void> 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<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 {
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<PlatformAsset> {
List<TrashedAsset> toTrashedAssets(String albumId) {
return map(
(e) => TrashedAsset(
id: e.id,
name: e.name,
extension on PlatformAsset {
TrashedAsset toTrashedAsset(String albumId) => TrashedAsset(
id: id,
name: 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,
type: AssetType.values.elementAtOrNull(type) ?? AssetType.other,
createdAt: tryFromSecondsSinceEpoch(createdAt) ?? DateTime.now(),
updatedAt: tryFromSecondsSinceEpoch(updatedAt) ?? DateTime.now(),
size: 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) {
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<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() {
final t = _db.trashedLocalAssetEntity;
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.orientation,
required this.isFavorite,
required this.isTrashed,
this.size,
});
@ -64,6 +65,8 @@ class PlatformAsset {
bool isFavorite;
bool isTrashed;
int? size;
List<Object?> _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?,
);
}

View file

@ -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,
});
}