refactor sync service

This commit is contained in:
shenlong-tanwen 2025-04-12 15:34:13 +05:30
parent ca42420ca3
commit c4047fd9b2
23 changed files with 1527 additions and 737 deletions

View File

@ -55,9 +55,7 @@ custom_lint:
restrict: package:photo_manager
allowed:
# required / wanted
- 'lib/domain/interfaces/album_media.interface.dart'
- 'lib/infrastructure/repositories/album_media.repository.dart'
- 'lib/domain/services/sync.service.dart'
- 'lib/repositories/{album,asset,file}_media.repository.dart'
# acceptable exceptions for the time being
- lib/entities/asset.entity.dart # to provide local AssetEntity for now

View File

@ -1,10 +1,31 @@
import 'package:immich_mobile/domain/models/asset/asset.model.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:immich_mobile/domain/models/local_album.model.dart';
abstract interface class IAlbumMediaRepository {
Future<List<AssetPathEntity>> getAll({PMFilter? filter});
Future<List<LocalAlbum>> getAll({
bool withModifiedTime = false,
bool withAssetCount = false,
bool withAssetTitle = false,
});
Future<List<LocalAsset>> getAssetsForAlbum(AssetPathEntity album);
Future<List<LocalAsset>> getAssetsForAlbum(
String albumId, {
bool withModifiedTime = false,
bool withAssetTitle = true,
DateTimeFilter? updateTimeCond,
});
Future<AssetPathEntity> refresh(String albumId, {PMFilter? filter});
Future<LocalAlbum> refresh(
String albumId, {
bool withModifiedTime = false,
bool withAssetCount = false,
bool withAssetTitle = false,
});
}
class DateTimeFilter {
final DateTime min;
final DateTime max;
const DateTimeFilter({required this.min, required this.max});
}

View File

@ -1,17 +1,21 @@
import 'package:immich_mobile/domain/interfaces/db.interface.dart';
import 'package:immich_mobile/domain/models/asset/asset.model.dart';
import 'package:immich_mobile/domain/models/local_album.model.dart';
abstract interface class ILocalAlbumRepository implements IDatabaseRepository {
Future<void> upsert(LocalAlbum localAlbum);
Future<void> insert(LocalAlbum localAlbum, Iterable<LocalAsset> assets);
Future<void> addAssets(String albumId, Iterable<LocalAsset> assets);
Future<List<LocalAlbum>> getAll({SortLocalAlbumsBy? sortBy});
/// Get all asset ids that are only in the album and not in other albums.
/// This is used to determine which assets are unique to the album.
/// This is useful in cases where the album is a smart album or a user-created album, especially in iOS
Future<List<String>> getAssetIdsOnlyInAlbum(String albumId);
Future<List<LocalAsset>> getAssetsForAlbum(String albumId);
Future<void> update(LocalAlbum localAlbum);
Future<void> delete(String albumId);
Future<void> removeAssets(String albumId, Iterable<String> assetIds);
}
enum SortLocalAlbumsBy { id }

View File

@ -1,11 +0,0 @@
import 'package:immich_mobile/domain/interfaces/db.interface.dart';
import 'package:immich_mobile/domain/models/asset/asset.model.dart';
abstract interface class ILocalAlbumAssetRepository
implements IDatabaseRepository {
Future<List<LocalAsset>> getAssetsForAlbum(String albumId);
Future<void> linkAssetsToAlbum(String albumId, Iterable<String> assetIds);
Future<void> unlinkAssetsFromAlbum(String albumId, Iterable<String> assetIds);
}

View File

@ -3,8 +3,4 @@ import 'package:immich_mobile/domain/models/asset/asset.model.dart';
abstract interface class ILocalAssetRepository implements IDatabaseRepository {
Future<LocalAsset> get(String assetId);
Future<void> upsertAll(Iterable<LocalAsset> localAssets);
Future<void> deleteIds(Iterable<String> ids);
}

View File

