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 0000000000..0f3d7352e7 Binary files /dev/null and b/mobile/drift_schemas/main/drift_schema_v31.json differ 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 fe03f9b208..be78877313 100644 Binary files a/mobile/lib/infrastructure/entities/local_asset.entity.drift.dart and b/mobile/lib/infrastructure/entities/local_asset.entity.drift.dart differ diff --git a/mobile/lib/infrastructure/entities/merged_asset.drift b/mobile/lib/infrastructure/entities/merged_asset.drift index d0321ab1ef..0b00cba480 100644 --- a/mobile/lib/infrastructure/entities/merged_asset.drift +++ b/mobile/lib/infrastructure/entities/merged_asset.drift @@ -1,8 +1,6 @@ import 'remote_asset.entity.dart'; import 'stack.entity.dart'; import 'local_asset.entity.dart'; -import 'local_album.entity.dart'; -import 'local_album_asset.entity.dart'; mergedAsset: SELECT @@ -73,16 +71,7 @@ FROM WHERE NOT EXISTS ( SELECT 1 FROM remote_asset_entity rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN :user_ids ) -AND EXISTS ( - SELECT 1 FROM local_album_asset_entity laa - INNER JOIN local_album_entity la on laa.album_id = la.id - WHERE laa.asset_id = lae.id AND la.backup_selection = 0 -- selected -) -AND NOT EXISTS ( - SELECT 1 FROM local_album_asset_entity laa - INNER JOIN local_album_entity la on laa.album_id = la.id - WHERE laa.asset_id = lae.id AND la.backup_selection = 2 -- excluded -) +AND lae.is_backup_candidate ORDER BY created_at DESC LIMIT $limit; @@ -126,16 +115,7 @@ FROM WHERE NOT EXISTS ( SELECT 1 FROM remote_asset_entity rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN :user_ids ) - AND EXISTS ( - SELECT 1 FROM local_album_asset_entity laa - INNER JOIN local_album_entity la on laa.album_id = la.id - WHERE laa.asset_id = lae.id AND la.backup_selection = 0 -- selected - ) - AND NOT EXISTS ( - SELECT 1 FROM local_album_asset_entity laa - INNER JOIN local_album_entity la on laa.album_id = la.id - WHERE laa.asset_id = lae.id AND la.backup_selection = 2 -- excluded - ) + AND lae.is_backup_candidate ) GROUP BY bucket_date ORDER BY bucket_date DESC; diff --git a/mobile/lib/infrastructure/entities/merged_asset.drift.dart b/mobile/lib/infrastructure/entities/merged_asset.drift.dart index 2d05ef6ceb..27b8ceab7d 100644 Binary files a/mobile/lib/infrastructure/entities/merged_asset.drift.dart and b/mobile/lib/infrastructure/entities/merged_asset.drift.dart differ diff --git a/mobile/lib/infrastructure/mapper.dart b/mobile/lib/infrastructure/mapper.dart index a53aa2419a..5bef6ef453 100644 --- a/mobile/lib/infrastructure/mapper.dart +++ b/mobile/lib/infrastructure/mapper.dart @@ -1,4 +1,8 @@ +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/domain/models/user.model.dart'; +import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'; @@ -13,3 +17,34 @@ User mapToUser(UserEntityData data) => 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 887daea723..7c878a6f61 100644 Binary files a/mobile/lib/infrastructure/repositories/db.repository.drift.dart and b/mobile/lib/infrastructure/repositories/db.repository.drift.dart differ diff --git a/mobile/lib/infrastructure/repositories/db.repository.steps.dart b/mobile/lib/infrastructure/repositories/db.repository.steps.dart index 91f5e8c3ef..3138e84f33 100644 Binary files a/mobile/lib/infrastructure/repositories/db.repository.steps.dart and b/mobile/lib/infrastructure/repositories/db.repository.steps.dart differ diff --git a/mobile/lib/infrastructure/repositories/local_album.repository.dart b/mobile/lib/infrastructure/repositories/local_album.repository.dart index 2c80385c34..8fe005bb24 100644 --- a/mobile/lib/infrastructure/repositories/local_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_album.repository.dart @@ -1,14 +1,15 @@ import 'dart:async'; +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/extensions/platform_extensions.dart'; -import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart'; import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.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'; enum SortLocalAlbumsBy { id, backupSelection, isIosSharedAlbum, name, assetCount, newestAsset } @@ -47,26 +48,32 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository { query.orderBy(orderings); } - return query.map((row) => 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 c0302f2805..ee5900c1d1 100644 Binary files a/mobile/test/drift/main/generated/schema.dart and b/mobile/test/drift/main/generated/schema.dart differ diff --git a/mobile/test/drift/main/generated/schema_v31.dart b/mobile/test/drift/main/generated/schema_v31.dart new file mode 100644 index 0000000000..ef7614e65d Binary files /dev/null and b/mobile/test/drift/main/generated/schema_v31.dart differ diff --git a/mobile/test/medium/repositories/backup_repository_test.dart b/mobile/test/medium/repositories/backup_repository_test.dart index 00ab28df87..42191577d7 100644 --- a/mobile/test/medium/repositories/backup_repository_test.dart +++ b/mobile/test/medium/repositories/backup_repository_test.dart @@ -14,10 +14,6 @@ void main() { sut = DriftBackupRepository(ctx.db); }); - tearDown(() async { - await ctx.dispose(); - }); - group('getAllCounts', () { late String userId; diff --git a/mobile/test/medium/repositories/local_album_repository_test.dart b/mobile/test/medium/repositories/local_album_repository_test.dart new file mode 100644 index 0000000000..8f7956c554 --- /dev/null +++ b/mobile/test/medium/repositories/local_album_repository_test.dart @@ -0,0 +1,366 @@ +import 'package:drift/drift.dart' show TableStatements, Value; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.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/mapper.dart'; +import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; + +import '../repository_context.dart'; + +void main() { + late MediumRepositoryContext ctx; + late DriftLocalAlbumRepository sut; + + setUp(() { + ctx = MediumRepositoryContext(); + sut = DriftLocalAlbumRepository(ctx.db); + }); + + group('recomputeAllBackupCandidates', () { + test('sets flag true when asset is only in a selected album', () async { + final selected = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected); + final asset = await ctx.newLocalAsset(); + await ctx.newLocalAlbumAsset(albumId: selected.id, assetId: asset.id, recomputeBackupCandidates: false); + + expect(await ctx.isAssetBackupCandidate(asset.id), isFalse); + await sut.recomputeAllBackupCandidates(); + expect(await ctx.isAssetBackupCandidate(asset.id), isTrue); + }); + + test('keeps flag false when asset is only in a none / excluded album', () async { + final none = await ctx.newLocalAlbum(backupSelection: BackupSelection.none); + final excluded = await ctx.newLocalAlbum(backupSelection: BackupSelection.excluded); + final inNone = await ctx.newLocalAsset(); + final inExcluded = await ctx.newLocalAsset(); + await ctx.newLocalAlbumAsset(albumId: none.id, assetId: inNone.id, recomputeBackupCandidates: false); + await ctx.newLocalAlbumAsset(albumId: excluded.id, assetId: inExcluded.id, recomputeBackupCandidates: false); + + await sut.recomputeAllBackupCandidates(); + expect(await ctx.isAssetBackupCandidate(inNone.id), isFalse); + expect(await ctx.isAssetBackupCandidate(inExcluded.id), isFalse); + }); + + test('keeps flag false when asset is in both a selected and an excluded album', () 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); + }); + + test('flipping selection to excluded flips a candidate back to false', () async { + final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected); + final asset = await ctx.newLocalAsset(); + await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id, recomputeBackupCandidates: false); + await sut.recomputeAllBackupCandidates(); + expect(await ctx.isAssetBackupCandidate(asset.id), isTrue); + + await (ctx.db.localAlbumEntity.update()..where((row) => 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();