fix(mobile): back up files moved into a watched folder on android

This commit is contained in:
Santo Shakil 2026-07-13 15:44:39 +06:00
parent 557189d7a8
commit 7e2118901e
2 changed files with 81 additions and 7 deletions

View File

@ -210,17 +210,19 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
await _deleteAssets(deletes);
await _upsertAssets(updates);
// Drop every existing album link for each changed asset before re-adding the
// ones the native side reports. A moved asset only reports its new album here,
// so leaving the old link around makes the per-album delete sweep wipe the
// asset entirely (it is still linked to a bucket it no longer lives in).
await _db.batch((batch) async {
for (final assetId in assetAlbums.keys) {
batch.deleteWhere(_db.localAlbumAssetEntity, (f) => f.assetId.equals(assetId));
}
});
// The ugly casting below is required for now because the generated code
// casts the returned values from the platform during decoding them
// and iterating over them causes the type to be List<Object?> instead of
// List<String>
await _db.batch((batch) async {
assetAlbums.cast<String, List<Object?>>().forEach((assetId, albumIds) {
for (final albumId in albumIds.cast<String?>().nonNulls) {
batch.deleteWhere(_db.localAlbumAssetEntity, (f) => f.albumId.equals(albumId) & f.assetId.equals(assetId));
}
});
});
await _db.batch((batch) async {
assetAlbums.cast<String, List<Object?>>().forEach((assetId, albumIds) {
batch.insertAll(

View File

@ -2,6 +2,7 @@ import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.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/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
@ -35,4 +36,75 @@ void main() {
expect(albums[3].id, '2'); // excluded
});
});
group('processDelta', () {
// Regression for #22844: an asset moved (not copied) into a backup album on
// Android was dropped. The delta reports only the asset's new album, and the
// stale link to its old album made the per-album delete sweep wipe the asset.
test('keeps an asset moved to another album that still holds other assets', () async {
final localAlbumRepo = mediumFactory.getRepository<DriftLocalAlbumRepository>();
final moved = _localAsset('moved');
final other = _localAsset('other');
await localAlbumRepo.upsert(
mediumFactory.localAlbum(id: 'src', backupSelection: BackupSelection.none),
toUpsert: [moved, other],
);
final anchor = _localAsset('anchor');
await localAlbumRepo.upsert(
mediumFactory.localAlbum(id: 'dst', backupSelection: BackupSelection.selected),
toUpsert: [anchor],
);
// Delta reports the moved asset now living only in the destination album.
await localAlbumRepo.processDelta(
updates: [moved],
deletes: [],
assetAlbums: {
'moved': ['dst'],
},
);
// Per-album delete sweep, as sync() runs on Android. src now physically
// holds only `other`; dst holds `anchor` and the moved asset.
await localAlbumRepo.syncDeletes('src', ['other']);
await localAlbumRepo.syncDeletes('dst', ['anchor', 'moved']);
final dstIds = (await localAlbumRepo.getAssets('dst')).map((a) => a.id).toSet();
final srcIds = await localAlbumRepo.getAssetIds('src');
expect(dstIds, contains('moved')); // survived and linked to the backed-up album
expect(srcIds, isNot(contains('moved'))); // stale source link cleared
expect(srcIds, contains('other')); // untouched asset stays put
});
test('replaces album membership with exactly what the delta reports', () async {
final localAlbumRepo = mediumFactory.getRepository<DriftLocalAlbumRepository>();
final moved = _localAsset('moved');
await localAlbumRepo.upsert(mediumFactory.localAlbum(id: 'src'), toUpsert: [moved]);
await localAlbumRepo.upsert(mediumFactory.localAlbum(id: 'dst'));
await localAlbumRepo.processDelta(
updates: [moved],
deletes: [],
assetAlbums: {
'moved': ['dst'],
},
);
expect(await localAlbumRepo.getAssetIds('src'), isEmpty);
expect(await localAlbumRepo.getAssetIds('dst'), ['moved']);
});
});
}
LocalAsset _localAsset(String id) => LocalAsset(
id: id,
name: '$id.jpg',
type: AssetType.image,
createdAt: DateTime(2024),
updatedAt: DateTime(2024),
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
);