Compare commits

...

1 Commits

Author SHA1 Message Date
shenlong-tanwen e6db274aa9 refactor: per asset backup flag 2026-06-13 22:35:34 +05:30
30 changed files with 15222 additions and 305 deletions
File diff suppressed because it is too large Load Diff
@@ -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();
}
@@ -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,
);
}
}
@@ -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,
);
}
@@ -20,6 +20,7 @@ typedef $$LocalAssetEntityTableCreateCompanionBuilder =
required String id,
i0.Value<String?> checksum,
i0.Value<bool> isFavorite,
i0.Value<bool> isBackupCandidate,
i0.Value<int> orientation,
i0.Value<String?> iCloudId,
i0.Value<DateTime?> adjustmentTime,
@@ -39,6 +40,7 @@ typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
i0.Value<String> id,
i0.Value<String?> checksum,
i0.Value<bool> isFavorite,
i0.Value<bool> isBackupCandidate,
i0.Value<int> orientation,
i0.Value<String?> iCloudId,
i0.Value<DateTime?> adjustmentTime,
@@ -107,6 +109,11 @@ class $$LocalAssetEntityTableFilterComposer
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<bool> get isBackupCandidate => $composableBuilder(
column: $table.isBackupCandidate,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<int> get orientation => $composableBuilder(
column: $table.orientation,
builder: (column) => i0.ColumnFilters(column),
@@ -202,6 +209,11 @@ class $$LocalAssetEntityTableOrderingComposer
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<bool> get isBackupCandidate => $composableBuilder(
column: $table.isBackupCandidate,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<int> get orientation => $composableBuilder(
column: $table.orientation,
builder: (column) => i0.ColumnOrderings(column),
@@ -276,6 +288,11 @@ class $$LocalAssetEntityTableAnnotationComposer
builder: (column) => column,
);
i0.GeneratedColumn<bool> get isBackupCandidate => $composableBuilder(
column: $table.isBackupCandidate,
builder: (column) => column,
);
i0.GeneratedColumn<int> get orientation => $composableBuilder(
column: $table.orientation,
builder: (column) => column,
@@ -352,6 +369,7 @@ class $$LocalAssetEntityTableTableManager
i0.Value<String> id = const i0.Value.absent(),
i0.Value<String?> checksum = const i0.Value.absent(),
i0.Value<bool> isFavorite = const i0.Value.absent(),
i0.Value<bool> isBackupCandidate = const i0.Value.absent(),
i0.Value<int> orientation = const i0.Value.absent(),
i0.Value<String?> iCloudId = const i0.Value.absent(),
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
@@ -370,6 +388,7 @@ class $$LocalAssetEntityTableTableManager
id: id,
checksum: checksum,
isFavorite: isFavorite,
isBackupCandidate: isBackupCandidate,
orientation: orientation,
iCloudId: iCloudId,
adjustmentTime: adjustmentTime,
@@ -389,6 +408,7 @@ class $$LocalAssetEntityTableTableManager
required String id,
i0.Value<String?> checksum = const i0.Value.absent(),
i0.Value<bool> isFavorite = const i0.Value.absent(),
i0.Value<bool> isBackupCandidate = const i0.Value.absent(),
i0.Value<int> orientation = const i0.Value.absent(),
i0.Value<String?> iCloudId = const i0.Value.absent(),
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
@@ -407,6 +427,7 @@ class $$LocalAssetEntityTableTableManager
id: id,
checksum: checksum,
isFavorite: isFavorite,
isBackupCandidate: isBackupCandidate,
orientation: orientation,
iCloudId: iCloudId,
adjustmentTime: adjustmentTime,
@@ -568,6 +589,21 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
),
defaultValue: const i4.Constant(false),
);
static const i0.VerificationMeta _isBackupCandidateMeta =
const i0.VerificationMeta('isBackupCandidate');
@override
late final i0.GeneratedColumn<bool> isBackupCandidate =
i0.GeneratedColumn<bool>(
'is_backup_candidate',
aliasedName,
false,
type: i0.DriftSqlType.bool,
requiredDuringInsert: false,
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
'CHECK ("is_backup_candidate" IN (0, 1))',
),
defaultValue: const i4.Constant(false),
);
static const i0.VerificationMeta _orientationMeta = const i0.VerificationMeta(
'orientation',
);
@@ -649,6 +685,7 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
id,
checksum,
isFavorite,
isBackupCandidate,
orientation,
iCloudId,
adjustmentTime,
@@ -723,6 +760,15 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
isFavorite.isAcceptableOrUnknown(data['is_favorite']!, _isFavoriteMeta),
);
}
if (data.containsKey('is_backup_candidate')) {
context.handle(
_isBackupCandidateMeta,
isBackupCandidate.isAcceptableOrUnknown(
data['is_backup_candidate']!,
_isBackupCandidateMeta,
),
);
}
if (data.containsKey('orientation')) {
context.handle(
_orientationMeta,
@@ -813,6 +859,10 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
i0.DriftSqlType.bool,
data['${effectivePrefix}is_favorite'],
)!,
isBackupCandidate: attachedDatabase.typeMapping.read(
i0.DriftSqlType.bool,
data['${effectivePrefix}is_backup_candidate'],
)!,
orientation: attachedDatabase.typeMapping.read(
i0.DriftSqlType.int,
data['${effectivePrefix}orientation'],
@@ -871,6 +921,7 @@ class LocalAssetEntityData extends i0.DataClass
final String id;
final String? checksum;
final bool isFavorite;
final bool isBackupCandidate;
final int orientation;
final String? iCloudId;
final DateTime? adjustmentTime;
@@ -888,6 +939,7 @@ class LocalAssetEntityData extends i0.DataClass
required this.id,
this.checksum,
required this.isFavorite,
required this.isBackupCandidate,
required this.orientation,
this.iCloudId,
this.adjustmentTime,
@@ -920,6 +972,7 @@ class LocalAssetEntityData extends i0.DataClass
map['checksum'] = i0.Variable<String>(checksum);
}
map['is_favorite'] = i0.Variable<bool>(isFavorite);
map['is_backup_candidate'] = i0.Variable<bool>(isBackupCandidate);
map['orientation'] = i0.Variable<int>(orientation);
if (!nullToAbsent || iCloudId != null) {
map['i_cloud_id'] = i0.Variable<String>(iCloudId);
@@ -959,6 +1012,7 @@ class LocalAssetEntityData extends i0.DataClass
id: serializer.fromJson<String>(json['id']),
checksum: serializer.fromJson<String?>(json['checksum']),
isFavorite: serializer.fromJson<bool>(json['isFavorite']),
isBackupCandidate: serializer.fromJson<bool>(json['isBackupCandidate']),
orientation: serializer.fromJson<int>(json['orientation']),
iCloudId: serializer.fromJson<String?>(json['iCloudId']),
adjustmentTime: serializer.fromJson<DateTime?>(json['adjustmentTime']),
@@ -985,6 +1039,7 @@ class LocalAssetEntityData extends i0.DataClass
'id': serializer.toJson<String>(id),
'checksum': serializer.toJson<String?>(checksum),
'isFavorite': serializer.toJson<bool>(isFavorite),
'isBackupCandidate': serializer.toJson<bool>(isBackupCandidate),
'orientation': serializer.toJson<int>(orientation),
'iCloudId': serializer.toJson<String?>(iCloudId),
'adjustmentTime': serializer.toJson<DateTime?>(adjustmentTime),
@@ -1007,6 +1062,7 @@ class LocalAssetEntityData extends i0.DataClass
String? id,
i0.Value<String?> checksum = const i0.Value.absent(),
bool? isFavorite,
bool? isBackupCandidate,
int? orientation,
i0.Value<String?> iCloudId = const i0.Value.absent(),
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
@@ -1024,6 +1080,7 @@ class LocalAssetEntityData extends i0.DataClass
id: id ?? this.id,
checksum: checksum.present ? checksum.value : this.checksum,
isFavorite: isFavorite ?? this.isFavorite,
isBackupCandidate: isBackupCandidate ?? this.isBackupCandidate,
orientation: orientation ?? this.orientation,
iCloudId: iCloudId.present ? iCloudId.value : this.iCloudId,
adjustmentTime: adjustmentTime.present
@@ -1049,6 +1106,9 @@ class LocalAssetEntityData extends i0.DataClass
isFavorite: data.isFavorite.present
? data.isFavorite.value
: this.isFavorite,
isBackupCandidate: data.isBackupCandidate.present
? data.isBackupCandidate.value
: this.isBackupCandidate,
orientation: data.orientation.present
? data.orientation.value
: this.orientation,
@@ -1077,6 +1137,7 @@ class LocalAssetEntityData extends i0.DataClass
..write('id: $id, ')
..write('checksum: $checksum, ')
..write('isFavorite: $isFavorite, ')
..write('isBackupCandidate: $isBackupCandidate, ')
..write('orientation: $orientation, ')
..write('iCloudId: $iCloudId, ')
..write('adjustmentTime: $adjustmentTime, ')
@@ -1099,6 +1160,7 @@ class LocalAssetEntityData extends i0.DataClass
id,
checksum,
isFavorite,
isBackupCandidate,
orientation,
iCloudId,
adjustmentTime,
@@ -1120,6 +1182,7 @@ class LocalAssetEntityData extends i0.DataClass
other.id == this.id &&
other.checksum == this.checksum &&
other.isFavorite == this.isFavorite &&
other.isBackupCandidate == this.isBackupCandidate &&
other.orientation == this.orientation &&
other.iCloudId == this.iCloudId &&
other.adjustmentTime == this.adjustmentTime &&
@@ -1140,6 +1203,7 @@ class LocalAssetEntityCompanion
final i0.Value<String> id;
final i0.Value<String?> checksum;
final i0.Value<bool> isFavorite;
final i0.Value<bool> isBackupCandidate;
final i0.Value<int> orientation;
final i0.Value<String?> iCloudId;
final i0.Value<DateTime?> adjustmentTime;
@@ -1157,6 +1221,7 @@ class LocalAssetEntityCompanion
this.id = const i0.Value.absent(),
this.checksum = const i0.Value.absent(),
this.isFavorite = const i0.Value.absent(),
this.isBackupCandidate = const i0.Value.absent(),
this.orientation = const i0.Value.absent(),
this.iCloudId = const i0.Value.absent(),
this.adjustmentTime = const i0.Value.absent(),
@@ -1175,6 +1240,7 @@ class LocalAssetEntityCompanion
required String id,
this.checksum = const i0.Value.absent(),
this.isFavorite = const i0.Value.absent(),
this.isBackupCandidate = const i0.Value.absent(),
this.orientation = const i0.Value.absent(),
this.iCloudId = const i0.Value.absent(),
this.adjustmentTime = const i0.Value.absent(),
@@ -1195,6 +1261,7 @@ class LocalAssetEntityCompanion
i0.Expression<String>? id,
i0.Expression<String>? checksum,
i0.Expression<bool>? isFavorite,
i0.Expression<bool>? isBackupCandidate,
i0.Expression<int>? orientation,
i0.Expression<String>? iCloudId,
i0.Expression<DateTime>? adjustmentTime,
@@ -1213,6 +1280,7 @@ class LocalAssetEntityCompanion
if (id != null) 'id': id,
if (checksum != null) 'checksum': checksum,
if (isFavorite != null) 'is_favorite': isFavorite,
if (isBackupCandidate != null) 'is_backup_candidate': isBackupCandidate,
if (orientation != null) 'orientation': orientation,
if (iCloudId != null) 'i_cloud_id': iCloudId,
if (adjustmentTime != null) 'adjustment_time': adjustmentTime,
@@ -1233,6 +1301,7 @@ class LocalAssetEntityCompanion
i0.Value<String>? id,
i0.Value<String?>? checksum,
i0.Value<bool>? isFavorite,
i0.Value<bool>? isBackupCandidate,
i0.Value<int>? orientation,
i0.Value<String?>? iCloudId,
i0.Value<DateTime?>? adjustmentTime,
@@ -1251,6 +1320,7 @@ class LocalAssetEntityCompanion
id: id ?? this.id,
checksum: checksum ?? this.checksum,
isFavorite: isFavorite ?? this.isFavorite,
isBackupCandidate: isBackupCandidate ?? this.isBackupCandidate,
orientation: orientation ?? this.orientation,
iCloudId: iCloudId ?? this.iCloudId,
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
@@ -1295,6 +1365,9 @@ class LocalAssetEntityCompanion
if (isFavorite.present) {
map['is_favorite'] = i0.Variable<bool>(isFavorite.value);
}
if (isBackupCandidate.present) {
map['is_backup_candidate'] = i0.Variable<bool>(isBackupCandidate.value);
}
if (orientation.present) {
map['orientation'] = i0.Variable<int>(orientation.value);
}
@@ -1333,6 +1406,7 @@ class LocalAssetEntityCompanion
..write('id: $id, ')
..write('checksum: $checksum, ')
..write('isFavorite: $isFavorite, ')
..write('isBackupCandidate: $isBackupCandidate, ')
..write('orientation: $orientation, ')
..write('iCloudId: $iCloudId, ')
..write('adjustmentTime: $adjustmentTime, ')
@@ -1352,3 +1426,7 @@ i0.Index get idxLocalAssetCreatedAt => i0.Index(
'idx_local_asset_created_at',
'CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)',
);
i0.Index get idxLocalAssetBackupCandidate => i0.Index(
'idx_local_asset_backup_candidate',
'CREATE INDEX IF NOT EXISTS idx_local_asset_backup_candidate ON local_asset_entity (is_backup_candidate)',
);
@@ -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;
+3 -22
View File
@@ -9,10 +9,6 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.
as i4;
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart'
as i5;
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'
as i6;
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'
as i7;
class MergedAssetDrift extends i1.ModularAccessor {
MergedAssetDrift(i0.GeneratedDatabase db) : super(db);
@@ -29,7 +25,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
);
$arrayStartIndex += generatedlimit.amountOfVariables;
return customSelect(
'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_ms, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited, 0 AS playback_style, rae.uploaded_at FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_ms, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited, lae.playback_style, NULL AS uploaded_at FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) ORDER BY created_at DESC ${generatedlimit.sql}',
'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_ms, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited, 0 AS playback_style, rae.uploaded_at FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_ms, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited, lae.playback_style, NULL AS uploaded_at FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND lae.is_backup_candidate ORDER BY created_at DESC ${generatedlimit.sql}',
variables: [
for (var $ in userIds) i0.Variable<String>($),
...generatedlimit.introducedVariables,
@@ -38,8 +34,6 @@ class MergedAssetDrift extends i1.ModularAccessor {
remoteAssetEntity,
localAssetEntity,
stackEntity,
localAlbumAssetEntity,
localAlbumEntity,
...generatedlimit.watchedTables,
},
).map(
@@ -81,18 +75,12 @@ class MergedAssetDrift extends i1.ModularAccessor {
final expandeduserIds = $expandVar($arrayStartIndex, userIds.length);
$arrayStartIndex += userIds.length;
return customSelect(
'SELECT COUNT(*) AS asset_count, bucket_date FROM (SELECT CASE WHEN ?1 = 0 THEN COALESCE(STRFTIME(\'%Y-%m-%d\', rae.local_date_time), STRFTIME(\'%Y-%m-%d\', rae.created_at, \'localtime\')) WHEN ?1 = 1 THEN COALESCE(STRFTIME(\'%Y-%m\', rae.local_date_time), STRFTIME(\'%Y-%m\', rae.created_at, \'localtime\')) END AS bucket_date FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', lae.created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', lae.created_at, \'localtime\') END AS bucket_date FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2)) GROUP BY bucket_date ORDER BY bucket_date DESC',
'SELECT COUNT(*) AS asset_count, bucket_date FROM (SELECT CASE WHEN ?1 = 0 THEN COALESCE(STRFTIME(\'%Y-%m-%d\', rae.local_date_time), STRFTIME(\'%Y-%m-%d\', rae.created_at, \'localtime\')) WHEN ?1 = 1 THEN COALESCE(STRFTIME(\'%Y-%m\', rae.local_date_time), STRFTIME(\'%Y-%m\', rae.created_at, \'localtime\')) END AS bucket_date FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', lae.created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', lae.created_at, \'localtime\') END AS bucket_date FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND lae.is_backup_candidate) GROUP BY bucket_date ORDER BY bucket_date DESC',
variables: [
i0.Variable<int>(groupBy),
for (var $ in userIds) i0.Variable<String>($),
],
readsFrom: {
remoteAssetEntity,
stackEntity,
localAssetEntity,
localAlbumAssetEntity,
localAlbumEntity,
},
readsFrom: {remoteAssetEntity, stackEntity, localAssetEntity},
).map(
(i0.QueryRow row) => MergedBucketResult(
assetCount: row.read<int>('asset_count'),
@@ -110,13 +98,6 @@ class MergedAssetDrift extends i1.ModularAccessor {
i3.$LocalAssetEntityTable get localAssetEntity => i1.ReadDatabaseContainer(
attachedDatabase,
).resultSet<i3.$LocalAssetEntityTable>('local_asset_entity');
i6.$LocalAlbumAssetEntityTable get localAlbumAssetEntity =>
i1.ReadDatabaseContainer(
attachedDatabase,
).resultSet<i6.$LocalAlbumAssetEntityTable>('local_album_asset_entity');
i7.$LocalAlbumEntityTable get localAlbumEntity => i1.ReadDatabaseContainer(
attachedDatabase,
).resultSet<i7.$LocalAlbumEntityTable>('local_album_entity');
}
class MergedAssetResult {
+35
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,
);
@@ -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();
}
}
@@ -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);
},
),
);
@@ -9,17 +9,17 @@ import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart'
as i3;
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'
as i4;
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart'
as i5;
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'
as i6;
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'
as i7;
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart'
as i8;
as i5;
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart'
as i9;
as i6;
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart'
as i7;
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart'
as i8;
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'
as i9;
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'
as i10;
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'
as i11;
@@ -60,20 +60,20 @@ abstract class $Drift extends i0.GeneratedDatabase {
late final i3.$StackEntityTable stackEntity = i3.$StackEntityTable(this);
late final i4.$LocalAssetEntityTable localAssetEntity = i4
.$LocalAssetEntityTable(this);
late final i5.$RemoteAlbumEntityTable remoteAlbumEntity = i5
.$RemoteAlbumEntityTable(this);
late final i6.$LocalAlbumEntityTable localAlbumEntity = i6
.$LocalAlbumEntityTable(this);
late final i7.$LocalAlbumAssetEntityTable localAlbumAssetEntity = i7
.$LocalAlbumAssetEntityTable(this);
late final i8.$AuthUserEntityTable authUserEntity = i8.$AuthUserEntityTable(
late final i5.$AuthUserEntityTable authUserEntity = i5.$AuthUserEntityTable(
this,
);
late final i9.$UserMetadataEntityTable userMetadataEntity = i9
late final i6.$UserMetadataEntityTable userMetadataEntity = i6
.$UserMetadataEntityTable(this);
late final i10.$PartnerEntityTable partnerEntity = i10.$PartnerEntityTable(
late final i7.$PartnerEntityTable partnerEntity = i7.$PartnerEntityTable(
this,
);
late final i8.$RemoteAlbumEntityTable remoteAlbumEntity = i8
.$RemoteAlbumEntityTable(this);
late final i9.$LocalAlbumEntityTable localAlbumEntity = i9
.$LocalAlbumEntityTable(this);
late final i10.$LocalAlbumAssetEntityTable localAlbumAssetEntity = i10
.$LocalAlbumAssetEntityTable(this);
late final i11.$RemoteExifEntityTable remoteExifEntity = i11
.$RemoteExifEntityTable(this);
late final i12.$RemoteAlbumAssetEntityTable remoteAlbumAssetEntity = i12
@@ -111,13 +111,10 @@ abstract class $Drift extends i0.GeneratedDatabase {
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
i7.idxLocalAlbumAssetAlbumAsset,
i4.idxLocalAssetChecksum,
i4.idxLocalAssetCloudId,
i4.idxLocalAssetCreatedAt,
i4.idxLocalAssetBackupCandidate,
i3.idxStackPrimaryAssetId,
i2.uQRemoteAssetsOwnerChecksum,
i2.uQRemoteAssetsOwnerLibraryChecksum,
@@ -127,6 +124,9 @@ abstract class $Drift extends i0.GeneratedDatabase {
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
@@ -140,7 +140,8 @@ abstract class $Drift extends i0.GeneratedDatabase {
assetEditEntity,
settingsEntity,
assetOcrEntity,
i10.idxPartnerSharedWithId,
i7.idxPartnerSharedWithId,
i10.idxLocalAlbumAssetAlbumAsset,
i11.idxLatLng,
i11.idxRemoteExifCity,
i12.idxRemoteAlbumAssetAlbumAsset,
@@ -173,6 +174,29 @@ abstract class $Drift extends i0.GeneratedDatabase {
),
result: [i0.TableUpdate('stack_entity', kind: i0.UpdateKind.delete)],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'user_entity',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [
i0.TableUpdate('user_metadata_entity', kind: i0.UpdateKind.delete),
],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'user_entity',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [i0.TableUpdate('partner_entity', kind: i0.UpdateKind.delete)],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'user_entity',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [i0.TableUpdate('partner_entity', kind: i0.UpdateKind.delete)],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'remote_asset_entity',
@@ -209,29 +233,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
i0.TableUpdate('local_album_asset_entity', kind: i0.UpdateKind.delete),
],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'user_entity',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [
i0.TableUpdate('user_metadata_entity', kind: i0.UpdateKind.delete),
],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'user_entity',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [i0.TableUpdate('partner_entity', kind: i0.UpdateKind.delete)],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'user_entity',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [i0.TableUpdate('partner_entity', kind: i0.UpdateKind.delete)],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'remote_asset_entity',
@@ -366,18 +367,18 @@ class $DriftManager {
i3.$$StackEntityTableTableManager(_db, _db.stackEntity);
i4.$$LocalAssetEntityTableTableManager get localAssetEntity =>
i4.$$LocalAssetEntityTableTableManager(_db, _db.localAssetEntity);
i5.$$RemoteAlbumEntityTableTableManager get remoteAlbumEntity =>
i5.$$RemoteAlbumEntityTableTableManager(_db, _db.remoteAlbumEntity);
i6.$$LocalAlbumEntityTableTableManager get localAlbumEntity =>
i6.$$LocalAlbumEntityTableTableManager(_db, _db.localAlbumEntity);
i7.$$LocalAlbumAssetEntityTableTableManager get localAlbumAssetEntity => i7
i5.$$AuthUserEntityTableTableManager get authUserEntity =>
i5.$$AuthUserEntityTableTableManager(_db, _db.authUserEntity);
i6.$$UserMetadataEntityTableTableManager get userMetadataEntity =>
i6.$$UserMetadataEntityTableTableManager(_db, _db.userMetadataEntity);
i7.$$PartnerEntityTableTableManager get partnerEntity =>
i7.$$PartnerEntityTableTableManager(_db, _db.partnerEntity);
i8.$$RemoteAlbumEntityTableTableManager get remoteAlbumEntity =>
i8.$$RemoteAlbumEntityTableTableManager(_db, _db.remoteAlbumEntity);
i9.$$LocalAlbumEntityTableTableManager get localAlbumEntity =>
i9.$$LocalAlbumEntityTableTableManager(_db, _db.localAlbumEntity);
i10.$$LocalAlbumAssetEntityTableTableManager get localAlbumAssetEntity => i10
.$$LocalAlbumAssetEntityTableTableManager(_db, _db.localAlbumAssetEntity);
i8.$$AuthUserEntityTableTableManager get authUserEntity =>
i8.$$AuthUserEntityTableTableManager(_db, _db.authUserEntity);
i9.$$UserMetadataEntityTableTableManager get userMetadataEntity =>
i9.$$UserMetadataEntityTableTableManager(_db, _db.userMetadataEntity);
i10.$$PartnerEntityTableTableManager get partnerEntity =>
i10.$$PartnerEntityTableTableManager(_db, _db.partnerEntity);
i11.$$RemoteExifEntityTableTableManager get remoteExifEntity =>
i11.$$RemoteExifEntityTableTableManager(_db, _db.remoteExifEntity);
i12.$$RemoteAlbumAssetEntityTableTableManager get remoteAlbumAssetEntity =>
@@ -15920,6 +15920,641 @@ i1.GeneratedColumn<String> _column_224(String aliasedName) =>
type: i1.DriftSqlType.string,
$customConstraints: 'NULL',
);
final class Schema31 extends i0.VersionedSchema {
Schema31({required super.database}) : super(version: 31);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
idxLocalAssetChecksum,
idxLocalAssetCloudId,
idxLocalAssetCreatedAt,
idxLocalAssetBackupCandidate,
idxStackPrimaryAssetId,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
idxRemoteAssetStackId,
idxRemoteAssetOwnerVisibilityDeletedCreated,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
remoteAssetCloudIdEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
assetEditEntity,
settings,
assetOcrEntity,
idxPartnerSharedWithId,
idxLocalAlbumAssetAlbumAsset,
idxLatLng,
idxRemoteExifCity,
idxRemoteAlbumAssetAlbumAsset,
idxRemoteAssetCloudId,
idxPersonOwnerId,
idxAssetFacePersonId,
idxAssetFaceAssetId,
idxAssetFaceVisiblePerson,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
idxAssetEditAssetId,
idxAssetOcrAssetId,
];
late final Shape33 userEntity = Shape33(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_110,
_column_111,
_column_112,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape50 remoteAssetEntity = Shape50(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_119,
_column_120,
_column_121,
_column_122,
_column_123,
_column_124,
_column_212,
_column_125,
_column_126,
_column_127,
_column_128,
_column_129,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape35 stackEntity = Shape35(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_130,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape52 localAssetEntity = Shape52(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_131,
_column_120,
_column_225,
_column_132,
_column_133,
_column_134,
_column_135,
_column_136,
_column_137,
],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
final i1.Index idxLocalAssetCloudId = i1.Index(
'idx_local_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
);
final i1.Index idxLocalAssetCreatedAt = i1.Index(
'idx_local_asset_created_at',
'CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)',
);
final i1.Index idxLocalAssetBackupCandidate = i1.Index(
'idx_local_asset_backup_candidate',
'CREATE INDEX IF NOT EXISTS idx_local_asset_backup_candidate ON local_asset_entity (is_backup_candidate)',
);
final i1.Index idxStackPrimaryAssetId = i1.Index(
'idx_stack_primary_asset_id',
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
);
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
'UQ_remote_assets_owner_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
);
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
'UQ_remote_assets_owner_library_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
);
final i1.Index idxRemoteAssetChecksum = i1.Index(
'idx_remote_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
final i1.Index idxRemoteAssetStackId = i1.Index(
'idx_remote_asset_stack_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
);
final i1.Index idxRemoteAssetOwnerVisibilityDeletedCreated = i1.Index(
'idx_remote_asset_owner_visibility_deleted_created',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)',
);
late final Shape40 authUserEntity = Shape40(
source: i0.VersionedTable(
entityName: 'auth_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_148,
_column_110,
_column_111,
_column_149,
_column_150,
_column_151,
_column_152,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
columns: [_column_153, _column_154, _column_155],
attachedDatabase: database,
),
alias: null,
);
late final Shape41 partnerEntity = Shape41(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_156, _column_157, _column_158],
attachedDatabase: database,
),
alias: null,
);
late final Shape48 remoteAlbumEntity = Shape48(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_138,
_column_114,
_column_115,
_column_139,
_column_140,
_column_141,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape38 localAlbumEntity = Shape38(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_115,
_column_142,
_column_143,
_column_144,
_column_145,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape39 localAlbumAssetEntity = Shape39(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_146, _column_147, _column_145],
attachedDatabase: database,
),
alias: null,
);
late final Shape42 remoteExifEntity = Shape42(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_160,
_column_161,
_column_162,
_column_163,
_column_164,
_column_117,
_column_116,
_column_165,
_column_166,
_column_167,
_column_168,
_column_135,
_column_136,
_column_169,
_column_170,
_column_171,
_column_172,
_column_173,
_column_174,
_column_175,
_column_176,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 remoteAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'remote_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_159, _column_177],
attachedDatabase: database,
),
alias: null,
);
late final Shape10 remoteAlbumUserEntity = Shape10(
source: i0.VersionedTable(
entityName: 'remote_album_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
columns: [_column_177, _column_153, _column_178],
attachedDatabase: database,
),
alias: null,
);
late final Shape43 remoteAssetCloudIdEntity = Shape43(
source: i0.VersionedTable(
entityName: 'remote_asset_cloud_id_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_179,
_column_180,
_column_134,
_column_135,
_column_136,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape44 memoryEntity = Shape44(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_124,
_column_121,
_column_113,
_column_181,
_column_182,
_column_183,
_column_184,
_column_185,
_column_186,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape12 memoryAssetEntity = Shape12(
source: i0.VersionedTable(
entityName: 'memory_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
columns: [_column_159, _column_187],
attachedDatabase: database,
),
alias: null,
);
late final Shape45 personEntity = Shape45(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_108,
_column_188,
_column_189,
_column_190,
_column_191,
_column_192,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape46 assetFaceEntity = Shape46(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_193,
_column_194,
_column_195,
_column_196,
_column_197,
_column_198,
_column_199,
_column_200,
_column_201,
_column_124,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape18 storeEntity = Shape18(
source: i0.VersionedTable(
entityName: 'store_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_202, _column_203, _column_204],
attachedDatabase: database,
),
alias: null,
);
late final Shape47 trashedLocalAssetEntity = Shape47(
source: i0.VersionedTable(
entityName: 'trashed_local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id, album_id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_205,
_column_131,
_column_120,
_column_132,
_column_206,
_column_137,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape32 assetEditEntity = Shape32(
source: i0.VersionedTable(
entityName: 'asset_edit_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_207,
_column_208,
_column_209,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape49 settings = Shape49(
source: i0.VersionedTable(
entityName: 'settings',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY("key")'],
columns: [_column_210, _column_224, _column_115],
attachedDatabase: database,
),
alias: null,
);
late final Shape51 assetOcrEntity = Shape51(
source: i0.VersionedTable(
entityName: 'asset_ocr_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_213,
_column_214,
_column_215,
_column_216,
_column_217,
_column_218,
_column_219,
_column_220,
_column_221,
_column_222,
_column_223,
_column_201,
],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxPartnerSharedWithId = i1.Index(
'idx_partner_shared_with_id',
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
);
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
'idx_local_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
final i1.Index idxRemoteExifCity = i1.Index(
'idx_remote_exif_city',
'CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL',
);
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
'idx_remote_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxRemoteAssetCloudId = i1.Index(
'idx_remote_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
);
final i1.Index idxPersonOwnerId = i1.Index(
'idx_person_owner_id',
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
);
final i1.Index idxAssetFacePersonId = i1.Index(
'idx_asset_face_person_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
);
final i1.Index idxAssetFaceAssetId = i1.Index(
'idx_asset_face_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
);
final i1.Index idxAssetFaceVisiblePerson = i1.Index(
'idx_asset_face_visible_person',
'CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL',
);
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
'idx_trashed_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
);
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
'idx_trashed_local_asset_album',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
);
final i1.Index idxAssetEditAssetId = i1.Index(
'idx_asset_edit_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
);
final i1.Index idxAssetOcrAssetId = i1.Index(
'idx_asset_ocr_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_ocr_asset_id ON asset_ocr_entity (asset_id)',
);
}
class Shape52 extends i0.VersionedTable {
Shape52({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get type =>
columnsByName['type']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get width =>
columnsByName['width']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get height =>
columnsByName['height']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get durationMs =>
columnsByName['duration_ms']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get checksum =>
columnsByName['checksum']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get isFavorite =>
columnsByName['is_favorite']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get isBackupCandidate =>
columnsByName['is_backup_candidate']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get orientation =>
columnsByName['orientation']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get iCloudId =>
columnsByName['i_cloud_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get adjustmentTime =>
columnsByName['adjustment_time']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<double> get latitude =>
columnsByName['latitude']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<double> get longitude =>
columnsByName['longitude']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<int> get playbackStyle =>
columnsByName['playback_style']! as i1.GeneratedColumn<int>;
}
i1.GeneratedColumn<int> _column_225(String aliasedName) =>
i1.GeneratedColumn<int>(
'is_backup_candidate',
aliasedName,
false,
type: i1.DriftSqlType.int,
$customConstraints:
'NOT NULL DEFAULT 0 CHECK (is_backup_candidate IN (0, 1))',
defaultValue: const i1.CustomExpression('0'),
);
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -15950,6 +16585,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28,
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30,
required Future<void> Function(i1.Migrator m, Schema31 schema) from30To31,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@@ -16098,6 +16734,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from29To30(migrator, schema);
return 30;
case 30:
final schema = Schema31(database: database);
final migrator = i1.Migrator(database, schema);
await from30To31(migrator, schema);
return 31;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@@ -16134,6 +16775,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28,
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30,
required Future<void> Function(i1.Migrator m, Schema31 schema) from30To31,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@@ -16165,5 +16807,6 @@ i1.OnUpgrade stepByStep({
from27To28: from27To28,
from28To29: from28To29,
from29To30: from29To30,
from30To31: from30To31,
),
);
@@ -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;
}
@@ -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 {
@@ -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();
}
@@ -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);
}
+6 -1
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;
}
+4
View File
@@ -34,6 +34,7 @@ import 'schema_v27.dart' as v27;
import 'schema_v28.dart' as v28;
import 'schema_v29.dart' as v29;
import 'schema_v30.dart' as v30;
import 'schema_v31.dart' as v31;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@@ -99,6 +100,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v29.DatabaseAtV29(db);
case 30:
return v30.DatabaseAtV30(db);
case 31:
return v31.DatabaseAtV31(db);
default:
throw MissingSchemaException(version, versions);
}
@@ -135,5 +138,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
28,
29,
30,
31,
];
}
File diff suppressed because it is too large Load Diff
@@ -14,10 +14,6 @@ void main() {
sut = DriftBackupRepository(ctx.db);
});
tearDown(() async {
await ctx.dispose();
});
group('getAllCounts', () {
late String userId;
@@ -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);
});
});
}
@@ -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);
@@ -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();
@@ -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
@@ -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();
@@ -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();
@@ -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
@@ -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);
});
});
}
+34 -6
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]);
}
}
}
@@ -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();