From e6db274aa9b5fa88c2ccf34e2bef997ae886fb2d Mon Sep 17 00:00:00 2001 From: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Date: Sat, 13 Jun 2026 22:09:03 +0530 Subject: [PATCH] refactor: per asset backup flag --- .../drift_schemas/main/drift_schema_v31.json | Bin 0 -> 116671 bytes .../lib/domain/utils/migrate_cloud_ids.dart | 4 +- .../entities/local_album.entity.dart | 15 - .../entities/local_asset.entity.dart | 29 +- .../entities/local_asset.entity.drift.dart | Bin 46287 -> 49523 bytes .../entities/merged_asset.drift | 24 +- .../entities/merged_asset.drift.dart | Bin 9073 -> 7713 bytes mobile/lib/infrastructure/mapper.dart | 35 ++ .../repositories/backup.repository.dart | 57 +-- .../repositories/db.repository.dart | 6 +- .../repositories/db.repository.drift.dart | Bin 15891 -> 15928 bytes .../repositories/db.repository.steps.dart | Bin 480553 -> 500227 bytes .../repositories/local_album.repository.dart | 215 +++++++--- .../repositories/local_asset.repository.dart | 17 +- .../repositories/timeline.repository.dart | 6 +- .../trashed_local_asset.repository.dart | 7 +- mobile/lib/utils/migration.dart | 7 +- mobile/test/drift/main/generated/schema.dart | Bin 3256 -> 3350 bytes .../test/drift/main/generated/schema_v31.dart | Bin 0 -> 326229 bytes .../repositories/backup_repository_test.dart | 4 - .../local_album_repository_test.dart | 366 ++++++++++++++++++ .../local_asset_repository_test.dart | 4 - .../repositories/partner_repository_test.dart | 4 - .../repositories/people_repository_test.dart | 4 - .../remote_album_repository_test.dart | 4 - .../settings_repository_test.dart | 4 - .../timeline_repository_test.dart | 4 - .../trashed_local_asset_repository_test.dart | 93 +++++ mobile/test/medium/repository_context.dart | 40 +- .../medium/services/partner_service_test.dart | 4 - 30 files changed, 726 insertions(+), 227 deletions(-) create mode 100644 mobile/drift_schemas/main/drift_schema_v31.json create mode 100644 mobile/test/drift/main/generated/schema_v31.dart create mode 100644 mobile/test/medium/repositories/local_album_repository_test.dart create mode 100644 mobile/test/medium/repositories/trashed_local_asset_repository_test.dart diff --git a/mobile/drift_schemas/main/drift_schema_v31.json b/mobile/drift_schemas/main/drift_schema_v31.json new file mode 100644 index 0000000000000000000000000000000000000000..0f3d7352e70a36496fc1ba3381555981dccfc181 GIT binary patch literal 116671 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 QjhQ1|Wr^5X>`(vwe_FK#lK=n! literal 0 HcmV?d00001 diff --git a/mobile/lib/domain/utils/migrate_cloud_ids.dart b/mobile/lib/domain/utils/migrate_cloud_ids.dart index efef6e8327..f8a3a8e96a 100644 --- a/mobile/lib/domain/utils/migrate_cloud_ids.dart +++ b/mobile/lib/domain/utils/migrate_cloud_ids.dart @@ -6,7 +6,7 @@ import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/asset/asset_metadata.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/infrastructure/entities/local_asset.entity.dart'; +import 'package:immich_mobile/infrastructure/mapper.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart'; @@ -211,7 +211,7 @@ Future> _fetchCloudIdMappings(Drift drift, String userId, return query.map((row) { return ( remoteAssetId: row.read(drift.remoteAssetEntity.id)!, - localAsset: row.readTable(drift.localAssetEntity).toDto(), + localAsset: mapToLocalAsset(row.readTable(drift.localAssetEntity)), ); }).get(); } diff --git a/mobile/lib/infrastructure/entities/local_album.entity.dart b/mobile/lib/infrastructure/entities/local_album.entity.dart index 641a5359f6..d9e6dcde39 100644 --- a/mobile/lib/infrastructure/entities/local_album.entity.dart +++ b/mobile/lib/infrastructure/entities/local_album.entity.dart @@ -1,6 +1,5 @@ import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; -import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album.entity.dart'; import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; @@ -23,17 +22,3 @@ class LocalAlbumEntity extends Table with DriftDefaultsMixin { @override Set get primaryKey => {id}; } - -extension LocalAlbumEntityDataHelper on LocalAlbumEntityData { - LocalAlbum toDto({int assetCount = 0}) { - return LocalAlbum( - id: id, - name: name, - updatedAt: updatedAt, - assetCount: assetCount, - backupSelection: backupSelection, - linkedRemoteAlbumId: linkedRemoteAlbumId, - isIosSharedAlbum: isIosSharedAlbum, - ); - } -} diff --git a/mobile/lib/infrastructure/entities/local_asset.entity.dart b/mobile/lib/infrastructure/entities/local_asset.entity.dart index a19c1aa540..351a179d42 100644 --- a/mobile/lib/infrastructure/entities/local_asset.entity.dart +++ b/mobile/lib/infrastructure/entities/local_asset.entity.dart @@ -1,12 +1,14 @@ import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/utils/asset.mixin.dart'; import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; @TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)') @TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)') @TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)') +@TableIndex.sql( + 'CREATE INDEX IF NOT EXISTS idx_local_asset_backup_candidate ON local_asset_entity (is_backup_candidate)', +) class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin { const LocalAssetEntity(); @@ -16,6 +18,8 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin { // Only used during backup to mirror the favorite status of the asset in the server BoolColumn get isFavorite => boolean().withDefault(const Constant(false))(); + BoolColumn get isBackupCandidate => boolean().withDefault(const Constant(false))(); + IntColumn get orientation => integer().withDefault(const Constant(0))(); TextColumn get iCloudId => text().nullable()(); @@ -31,26 +35,3 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin { @override Set get primaryKey => {id}; } - -extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData { - LocalAsset toDto({String? remoteId}) => LocalAsset( - id: id, - name: name, - checksum: checksum, - type: type, - createdAt: createdAt, - updatedAt: updatedAt, - durationMs: durationMs, - isFavorite: isFavorite, - height: height, - width: width, - remoteId: remoteId, - orientation: orientation, - playbackStyle: playbackStyle, - adjustmentTime: adjustmentTime, - latitude: latitude, - longitude: longitude, - cloudId: iCloudId, - isEdited: false, - ); -} diff --git a/mobile/lib/infrastructure/entities/local_asset.entity.drift.dart b/mobile/lib/infrastructure/entities/local_asset.entity.drift.dart index fe03f9b208621978246e2a110a5b336be8a0a936..be78877313389adac946182bee8ca994a8be1573 100644 GIT binary patch delta 1847 zcmb7E?MqWp9OiCwZZqedI=5WrnVR$3+)I(=bec{fnfZbUfym{mx3wCZu`6K)>qYut zP}ol-tYrz|my+4(YY~0wizFCCn2E{@{&N`TOwz2%t$`AV3Z$B$;=P^)fx7;wn5CVgO>pnSh~xB^=VO_)fI>{HRLiOEs3E_D)pgvLiRg*?>tDz@1hWmV5Knr-e{dMlAC4={8QSerseg( z(j_B&y{J#GemcIDL`_4GL~vg=8dH9%B69ds%&$zPnkd?p*Q%AriF%2C=8}k-nvirR z7-puns;OreMXZm1pYGfUO~Sfcf1nM-ZL}! z!|V%YyELmbrNp8nk-+`H2qX8^I5GE5(M%JydQo^Z6yh?v+D89SWAkf@qRe+;-=ld& z19b(3vAz6wZDArSgMdAc&1|N3Pky&o7R{J{vbsBA8yFnM0t4r9aUSV{GXu zvvi9h1Cs@_GLw*62(uKA%mlM#lt%=!>6q^Uv+tUh0<*=P-2$`Bpu_^R#-r8(v&N$s r0ki6-_yLp9ss^(Zs@DRuI<3tDvo5fa0<#6PdIhujxYGo)3BH{ImXIGb%BfO?+q%;oWDHVKm?Thp~ix@<$G_`kega#GLp< zkiz)H;^Nej_|&|T%#un4$6$q=#6$&8KR?$X1uuV3KLuo^$chyFfpUq7dJsc0Qxt50 zTs=)hPDw^CaA-J!12~3D8XUHpCvfF)jfN_rIL98UlSM^$Pxj?fncU4KI=PTb z3N3)SKq2i4PMVYVbD3|}7fWRlP*BJ;(^K&QC1FTR{1%s%04a0?r8kIb1zS5V1rV70 zPE3BXorHuOh@+`jlv-SxQxcq7VuNNzwuBTUrv!+L@R}nVKS@FiBGV}Di7GR>KtdFf K1Ue;VumS)HA1~ User( Partner mapToPartner(UserEntityData user, PartnerEntityData partner) => Partner.fromUser(mapToUser(user), inTimeline: partner.inTimeline); + +LocalAlbum mapToLocalAlbum(LocalAlbumEntityData data, {int assetCount = 0}) => LocalAlbum( + id: data.id, + name: data.name, + updatedAt: data.updatedAt, + assetCount: assetCount, + backupSelection: data.backupSelection, + linkedRemoteAlbumId: data.linkedRemoteAlbumId, + isIosSharedAlbum: data.isIosSharedAlbum, +); + +LocalAsset mapToLocalAsset(LocalAssetEntityData data, {String? remoteId}) => LocalAsset( + id: data.id, + name: data.name, + checksum: data.checksum, + type: data.type, + createdAt: data.createdAt, + updatedAt: data.updatedAt, + durationMs: data.durationMs, + isFavorite: data.isFavorite, + height: data.height, + width: data.width, + remoteId: remoteId, + orientation: data.orientation, + playbackStyle: data.playbackStyle, + adjustmentTime: data.adjustmentTime, + latitude: data.latitude, + longitude: data.longitude, + cloudId: data.iCloudId, + isEdited: false, +); diff --git a/mobile/lib/infrastructure/repositories/backup.repository.dart b/mobile/lib/infrastructure/repositories/backup.repository.dart index 0241711d4b..faae0275cc 100644 --- a/mobile/lib/infrastructure/repositories/backup.repository.dart +++ b/mobile/lib/infrastructure/repositories/backup.repository.dart @@ -2,9 +2,8 @@ import 'dart:async'; import 'package:drift/drift.dart'; import 'package:hooks_riverpod/hooks_riverpod.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/mapper.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; @@ -16,19 +15,6 @@ class DriftBackupRepository extends DriftDatabaseRepository { final Drift _db; const DriftBackupRepository(this._db) : super(_db); - _getExcludedSubquery() { - return _db.localAlbumAssetEntity.selectOnly() - ..addColumns([_db.localAlbumAssetEntity.assetId]) - ..join([ - innerJoin( - _db.localAlbumEntity, - _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id), - useColumns: false, - ), - ]) - ..where(_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.excluded)); - } - /// Returns all backup-related counts in a single query. /// /// - total: number of distinct assets in selected albums, excluding those that are also in excluded albums @@ -45,31 +31,14 @@ class DriftBackupRepository extends DriftDatabaseRepository { FROM local_asset_entity lae LEFT JOIN main.remote_asset_entity rae ON lae.checksum = rae.checksum AND rae.owner_id = ?1 - WHERE EXISTS ( - SELECT 1 - FROM local_album_asset_entity laa - INNER JOIN main.local_album_entity la on laa.album_id = la.id - WHERE laa.asset_id = lae.id - AND la.backup_selection = ?2 - ) - AND NOT EXISTS ( - SELECT 1 - FROM local_album_asset_entity laa - INNER JOIN main.local_album_entity la on laa.album_id = la.id - WHERE laa.asset_id = lae.id - AND la.backup_selection = ?3 - ); + WHERE lae.is_backup_candidate; '''; final row = await _db .customSelect( sql, - variables: [ - Variable.withString(userId), - Variable.withInt(BackupSelection.selected.index), - Variable.withInt(BackupSelection.excluded.index), - ], - readsFrom: {_db.localAlbumAssetEntity, _db.localAlbumEntity, _db.localAssetEntity, _db.remoteAssetEntity}, + variables: [Variable.withString(userId)], + readsFrom: {_db.localAssetEntity, _db.remoteAssetEntity}, ) .getSingle(); @@ -82,29 +51,17 @@ class DriftBackupRepository extends DriftDatabaseRepository { } Future> getCandidates(String userId, {bool onlyHashed = true}) async { - final selectedAlbumIds = _db.localAlbumEntity.selectOnly(distinct: true) - ..addColumns([_db.localAlbumEntity.id]) - ..where(_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected)); - final query = _db.localAssetEntity.select() ..where( (lae) => - existsQuery( - _db.localAlbumAssetEntity.selectOnly() - ..addColumns([_db.localAlbumAssetEntity.assetId]) - ..where( - _db.localAlbumAssetEntity.albumId.isInQuery(selectedAlbumIds) & - _db.localAlbumAssetEntity.assetId.equalsExp(lae.id), - ), - ) & + lae.isBackupCandidate.equals(true) & notExistsQuery( _db.remoteAssetEntity.selectOnly() ..addColumns([_db.remoteAssetEntity.checksum]) ..where( _db.remoteAssetEntity.checksum.equalsExp(lae.checksum) & _db.remoteAssetEntity.ownerId.equals(userId), ), - ) & - lae.id.isNotInQuery(_getExcludedSubquery()), + ), ) ..orderBy([(localAsset) => OrderingTerm.desc(localAsset.createdAt)]); @@ -112,6 +69,6 @@ class DriftBackupRepository extends DriftDatabaseRepository { query.where((lae) => lae.checksum.isNotNull()); } - return query.map((localAsset) => localAsset.toDto()).get(); + return query.map(mapToLocalAsset).get(); } } diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart index d39706b512..f16f0ed32e 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.dart @@ -120,7 +120,7 @@ class Drift extends $Drift { } @override - int get schemaVersion => 30; + int get schemaVersion => 31; @override MigrationStrategy get migration => MigrationStrategy( @@ -311,6 +311,10 @@ class Drift extends $Drift { from29To30: (m, v30) async { await m.alterTable(TableMigration(v30.settings)); }, + from30To31: (m, v31) async { + await m.addColumn(v31.localAssetEntity, v31.localAssetEntity.isBackupCandidate); + await m.createIndex(v31.idxLocalAssetBackupCandidate); + }, ), ); diff --git a/mobile/lib/infrastructure/repositories/db.repository.drift.dart b/mobile/lib/infrastructure/repositories/db.repository.drift.dart index 887daea723103902b967f85b9eb26e651d30eeea..7c878a6f61191acae5407c50c0705396127f6113 100644 GIT binary patch delta 440 zcmYL_%}X0m6vdf0QDb}$6*KWO#*#16+F_>iVWJ?`wiZFOr34q+!r-JhT1H7mY$3~T zsv_u7Ty!P)2MBLb7cPZD7uN2@h5KUZLU-C{Hoc4A;a)C>b1$k#)x$;tO-@561#HSW zYzh4%CvhXRxsF01Jt)crERZa|3H?DH^S03E2K=NM$-*@?B^!^JCOFG%X#hJcBY4aV zDUaPIRq&+AA_Wy^MhvY~!3>Mwm!Y7_y6}#gjk*@;s}*Kx56&Vne#O4mBdN^vnyTl1 zoLHK3K0K{dO2Mczx45$W%<)~%b%GLKRUS$>j*jxLv8?o9;C}g)Qx5!6`OUmjF1gcQ zFhB10*X%uA+g4KivON^q?k-|7)5$A+7X*H$9=GW*))ETV6$QpOS^IUq#YY(l;|4v%_3j3zcl;< DJ1>d~ delta 456 zcmYjNJxjx25GIM%wr@gh)zs36)y8iPt<9U*q17&;;737-Q_yg&s;n_l_H{o0*#MjTWG@` z!xy0kuMAy$3r2-F=J^gBGrZ-)R!zlL6;f2jy(qh=J7k-N0qTcBWX{02bte(Fv_5gRBy;(v3#~`nxk$R{7wqmGHZV-Peq--byT(1l`ojQ6%Z0U}!4R zl8kEV#R(1m<07u(Zj8ko_@veB-kj0F^aR_I#}P^-Lq!u)M#kBvscLGuT)<&8XwTn` o1^y>n@EEIQABgC!cG;&l*lg(MA8UH@7n2tGFAQy2rIHH(B&r@^=j0yT3(!JCV7ggfs=Wo*vhGJ0Z#LX5| z@8Nw=cH^s{ex(m!<|tKA1hP}q1I7iyp`1pGe14i52&`Hs33o3Ge#gl*AN`5HMqptC zYxUC`_r<2IgSq|<>(KgM@NxAKbxGXuS^PqvQY21(Cr+atp7pY<0BVVT1Y=T|vj&J7EvKT`!@WF~=K0xwTEY6oCTqtMkj=B@{A#wyN?}!JwYQ#1^ zL$(EtDd delta 54 zcmZoZBDZptY(ooU3sVbo3rh=Y3)>d row.readTable(_db.localAlbumEntity).toDto(assetCount: row.read(assetCount) ?? 0)).get(); + return query + .map((row) => mapToLocalAlbum(row.readTable(_db.localAlbumEntity), assetCount: row.read(assetCount) ?? 0)) + .get(); } Future> getBackupAlbums() async { final query = _db.localAlbumEntity.select() ..where((row) => row.backupSelection.equalsValue(BackupSelection.selected)); - return query.map((row) => row.toDto()).get(); + return query.map(mapToLocalAlbum).get(); } Future delete(String albumId) => transaction(() async { // Remove all assets that are only in this particular album // We cannot remove all assets in the album because they might be in other albums in iOS // That is not the case on Android since asset <-> album has one:one mapping - final assetsToDelete = CurrentPlatform.isIOS ? await _getUniqueAssetsInAlbum(albumId) : await getAssetIds(albumId); - await _deleteAssets(assetsToDelete); - - await _db.managers.localAlbumEntity - .filter((a) => a.id.equals(albumId) & a.backupSelection.equals(BackupSelection.none)) - .delete(); + final affectedAssetIds = await getAssetIds(albumId); + final assetsToDelete = CurrentPlatform.isIOS ? await _getUniqueAssetsInAlbum(albumId) : affectedAssetIds; + await _db.transaction(() async { + await _deleteAssets(assetsToDelete); + await _db.localAlbumAssetEntity.deleteWhere((f) => f.albumId.equals(albumId)); + await _db.managers.localAlbumEntity + .filter((a) => a.id.equals(albumId) & a.backupSelection.equals(BackupSelection.none)) + .delete(); + await recomputeBackupCandidatesForAssets(affectedAssetIds); + }); }); Future syncDeletes(String albumId, Iterable assetIdsToKeep) async { @@ -126,64 +133,93 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository { ); } await _removeAssets(localAlbum.id, toDelete); + await recomputeBackupCandidatesForAlbum(localAlbum.id); + await recomputeBackupCandidatesForAssets(toDelete); }); } Future updateAll(Iterable albums) { return _db.transaction(() async { - await _db.localAlbumEntity.update().write(const LocalAlbumEntityCompanion(marker_: Value(true))); - - await _db.batch((batch) { - for (final album in albums) { - final companion = LocalAlbumEntityCompanion.insert( - id: album.id, - name: album.name, - updatedAt: Value(album.updatedAt), - backupSelection: album.backupSelection, - isIosSharedAlbum: Value(album.isIosSharedAlbum), - marker_: const Value(null), - ); - - batch.insert( - _db.localAlbumEntity, - companion, - onConflict: DoUpdate( - (old) => LocalAlbumEntityCompanion( - id: companion.id, - name: companion.name, - updatedAt: companion.updatedAt, - isIosSharedAlbum: companion.isIosSharedAlbum, - marker_: companion.marker_, - ), - ), - ); - } - }); - + await _markAllAlbums(); + await _upsertAndUnmarkIncoming(albums); if (CurrentPlatform.isAndroid) { - // On Android, an asset can only be in one album - // So, get the albums that are marked for deletion + // On Android, an asset can only be in one album. So, get the albums that are marked for deletion // and delete all the assets that are in those albums - final deleteSmt = _db.localAssetEntity.delete(); - deleteSmt.where((localAsset) { - final subQuery = _db.localAlbumAssetEntity.selectOnly() - ..addColumns([_db.localAlbumAssetEntity.assetId]) - ..join([ - innerJoin(_db.localAlbumEntity, _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id)), - ]); - subQuery.where(_db.localAlbumEntity.marker_.isNotNull()); - return localAsset.id.isInQuery(subQuery); - }); - await deleteSmt.go(); + await _hardDeleteAssetsInMarkedAlbums(); } - - // Only remove albums that are not explicitly selected or excluded from backups - await _db.localAlbumEntity.deleteWhere( - (f) => f.marker_.isNotNull() & f.backupSelection.equalsValue(BackupSelection.none), - ); + final affectedAssetIds = await _removeAssetsFromMarkedAlbums(); + await _deleteMarkedNoneAlbums(); + await recomputeBackupCandidatesForAssets(affectedAssetIds); }); } + Future _markAllAlbums() async { + await _db.localAlbumEntity.update().write(const LocalAlbumEntityCompanion(marker_: Value(true))); + } + + Future _upsertAndUnmarkIncoming(Iterable albums) async { + await _db.batch((batch) { + for (final album in albums) { + final companion = LocalAlbumEntityCompanion.insert( + id: album.id, + name: album.name, + updatedAt: Value(album.updatedAt), + backupSelection: album.backupSelection, + isIosSharedAlbum: Value(album.isIosSharedAlbum), + marker_: const Value(null), + ); + batch.insert( + _db.localAlbumEntity, + companion, + onConflict: DoUpdate( + (old) => LocalAlbumEntityCompanion( + id: companion.id, + name: companion.name, + updatedAt: companion.updatedAt, + isIosSharedAlbum: companion.isIosSharedAlbum, + marker_: companion.marker_, + ), + ), + ); + } + }); + } + + Future _hardDeleteAssetsInMarkedAlbums() async { + final deleteSmt = _db.localAssetEntity.delete(); + deleteSmt.where((localAsset) { + final subQuery = _db.localAlbumAssetEntity.selectOnly() + ..addColumns([_db.localAlbumAssetEntity.assetId]) + ..join([innerJoin(_db.localAlbumEntity, _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id))]); + subQuery.where(_db.localAlbumEntity.marker_.isNotNull()); + return localAsset.id.isInQuery(subQuery); + }); + await deleteSmt.go(); + } + + Future> _removeAssetsFromMarkedAlbums() async { + final orphanedAlbumIds = _db.localAlbumEntity.selectOnly() + ..addColumns([_db.localAlbumEntity.id]) + ..where(_db.localAlbumEntity.marker_.isNotNull()); + + final affectedAssetIds = + await (_db.localAlbumAssetEntity.selectOnly(distinct: true) + ..addColumns([_db.localAlbumAssetEntity.assetId]) + ..where(_db.localAlbumAssetEntity.albumId.isInQuery(orphanedAlbumIds))) + .map((row) => row.read(_db.localAlbumAssetEntity.assetId)!) + .get(); + + await (_db.localAlbumAssetEntity.delete()..where((f) => f.albumId.isInQuery(orphanedAlbumIds))).go(); + + return affectedAssetIds; + } + + Future _deleteMarkedNoneAlbums() async { + await _db.localAlbumEntity.deleteWhere( + (f) => f.marker_.isNotNull() & f.backupSelection.equalsValue(BackupSelection.none), + ); + } + Future> getAssets(String albumId) { final query = _db.localAlbumAssetEntity.select().join([ @@ -191,7 +227,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository { ]) ..where(_db.localAlbumAssetEntity.albumId.equals(albumId)) ..orderBy([OrderingTerm.asc(_db.localAssetEntity.id)]); - return query.map((row) => row.readTable(_db.localAssetEntity).toDto()).get(); + return query.map((row) => mapToLocalAsset(row.readTable(_db.localAssetEntity))).get(); } Future> getAssetIds(String albumId) { @@ -232,6 +268,8 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository { ); }); }); + + await recomputeBackupCandidatesForAssets(assetAlbums.keys); }); } @@ -240,10 +278,65 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository { _db.localAlbumAssetEntity.select().join([ innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)), ]) - ..where(_db.localAlbumAssetEntity.albumId.equals(albumId) & _db.localAssetEntity.checksum.isNull()) + ..where( + _db.localAlbumAssetEntity.albumId.equals(albumId) & + _db.localAssetEntity.checksum.isNull() & + _db.localAssetEntity.isBackupCandidate.equals(true), + ) ..orderBy([OrderingTerm.desc(_db.localAssetEntity.createdAt)]); - return query.map((row) => row.readTable(_db.localAssetEntity).toDto()).get(); + return query.map((row) => mapToLocalAsset(row.readTable(_db.localAssetEntity))).get(); + } + + Future recomputeBackupCandidatesForAlbum(String albumId) => _recomputeBackupCandidates( + whereClause: 'WHERE id IN (SELECT asset_id FROM local_album_asset_entity WHERE album_id = ?)', + extraVariables: [Variable.withString(albumId)], + ); + + Future recomputeBackupCandidatesForAssets(Iterable assetIds) async { + final ids = assetIds.toList(growable: false); + if (ids.isEmpty) { + return; + } + for (final slice in ids.slices(kDriftMaxChunk)) { + await _recomputeBackupCandidates( + whereClause: 'WHERE id IN (${List.filled(slice.length, '?').join(',')})', + extraVariables: slice.map(Variable.withString).toList(growable: false), + ); + } + } + + Future recomputeAllBackupCandidates() => _recomputeBackupCandidates(whereClause: '', extraVariables: const []); + + Future _recomputeBackupCandidates({ + required String whereClause, + required List> extraVariables, + }) async { + await _db.customUpdate( + ''' + UPDATE local_asset_entity + SET is_backup_candidate = ( + EXISTS ( + SELECT 1 FROM local_album_asset_entity laa + INNER JOIN local_album_entity la ON la.id = laa.album_id + WHERE laa.asset_id = local_asset_entity.id AND la.backup_selection = ? + ) + AND NOT EXISTS ( + SELECT 1 FROM local_album_asset_entity laa + INNER JOIN local_album_entity la ON la.id = laa.album_id + WHERE laa.asset_id = local_asset_entity.id AND la.backup_selection = ? + ) + ) + $whereClause + ''', + variables: [ + Variable.withInt(BackupSelection.selected.index), + Variable.withInt(BackupSelection.excluded.index), + ...extraVariables, + ], + updates: {_db.localAssetEntity}, + updateKind: UpdateKind.update, + ); } Future updateCloudMapping(Map cloudMapping) { @@ -424,7 +517,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository { ..orderBy([OrderingTerm.desc(_db.localAssetEntity.createdAt)]) ..limit(1); - final results = await query.map((row) => row.readTable(_db.localAssetEntity).toDto()).get(); + final results = await query.map((row) => mapToLocalAsset(row.readTable(_db.localAssetEntity))).get(); return results.isNotEmpty ? results.first : null; } diff --git a/mobile/lib/infrastructure/repositories/local_asset.repository.dart b/mobile/lib/infrastructure/repositories/local_asset.repository.dart index c34d2c4697..47f17d6b77 100644 --- a/mobile/lib/infrastructure/repositories/local_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_asset.repository.dart @@ -6,9 +6,8 @@ import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/enums.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_album.entity.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/mapper.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; class RemovalCandidatesResult { @@ -33,7 +32,7 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository { ])..where(_db.localAssetEntity.id.equals(id)); return query.map((row) { - final asset = row.readTable(_db.localAssetEntity).toDto(); + final asset = mapToLocalAsset(row.readTable(_db.localAssetEntity)); return asset.copyWith(remoteId: row.read(_db.remoteAssetEntity.id)); }); } @@ -43,7 +42,7 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository { Future> getByChecksum(String checksum) { final query = _db.localAssetEntity.select()..where((lae) => lae.checksum.equals(checksum)); - return query.map((row) => row.toDto()).get(); + return query.map(mapToLocalAsset).get(); } Stream watch(String id) => _assetSelectable(id).watchSingleOrNull(); @@ -79,7 +78,7 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository { Future getById(String id) { final query = _db.localAssetEntity.select()..where((lae) => lae.id.equals(id)); - return query.map((row) => row.toDto()).getSingleOrNull(); + return query.map(mapToLocalAsset).getSingleOrNull(); } Future getCount() { @@ -106,7 +105,7 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository { if (backupSelection != null) { query.where((lae) => lae.backupSelection.equalsValue(backupSelection)); } - return query.map((localAlbum) => localAlbum.toDto()).get(); + return query.map(mapToLocalAlbum).get(); } Future>> getAssetsFromBackupAlbums(Iterable remoteIds) async { @@ -138,7 +137,7 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository { for (final row in rows) { final albumId = row.readTable(_db.localAlbumAssetEntity).albumId; - final asset = row.readTable(_db.localAssetEntity).toDto(); + final asset = mapToLocalAsset(row.readTable(_db.localAssetEntity)); (result[albumId] ??= []).add(asset); } } @@ -200,7 +199,7 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository { query.where(whereClause); final rows = await query.get(); - final assets = rows.map((row) => row.readTable(_db.localAssetEntity).toDto()).toList(); + final assets = rows.map((row) => mapToLocalAsset(row.readTable(_db.localAssetEntity))).toList(); final totalBytes = rows.fold(0, (sum, row) { final fileSize = row.readTableOrNull(_db.remoteExifEntity)?.fileSize; return sum + (fileSize ?? 0); @@ -211,7 +210,7 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository { Future> getEmptyCloudIdAssets() { final query = _db.localAssetEntity.select()..where((row) => row.iCloudId.isNull()); - return query.map((row) => row.toDto()).get(); + return query.map(mapToLocalAsset).get(); } Future reconcileHashesFromCloudId() async { diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index 9c4501b665..758028e5ea 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -7,9 +7,9 @@ import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; -import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/mapper.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/map.repository.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @@ -175,7 +175,9 @@ class DriftTimelineRepository extends DriftDatabaseRepository { ..limit(count, offset: offset); return query - .map((row) => row.readTable(_db.localAssetEntity).toDto(remoteId: row.read(_db.remoteAssetEntity.id))) + .map( + (row) => mapToLocalAsset(row.readTable(_db.localAssetEntity), remoteId: row.read(_db.remoteAssetEntity.id)), + ) .get(); } diff --git a/mobile/lib/infrastructure/repositories/trashed_local_asset.repository.dart b/mobile/lib/infrastructure/repositories/trashed_local_asset.repository.dart index 08712588d9..2ef3ac49f1 100644 --- a/mobile/lib/infrastructure/repositories/trashed_local_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/trashed_local_asset.repository.dart @@ -3,10 +3,10 @@ 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/mapper.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; typedef TrashedAsset = ({String albumId, LocalAsset asset}); @@ -198,6 +198,9 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository { isFavorite: Value(e.isFavorite), orientation: Value(e.orientation), playbackStyle: Value(e.playbackStyle), + // getToRestore only restores assets whose album is selected + // TODO: Refactor getToRestore to not assume that and remove the backup candidate flag from here + isBackupCandidate: const Value(true), ); }); @@ -283,7 +286,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository { for (final row in rows) { final albumId = row.readTable(_db.localAlbumAssetEntity).albumId; - final asset = row.readTable(_db.localAssetEntity).toDto(); + final asset = mapToLocalAsset(row.readTable(_db.localAssetEntity)); (result[albumId] ??= []).add(asset); } diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index d387c274ea..05c16b394d 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -14,11 +14,12 @@ import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; -const int targetVersion = 26; +const int targetVersion = 27; Future migrateDatabaseIfNeeded(Drift drift) async { final int version = Store.get(StoreKey.version, targetVersion); @@ -31,6 +32,10 @@ Future migrateDatabaseIfNeeded(Drift drift) async { await _migrateTo26(drift); } + if (version < 27) { + await DriftLocalAlbumRepository(drift).recomputeAllBackupCandidates(); + } + await Store.put(StoreKey.version, targetVersion); return; } diff --git a/mobile/test/drift/main/generated/schema.dart b/mobile/test/drift/main/generated/schema.dart index c0302f2805e2d4ba74ac3c3e41d62419d3302657..ee5900c1d1bcdb37eeb4a40446e6f501dea18744 100644 GIT binary patch delta 92 zcmdlXIZbNAM3%|SjDmcIdMSxTCF%-^#R_G{hMW6Y&ai1FCl;qF7#mt~DL_F{YDsBP k9!PVc;Bn+tBpF4$-R{^M22dFmInyZ!z0N@)LPXGV_ delta 23 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 row.id.equals(album.id))).write( + const LocalAlbumEntityCompanion(backupSelection: Value(BackupSelection.excluded)), + ); + await sut.recomputeAllBackupCandidates(); + expect(await ctx.isAssetBackupCandidate(asset.id), isFalse); + }); + }); + + group('recomputeBackupCandidatesForAlbum', () { + test('only touches assets in the given album', () async { + final albumA = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected); + final albumB = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected); + final inAlbumA = await ctx.newLocalAsset(); + final inAlbumB = await ctx.newLocalAsset(); + await ctx.newLocalAlbumAsset(albumId: albumA.id, assetId: inAlbumA.id, recomputeBackupCandidates: false); + await ctx.newLocalAlbumAsset(albumId: albumB.id, assetId: inAlbumB.id, recomputeBackupCandidates: false); + await sut.recomputeBackupCandidatesForAlbum(albumA.id); + + expect(await ctx.isAssetBackupCandidate(inAlbumA.id), isTrue); + expect( + await ctx.isAssetBackupCandidate(inAlbumB.id), + isFalse, + reason: 'asset in album B should be untouched by an album A-scoped recompute', + ); + }); + }); + + group('recomputeBackupCandidatesForAssets', () { + test('only touches the listed assets', () async { + final selected = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected); + final touched = await ctx.newLocalAsset(); + final untouched = await ctx.newLocalAsset(); + await ctx.newLocalAlbumAsset(albumId: selected.id, assetId: touched.id, recomputeBackupCandidates: false); + await ctx.newLocalAlbumAsset(albumId: selected.id, assetId: untouched.id, recomputeBackupCandidates: false); + await sut.recomputeBackupCandidatesForAssets([touched.id]); + + expect(await ctx.isAssetBackupCandidate(touched.id), isTrue); + expect(await ctx.isAssetBackupCandidate(untouched.id), isFalse); + }); + }); + + group('upsert', () { + test('flipping selection via upsert recomputes the album', () async { + final asset = await ctx.newLocalAsset(); + final album = mapToLocalAlbum(await ctx.newLocalAlbum(backupSelection: BackupSelection.none), assetCount: 1); + await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id, recomputeBackupCandidates: false); + await sut.recomputeAllBackupCandidates(); + expect(await ctx.isAssetBackupCandidate(asset.id), isFalse); + + await sut.upsert(album.copyWith(backupSelection: BackupSelection.selected, updatedAt: DateTime.now())); + expect(await ctx.isAssetBackupCandidate(asset.id), isTrue); + }); + }); + + group('delete (iOS)', () { + setUp(() { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + }); + tearDown(() { + debugDefaultTargetPlatformOverride = null; + }); + + test('flips candidate flag when the only selected album is deleted', () async { + final selected = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected); + final other = await ctx.newLocalAlbum(backupSelection: BackupSelection.none); + final asset = await ctx.newLocalAsset(); + await ctx.newLocalAlbumAsset(albumId: selected.id, assetId: asset.id, recomputeBackupCandidates: false); + await ctx.newLocalAlbumAsset(albumId: other.id, assetId: asset.id, recomputeBackupCandidates: false); + await sut.recomputeAllBackupCandidates(); + expect(await ctx.isAssetBackupCandidate(asset.id), isTrue); + + await sut.delete(selected.id); + expect(await ctx.isAssetBackupCandidate(asset.id), isFalse, reason: 'survivor lost its only selected membership'); + expect(await ctx.albumAssetCount(selected.id), 0, reason: 'memberships must reflect the now-empty album'); + expect(await ctx.hasLocalAlbum(selected.id), isTrue, reason: 'selected album row is preserved as a bookmark'); + }); + + test('keeps candidate flag when another selected album remains', () async { + final albumA = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected); + final albumB = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected); + final asset = await ctx.newLocalAsset(); + await ctx.newLocalAlbumAsset(albumId: albumA.id, assetId: asset.id, recomputeBackupCandidates: false); + await ctx.newLocalAlbumAsset(albumId: albumB.id, assetId: asset.id, recomputeBackupCandidates: false); + await sut.recomputeAllBackupCandidates(); + expect(await ctx.isAssetBackupCandidate(asset.id), isTrue); + + await sut.delete(albumA.id); + expect(await ctx.isAssetBackupCandidate(asset.id), isTrue, reason: 'albumB still keeps the asset a candidate'); + expect(await ctx.albumAssetCount(albumA.id), 0); + }); + + test('deleting an excluded album can flip a flag from false to true', () async { + final selected = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected); + final excluded = await ctx.newLocalAlbum(backupSelection: BackupSelection.excluded); + final asset = await ctx.newLocalAsset(); + await ctx.newLocalAlbumAsset(albumId: selected.id, assetId: asset.id, recomputeBackupCandidates: false); + await ctx.newLocalAlbumAsset(albumId: excluded.id, assetId: asset.id, recomputeBackupCandidates: false); + await sut.recomputeAllBackupCandidates(); + expect( + await ctx.isAssetBackupCandidate(asset.id), + isFalse, + reason: 'excluded suppresses an otherwise-candidate asset', + ); + + await sut.delete(excluded.id); + expect( + await ctx.isAssetBackupCandidate(asset.id), + isTrue, + reason: 'with excluded gone, the selected membership wins', + ); + expect(await ctx.albumAssetCount(excluded.id), 0); + expect(await ctx.hasLocalAlbum(excluded.id), isTrue, reason: 'excluded album row is preserved as a bookmark'); + }); + + test('deleting a none album does not flip flags, and the row is removed', () async { + final selected = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected); + final none = await ctx.newLocalAlbum(backupSelection: BackupSelection.none); + final asset = await ctx.newLocalAsset(); + await ctx.newLocalAlbumAsset(albumId: selected.id, assetId: asset.id, recomputeBackupCandidates: false); + await ctx.newLocalAlbumAsset(albumId: none.id, assetId: asset.id, recomputeBackupCandidates: false); + await sut.recomputeAllBackupCandidates(); + expect(await ctx.isAssetBackupCandidate(asset.id), isTrue); + + await sut.delete(none.id); + expect( + await ctx.isAssetBackupCandidate(asset.id), + isTrue, + reason: 'none membership never affected the predicate', + ); + expect(await ctx.albumAssetCount(none.id), 0); + expect(await ctx.hasLocalAlbum(none.id), isFalse, reason: 'none album row is hard-deleted'); + }); + }); + + group('updateAll (iOS)', () { + setUp(() { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + }); + tearDown(() { + debugDefaultTargetPlatformOverride = null; + }); + + test('drops selected memberships when album removed from device', () async { + final selected = mapToLocalAlbum(await ctx.newLocalAlbum(backupSelection: BackupSelection.selected)); + final kept = mapToLocalAlbum(await ctx.newLocalAlbum(backupSelection: BackupSelection.none)); + final asset = await ctx.newLocalAsset(); + await ctx.newLocalAlbumAsset(albumId: selected.id, assetId: asset.id, recomputeBackupCandidates: false); + await ctx.newLocalAlbumAsset(albumId: kept.id, assetId: asset.id, recomputeBackupCandidates: false); + await sut.recomputeAllBackupCandidates(); + expect(await ctx.isAssetBackupCandidate(asset.id), isTrue); + + await sut.updateAll([kept]); + expect(await ctx.isAssetBackupCandidate(asset.id), isFalse, reason: 'survivor lost its only selected membership'); + expect(await ctx.albumAssetCount(selected.id), 0, reason: 'selected album must be empty'); + expect(await ctx.hasLocalAlbum(selected.id), isTrue, reason: 'selected album row preserved as bookmark'); + }); + + test('drops excluded memberships when album removed from device', () async { + final excluded = mapToLocalAlbum(await ctx.newLocalAlbum(backupSelection: BackupSelection.excluded)); + final selected = mapToLocalAlbum(await ctx.newLocalAlbum(backupSelection: BackupSelection.selected)); + final asset = await ctx.newLocalAsset(); + await ctx.newLocalAlbumAsset(albumId: excluded.id, assetId: asset.id, recomputeBackupCandidates: false); + await ctx.newLocalAlbumAsset(albumId: selected.id, assetId: asset.id, recomputeBackupCandidates: false); + await sut.recomputeAllBackupCandidates(); + expect( + await ctx.isAssetBackupCandidate(asset.id), + isFalse, + reason: 'excluded suppresses an otherwise-candidate asset', + ); + + await sut.updateAll([selected]); + expect(await ctx.isAssetBackupCandidate(asset.id), isTrue, reason: 'with excluded gone, selected wins'); + expect(await ctx.albumAssetCount(excluded.id), 0); + expect(await ctx.hasLocalAlbum(excluded.id), isTrue, reason: 'excluded album row preserved as a bookmark'); + }); + + test('removes none rows entirely and leaves untouched albums alone', () async { + final selected = mapToLocalAlbum(await ctx.newLocalAlbum(backupSelection: BackupSelection.selected)); + final none = mapToLocalAlbum(await ctx.newLocalAlbum(backupSelection: BackupSelection.none)); + final asset = await ctx.newLocalAsset(); + await ctx.newLocalAlbumAsset(albumId: selected.id, assetId: asset.id, recomputeBackupCandidates: false); + await ctx.newLocalAlbumAsset(albumId: none.id, assetId: asset.id, recomputeBackupCandidates: false); + await sut.recomputeAllBackupCandidates(); + expect(await ctx.isAssetBackupCandidate(asset.id), isTrue); + + await sut.updateAll([selected]); + + expect( + await ctx.isAssetBackupCandidate(asset.id), + isTrue, + reason: 'none membership never affected the predicate', + ); + expect(await ctx.hasLocalAlbum(none.id), isFalse, reason: 'none removed rows are hard-deleted'); + expect(await ctx.hasLocalAlbum(selected.id), isTrue); + expect(await ctx.albumAssetCount(selected.id), 1, reason: 'kept album retains its membership'); + }); + }); + + group('delete (Android)', () { + setUp(() { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + }); + tearDown(() { + debugDefaultTargetPlatformOverride = null; + }); + + test('hard-deletes all member assets and preserves a selected album as a bookmark', () async { + final selected = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected); + final other = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected); + final assetA = await ctx.newLocalAsset(); + final assetB = await ctx.newLocalAsset(); + final assetC = await ctx.newLocalAsset(); + await ctx.newLocalAlbumAsset(albumId: selected.id, assetId: assetA.id, recomputeBackupCandidates: false); + await ctx.newLocalAlbumAsset(albumId: selected.id, assetId: assetB.id, recomputeBackupCandidates: false); + await ctx.newLocalAlbumAsset(albumId: other.id, assetId: assetC.id, recomputeBackupCandidates: false); + await sut.recomputeAllBackupCandidates(); + expect(await ctx.isAssetBackupCandidate(assetA.id), isTrue); + + await sut.delete(selected.id); + expect(await ctx.hasLocalAsset(assetA.id), isFalse, reason: 'Android removes every member asset of the album'); + expect(await ctx.hasLocalAsset(assetB.id), isFalse, reason: 'Android removes every member asset of the album'); + expect(await ctx.albumAssetCount(selected.id), 0, reason: 'cascade clears memberships of deleted assets'); + expect(await ctx.hasLocalAlbum(selected.id), isTrue, reason: 'selected album row is preserved as a bookmark'); + expect(await ctx.hasLocalAsset(assetC.id), isTrue, reason: 'a different album keeps its own distinct assets'); + }); + + test('hard-deletes member assets and removes a none album row', () async { + final none = await ctx.newLocalAlbum(backupSelection: BackupSelection.none); + final asset = await ctx.newLocalAsset(); + await ctx.newLocalAlbumAsset(albumId: none.id, assetId: asset.id, recomputeBackupCandidates: false); + + await sut.delete(none.id); + expect(await ctx.hasLocalAsset(asset.id), isFalse); + expect(await ctx.albumAssetCount(none.id), 0); + expect(await ctx.hasLocalAlbum(none.id), isFalse, reason: 'none album row is hard-deleted'); + }); + }); + + group('updateAll (Android)', () { + setUp(() { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + }); + tearDown(() { + debugDefaultTargetPlatformOverride = null; + }); + + test('hard-deletes assets of a removed selected album, preserving its row as a bookmark', () async { + final removed = mapToLocalAlbum(await ctx.newLocalAlbum(backupSelection: BackupSelection.selected)); + final kept = mapToLocalAlbum(await ctx.newLocalAlbum(backupSelection: BackupSelection.selected)); + final removedAsset = await ctx.newLocalAsset(); + final keptAsset = await ctx.newLocalAsset(); + await ctx.newLocalAlbumAsset(albumId: removed.id, assetId: removedAsset.id, recomputeBackupCandidates: false); + await ctx.newLocalAlbumAsset(albumId: kept.id, assetId: keptAsset.id, recomputeBackupCandidates: false); + await sut.recomputeAllBackupCandidates(); + await sut.updateAll([kept]); + + expect( + await ctx.hasLocalAsset(removedAsset.id), + isFalse, + reason: 'removed album assets are gone from the device', + ); + expect(await ctx.albumAssetCount(removed.id), 0, reason: 'cascade clears the removed album memberships'); + expect(await ctx.hasLocalAlbum(removed.id), isTrue, reason: 'selected removed row preserved as bookmark'); + expect(await ctx.isAssetBackupCandidate(keptAsset.id), isTrue, reason: 'surviving album assets are untouched'); + expect(await ctx.albumAssetCount(kept.id), 1); + }); + + test('hard-deletes assets of a removed none album and removes the row', () async { + final removed = mapToLocalAlbum(await ctx.newLocalAlbum(backupSelection: BackupSelection.none)); + final kept = mapToLocalAlbum(await ctx.newLocalAlbum(backupSelection: BackupSelection.selected)); + final removedAsset = await ctx.newLocalAsset(); + await ctx.newLocalAlbumAsset(albumId: removed.id, assetId: removedAsset.id, recomputeBackupCandidates: false); + + await sut.updateAll([kept]); + + expect(await ctx.hasLocalAsset(removedAsset.id), isFalse); + expect(await ctx.hasLocalAlbum(removed.id), isFalse, reason: 'none removed rows are hard-deleted'); + expect(await ctx.hasLocalAlbum(kept.id), isTrue); + }); + }); + + group('upsert selection flips', () { + test('flipping selected to excluded flips a candidate back to false', () async { + final asset = await ctx.newLocalAsset(); + final album = mapToLocalAlbum(await ctx.newLocalAlbum(backupSelection: BackupSelection.selected), assetCount: 1); + await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id, recomputeBackupCandidates: false); + await sut.recomputeAllBackupCandidates(); + expect(await ctx.isAssetBackupCandidate(asset.id), isTrue); + + await sut.upsert(album.copyWith(backupSelection: BackupSelection.excluded, updatedAt: DateTime.now())); + expect(await ctx.isAssetBackupCandidate(asset.id), isFalse); + }); + + test('flipping selected to none flips a candidate back to false', () async { + final asset = await ctx.newLocalAsset(); + final album = mapToLocalAlbum(await ctx.newLocalAlbum(backupSelection: BackupSelection.selected), assetCount: 1); + await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id, recomputeBackupCandidates: false); + await sut.recomputeAllBackupCandidates(); + expect(await ctx.isAssetBackupCandidate(asset.id), isTrue); + + await sut.upsert(album.copyWith(backupSelection: BackupSelection.none, updatedAt: DateTime.now())); + expect(await ctx.isAssetBackupCandidate(asset.id), isFalse); + }); + }); +} diff --git a/mobile/test/medium/repositories/local_asset_repository_test.dart b/mobile/test/medium/repositories/local_asset_repository_test.dart index bc74728346..6a882d0744 100644 --- a/mobile/test/medium/repositories/local_asset_repository_test.dart +++ b/mobile/test/medium/repositories/local_asset_repository_test.dart @@ -15,10 +15,6 @@ void main() { sut = DriftLocalAssetRepository(ctx.db); }); - tearDown(() async { - await ctx.dispose(); - }); - group('getRemovalCandidates', () { final cutoffDate = DateTime(2024, 1, 1); final beforeCutoff = DateTime(2023, 12, 31); diff --git a/mobile/test/medium/repositories/partner_repository_test.dart b/mobile/test/medium/repositories/partner_repository_test.dart index 298b8b852d..4d22a115cc 100644 --- a/mobile/test/medium/repositories/partner_repository_test.dart +++ b/mobile/test/medium/repositories/partner_repository_test.dart @@ -12,10 +12,6 @@ void main() { sut = PartnerRepository(ctx.db); }); - tearDown(() async { - await ctx.dispose(); - }); - group('search', () { test('sharedBy returns users the current user shares their library to', () async { final me = await ctx.newUser(); diff --git a/mobile/test/medium/repositories/people_repository_test.dart b/mobile/test/medium/repositories/people_repository_test.dart index 4b51862a7d..cbc6bff762 100644 --- a/mobile/test/medium/repositories/people_repository_test.dart +++ b/mobile/test/medium/repositories/people_repository_test.dart @@ -12,10 +12,6 @@ void main() { sut = DriftPeopleRepository(ctx.db); }); - tearDown(() async { - await ctx.dispose(); - }); - group('getAssetPeople', () { test('does not duplicate a person with multiple face records on the same asset', () async { // Regression check for #20585: a join on asset_face_entity returned one row diff --git a/mobile/test/medium/repositories/remote_album_repository_test.dart b/mobile/test/medium/repositories/remote_album_repository_test.dart index 5a82ff315f..24178c1798 100644 --- a/mobile/test/medium/repositories/remote_album_repository_test.dart +++ b/mobile/test/medium/repositories/remote_album_repository_test.dart @@ -13,10 +13,6 @@ void main() { sut = DriftRemoteAlbumRepository(ctx.db); }); - tearDown(() async { - await ctx.dispose(); - }); - group('addAssets', () { test('sets the first added asset as thumbnail when the album has no thumbnail', () async { final user = await ctx.newUser(); diff --git a/mobile/test/medium/repositories/settings_repository_test.dart b/mobile/test/medium/repositories/settings_repository_test.dart index 087b3aece4..38a3e812de 100644 --- a/mobile/test/medium/repositories/settings_repository_test.dart +++ b/mobile/test/medium/repositories/settings_repository_test.dart @@ -16,10 +16,6 @@ void main() { sut = await SettingsRepository.ensureInitialized(ctx.db); }); - tearDownAll(() async { - await ctx.dispose(); - }); - setUp(() async { await ctx.db.delete(ctx.db.settingsEntity).go(); await SettingsRepository.instance.refresh(); diff --git a/mobile/test/medium/repositories/timeline_repository_test.dart b/mobile/test/medium/repositories/timeline_repository_test.dart index f604f53c3e..20a5036408 100644 --- a/mobile/test/medium/repositories/timeline_repository_test.dart +++ b/mobile/test/medium/repositories/timeline_repository_test.dart @@ -18,10 +18,6 @@ void main() { sut = DriftTimelineRepository(ctx.db); }); - tearDown(() async { - await ctx.dispose(); - }); - group('remoteAlbum assets', () { test('no duplicate assets when identical checksum appears in multiple local asset rows', () async { // Regression check for #23273: a LEFT OUTER JOIN on checksum would fan out and create duplicates diff --git a/mobile/test/medium/repositories/trashed_local_asset_repository_test.dart b/mobile/test/medium/repositories/trashed_local_asset_repository_test.dart new file mode 100644 index 0000000000..a4dff646c3 --- /dev/null +++ b/mobile/test/medium/repositories/trashed_local_asset_repository_test.dart @@ -0,0 +1,93 @@ +import 'package:drift/drift.dart' show TableOrViewStatements; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/album/local_album.model.dart'; +import 'package:immich_mobile/infrastructure/mapper.dart'; +import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; + +import '../repository_context.dart'; + +void main() { + late MediumRepositoryContext ctx; + late DriftTrashedLocalAssetRepository sut; + + setUp(() { + ctx = MediumRepositoryContext(); + sut = DriftTrashedLocalAssetRepository(ctx.db); + }); + + Future trashedCount(String assetId) async => + await (ctx.db.trashedLocalAssetEntity.count(where: (row) => row.id.equals(assetId))).getSingle(); + + group('trash and restore lifecycle', () { + test('trashing a candidate removes the local asset and cascades its album membership', () async { + final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected); + final asset = await ctx.newLocalAsset(); + await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id); + + expect( + await ctx.isAssetBackupCandidate(asset.id), + isTrue, + reason: 'asset in a selected album starts as a candidate', + ); + expect(await ctx.albumAssetCount(album.id), 1); + + await sut.trashLocalAsset({ + album.id: [mapToLocalAsset(asset)], + }); + + expect(await ctx.hasLocalAsset(asset.id), isFalse, reason: 'row moves out of local_asset_entity'); + expect(await ctx.albumAssetCount(album.id), 0, reason: 'FK cascade removes the album membership'); + expect(await trashedCount(asset.id), 1, reason: 'asset now lives in the trashed table'); + }); + + test('restoring re-inserts the asset and marks it a backup candidate', () async { + final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected); + final asset = await ctx.newLocalAsset(); + await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id); + await sut.trashLocalAsset({ + album.id: [mapToLocalAsset(asset)], + }); + + await sut.applyRestoredAssets([asset.id]); + + expect(await ctx.hasLocalAsset(asset.id), isTrue, reason: 'row returns to local_asset_entity'); + expect(await trashedCount(asset.id), 0, reason: 'trashed row is consumed'); + expect(await ctx.isAssetBackupCandidate(asset.id), isTrue, reason: 'restored asset is a candidate again'); + expect(await ctx.albumAssetCount(album.id), 0, reason: 'restore does not re-link membership'); + }); + }); + + group('getToRestore', () { + test('returns trashed assets whose album is selected and whose remote copy is live again', () async { + const checksum = 'shared-checksum'; + final owner = await ctx.newUser(); + final selected = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected); + final asset = await ctx.newLocalAsset(checksum: checksum); + await ctx.newLocalAlbumAsset(albumId: selected.id, assetId: asset.id); + await ctx.newRemoteAsset(checksum: checksum, ownerId: owner.id); + await sut.trashLocalAsset({ + selected.id: [mapToLocalAsset(asset)], + }); + + final toRestore = await sut.getToRestore(); + + expect(toRestore.map((a) => a.id), contains(asset.id)); + }); + + test('ignores trashed assets whose album is not selected', () async { + const checksum = 'shared-checksum'; + final owner = await ctx.newUser(); + final none = await ctx.newLocalAlbum(backupSelection: BackupSelection.none); + final asset = await ctx.newLocalAsset(checksum: checksum); + await ctx.newLocalAlbumAsset(albumId: none.id, assetId: asset.id); + await ctx.newRemoteAsset(checksum: checksum, ownerId: owner.id); + await sut.trashLocalAsset({ + none.id: [mapToLocalAsset(asset)], + }); + + final toRestore = await sut.getToRestore(); + + expect(toRestore, isEmpty); + }); + }); +} diff --git a/mobile/test/medium/repository_context.dart b/mobile/test/medium/repository_context.dart index 436a58aaf8..2b03efece5 100644 --- a/mobile/test/medium/repository_context.dart +++ b/mobile/test/medium/repository_context.dart @@ -1,5 +1,6 @@ import 'package:drift/drift.dart'; import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/domain/models/album/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'; @@ -17,6 +18,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift. import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/utils/option.dart'; import 'package:uuid/uuid.dart'; @@ -25,12 +27,29 @@ import '../utils.dart'; class MediumRepositoryContext { final Drift db; - MediumRepositoryContext() : db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); + MediumRepositoryContext() : db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)) { + addTearDown(dispose); + } Future dispose() async { await db.close(); } + Future isAssetBackupCandidate(String assetId) async => + await (db.localAssetEntity.select()..where((row) => row.id.equals(assetId))) + .map((row) => row.isBackupCandidate) + .getSingleOrNull() == + true; + + Future hasLocalAsset(String assetId) async => + await (db.localAssetEntity.count(where: (row) => row.id.equals(assetId))).getSingle() == 1; + + Future hasLocalAlbum(String albumId) async => + await (db.localAlbumEntity.count(where: (row) => row.id.equals(albumId))).getSingle() == 1; + + Future albumAssetCount(String albumId) async => + await (db.localAlbumAssetEntity.count(where: (row) => row.albumId.equals(albumId))).getSingle(); + static Value _resolveUndefined(T? plain, Option? option, T fallback) { if (plain != null) { return .new(plain); @@ -214,8 +233,8 @@ class MediumRepositoryContext { } Future newFace({String? assetId, String? personId, int? imageWidth, int? imageHeight}) { - imageWidth ??= TestUtils.randInt(999) + 1; - imageHeight ??= TestUtils.randInt(999) + 1; + imageWidth ??= TestUtils.randInt(998) + 2; + imageHeight ??= TestUtils.randInt(998) + 2; final x1 = TestUtils.randInt(imageWidth - 1); final y1 = TestUtils.randInt(imageHeight - 1); @@ -306,7 +325,16 @@ class MediumRepositoryContext { ); } - Future newLocalAlbumAsset({required String albumId, required String assetId}) => db - .into(db.localAlbumAssetEntity) - .insert(LocalAlbumAssetEntityCompanion(albumId: .new(albumId), assetId: .new(assetId))); + Future newLocalAlbumAsset({ + required String albumId, + required String assetId, + bool recomputeBackupCandidates = true, + }) async { + await db + .into(db.localAlbumAssetEntity) + .insert(LocalAlbumAssetEntityCompanion(albumId: .new(albumId), assetId: .new(assetId))); + if (recomputeBackupCandidates) { + await DriftLocalAlbumRepository(db).recomputeBackupCandidatesForAssets([assetId]); + } + } } diff --git a/mobile/test/medium/services/partner_service_test.dart b/mobile/test/medium/services/partner_service_test.dart index 4c31c9212e..3e5fcb8bee 100644 --- a/mobile/test/medium/services/partner_service_test.dart +++ b/mobile/test/medium/services/partner_service_test.dart @@ -13,10 +13,6 @@ void main() { sut = PartnerService(ctx.userRepository, ctx.partnerRepository, ctx.partnerApi); }); - tearDown(() async { - await ctx.dispose(); - }); - group('getCandidates', () { test('returns the other users and excludes the current user', () async { final me = await ctx.newUser();