mirror of
https://github.com/immich-app/immich.git
synced 2025-11-29 01:35:21 -05:00
* feature(mobile, beta, Android): handle remote asset trash/restore events and rescan media - Handle move to trash and restore from trash for remote assets on Android - Trigger MediaScannerConnection to rescan affected media files * feature(mobile, beta, Android): fix rescan * fix imports * fix checking conditions * refactor naming * fix line breaks * refactor code rollback changes in BackgroundServicePlugin * refactor code (use separate TrashService) * refactor code * parallelize restoreFromTrash calls with Future.wait format trash.provider.dart * try to re-format trash.provider.dart * re-format trash.provider.dart * rename TrashService to TrashSyncService to avoid duplicated names revert changes in original trash.provider.dart * refactor code (minor nitpicks) * process restoreFromTrash sequentially instead of Future.wait * group local assets by checksum before moving to trash delete LocalAssetEntity records when moved to trash refactor code * fix format * use checksum for asset restoration refactro code * fix format * sync trash only for backup-selected assets * feat(db): add local_trashed_asset table and integrate with restoration flow - Add new `local_trashed_asset` table to store metadata of trashed assets - Save trashed asset info into `local_trashed_asset` before deletion - Use `local_trashed_asset` as source for asset restoration - Implement file restoration by `mediaId` * resolve merge conflicts * fix index creating on migration * rework trashed assets handling - add new table trashed_local_asset - mirror trashed assets data in trashed_local_asset. - compute checksums for assets trashed out-of-app. - restore assets present in trashed_local_asset and non-trashed in remote_asset. - simplify moving-to-trash logic based on remote_asset events. * resolve merge conflicts use updated approach for calculating checksums * use CurrentPlatform instead _platform fix mocks * revert redundant changes * Include trashed items in getMediaChanges Process trashed items delta during incremental sync * fix merge conflicts * fix format * trashed_local_asset table mirror of local_asset table structure trashed_local_asset<->local_asset transfer data on move to trash or restore refactor code * refactor and format code * refactor TrashedAsset model fix missed data transfering * refactor code remove unused model * fix label * fix merge conflicts * optimize, refactor code remove redundant code and checking getTrashedAssetsForAlbum for iOS tests for hash trashed assets * format code * fix migration fix tests * fix generated file * reuse exist checksums on trash data update handle restoration errors fix import * format code * sync_stream.service depend on repos refactor assets restoration update dependencies in tests * remove trashed asset model remove trash_sync.service refactor DriftTrashedLocalAssetRepository, LocalSyncService * rework fetching trashed assets data on native side optimize handling trashed assets in local sync service refactor code * update NativeSyncApi on iOS side remove unused code * optimize sync trashed assets call in full sync mode refactor code * fix format * remove albumIds from getTrashedAssets params fix upsert in trashed local asset repo refactor code * fix getTrashedAssets params * fix(trash-sync): clean up NativeSyncApiImplBase and correct applyDelta * refactor(trash-sync): optimize performance and fix minor issues * refactor(trash-sync): add missed index * feat(trash-sync): remove sinceLastCheckpoint param from getTrashedAssets * fix(trash-sync): fix target table * fix(trash-sync): remove unused extension * fix(trash-sync): remove unused code * fix(trash-sync): refactor code * fix(trash-sync): reformat file * fix(trash_sync): refactor code * fix(trash_sync): improve moving to trash * refactor(trash_sync): integrate MANAGE_MEDIA permission request into login flow and advanced settings * refactor(trash_sync): add additional checking for experimental trash sync flag and MANAGE_MEDIA permission. * refactor(trash_sync): resolve merge conflicts * refactor(trash_sync): fix format * resolve merge conflicts add await for alert dialog add missed request * refactor(trash_sync): rework MANAGE_MEDIA info widget show rationale text in permission request alert dialog refactor setting getter * fix(trash_sync): restore missing text values * fix(trash_sync): format file * fix(trash_sync): check backup enabled and remove remote asset existence check * fix(trash_sync): remove checking backup enabled test(trash_sync): cover sync-stream trash/restore paths and dedupe mocks * test(trash_sync): cover trash/restore flows for local_sync_service * chore(e2e): restore test-assets submodule pointer --------- Co-authored-by: Peter Ombodi <peter.ombodi@gmail.com> Co-authored-by: Alex <alex.tran1502@gmail.com>
253 lines
9.4 KiB
Dart
253 lines
9.4 KiB
Dart
import 'package:collection/collection.dart';
|
|
import 'package:drift/drift.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/infrastructure/entities/local_asset.entity.dart';
|
|
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
|
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
|
|
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
|
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
|
|
|
typedef TrashedAsset = ({String albumId, LocalAsset asset});
|
|
|
|
class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
|
|
final Drift _db;
|
|
|
|
const DriftTrashedLocalAssetRepository(this._db) : super(_db);
|
|
|
|
Future<void> updateHashes(Map<String, String> hashes) {
|
|
if (hashes.isEmpty) {
|
|
return Future.value();
|
|
}
|
|
return _db.batch((batch) async {
|
|
for (final entry in hashes.entries) {
|
|
batch.update(
|
|
_db.trashedLocalAssetEntity,
|
|
TrashedLocalAssetEntityCompanion(checksum: Value(entry.value)),
|
|
where: (e) => e.id.equals(entry.key),
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<List<LocalAsset>> getAssetsToHash(Iterable<String> albumIds) {
|
|
final query = _db.trashedLocalAssetEntity.select()..where((r) => r.albumId.isIn(albumIds) & r.checksum.isNull());
|
|
return query.map((row) => row.toLocalAsset()).get();
|
|
}
|
|
|
|
Future<Iterable<LocalAsset>> getToRestore() async {
|
|
final selectedAlbumIds = (_db.selectOnly(_db.localAlbumEntity)
|
|
..addColumns([_db.localAlbumEntity.id])
|
|
..where(_db.localAlbumEntity.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();
|
|
|
|
return rows.map((result) => result.readTable(_db.trashedLocalAssetEntity).toLocalAsset());
|
|
}
|
|
|
|
/// Applies resulted snapshot of trashed assets:
|
|
/// - upserts incoming rows
|
|
/// - deletes rows that are not present in the snapshot
|
|
Future<void> processTrashSnapshot(Iterable<TrashedAsset> trashedAssets) async {
|
|
if (trashedAssets.isEmpty) {
|
|
await _db.delete(_db.trashedLocalAssetEntity).go();
|
|
return;
|
|
}
|
|
final assetIds = trashedAssets.map((e) => e.asset.id).toSet();
|
|
Map<String, String> localChecksumById = await _getCachedChecksums(assetIds);
|
|
|
|
return _db.transaction(() async {
|
|
await _db.batch((batch) {
|
|
for (final item in trashedAssets) {
|
|
final effectiveChecksum = localChecksumById[item.asset.id] ?? item.asset.checksum;
|
|
final companion = TrashedLocalAssetEntityCompanion.insert(
|
|
id: item.asset.id,
|
|
albumId: item.albumId,
|
|
checksum: Value(effectiveChecksum),
|
|
name: item.asset.name,
|
|
type: item.asset.type,
|
|
createdAt: Value(item.asset.createdAt),
|
|
updatedAt: Value(item.asset.updatedAt),
|
|
width: Value(item.asset.width),
|
|
height: Value(item.asset.height),
|
|
durationInSeconds: Value(item.asset.durationInSeconds),
|
|
isFavorite: Value(item.asset.isFavorite),
|
|
orientation: Value(item.asset.orientation),
|
|
);
|
|
|
|
batch.insert<$TrashedLocalAssetEntityTable, TrashedLocalAssetEntityData>(
|
|
_db.trashedLocalAssetEntity,
|
|
companion,
|
|
onConflict: DoUpdate((_) => companion, where: (old) => old.updatedAt.isNotValue(item.asset.updatedAt)),
|
|
);
|
|
}
|
|
});
|
|
|
|
if (assetIds.length <= kDriftMaxChunk) {
|
|
await (_db.delete(_db.trashedLocalAssetEntity)..where((row) => row.id.isNotIn(assetIds))).go();
|
|
} else {
|
|
final existingIds = await (_db.selectOnly(
|
|
_db.trashedLocalAssetEntity,
|
|
)..addColumns([_db.trashedLocalAssetEntity.id])).map((r) => r.read(_db.trashedLocalAssetEntity.id)!).get();
|
|
final idToDelete = existingIds.where((id) => !assetIds.contains(id));
|
|
for (final slice in idToDelete.slices(kDriftMaxChunk)) {
|
|
await (_db.delete(_db.trashedLocalAssetEntity)..where((t) => t.id.isIn(slice))).go();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
Stream<int> watchCount() {
|
|
return (_db.selectOnly(_db.trashedLocalAssetEntity)..addColumns([_db.trashedLocalAssetEntity.id.count()]))
|
|
.watchSingle()
|
|
.map((row) => row.read<int>(_db.trashedLocalAssetEntity.id.count()) ?? 0);
|
|
}
|
|
|
|
Stream<int> watchHashedCount() {
|
|
return (_db.selectOnly(_db.trashedLocalAssetEntity)
|
|
..addColumns([_db.trashedLocalAssetEntity.id.count()])
|
|
..where(_db.trashedLocalAssetEntity.checksum.isNotNull()))
|
|
.watchSingle()
|
|
.map((row) => row.read<int>(_db.trashedLocalAssetEntity.id.count()) ?? 0);
|
|
}
|
|
|
|
Future<void> trashLocalAsset(Map<String, List<LocalAsset>> assetsByAlbums) async {
|
|
if (assetsByAlbums.isEmpty) {
|
|
return;
|
|
}
|
|
|
|
final companions = <TrashedLocalAssetEntityCompanion>[];
|
|
final idToDelete = <String>{};
|
|
|
|
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(entry.key),
|
|
checksum: Value(asset.checksum),
|
|
type: Value(asset.type),
|
|
width: Value(asset.width),
|
|
height: Value(asset.height),
|
|
durationInSeconds: Value(asset.durationInSeconds),
|
|
isFavorite: Value(asset.isFavorite),
|
|
orientation: Value(asset.orientation),
|
|
createdAt: Value(asset.createdAt),
|
|
updatedAt: Value(asset.updatedAt),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
await _db.transaction(() async {
|
|
for (final companion in companions) {
|
|
await _db.into(_db.trashedLocalAssetEntity).insertOnConflictUpdate(companion);
|
|
}
|
|
|
|
for (final slice in idToDelete.slices(kDriftMaxChunk)) {
|
|
await (_db.delete(_db.localAssetEntity)..where((t) => t.id.isIn(slice))).go();
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> applyRestoredAssets(List<String> idList) async {
|
|
if (idList.isEmpty) {
|
|
return;
|
|
}
|
|
|
|
final trashedAssets = <TrashedLocalAssetEntityData>[];
|
|
|
|
for (final slice in idList.slices(kDriftMaxChunk)) {
|
|
final q = _db.select(_db.trashedLocalAssetEntity)..where((t) => t.id.isIn(slice));
|
|
trashedAssets.addAll(await q.get());
|
|
}
|
|
|
|
if (trashedAssets.isEmpty) {
|
|
return;
|
|
}
|
|
|
|
final companions = trashedAssets.map((e) {
|
|
return LocalAssetEntityCompanion.insert(
|
|
id: e.id,
|
|
name: e.name,
|
|
type: e.type,
|
|
createdAt: Value(e.createdAt),
|
|
updatedAt: Value(e.updatedAt),
|
|
width: Value(e.width),
|
|
height: Value(e.height),
|
|
durationInSeconds: Value(e.durationInSeconds),
|
|
checksum: Value(e.checksum),
|
|
isFavorite: Value(e.isFavorite),
|
|
orientation: Value(e.orientation),
|
|
);
|
|
});
|
|
|
|
await _db.transaction(() async {
|
|
for (final companion in companions) {
|
|
await _db.into(_db.localAssetEntity).insertOnConflictUpdate(companion);
|
|
}
|
|
for (final slice in idList.slices(kDriftMaxChunk)) {
|
|
await (_db.delete(_db.trashedLocalAssetEntity)..where((t) => t.id.isIn(slice))).go();
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<Map<String, List<LocalAsset>>> getToTrash() async {
|
|
final result = <String, List<LocalAsset>>{};
|
|
|
|
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)),
|
|
leftOuterJoin(
|
|
_db.remoteAssetEntity,
|
|
_db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum),
|
|
),
|
|
])..where(
|
|
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) &
|
|
_db.remoteAssetEntity.deletedAt.isNotNull(),
|
|
))
|
|
.get();
|
|
|
|
for (final row in rows) {
|
|
final albumId = row.readTable(_db.localAlbumAssetEntity).albumId;
|
|
final asset = row.readTable(_db.localAssetEntity).toDto();
|
|
(result[albumId] ??= <LocalAsset>[]).add(asset);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
//attempt to reuse existing checksums
|
|
Future<Map<String, String>> _getCachedChecksums(Set<String> assetIds) async {
|
|
final localChecksumById = <String, String>{};
|
|
|
|
for (final slice in assetIds.slices(kDriftMaxChunk)) {
|
|
final rows =
|
|
await (_db.selectOnly(_db.localAssetEntity)
|
|
..where(_db.localAssetEntity.id.isIn(slice) & _db.localAssetEntity.checksum.isNotNull())
|
|
..addColumns([_db.localAssetEntity.id, _db.localAssetEntity.checksum]))
|
|
.get();
|
|
|
|
for (final r in rows) {
|
|
localChecksumById[r.read(_db.localAssetEntity.id)!] = r.read(_db.localAssetEntity.checksum)!;
|
|
}
|
|
}
|
|
|
|
return localChecksumById;
|
|
}
|
|
}
|