From 7e2118901eb77894ff3f57d35b9a7202462fcb01 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Mon, 13 Jul 2026 15:44:39 +0600 Subject: [PATCH] fix(mobile): back up files moved into a watched folder on android --- .../repositories/local_album.repository.dart | 16 +++-- .../local_album_repository_test.dart | 72 +++++++++++++++++++ 2 files changed, 81 insertions(+), 7 deletions(-) diff --git a/mobile/lib/infrastructure/repositories/local_album.repository.dart b/mobile/lib/infrastructure/repositories/local_album.repository.dart index 2c80385c34..a443ffb975 100644 --- a/mobile/lib/infrastructure/repositories/local_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_album.repository.dart @@ -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 instead of // List - await _db.batch((batch) async { - assetAlbums.cast>().forEach((assetId, albumIds) { - for (final albumId in albumIds.cast().nonNulls) { - batch.deleteWhere(_db.localAlbumAssetEntity, (f) => f.albumId.equals(albumId) & f.assetId.equals(assetId)); - } - }); - }); await _db.batch((batch) async { assetAlbums.cast>().forEach((assetId, albumIds) { batch.insertAll( diff --git a/mobile/test/infrastructure/repositories/local_album_repository_test.dart b/mobile/test/infrastructure/repositories/local_album_repository_test.dart index fae0e09171..bd4acca483 100644 --- a/mobile/test/infrastructure/repositories/local_album_repository_test.dart +++ b/mobile/test/infrastructure/repositories/local_album_repository_test.dart @@ -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(); + + 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(); + + 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, +);