refactor: per asset backup flag

This commit is contained in:
shenlong-tanwen 2026-06-13 22:09:03 +05:30
parent b21af78454
commit e6db274aa9
30 changed files with 726 additions and 227 deletions

Binary file not shown.

View File

@ -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<List<_CloudIdMapping>> _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();
}

View File

@ -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<Column> 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,
);
}
}

View File

@ -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<Column> 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,
);
}

View File

@ -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;

View File

@ -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,
);

View File

@ -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<List<LocalAsset>> 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();
}
}

View File

@ -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);
},
),
);

View File

@ -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<List<LocalAlbum>> 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<void> 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<void> syncDeletes(String albumId, Iterable<String> assetIdsToKeep) async {
@ -126,64 +133,93 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
);
}
await _removeAssets(localAlbum.id, toDelete);
await recomputeBackupCandidatesForAlbum(localAlbum.id);
await recomputeBackupCandidatesForAssets(toDelete);
});
}
Future<void> updateAll(Iterable<LocalAlbum> 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<void> _markAllAlbums() async {
await _db.localAlbumEntity.update().write(const LocalAlbumEntityCompanion(marker_: Value(true)));
}
Future<void> _upsertAndUnmarkIncoming(Iterable<LocalAlbum> 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<void> _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<List<String>> _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<void> _deleteMarkedNoneAlbums() async {
await _db.localAlbumEntity.deleteWhere(
(f) => f.marker_.isNotNull() & f.backupSelection.equalsValue(BackupSelection.none),
);
}
Future<List<LocalAsset>> 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<List<String>> 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<void> recomputeBackupCandidatesForAlbum(String albumId) => _recomputeBackupCandidates(
whereClause: 'WHERE id IN (SELECT asset_id FROM local_album_asset_entity WHERE album_id = ?)',
extraVariables: [Variable.withString(albumId)],
);
Future<void> recomputeBackupCandidatesForAssets(Iterable<String> 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<void> recomputeAllBackupCandidates() => _recomputeBackupCandidates(whereClause: '', extraVariables: const []);
Future<void> _recomputeBackupCandidates({
required String whereClause,
required List<Variable<Object>> 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<void> updateCloudMapping(Map<String, String> 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;
}

View File

@ -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<List<LocalAsset?>> 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<LocalAsset?> watch(String id) => _assetSelectable(id).watchSingleOrNull();
@ -79,7 +78,7 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
Future<LocalAsset?> 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<int> 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<Map<String, List<LocalAsset>>> getAssetsFromBackupAlbums(Iterable<String> 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] ??= <LocalAsset>[]).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<int>(0, (sum, row) {
final fileSize = row.readTableOrNull(_db.remoteExifEntity)?.fileSize;
return sum + (fileSize ?? 0);
@ -211,7 +210,7 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
Future<List<LocalAsset>> getEmptyCloudIdAssets() {
final query = _db.localAssetEntity.select()..where((row) => row.iCloudId.isNull());
return query.map((row) => row.toDto()).get();
return query.map(mapToLocalAsset).get();
}
Future<void> reconcileHashesFromCloudId() async {

View File

@ -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();
}

View File

@ -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] ??= <LocalAsset>[]).add(asset);
}

View File

@ -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<void> migrateDatabaseIfNeeded(Drift drift) async {
final int version = Store.get(StoreKey.version, targetVersion);
@ -31,6 +32,10 @@ Future<void> migrateDatabaseIfNeeded(Drift drift) async {
await _migrateTo26(drift);
}
if (version < 27) {
await DriftLocalAlbumRepository(drift).recomputeAllBackupCandidates();
}
await Store.put(StoreKey.version, targetVersion);
return;
}

Binary file not shown.

View File

@ -14,10 +14,6 @@ void main() {
sut = DriftBackupRepository(ctx.db);
});
tearDown(() async {
await ctx.dispose();
});
group('getAllCounts', () {
late String userId;

View File

@ -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);
});
});
}

View File

@ -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);

View File

@ -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();

View File

@ -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

View File

@ -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();

View File

@ -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();

View File

@ -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

View File

@ -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<int> 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);
});
});
}

View File

@ -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<void> dispose() async {
await db.close();
}
Future<bool> isAssetBackupCandidate(String assetId) async =>
await (db.localAssetEntity.select()..where((row) => row.id.equals(assetId)))
.map((row) => row.isBackupCandidate)
.getSingleOrNull() ==
true;
Future<bool> hasLocalAsset(String assetId) async =>
await (db.localAssetEntity.count(where: (row) => row.id.equals(assetId))).getSingle() == 1;
Future<bool> hasLocalAlbum(String albumId) async =>
await (db.localAlbumEntity.count(where: (row) => row.id.equals(albumId))).getSingle() == 1;
Future<int> albumAssetCount(String albumId) async =>
await (db.localAlbumAssetEntity.count(where: (row) => row.albumId.equals(albumId))).getSingle();
static Value<T> _resolveUndefined<T>(T? plain, Option<T>? option, T fallback) {
if (plain != null) {
return .new(plain);
@ -214,8 +233,8 @@ class MediumRepositoryContext {
}
Future<AssetFaceEntityData> 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<void> newLocalAlbumAsset({required String albumId, required String assetId}) => db
.into(db.localAlbumAssetEntity)
.insert(LocalAlbumAssetEntityCompanion(albumId: .new(albumId), assetId: .new(assetId)));
Future<void> 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]);
}
}
}

View File

@ -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();