@ -30,7 +30,8 @@ class LocalAsset extends Asset {
}
@override
bool operator ==(covariant LocalAsset other) {
bool operator ==(Object other) {
if (other is! LocalAsset) return false;
if (identical(this, other)) return true;
return super == other && localId == other.localId;
}

View File

@ -33,7 +33,8 @@ class MergedAsset extends Asset {
}
@override
bool operator ==(covariant MergedAsset other) {
bool operator ==(Object other) {
if (other is! MergedAsset) return false;
if (identical(this, other)) return true;
return super == other &&
remoteId == other.remoteId &&

View File

@ -30,7 +30,8 @@ class RemoteAsset extends Asset {
}
@override
bool operator ==(covariant RemoteAsset other) {
bool operator ==(Object other) {
if (other is! RemoteAsset) return false;
if (identical(this, other)) return true;
return super == other && remoteId == other.remoteId;
}

View File

@ -1,3 +1,5 @@
import 'package:immich_mobile/utils/nullable_value.dart';
enum BackupSelection {
none,
selected,
@ -30,7 +32,7 @@ class LocalAlbum {
String? name,
DateTime? updatedAt,
int? assetCount,
String? thumbnailId,
NullableValue<String>? thumbnailId,
BackupSelection? backupSelection,
bool? isAll,
}) {
@ -39,14 +41,15 @@ class LocalAlbum {
name: name ?? this.name,
updatedAt: updatedAt ?? this.updatedAt,
assetCount: assetCount ?? this.assetCount,
thumbnailId: thumbnailId ?? this.thumbnailId,
thumbnailId: thumbnailId?.getOrDefault(this.thumbnailId),
backupSelection: backupSelection ?? this.backupSelection,
isAll: isAll ?? this.isAll,
);
}
@override
bool operator ==(covariant LocalAlbum other) {
bool operator ==(Object other) {
if (other is! LocalAlbum) return false;
if (identical(this, other)) return true;
return other.id == id &&

View File

@ -3,72 +3,42 @@ import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/interfaces/album_media.interface.dart';
import 'package:immich_mobile/domain/interfaces/local_album.interface.dart';
import 'package:immich_mobile/domain/interfaces/local_album_asset.interface.dart';
import 'package:immich_mobile/domain/interfaces/local_asset.interface.dart';
import 'package:immich_mobile/domain/models/asset/asset.model.dart'
hide AssetType;
import 'package:immich_mobile/domain/models/asset/asset.model.dart';
import 'package:immich_mobile/domain/models/local_album.model.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:immich_mobile/utils/nullable_value.dart';
import 'package:logging/logging.dart';
import 'package:photo_manager/photo_manager.dart';
class SyncService {
final IAlbumMediaRepository _albumMediaRepository;
final ILocalAlbumRepository _localAlbumRepository;
final ILocalAssetRepository _localAssetRepository;
final ILocalAlbumAssetRepository _localAlbumAssetRepository;
final Logger _log = Logger("SyncService");
SyncService({
required IAlbumMediaRepository albumMediaRepository,
required ILocalAlbumRepository localAlbumRepository,
required ILocalAssetRepository localAssetRepository,
required ILocalAlbumAssetRepository localAlbumAssetRepository,
}) : _albumMediaRepository = albumMediaRepository,
_localAlbumRepository = localAlbumRepository,
_localAssetRepository = localAssetRepository,
_localAlbumAssetRepository = localAlbumAssetRepository;
late final albumFilter = FilterOptionGroup(
imageOption: const FilterOption(
// needTitle is expected to be slow on iOS but is required to fetch the asset title
needTitle: true,
sizeConstraint: SizeConstraint(ignoreSize: true),
),
videoOption: const FilterOption(
needTitle: true,
sizeConstraint: SizeConstraint(ignoreSize: true),
durationConstraint: DurationConstraint(allowNullable: true),
),
// This is needed to get the modified time of the album
containsPathModified: true,
createTimeCond: DateTimeCond.def().copyWith(ignore: true),
updateTimeCond: DateTimeCond.def().copyWith(ignore: true),
orders: const [
// Always sort the result by createdDate.des to update the thumbnail
OrderOption(type: OrderOptionType.createDate, asc: false),
],
);
_localAssetRepository = localAssetRepository;
Future<bool> syncLocalAlbums() async {
try {
final Stopwatch stopwatch = Stopwatch()..start();
// Use an AdvancedCustomFilter to get all albums faster
final filter = AdvancedCustomFilter(
orderBy: [OrderByItem.asc(CustomColumns.base.id)],
);
final deviceAlbums = await _albumMediaRepository.getAll(filter: filter);
// The deviceAlbums will not have the updatedAt field
// and the assetCount will be 0. They are refreshed later
// after the comparison
final deviceAlbums = await _albumMediaRepository.getAll();
final dbAlbums =
await _localAlbumRepository.getAll(sortBy: SortLocalAlbumsBy.id);
final hasChange = await diffSortedLists(
dbAlbums,
await Future.wait(
deviceAlbums.map((a) => a.toDto(withAssetCount: false)),
),
deviceAlbums,
compare: (a, b) => a.id.compareTo(b.id),
both: diffLocalAlbums,
both: syncLocalAlbum,
onlyFirst: removeLocalAlbum,
onlySecond: addLocalAlbum,
);
@ -85,31 +55,23 @@ class SyncService {
Future<void> addLocalAlbum(LocalAlbum newAlbum) async {
try {
_log.info("Adding device album ${newAlbum.name}");
final deviceAlbum =
await _albumMediaRepository.refresh(newAlbum.id, filter: albumFilter);
final assets = newAlbum.assetCount > 0
? (await _albumMediaRepository.getAssetsForAlbum(deviceAlbum))
: <LocalAsset>[];
final album = (await deviceAlbum.toDto()).copyWith(
// The below assumes the list is already sorted by createdDate from the filter
thumbnailId: assets.firstOrNull?.localId,
final deviceAlbum = await _albumMediaRepository.refresh(
newAlbum.id,
withModifiedTime: true,
withAssetCount: true,
);
await _localAlbumRepository.transaction(() async {
if (newAlbum.assetCount > 0) {
await _localAssetRepository.upsertAll(assets);
}
// Needs to be after asset upsert to link the thumbnail
await _localAlbumRepository.upsert(album);
final assets = deviceAlbum.assetCount > 0
? (await _albumMediaRepository.getAssetsForAlbum(deviceAlbum.id))
: <LocalAsset>[];
if (newAlbum.assetCount > 0) {
await _localAlbumAssetRepository.linkAssetsToAlbum(
album.id,
assets.map((a) => a.localId),
);
}
});
final album = deviceAlbum.copyWith(
// The below assumes the list is already sorted by createdDate from the filter
thumbnailId: NullableValue.valueOrEmpty(assets.firstOrNull?.localId),
);
await _localAlbumRepository.insert(album, assets);
_log.info("Successfully added device album ${album.name}");
} catch (e, s) {
_log.warning("Error while adding device album", e, s);
}
@ -118,55 +80,23 @@ class SyncService {
Future<void> removeLocalAlbum(LocalAlbum a) async {
_log.info("Removing device album ${a.name}");
try {
// Do not request title to speed things up on iOS
final filter = albumFilter;
filter.setOption(
AssetType.image,
filter.getOption(AssetType.image).copyWith(needTitle: false),
);
filter.setOption(
AssetType.video,
filter.getOption(AssetType.video).copyWith(needTitle: false),
);
final deviceAlbum =
await _albumMediaRepository.refresh(a.id, filter: filter);
final assetsToDelete =
(await _localAlbumAssetRepository.getAssetsForAlbum(deviceAlbum.id))
.map((asset) => asset.localId)
.toSet();
// 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
final assetsOnlyInAlbum = assetsToDelete.isEmpty
? <String>{}
: (await _localAlbumRepository.getAssetIdsOnlyInAlbum(deviceAlbum.id))
.toSet();
await _localAlbumRepository.transaction(() async {
// Delete all assets that are only in this particular album
await _localAssetRepository.deleteIds(
assetsToDelete.intersection(assetsOnlyInAlbum),
);
// Unlink the others
await _localAlbumAssetRepository.unlinkAssetsFromAlbum(
deviceAlbum.id,
assetsToDelete.difference(assetsOnlyInAlbum),
);
await _localAlbumRepository.delete(deviceAlbum.id);
});
// Asset deletion is handled in the repository
await _localAlbumRepository.delete(a.id);
} catch (e, s) {
_log.warning("Error while removing device album", e, s);
}
}
@visibleForTesting
// The deviceAlbum is ignored since we are going to refresh it anyways
FutureOr<bool> diffLocalAlbums(LocalAlbum dbAlbum, LocalAlbum _) async {
FutureOr<bool> syncLocalAlbum(LocalAlbum dbAlbum, LocalAlbum _) async {
try {
_log.info("Syncing device album ${dbAlbum.name}");
final albumEntity =
await _albumMediaRepository.refresh(dbAlbum.id, filter: albumFilter);
final deviceAlbum = await albumEntity.toDto();
final deviceAlbum = await _albumMediaRepository.refresh(
dbAlbum.id,
withModifiedTime: true,
withAssetCount: true,
);
// Early return if album hasn't changed
if (deviceAlbum.updatedAt.isAtSameMomentAs(dbAlbum.updatedAt) &&
@ -179,7 +109,7 @@ class SyncService {
// Skip empty albums that don't need syncing
if (deviceAlbum.assetCount == 0 && dbAlbum.assetCount == 0) {
await _localAlbumRepository.upsert(
await _localAlbumRepository.update(
deviceAlbum.copyWith(backupSelection: dbAlbum.backupSelection),
);
_log.info("Album ${dbAlbum.name} is empty. Only metadata updated.");
@ -188,14 +118,14 @@ class SyncService {
_log.info("Device album ${dbAlbum.name} has changed. Syncing...");
// Handle the case where assets are only added - fast path
if (await handleOnlyAssetsAdded(dbAlbum, deviceAlbum)) {
// Faster path - only assets added
if (await tryFastSync(dbAlbum, deviceAlbum)) {
_log.info("Fast synced device album ${dbAlbum.name}");
return true;
}
// Slower path - full sync
return await handleAssetUpdate(dbAlbum, deviceAlbum, albumEntity);
return await fullSync(dbAlbum, deviceAlbum);
} catch (e, s) {
_log.warning("Error while diff device album", e, s);
}
@ -203,10 +133,9 @@ class SyncService {
}
@visibleForTesting
Future<bool> handleOnlyAssetsAdded(
LocalAlbum dbAlbum,
LocalAlbum deviceAlbum,
) async {
// The [deviceAlbum] is expected to be refreshed before calling this method
// with modified time and asset count
Future<bool> tryFastSync(LocalAlbum dbAlbum, LocalAlbum deviceAlbum) async {
try {
_log.info("Fast syncing device album ${dbAlbum.name}");
if (!deviceAlbum.updatedAt.isAfter(dbAlbum.updatedAt)) {
@ -223,16 +152,13 @@ class SyncService {
}
// Get all assets that are modified after the last known modifiedTime
final filter = albumFilter.copyWith(
updateTimeCond: DateTimeCond(
final newAssets = await _albumMediaRepository.getAssetsForAlbum(
deviceAlbum.id,
updateTimeCond: DateTimeFilter(
min: dbAlbum.updatedAt.add(const Duration(seconds: 1)),
max: deviceAlbum.updatedAt,
),
);
final modifiedAlbum =
await _albumMediaRepository.refresh(deviceAlbum.id, filter: filter);
final newAssets =
await _albumMediaRepository.getAssetsForAlbum(modifiedAlbum);
// Early return if no new assets were found
if (newAssets.isEmpty) {
@ -262,19 +188,13 @@ class SyncService {
}
}
await _localAlbumRepository.transaction(() async {
await _localAssetRepository.upsertAll(newAssets);
await _localAlbumAssetRepository.linkAssetsToAlbum(
deviceAlbum.id,
newAssets.map(((a) => a.localId)),
);
await _localAlbumRepository.upsert(
deviceAlbum.copyWith(
thumbnailId: thumbnailId,
backupSelection: dbAlbum.backupSelection,
),
);
});
await _handleUpdate(
deviceAlbum.copyWith(
thumbnailId: NullableValue.valueOrEmpty(thumbnailId),
backupSelection: dbAlbum.backupSelection,
),
assetsToUpsert: newAssets,
);
return true;
} catch (e, s) {
@ -284,100 +204,123 @@ class SyncService {
}
@visibleForTesting
Future<bool> handleAssetUpdate(
LocalAlbum dbAlbum,
LocalAlbum deviceAlbum,
AssetPathEntity deviceAlbumEntity,
) async {
// The [deviceAlbum] is expected to be refreshed before calling this method
// with modified time and asset count
Future<bool> fullSync(LocalAlbum dbAlbum, LocalAlbum deviceAlbum) async {
try {
final assetsInDevice = deviceAlbum.assetCount > 0
? await _albumMediaRepository.getAssetsForAlbum(deviceAlbumEntity)
? await _albumMediaRepository.getAssetsForAlbum(deviceAlbum.id)
: <LocalAsset>[];
final assetsInDb = dbAlbum.assetCount > 0
? await _localAlbumRepository.getAssetsForAlbum(dbAlbum.id)
: <LocalAsset>[];
final assetsInDb = dbAlbum.assetCount > 0
? await _localAlbumAssetRepository.getAssetsForAlbum(dbAlbum.id)
: <LocalAsset>[];
if (deviceAlbum.assetCount == 0) {
_log.fine(
"Device album ${deviceAlbum.name} is empty. Removing assets from DB.",
);
await _handleUpdate(
deviceAlbum.copyWith(
// Clear thumbnail for empty album
thumbnailId: const NullableValue.empty(),
backupSelection: dbAlbum.backupSelection,
),
assetIdsToDelete: assetsInDb.map((a) => a.localId),
);
return true;
}
// The below assumes the list is already sorted by createdDate from the filter
String? thumbnailId =
assetsInDevice.firstOrNull?.localId ?? dbAlbum.thumbnailId;
String? thumbnailId = assetsInDevice.isNotEmpty
? assetsInDevice.firstOrNull?.localId
: dbAlbum.thumbnailId;
final assetsToAdd = <LocalAsset>{},
assetsToUpsert = <LocalAsset>{},
assetsToDelete = <String>{};
if (deviceAlbum.assetCount == 0) {
assetsToDelete.addAll(assetsInDb.map((asset) => asset.localId));
thumbnailId = null;
} else if (dbAlbum.assetCount == 0) {
assetsToAdd.addAll(assetsInDevice);
} else {
assetsInDb.sort((a, b) => a.localId.compareTo(b.localId));
assetsInDevice.sort((a, b) => a.localId.compareTo(b.localId));
diffSortedListsSync(
assetsInDb,
assetsInDevice,
compare: (a, b) => a.localId.compareTo(b.localId),
both: (dbAsset, deviceAsset) {
if (dbAsset == deviceAsset) {
return false;
}
assetsToUpsert.add(deviceAsset);
return true;
},
onlyFirst: (dbAsset) => assetsToDelete.add(dbAsset.localId),
onlySecond: (deviceAsset) => assetsToAdd.add(deviceAsset),
);
}
_log.info(
"Syncing ${deviceAlbum.name}. ${assetsToAdd.length} assets to add, ${assetsToUpsert.length} assets to update and ${assetsToDelete.length} assets to delete",
);
// Populate the album meta
final updatedAlbum = deviceAlbum.copyWith(
thumbnailId: thumbnailId,
final updatedDeviceAlbum = deviceAlbum.copyWith(
thumbnailId: NullableValue.valueOrEmpty(thumbnailId),
backupSelection: dbAlbum.backupSelection,
);
// 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
final assetsOnlyInAlbum = assetsToDelete.isEmpty
? <String>{}
: (await _localAlbumRepository.getAssetIdsOnlyInAlbum(deviceAlbum.id))
.toSet();
if (dbAlbum.assetCount == 0) {
_log.fine(
"Device album ${deviceAlbum.name} is empty. Adding assets to DB.",
);
await _handleUpdate(updatedDeviceAlbum, assetsToUpsert: assetsInDevice);
return true;
}
await _localAlbumRepository.transaction(() async {
await _localAssetRepository
.upsertAll(assetsToAdd.followedBy(assetsToUpsert));
await _localAlbumAssetRepository.linkAssetsToAlbum(
dbAlbum.id,
assetsToAdd.map((a) => a.localId),
// Sort assets by localId for the diffSortedLists function
assetsInDb.sort((a, b) => a.localId.compareTo(b.localId));
assetsInDevice.sort((a, b) => a.localId.compareTo(b.localId));
final assetsToAddOrUpdate = <LocalAsset>[];
final assetIdsToDelete = <String>[];
diffSortedListsSync(
assetsInDb,
assetsInDevice,
compare: (a, b) => a.localId.compareTo(b.localId),
both: (dbAsset, deviceAsset) {
if (!_assetsEqual(dbAsset, deviceAsset)) {
assetsToAddOrUpdate.add(deviceAsset);
return true;
}
return false;
},
onlyFirst: (dbAsset) => assetIdsToDelete.add(dbAsset.localId),
onlySecond: (deviceAsset) => assetsToAddOrUpdate.add(deviceAsset),
);
_log.info(
"Syncing ${deviceAlbum.name}. ${assetsToAddOrUpdate.length} assets to add/update and ${assetIdsToDelete.length} assets to delete",
);
if (assetsToAddOrUpdate.isEmpty && assetIdsToDelete.isEmpty) {
_log.fine(
"No asset changes detected in album ${deviceAlbum.name}. Updating metadata.",
);
await _localAlbumRepository.upsert(updatedAlbum);
// Delete all assets that are only in this particular album
await _localAssetRepository.deleteIds(
assetsToDelete.intersection(assetsOnlyInAlbum),
);
// Unlink the others
await _localAlbumAssetRepository.unlinkAssetsFromAlbum(
dbAlbum.id,
assetsToDelete.difference(assetsOnlyInAlbum),
);
});
_localAlbumRepository.update(updatedDeviceAlbum);
return true;
}
await _handleUpdate(
updatedDeviceAlbum,
assetsToUpsert: assetsToAddOrUpdate,
assetIdsToDelete: assetIdsToDelete,
);
return true;
} catch (e, s) {
_log.warning("Error on full syncing local album: ${dbAlbum.name}", e, s);
}
return true;
return false;
}
Future<void> _handleUpdate(
LocalAlbum album, {
Iterable<LocalAsset>? assetsToUpsert,
Iterable<String>? assetIdsToDelete,
}) =>
_localAlbumRepository.transaction(() async {
if (assetsToUpsert != null && assetsToUpsert.isNotEmpty) {
await _localAlbumRepository.addAssets(album.id, assetsToUpsert);
}
await _localAlbumRepository.update(album);
if (assetIdsToDelete != null && assetIdsToDelete.isNotEmpty) {
await _localAlbumRepository.removeAssets(
album.id,
assetIdsToDelete,
);
}
});
// Helper method to check if assets are equal without relying on the checksum
bool _assetsEqual(LocalAsset a, LocalAsset b) {
return a.updatedAt.isAtSameMomentAs(b.updatedAt) &&
a.createdAt.isAtSameMomentAs(b.createdAt) &&
a.width == b.width &&
a.height == b.height &&
a.durationInSeconds == b.durationInSeconds;
}
}
extension AssetPathEntitySyncX on AssetPathEntity {
Future<LocalAlbum> toDto({bool withAssetCount = true}) async => LocalAlbum(
id: id,
name: name,
updatedAt: lastModified ?? DateTime.now(),
// the assetCountAsync call is expensive for larger albums with several thousand assets
assetCount: withAssetCount ? await assetCountAsync : 0,
backupSelection: BackupSelection.none,
isAll: isAll,
);
}

View File

@ -1,23 +1,79 @@
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/interfaces/album_media.interface.dart';
import 'package:immich_mobile/domain/models/asset/asset.model.dart' as asset;
import 'package:immich_mobile/domain/models/local_album.model.dart';
import 'package:photo_manager/photo_manager.dart';
class AlbumMediaRepository implements IAlbumMediaRepository {
const AlbumMediaRepository();
PMFilter _getAlbumFilter({
withAssetTitle = false,
withModifiedTime = false,
DateTimeFilter? updateTimeCond,
}) =>
FilterOptionGroup(
imageOption: FilterOption(
// needTitle is expected to be slow on iOS but is required to fetch the asset title
needTitle: withAssetTitle,
sizeConstraint: const SizeConstraint(ignoreSize: true),
),
videoOption: FilterOption(
needTitle: withAssetTitle,
sizeConstraint: const SizeConstraint(ignoreSize: true),
durationConstraint: const DurationConstraint(allowNullable: true),
),
// This is needed to get the modified time of the album
containsPathModified: withModifiedTime,
createTimeCond: DateTimeCond.def().copyWith(ignore: true),
updateTimeCond: updateTimeCond == null
? DateTimeCond.def().copyWith(ignore: true)
: DateTimeCond(min: updateTimeCond.min, max: updateTimeCond.max),
orders: const [
// Always sort the result by createdDate.des to update the thumbnail
OrderOption(type: OrderOptionType.createDate, asc: false),
],
);
@override
Future<List<AssetPathEntity>> getAll({PMFilter? filter}) async {
return await PhotoManager.getAssetPathList(
Future<List<LocalAlbum>> getAll({
withModifiedTime = false,
withAssetCount = false,
withAssetTitle = false,
}) async {
final filter = withModifiedTime || withAssetTitle
? _getAlbumFilter(
withAssetTitle: withAssetTitle,
withModifiedTime: withModifiedTime,
)
// Use an AdvancedCustomFilter to get all albums faster
: AdvancedCustomFilter(
orderBy: [OrderByItem.asc(CustomColumns.base.id)],
);
final entities = await PhotoManager.getAssetPathList(
hasAll: true,
filterOption: filter,
);
return entities.toDtoList(withAssetCount: withAssetCount);
}
@override
Future<List<asset.LocalAsset>> getAssetsForAlbum(
AssetPathEntity assetPathEntity,
) async {
String albumId, {
withModifiedTime = false,
withAssetTitle = true,
DateTimeFilter? updateTimeCond,
}) async {
final assetPathEntity = await AssetPathEntity.obtainPathFromProperties(
id: albumId,
optionGroup: _getAlbumFilter(
withAssetTitle: withAssetTitle,
withModifiedTime: withModifiedTime,
updateTimeCond: updateTimeCond,
),
);
final assets = <AssetEntity>[];
int pageNumber = 0, lastPageCount = 0;
do {
@ -33,14 +89,23 @@ class AlbumMediaRepository implements IAlbumMediaRepository {
}
@override
Future<AssetPathEntity> refresh(String albumId, {PMFilter? filter}) =>
AssetPathEntity.obtainPathFromProperties(
Future<LocalAlbum> refresh(
String albumId, {
withModifiedTime = false,
withAssetCount = false,
withAssetTitle = false,
}) async =>
(await AssetPathEntity.obtainPathFromProperties(
id: albumId,
optionGroup: filter,
);
optionGroup: _getAlbumFilter(
withAssetTitle: withAssetTitle,
withModifiedTime: withModifiedTime,
),
))
.toDto(withAssetCount: withAssetCount);
}
extension AssetEntityMediaRepoX on AssetEntity {
extension on AssetEntity {
Future<asset.LocalAsset> toDto() async {
return asset.LocalAsset(
localId: id,
@ -60,7 +125,24 @@ extension AssetEntityMediaRepoX on AssetEntity {
}
}
extension AssetEntityListMediaRepoX on List<AssetEntity> {
extension on List<AssetEntity> {
Future<List<asset.LocalAsset>> toDtoList() =>
Future.wait(map((a) => a.toDto()));
}
extension on AssetPathEntity {
Future<LocalAlbum> toDto({bool withAssetCount = true}) async => LocalAlbum(
id: id,
name: name,
updatedAt: lastModified ?? DateTime.now(),
// the assetCountAsync call is expensive for larger albums with several thousand assets
assetCount: withAssetCount ? await assetCountAsync : 0,
backupSelection: BackupSelection.none,
isAll: isAll,
);
}
extension on List<AssetPathEntity> {
Future<List<LocalAlbum>> toDtoList({bool withAssetCount = true}) =>
Future.wait(map((a) => a.toDto(withAssetCount: withAssetCount)));
}

View File

@ -1,17 +1,118 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/interfaces/local_album.interface.dart';
import 'package:immich_mobile/domain/models/asset/asset.model.dart';
import 'package:immich_mobile/domain/models/local_album.model.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/repositories/db.repository.dart';
import 'package:platform/platform.dart';
class DriftLocalAlbumRepository extends DriftDatabaseRepository
implements ILocalAlbumRepository {
final Drift _db;
const DriftLocalAlbumRepository(this._db) : super(_db);
final Platform _platform;
const DriftLocalAlbumRepository(this._db, {Platform? platform})
: _platform = platform ?? const LocalPlatform(),
super(_db);
@override
Future<void> upsert(LocalAlbum localAlbum) {
Future<List<LocalAlbum>> getAll({SortLocalAlbumsBy? sortBy}) {
final query = _db.localAlbumEntity.select();
if (sortBy == SortLocalAlbumsBy.id) {
query.orderBy([(a) => OrderingTerm.asc(a.id)]);
}
return query.map((a) => a.toDto()).get();
}
@override
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 = _platform.isIOS
? await _getUniqueAssetsInAlbum(albumId)
: await _getAssetsIdsInAlbum(albumId);
if (assetsToDelete.isNotEmpty) {
await _deleteAssets(assetsToDelete);
}
// All the other assets that are still associated will be unlinked automatically on-cascade
await _db.managers.localAlbumEntity
.filter((a) => a.id.equals(albumId))
.delete();
});
@override
Future<void> insert(LocalAlbum localAlbum, Iterable<LocalAsset> assets) =>
transaction(() async {
if (localAlbum.assetCount > 0) {
await _upsertAssets(assets);
}
// Needs to be after asset upsert to link the thumbnail
await _upsertAlbum(localAlbum);
if (localAlbum.assetCount > 0) {
await _linkAssetsToAlbum(localAlbum.id, assets);
}
});
@override
Future<void> addAssets(String albumId, Iterable<LocalAsset> assets) =>
transaction(() async {
await _upsertAssets(assets);
await _linkAssetsToAlbum(albumId, assets);
});
@override
Future<void> removeAssets(String albumId, Iterable<String> assetIds) async {
if (_platform.isAndroid) {
await _deleteAssets(assetIds);
return;
}
final uniqueAssets = await _getUniqueAssetsInAlbum(albumId);
if (uniqueAssets.isEmpty) {
await _unlinkAssetsFromAlbum(albumId, assetIds);
return;
}
// Delete unique assets and unlink others
final uniqueSet = uniqueAssets.toSet();
final assetsToDelete = <String>[];
final assetsToUnLink = <String>[];
for (final assetId in assetIds) {
if (uniqueSet.contains(assetId)) {
assetsToDelete.add(assetId);
} else {
assetsToUnLink.add(assetId);
}
}
await _unlinkAssetsFromAlbum(albumId, assetsToUnLink);
await _deleteAssets(assetsToDelete);
}
@override
Future<void> update(LocalAlbum localAlbum) => _upsertAlbum(localAlbum);
@override
Future<List<LocalAsset>> getAssetsForAlbum(String albumId) {
final query = _db.localAlbumAssetEntity.select().join(
[
innerJoin(
_db.localAssetEntity,
_db.localAlbumAssetEntity.assetId
.equalsExp(_db.localAssetEntity.localId),
),
],
)..where(_db.localAlbumAssetEntity.albumId.equals(albumId));
return query
.map((row) => row.readTable(_db.localAssetEntity).toDto())
.get();
}
Future<void> _upsertAlbum(LocalAlbum localAlbum) {
final companion = LocalAlbumEntityCompanion.insert(
id: localAlbum.id,
name: localAlbum.name,
@ -26,22 +127,43 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository
.insertOne(companion, onConflict: DoUpdate((_) => companion));
}
@override
Future<List<LocalAlbum>> getAll({SortLocalAlbumsBy? sortBy}) {
final query = _db.localAlbumEntity.select();
if (sortBy == SortLocalAlbumsBy.id) {
query.orderBy([(a) => OrderingTerm.asc(a.id)]);
}
return query.map((a) => a.toDto()).get();
Future<void> _linkAssetsToAlbum(
String albumId,
Iterable<LocalAsset> assets,
) =>
_db.batch(
(batch) => batch.insertAll(
_db.localAlbumAssetEntity,
assets.map(
(a) => LocalAlbumAssetEntityCompanion.insert(
assetId: a.localId,
albumId: albumId,
),
),
mode: InsertMode.insertOrIgnore,
),
);
Future<void> _unlinkAssetsFromAlbum(
String albumId,
Iterable<String> assetIds,
) =>
_db.batch(
(batch) => batch.deleteWhere(
_db.localAlbumAssetEntity,
(f) => f.assetId.isIn(assetIds) & f.albumId.equals(albumId),
),
);
Future<List<String>> _getAssetsIdsInAlbum(String albumId) {
final query = _db.localAlbumAssetEntity.select()
..where((row) => row.albumId.equals(albumId));
return query.map((row) => row.assetId).get();
}
@override
Future<void> delete(String albumId) => _db.managers.localAlbumEntity
.filter((a) => a.id.equals(albumId))
.delete();
@override
Future<List<String>> getAssetIdsOnlyInAlbum(String albumId) {
/// Get all asset ids that are only in this album and not in other albums.
/// This is useful in cases where the album is a smart album or a user-created album, especially on iOS
Future<List<String>> _getUniqueAssetsInAlbum(String albumId) {
final assetId = _db.localAlbumAssetEntity.assetId;
final query = _db.localAlbumAssetEntity.selectOnly()
..addColumns([assetId])
@ -53,4 +175,31 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository
return query.map((row) => row.read(assetId)!).get();
}
Future<void> _upsertAssets(Iterable<LocalAsset> localAssets) =>
_db.batch((batch) async {
batch.insertAllOnConflictUpdate(
_db.localAssetEntity,
localAssets.map(
(a) => LocalAssetEntityCompanion.insert(
name: a.name,
type: a.type,
createdAt: Value(a.createdAt),
updatedAt: Value(a.updatedAt),
width: Value.absentIfNull(a.width),
height: Value.absentIfNull(a.height),
durationInSeconds: Value.absentIfNull(a.durationInSeconds),
localId: a.localId,
checksum: Value.absentIfNull(a.checksum),
),
),
);
});
Future<void> _deleteAssets(Iterable<String> ids) => _db.batch(
(batch) => batch.deleteWhere(
_db.localAssetEntity,
(f) => f.localId.isIn(ids),
),
);
}

View File

@ -1,55 +0,0 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/interfaces/local_album_asset.interface.dart';
import 'package:immich_mobile/domain/models/asset/asset.model.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/repositories/db.repository.dart';
class DriftLocalAlbumAssetRepository extends DriftDatabaseRepository
implements ILocalAlbumAssetRepository {
final Drift _db;
const DriftLocalAlbumAssetRepository(super._db) : _db = _db;
@override
Future<void> linkAssetsToAlbum(String albumId, Iterable<String> assetIds) =>
_db.batch(
(batch) => batch.insertAll(
_db.localAlbumAssetEntity,
assetIds.map(
(a) => LocalAlbumAssetEntityCompanion.insert(
assetId: a,
albumId: albumId,
),
),
mode: InsertMode.insertOrIgnore,
),
);
@override
Future<List<LocalAsset>> getAssetsForAlbum(String albumId) {
final query = _db.localAlbumAssetEntity.select().join(
[
innerJoin(
_db.localAssetEntity,
_db.localAlbumAssetEntity.assetId
.equalsExp(_db.localAssetEntity.localId),
),
],
)..where(_db.localAlbumAssetEntity.albumId.equals(albumId));
return query
.map((row) => row.readTable(_db.localAssetEntity).toDto())
.get();
}
@override
Future<void> unlinkAssetsFromAlbum(
String albumId,
Iterable<String> assetIds,
) =>
_db.batch(
(batch) => batch.deleteWhere(
_db.localAlbumAssetEntity,
(f) => f.assetId.isIn(assetIds),
),
);
}

View File

@ -1,8 +1,6 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/interfaces/local_asset.interface.dart';
import 'package:immich_mobile/domain/models/asset/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/repositories/db.repository.dart';
class DriftLocalAssetRepository extends DriftDatabaseRepository
@ -10,35 +8,6 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository
final Drift _db;
const DriftLocalAssetRepository(this._db) : super(_db);
@override
Future<void> deleteIds(Iterable<String> ids) => _db.batch(
(batch) => batch.deleteWhere(
_db.localAssetEntity,
(f) => f.localId.isIn(ids),
),
);
@override
Future<void> upsertAll(Iterable<LocalAsset> localAssets) =>
_db.batch((batch) async {
batch.insertAllOnConflictUpdate(
_db.localAssetEntity,
localAssets.map(
(a) => LocalAssetEntityCompanion.insert(
name: a.name,
type: a.type,
createdAt: Value(a.createdAt),
updatedAt: Value(a.updatedAt),
width: Value.absentIfNull(a.width),
height: Value.absentIfNull(a.height),
durationInSeconds: Value.absentIfNull(a.durationInSeconds),
localId: a.localId,
checksum: Value.absentIfNull(a.checksum),
),
),
);
});
@override
Future<LocalAsset> get(String assetId) => _db.managers.localAssetEntity
.filter((f) => f.localId(assetId))

View File

@ -0,0 +1,23 @@
/// A utility class to represent a value that can be either present or absent.
/// This is useful for cases where you want to distinguish between a value
/// being explicitly set to null and a value not being set at all.
class NullableValue<T> {
final bool _present;
final T? _value;
const NullableValue._(this._value, {bool present = false})
: _present = present;
/// Forces the value to be null
const NullableValue.empty() : this._(null, present: true);
/// Uses the value if it's not null, otherwise null or default value
const NullableValue.value(T? value) : this._(value, present: value != null);
/// Always uses the value even if it's null
const NullableValue.valueOrEmpty(T? value) : this._(value, present: true);
T? get() => _present ? _value : null;
T? getOrDefault(T? defaultValue) => _present ? _value : defaultValue;
}

View File

@ -1265,7 +1265,7 @@ packages:
source: hosted
version: "2.2.0"
platform:
dependency: transitive
dependency: "direct main"
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"

View File

@ -51,6 +51,7 @@ dependencies:
permission_handler: ^11.4.0
photo_manager: ^3.6.4
photo_manager_image_provider: ^2.2.0
platform: ^3.1.6
punycode: ^1.0.0
riverpod_annotation: ^2.6.1
scrollable_positioned_list: ^0.3.8

View File

@ -12,14 +12,16 @@ import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/services/hash.service.dart';
import 'package:mocktail/mocktail.dart';
import 'package:photo_manager/photo_manager.dart';
import '../../external.mock.dart';
import '../../fixtures/asset.stub.dart';
import '../../infrastructure/repository.mock.dart';
import '../../service.mocks.dart';
class MockAsset extends Mock implements Asset {}
class MockAssetEntity extends Mock implements AssetEntity {}
void main() {
late HashService sut;
late BackgroundService mockBackgroundService;

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +0,0 @@
import 'package:mocktail/mocktail.dart';
import 'package:photo_manager/photo_manager.dart';
class MockAssetEntity extends Mock implements AssetEntity {}
class FakeAssetEntity extends Fake implements AssetEntity {}
class MockAssetPathEntity extends Mock implements AssetPathEntity {}
class FakeAssetPathEntity extends Fake implements AssetPathEntity {}

View File

@ -12,4 +12,14 @@ abstract final class LocalAlbumStub {
backupSelection: BackupSelection.none,
isAll: false,
);
static LocalAlbum get album2 => LocalAlbum(
id: "album2",
name: "Album 2",
updatedAt: DateTime(2025),
assetCount: 2,
thumbnailId: null,
backupSelection: BackupSelection.selected,
isAll: true,
);
}

View File

@ -14,4 +14,28 @@ abstract final class LocalAssetStub {
height: 1080,
durationInSeconds: 0,
);
static LocalAsset get image2 => LocalAsset(
localId: "image2",
name: "image2.jpg",
checksum: "image2-checksum",
type: AssetType.image,
createdAt: DateTime(2020),
updatedAt: DateTime(2023),
width: 300,
height: 400,
durationInSeconds: 0,
);
static LocalAsset get video1 => LocalAsset(
localId: "video1",
name: "video1.mov",
checksum: "video1-checksum",
type: AssetType.video,
createdAt: DateTime(2021),
updatedAt: DateTime(2025),
width: 720,
height: 640,
durationInSeconds: 120,
);
}

View File

@ -1,7 +1,6 @@
import 'package:immich_mobile/domain/interfaces/album_media.interface.dart';
import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart';
import 'package:immich_mobile/domain/interfaces/local_album.interface.dart';
import 'package:immich_mobile/domain/interfaces/local_album_asset.interface.dart';
import 'package:immich_mobile/domain/interfaces/local_asset.interface.dart';
import 'package:immich_mobile/domain/interfaces/log.interface.dart';
import 'package:immich_mobile/domain/interfaces/store.interface.dart';
@ -28,9 +27,6 @@ class MockLocalAlbumRepository extends Mock implements ILocalAlbumRepository {}
class MockAlbumMediaRepository extends Mock implements IAlbumMediaRepository {}
class MockLocalAlbumAssetRepository extends Mock
implements ILocalAlbumAssetRepository {}
// API Repos
class MockUserApiRepository extends Mock implements IUserApiRepository {}