diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 6fa9a13212..dc81c10dec 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -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 diff --git a/mobile/lib/domain/interfaces/album_media.interface.dart b/mobile/lib/domain/interfaces/album_media.interface.dart index 4848bce89f..ab8f659e9e 100644 --- a/mobile/lib/domain/interfaces/album_media.interface.dart +++ b/mobile/lib/domain/interfaces/album_media.interface.dart @@ -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> getAll({PMFilter? filter}); + Future> getAll({ + bool withModifiedTime = false, + bool withAssetCount = false, + bool withAssetTitle = false, + }); - Future> getAssetsForAlbum(AssetPathEntity album); + Future> getAssetsForAlbum( + String albumId, { + bool withModifiedTime = false, + bool withAssetTitle = true, + DateTimeFilter? updateTimeCond, + }); - Future refresh(String albumId, {PMFilter? filter}); + Future 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}); } diff --git a/mobile/lib/domain/interfaces/local_album.interface.dart b/mobile/lib/domain/interfaces/local_album.interface.dart index 82a79963b2..85fff14893 100644 --- a/mobile/lib/domain/interfaces/local_album.interface.dart +++ b/mobile/lib/domain/interfaces/local_album.interface.dart @@ -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 upsert(LocalAlbum localAlbum); + Future insert(LocalAlbum localAlbum, Iterable assets); + + Future addAssets(String albumId, Iterable assets); Future> 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> getAssetIdsOnlyInAlbum(String albumId); + Future> getAssetsForAlbum(String albumId); + + Future update(LocalAlbum localAlbum); Future delete(String albumId); + + Future removeAssets(String albumId, Iterable assetIds); } enum SortLocalAlbumsBy { id } diff --git a/mobile/lib/domain/interfaces/local_album_asset.interface.dart b/mobile/lib/domain/interfaces/local_album_asset.interface.dart deleted file mode 100644 index 31f11117ff..0000000000 --- a/mobile/lib/domain/interfaces/local_album_asset.interface.dart +++ /dev/null @@ -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> getAssetsForAlbum(String albumId); - - Future linkAssetsToAlbum(String albumId, Iterable assetIds); - - Future unlinkAssetsFromAlbum(String albumId, Iterable assetIds); -} diff --git a/mobile/lib/domain/interfaces/local_asset.interface.dart b/mobile/lib/domain/interfaces/local_asset.interface.dart index 636a49d0e3..7cf05a55be 100644 --- a/mobile/lib/domain/interfaces/local_asset.interface.dart +++ b/mobile/lib/domain/interfaces/local_asset.interface.dart @@ -3,8 +3,4 @@ import 'package:immich_mobile/domain/models/asset/asset.model.dart'; abstract interface class ILocalAssetRepository implements IDatabaseRepository { Future get(String assetId); - - Future upsertAll(Iterable localAssets); - - Future deleteIds(Iterable ids); } diff --git a/mobile/lib/domain/models/asset/local_asset.model.dart b/mobile/lib/domain/models/asset/local_asset.model.dart index 9e4f655dff..afab9832b5 100644 --- a/mobile/lib/domain/models/asset/local_asset.model.dart +++ b/mobile/lib/domain/models/asset/local_asset.model.dart @@ -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; } diff --git a/mobile/lib/domain/models/asset/merged_asset.model.dart b/mobile/lib/domain/models/asset/merged_asset.model.dart index afb656ba82..7b0b6e16c4 100644 --- a/mobile/lib/domain/models/asset/merged_asset.model.dart +++ b/mobile/lib/domain/models/asset/merged_asset.model.dart @@ -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 && diff --git a/mobile/lib/domain/models/asset/remote_asset.model.dart b/mobile/lib/domain/models/asset/remote_asset.model.dart index 279baaa867..ae04499574 100644 --- a/mobile/lib/domain/models/asset/remote_asset.model.dart +++ b/mobile/lib/domain/models/asset/remote_asset.model.dart @@ -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; } diff --git a/mobile/lib/domain/models/local_album.model.dart b/mobile/lib/domain/models/local_album.model.dart index 9745e40550..de7c86a64f 100644 --- a/mobile/lib/domain/models/local_album.model.dart +++ b/mobile/lib/domain/models/local_album.model.dart @@ -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? 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 && diff --git a/mobile/lib/domain/services/sync.service.dart b/mobile/lib/domain/services/sync.service.dart index 0dd6f9b9b5..990f87168a 100644 --- a/mobile/lib/domain/services/sync.service.dart +++ b/mobile/lib/domain/services/sync.service.dart @@ -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 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 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)) - : []; - 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)) + : []; - 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 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 - ? {} - : (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 diffLocalAlbums(LocalAlbum dbAlbum, LocalAlbum _) async { + FutureOr 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 handleOnlyAssetsAdded( - LocalAlbum dbAlbum, - LocalAlbum deviceAlbum, - ) async { + // The [deviceAlbum] is expected to be refreshed before calling this method + // with modified time and asset count + Future 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 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 fullSync(LocalAlbum dbAlbum, LocalAlbum deviceAlbum) async { try { final assetsInDevice = deviceAlbum.assetCount > 0 - ? await _albumMediaRepository.getAssetsForAlbum(deviceAlbumEntity) + ? await _albumMediaRepository.getAssetsForAlbum(deviceAlbum.id) + : []; + final assetsInDb = dbAlbum.assetCount > 0 + ? await _localAlbumRepository.getAssetsForAlbum(dbAlbum.id) : []; - final assetsInDb = dbAlbum.assetCount > 0 - ? await _localAlbumAssetRepository.getAssetsForAlbum(dbAlbum.id) - : []; + 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 = {}, - assetsToUpsert = {}, - assetsToDelete = {}; - 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 - ? {} - : (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 = []; + final assetIdsToDelete = []; + + 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 _handleUpdate( + LocalAlbum album, { + Iterable? assetsToUpsert, + Iterable? 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 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, - ); -} diff --git a/mobile/lib/infrastructure/repositories/album_media.repository.dart b/mobile/lib/infrastructure/repositories/album_media.repository.dart index e6609cdd5f..cef5c73297 100644 --- a/mobile/lib/infrastructure/repositories/album_media.repository.dart +++ b/mobile/lib/infrastructure/repositories/album_media.repository.dart @@ -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> getAll({PMFilter? filter}) async { - return await PhotoManager.getAssetPathList( + Future> 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> 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 = []; int pageNumber = 0, lastPageCount = 0; do { @@ -33,14 +89,23 @@ class AlbumMediaRepository implements IAlbumMediaRepository { } @override - Future refresh(String albumId, {PMFilter? filter}) => - AssetPathEntity.obtainPathFromProperties( + Future 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 toDto() async { return asset.LocalAsset( localId: id, @@ -60,7 +125,24 @@ extension AssetEntityMediaRepoX on AssetEntity { } } -extension AssetEntityListMediaRepoX on List { +extension on List { Future> toDtoList() => Future.wait(map((a) => a.toDto())); } + +extension on AssetPathEntity { + Future 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 { + Future> toDtoList({bool withAssetCount = true}) => + Future.wait(map((a) => a.toDto(withAssetCount: withAssetCount))); +} diff --git a/mobile/lib/infrastructure/repositories/local_album.repository.dart b/mobile/lib/infrastructure/repositories/local_album.repository.dart index b09f48d3c4..9e5a67e74b 100644 --- a/mobile/lib/infrastructure/repositories/local_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_album.repository.dart @@ -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 upsert(LocalAlbum localAlbum) { + Future> 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 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 insert(LocalAlbum localAlbum, Iterable 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 addAssets(String albumId, Iterable assets) => + transaction(() async { + await _upsertAssets(assets); + await _linkAssetsToAlbum(albumId, assets); + }); + + @override + Future removeAssets(String albumId, Iterable 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 = []; + final assetsToUnLink = []; + 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 update(LocalAlbum localAlbum) => _upsertAlbum(localAlbum); + + @override + Future> 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 _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> 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 _linkAssetsToAlbum( + String albumId, + Iterable assets, + ) => + _db.batch( + (batch) => batch.insertAll( + _db.localAlbumAssetEntity, + assets.map( + (a) => LocalAlbumAssetEntityCompanion.insert( + assetId: a.localId, + albumId: albumId, + ), + ), + mode: InsertMode.insertOrIgnore, + ), + ); + + Future _unlinkAssetsFromAlbum( + String albumId, + Iterable assetIds, + ) => + _db.batch( + (batch) => batch.deleteWhere( + _db.localAlbumAssetEntity, + (f) => f.assetId.isIn(assetIds) & f.albumId.equals(albumId), + ), + ); + + Future> _getAssetsIdsInAlbum(String albumId) { + final query = _db.localAlbumAssetEntity.select() + ..where((row) => row.albumId.equals(albumId)); + return query.map((row) => row.assetId).get(); } - @override - Future delete(String albumId) => _db.managers.localAlbumEntity - .filter((a) => a.id.equals(albumId)) - .delete(); - - @override - Future> 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> _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 _upsertAssets(Iterable 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 _deleteAssets(Iterable ids) => _db.batch( + (batch) => batch.deleteWhere( + _db.localAssetEntity, + (f) => f.localId.isIn(ids), + ), + ); } diff --git a/mobile/lib/infrastructure/repositories/local_album_asset.repository.dart b/mobile/lib/infrastructure/repositories/local_album_asset.repository.dart deleted file mode 100644 index be951ec8b1..0000000000 --- a/mobile/lib/infrastructure/repositories/local_album_asset.repository.dart +++ /dev/null @@ -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 linkAssetsToAlbum(String albumId, Iterable assetIds) => - _db.batch( - (batch) => batch.insertAll( - _db.localAlbumAssetEntity, - assetIds.map( - (a) => LocalAlbumAssetEntityCompanion.insert( - assetId: a, - albumId: albumId, - ), - ), - mode: InsertMode.insertOrIgnore, - ), - ); - - @override - Future> 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 unlinkAssetsFromAlbum( - String albumId, - Iterable assetIds, - ) => - _db.batch( - (batch) => batch.deleteWhere( - _db.localAlbumAssetEntity, - (f) => f.assetId.isIn(assetIds), - ), - ); -} diff --git a/mobile/lib/infrastructure/repositories/local_asset.repository.dart b/mobile/lib/infrastructure/repositories/local_asset.repository.dart index 61bf93d521..c77e997d23 100644 --- a/mobile/lib/infrastructure/repositories/local_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_asset.repository.dart @@ -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 deleteIds(Iterable ids) => _db.batch( - (batch) => batch.deleteWhere( - _db.localAssetEntity, - (f) => f.localId.isIn(ids), - ), - ); - - @override - Future upsertAll(Iterable 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 get(String assetId) => _db.managers.localAssetEntity .filter((f) => f.localId(assetId)) diff --git a/mobile/lib/utils/nullable_value.dart b/mobile/lib/utils/nullable_value.dart new file mode 100644 index 0000000000..bd9c2c566c --- /dev/null +++ b/mobile/lib/utils/nullable_value.dart @@ -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 { + 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; +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 235b3f71c3..0c99fe19fe 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1265,7 +1265,7 @@ packages: source: hosted version: "2.2.0" platform: - dependency: transitive + dependency: "direct main" description: name: platform sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index fdd91e1f87..324b7c9e9e 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -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 diff --git a/mobile/test/domain/services/hash_service_test.dart b/mobile/test/domain/services/hash_service_test.dart index fdf2d0b9a2..2da41cd704 100644 --- a/mobile/test/domain/services/hash_service_test.dart +++ b/mobile/test/domain/services/hash_service_test.dart @@ -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; diff --git a/mobile/test/domain/services/sync_service_test.dart b/mobile/test/domain/services/sync_service_test.dart index ed8a5a167f..2fd913b9b7 100644 --- a/mobile/test/domain/services/sync_service_test.dart +++ b/mobile/test/domain/services/sync_service_test.dart @@ -1,436 +1,1078 @@ -import 'package:collection/collection.dart'; +// ignore_for_file: avoid-unsafe-collection-methods + +import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.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/domain/services/sync.service.dart'; -import 'package:immich_mobile/infrastructure/repositories/album_media.repository.dart'; +import 'package:immich_mobile/utils/nullable_value.dart'; import 'package:mocktail/mocktail.dart'; -import 'package:photo_manager/photo_manager.dart'; -import '../../external.mock.dart'; import '../../fixtures/local_album.stub.dart'; import '../../fixtures/local_asset.stub.dart'; import '../../infrastructure/repository.mock.dart'; void main() { - group('SyncService', () { - late SyncService sut; - late IAlbumMediaRepository mockAlbumMediaRepo; - late ILocalAlbumRepository mockLocalAlbumRepo; - late ILocalAssetRepository mockLocalAssetRepo; - late ILocalAlbumAssetRepository mockLocalAlbumAssetRepo; + late IAlbumMediaRepository mockAlbumMediaRepo; + late ILocalAlbumRepository mockLocalAlbumRepo; + late ILocalAssetRepository mockLocalAssetRepo; + late SyncService sut; - const albumId = 'test-album-id'; - final now = DateTime.now(); - final earlier = now.subtract(const Duration(days: 1)); + Future mockTransaction(Future Function() action) async { + return await action(); + } - late LocalAlbum dbAlbum; - late LocalAlbum deviceAlbum; - late AssetPathEntity deviceAlbumEntity; - late List deviceAssets; - late List dbAssets; + setUp(() { + mockAlbumMediaRepo = MockAlbumMediaRepository(); + mockLocalAlbumRepo = MockLocalAlbumRepository(); + mockLocalAssetRepo = MockLocalAssetRepository(); - setUp(() async { - mockAlbumMediaRepo = MockAlbumMediaRepository(); - mockLocalAlbumRepo = MockLocalAlbumRepository(); - mockLocalAssetRepo = MockLocalAssetRepository(); - mockLocalAlbumAssetRepo = MockLocalAlbumAssetRepository(); + sut = SyncService( + albumMediaRepository: mockAlbumMediaRepo, + localAlbumRepository: mockLocalAlbumRepo, + localAssetRepository: mockLocalAssetRepo, + ); - sut = SyncService( - albumMediaRepository: mockAlbumMediaRepo, - localAlbumRepository: mockLocalAlbumRepo, - localAssetRepository: mockLocalAssetRepo, - localAlbumAssetRepository: mockLocalAlbumAssetRepo, + registerFallbackValue(LocalAlbumStub.album1); + registerFallbackValue(LocalAssetStub.image1); + registerFallbackValue(SortLocalAlbumsBy.id); + registerFallbackValue([]); + + when(() => mockAlbumMediaRepo.getAll()).thenAnswer((_) async => []); + when(() => mockLocalAlbumRepo.getAll(sortBy: any(named: 'sortBy'))) + .thenAnswer((_) async => []); + when(() => mockLocalAlbumRepo.insert(any(), any())) + .thenAnswer((_) async => []); + when(() => mockLocalAlbumRepo.delete(any())).thenAnswer((_) async => true); + when(() => mockLocalAlbumRepo.update(any())).thenAnswer((_) async => true); + when(() => mockLocalAlbumRepo.addAssets(any(), any())) + .thenAnswer((_) async => true); + when(() => mockLocalAlbumRepo.removeAssets(any(), any())) + .thenAnswer((_) async => true); + when(() => mockLocalAlbumRepo.getAssetsForAlbum(any())) + .thenAnswer((_) async => []); + when(() => mockLocalAssetRepo.get(any())).thenAnswer( + (_) async => LocalAssetStub.image1, + ); + when( + () => mockAlbumMediaRepo.refresh( + any(), + withModifiedTime: any(named: 'withModifiedTime'), + withAssetCount: any(named: 'withAssetCount'), + ), + ).thenAnswer( + (inv) async => + LocalAlbumStub.album1.copyWith(id: inv.positionalArguments.first), + ); + when( + () => mockAlbumMediaRepo.getAssetsForAlbum( + any(), + updateTimeCond: any(named: 'updateTimeCond'), + ), + ).thenAnswer((_) async => []); + when(() => mockAlbumMediaRepo.getAssetsForAlbum(any())) + .thenAnswer((_) async => []); + + when(() => mockLocalAlbumRepo.transaction(any())).thenAnswer( + (inv) => mockTransaction( + inv.positionalArguments.first as Future Function(), + ), + ); + }); + + group('syncLocalAlbums', () { + test('should return false when no albums exist', () async { + final result = await sut.syncLocalAlbums(); + expect(result, isFalse); + verify(() => mockAlbumMediaRepo.getAll()).called(1); + verify(() => mockLocalAlbumRepo.getAll(sortBy: SortLocalAlbumsBy.id)) + .called(1); + verifyNever(() => mockLocalAlbumRepo.insert(any(), any())); + verifyNever(() => mockLocalAlbumRepo.delete(any())); + verifyNever( + () => mockAlbumMediaRepo.refresh( + any(), + withModifiedTime: any(named: 'withModifiedTime'), + withAssetCount: any(named: 'withAssetCount'), + ), ); + }); - dbAlbum = LocalAlbum( - id: albumId, - name: 'Test Album', - updatedAt: earlier, - assetCount: 5, - backupSelection: BackupSelection.none, - ); - - deviceAlbumEntity = MockAssetPathEntity(); - when(() => deviceAlbumEntity.id).thenReturn(albumId); - when(() => deviceAlbumEntity.name).thenReturn('Test Album'); - when(() => deviceAlbumEntity.lastModified).thenReturn(now); - when(() => deviceAlbumEntity.isAll).thenReturn(false); - when(() => deviceAlbumEntity.assetCountAsync).thenAnswer((_) async => 5); - deviceAlbum = await deviceAlbumEntity.toDto(); - - deviceAssets = await Future.wait( - List.generate(5, (i) { - final asset = MockAssetEntity(); - when(() => asset.id).thenReturn('asset-$i'); - when(() => asset.title).thenReturn('Asset $i'); - when(() => asset.createDateTime).thenReturn(now); - when(() => asset.modifiedDateTime).thenReturn(now); - when(() => asset.width).thenReturn(1920); - when(() => asset.height).thenReturn(1080); - when(() => asset.type).thenReturn(AssetType.image); - when(() => asset.duration).thenReturn(0); - return asset.toDto(); - }), - ); - - dbAssets = await Future.wait( - List.generate(5, (i) { - final asset = MockAssetEntity(); - when(() => asset.id).thenReturn('asset-$i'); - when(() => asset.title).thenReturn('Asset $i'); - when(() => asset.createDateTime).thenReturn(earlier); - when(() => asset.modifiedDateTime).thenReturn(earlier); - when(() => asset.width).thenReturn(1920); - when(() => asset.height).thenReturn(1080); - when(() => asset.type).thenReturn(AssetType.image); - when(() => asset.duration).thenReturn(0); - return asset.toDto(); - }), - ); - - registerFallbackValue(FakeAssetEntity()); - registerFallbackValue(FakeAssetPathEntity()); - registerFallbackValue(LocalAssetStub.image1); - registerFallbackValue(LocalAlbumStub.album1); - - when(() => mockAlbumMediaRepo.refresh(albumId)) - .thenAnswer((_) async => deviceAlbumEntity); + test('should call addLocalAlbum for new device albums', () async { + final deviceAlbums = [LocalAlbumStub.album1, LocalAlbumStub.album2]; + when(() => mockAlbumMediaRepo.getAll()) + .thenAnswer((_) async => deviceAlbums); + when(() => mockLocalAlbumRepo.getAll(sortBy: SortLocalAlbumsBy.id)) + .thenAnswer((_) async => []); when( () => mockAlbumMediaRepo.refresh( - albumId, - filter: any(named: 'filter'), + deviceAlbums.first.id, + withModifiedTime: true, + withAssetCount: true, ), - ).thenAnswer((_) async => deviceAlbumEntity); + ).thenAnswer( + (_) async => deviceAlbums.first + .copyWith(updatedAt: DateTime(2024), assetCount: 1), + ); + when( + () => mockAlbumMediaRepo.refresh( + deviceAlbums[1].id, + withModifiedTime: true, + withAssetCount: true, + ), + ).thenAnswer( + (_) async => + deviceAlbums[1].copyWith(updatedAt: DateTime(2024), assetCount: 0), + ); - when(() => mockAlbumMediaRepo.getAssetsForAlbum(deviceAlbumEntity)) - .thenAnswer((_) async => deviceAssets); + when(() => mockAlbumMediaRepo.getAssetsForAlbum(deviceAlbums.first.id)) + .thenAnswer((_) async => [LocalAssetStub.image1]); - when(() => mockLocalAlbumAssetRepo.getAssetsForAlbum(albumId)) - .thenAnswer((_) async => dbAssets); + final result = await sut.syncLocalAlbums(); - when(() => mockLocalAssetRepo.upsertAll(any())) - .thenAnswer((_) async => {}); + expect(result, isTrue); + verify(() => mockAlbumMediaRepo.getAll()).called(1); + verify(() => mockLocalAlbumRepo.getAll(sortBy: SortLocalAlbumsBy.id)) + .called(1); + // Verify addLocalAlbum logic was triggered twice + verify( + () => mockAlbumMediaRepo.refresh( + deviceAlbums.first.id, + withModifiedTime: true, + withAssetCount: true, + ), + ).called(1); + verify( + () => mockAlbumMediaRepo.refresh( + deviceAlbums[1].id, + withModifiedTime: true, + withAssetCount: true, + ), + ).called(1); + verify(() => mockAlbumMediaRepo.getAssetsForAlbum(deviceAlbums.first.id)) + .called(1); + verifyNever( + () => mockAlbumMediaRepo.getAssetsForAlbum(deviceAlbums[1].id), + ); // Not called for empty album + verify(() => mockLocalAlbumRepo.insert(any(), any())).called(2); + verifyNever(() => mockLocalAlbumRepo.delete(any())); + }); - when(() => mockLocalAlbumAssetRepo.linkAssetsToAlbum(any(), any())) - .thenAnswer((_) async => {}); + test('should call removeLocalAlbum for albums only in DB', () async { + final dbAlbums = [LocalAlbumStub.album1, LocalAlbumStub.album2]; + when(() => mockAlbumMediaRepo.getAll()).thenAnswer((_) async => []); + when(() => mockLocalAlbumRepo.getAll(sortBy: SortLocalAlbumsBy.id)) + .thenAnswer((_) async => dbAlbums); - when(() => mockLocalAlbumAssetRepo.unlinkAssetsFromAlbum(any(), any())) - .thenAnswer((_) async => {}); + final result = await sut.syncLocalAlbums(); - when(() => mockLocalAlbumRepo.upsert(any())).thenAnswer((_) async => {}); - - when(() => mockLocalAlbumRepo.delete(any())).thenAnswer((_) async => {}); - - when(() => mockLocalAssetRepo.deleteIds(any())) - .thenAnswer((_) async => {}); - - when(() => mockLocalAlbumRepo.transaction(any())) - .thenAnswer((_) async { - final capturedCallback = verify( - () => mockLocalAlbumRepo.transaction(captureAny()), - ).captured; - // Invoke the transaction callback - await (capturedCallback.firstOrNull as Future Function()?) - ?.call(); - }); + expect(result, isTrue); + verify(() => mockAlbumMediaRepo.getAll()).called(1); + verify(() => mockLocalAlbumRepo.getAll(sortBy: SortLocalAlbumsBy.id)) + .called(1); + verify(() => mockLocalAlbumRepo.delete(dbAlbums.first.id)).called(1); + verify(() => mockLocalAlbumRepo.delete(dbAlbums[1].id)).called(1); + verifyNever(() => mockLocalAlbumRepo.insert(any(), any())); + verifyNever( + () => mockAlbumMediaRepo.refresh( + any(), + withModifiedTime: any(named: 'withModifiedTime'), + withAssetCount: any(named: 'withAssetCount'), + ), + ); }); test( - 'album filter should be properly configured with expected settings', - () { - final albumFilter = sut.albumFilter; + 'should call syncLocalAlbum for albums in both DB and device', + () async { + final commonAlbum = LocalAlbumStub.album1; + final deviceAlbums = [commonAlbum]; + final dbAlbums = [ + commonAlbum.copyWith(updatedAt: DateTime(2023)), + ]; // Different updatedAt to trigger sync + when(() => mockAlbumMediaRepo.getAll()) + .thenAnswer((_) async => deviceAlbums); + when(() => mockLocalAlbumRepo.getAll(sortBy: SortLocalAlbumsBy.id)) + .thenAnswer((_) async => dbAlbums); - final imageOption = albumFilter.getOption(AssetType.image); - expect(imageOption.needTitle, isTrue); - expect(imageOption.sizeConstraint.ignoreSize, isTrue); - final videoOption = albumFilter.getOption(AssetType.video); - expect(videoOption.needTitle, isTrue); - expect(videoOption.sizeConstraint.ignoreSize, isTrue); - expect(videoOption.durationConstraint.allowNullable, isTrue); - expect(albumFilter.containsPathModified, isTrue); - expect(albumFilter.createTimeCond.ignore, isTrue); - expect(albumFilter.updateTimeCond.ignore, isTrue); - expect(albumFilter.orders.length, 1); - expect( - albumFilter.orders.firstOrNull?.type, - OrderOptionType.createDate, - ); - expect(albumFilter.orders.firstOrNull?.asc, isFalse); + final refreshedAlbum = + commonAlbum.copyWith(updatedAt: DateTime(2024), assetCount: 1); + when( + () => mockAlbumMediaRepo.refresh( + commonAlbum.id, + withModifiedTime: true, + withAssetCount: true, + ), + ).thenAnswer((_) async => refreshedAlbum); + + when(() => mockAlbumMediaRepo.getAssetsForAlbum(commonAlbum.id)) + .thenAnswer((_) async => [LocalAssetStub.image1]); + when(() => mockLocalAlbumRepo.getAssetsForAlbum(commonAlbum.id)) + .thenAnswer((_) async => []); + + final result = await sut.syncLocalAlbums(); + + expect(result, isTrue); + verify(() => mockAlbumMediaRepo.getAll()).called(1); + verify(() => mockLocalAlbumRepo.getAll(sortBy: SortLocalAlbumsBy.id)) + .called(1); + + verify( + () => mockAlbumMediaRepo.refresh( + commonAlbum.id, + withModifiedTime: true, + withAssetCount: true, + ), + ).called(1); + + verify(() => mockAlbumMediaRepo.getAssetsForAlbum(commonAlbum.id)) + .called(1); + verify(() => mockLocalAlbumRepo.getAssetsForAlbum(commonAlbum.id)) + .called(1); + verify(() => mockLocalAlbumRepo.transaction(any())).called(1); + verifyNever(() => mockLocalAlbumRepo.insert(any(), any())); + verifyNever(() => mockLocalAlbumRepo.delete(any())); }, ); - group('handleOnlyAssetsAdded: ', () { - // All the below tests expects the device album to have more assets - // than the DB album. This is to simulate the scenario where - // new assets are added to the device album. - setUp(() { - deviceAlbum = deviceAlbum.copyWith(assetCount: 10); - }); + test('should handle errors during repository calls', () async { + when(() => mockAlbumMediaRepo.getAll()) + .thenThrow(Exception("Device error")); - test( - 'early return when device album timestamp is not after DB album', - () async { - final result = await sut.handleOnlyAssetsAdded( - dbAlbum, - deviceAlbum.copyWith(updatedAt: earlier), - ); + final result = await sut.syncLocalAlbums(); - expect(result, isFalse); - verifyNever(() => mockAlbumMediaRepo.getAssetsForAlbum(any())); - verifyNever(() => mockLocalAssetRepo.upsertAll(any())); - verifyNever( - () => mockLocalAlbumAssetRepo.linkAssetsToAlbum(any(), any()), - ); - verifyNever(() => mockLocalAlbumRepo.upsert(any())); - }, - ); - - test( - 'early return when device album has fewer assets than DB album', - () async { - final result = await sut.handleOnlyAssetsAdded( - dbAlbum, - deviceAlbum.copyWith(assetCount: dbAlbum.assetCount - 1), - ); - - expect(result, isFalse); - verifyNever(() => mockAlbumMediaRepo.getAssetsForAlbum(any())); - verifyNever(() => mockLocalAssetRepo.upsertAll(any())); - verifyNever( - () => mockLocalAlbumAssetRepo.linkAssetsToAlbum(any(), any()), - ); - verifyNever(() => mockLocalAlbumRepo.upsert(any())); - }, - ); - - test( - 'correctly processes assets when new assets are added', - () async { - final result = await sut.handleOnlyAssetsAdded(dbAlbum, deviceAlbum); - - verify( - () => mockAlbumMediaRepo.getAssetsForAlbum(deviceAlbumEntity), - ).called(1); - - verify(() => mockLocalAssetRepo.upsertAll(any())).called(1); - verify( - () => mockLocalAlbumAssetRepo.linkAssetsToAlbum( - albumId, - deviceAssets.map((a) => a.localId), - ), - ).called(1); - - verify(() => mockLocalAlbumRepo.upsert(any())).called(1); - - expect(result, isTrue); - }, - ); - - test( - 'correct handling when filtering yields no new assets', - () async { - when(() => mockAlbumMediaRepo.getAssetsForAlbum(deviceAlbumEntity)) - .thenAnswer((_) async => deviceAssets.sublist(0, 2)); - - final result = await sut.handleOnlyAssetsAdded(dbAlbum, deviceAlbum); - - verify(() => mockAlbumMediaRepo.getAssetsForAlbum(deviceAlbumEntity)) - .called(1); - - verifyNever(() => mockLocalAssetRepo.upsertAll(any())); - verifyNever( - () => mockLocalAlbumAssetRepo.linkAssetsToAlbum(any(), any()), - ); - verifyNever(() => mockLocalAlbumRepo.upsert(any())); - - expect(result, isFalse); - }, - ); - - test( - 'thumbnail is updated when new asset is newer than existing thumbnail', - () async { - final oldThumbnailId = 'asset-100'; - when(() => mockLocalAssetRepo.get(oldThumbnailId)).thenAnswer( - (_) async => - LocalAssetStub.image1.copyWith(createdAt: DateTime(100)), - ); - - final result = await sut.handleOnlyAssetsAdded( - dbAlbum.copyWith(thumbnailId: oldThumbnailId), - deviceAlbum, - ); - - final capturedAlbum = - verify(() => mockLocalAlbumRepo.upsert(captureAny())) - .captured - .singleOrNull as LocalAlbum?; - expect(capturedAlbum?.thumbnailId, isNot(equals(oldThumbnailId))); - expect( - capturedAlbum?.thumbnailId, - equals(deviceAssets.firstOrNull?.localId), - ); - expect(result, isTrue); - }, - ); - - test( - 'thumbnail preservation when new asset is older than existing thumbnail', - () async { - final oldThumbnailId = 'asset-100'; - when(() => mockLocalAssetRepo.get(oldThumbnailId)).thenAnswer( - (_) async => LocalAssetStub.image1 - .copyWith(createdAt: now.add(const Duration(days: 1))), - ); - - final result = await sut.handleOnlyAssetsAdded( - dbAlbum.copyWith(thumbnailId: oldThumbnailId), - deviceAlbum, - ); - - final capturedAlbum = - verify(() => mockLocalAlbumRepo.upsert(captureAny())) - .captured - .singleOrNull as LocalAlbum?; - expect(capturedAlbum?.thumbnailId, equals(oldThumbnailId)); - expect(result, isTrue); - }, + expect(result, isFalse); + verify(() => mockAlbumMediaRepo.getAll()).called(1); + verifyNever( + () => mockLocalAlbumRepo.getAll(sortBy: any(named: 'sortBy')), ); }); - group('addLocalAlbum: ', () { - test('adding an album with no assets works correctly', () async { - when(() => deviceAlbumEntity.assetCountAsync) - .thenAnswer((_) async => 0); + test('should handle errors during diff callbacks', () async { + final deviceAlbums = [LocalAlbumStub.album1]; + when(() => mockAlbumMediaRepo.getAll()) + .thenAnswer((_) async => deviceAlbums); + when(() => mockLocalAlbumRepo.getAll(sortBy: SortLocalAlbumsBy.id)) + .thenAnswer((_) async => []); + when( + () => mockAlbumMediaRepo.refresh( + any(), + withModifiedTime: any(named: 'withModifiedTime'), + withAssetCount: any(named: 'withAssetCount'), + ), + ).thenThrow(Exception("Refresh error")); - await sut.addLocalAlbum(deviceAlbum.copyWith(assetCount: 0)); + final result = await sut.syncLocalAlbums(); - final albumUpsertCall = - verify(() => mockLocalAlbumRepo.upsert(captureAny())); - albumUpsertCall.called(1); + expect(result, isTrue); + verify(() => mockAlbumMediaRepo.getAll()).called(1); + verify(() => mockLocalAlbumRepo.getAll(sortBy: SortLocalAlbumsBy.id)) + .called(1); + verify( + () => mockAlbumMediaRepo.refresh( + deviceAlbums.first.id, + withModifiedTime: true, + withAssetCount: true, + ), + ).called(1); + verifyNever(() => mockLocalAlbumRepo.insert(any(), any())); + }); + }); + + group('addLocalAlbum', () { + test( + 'refreshes, gets assets, sets thumbnail, and inserts for non-empty album', + () async { + final newAlbum = LocalAlbumStub.album1.copyWith(assetCount: 0); + final refreshedAlbum = + newAlbum.copyWith(updatedAt: DateTime(2024), assetCount: 2); + final assets = [ + LocalAssetStub.image1 + .copyWith(localId: "asset1", createdAt: DateTime(2024, 1, 1)), + LocalAssetStub.image2.copyWith( + localId: "asset2", + createdAt: DateTime(2024, 1, 2), + ), + ]; + + when( + () => mockAlbumMediaRepo.refresh( + newAlbum.id, + withModifiedTime: true, + withAssetCount: true, + ), + ).thenAnswer((_) async => refreshedAlbum); + when(() => mockAlbumMediaRepo.getAssetsForAlbum(newAlbum.id)) + .thenAnswer((_) async => assets); + + await sut.addLocalAlbum(newAlbum); - // Always refreshed verify( - () => mockAlbumMediaRepo.refresh(albumId, filter: sut.albumFilter), + () => mockAlbumMediaRepo.refresh( + newAlbum.id, + withModifiedTime: true, + withAssetCount: true, + ), ).called(1); - verifyNever(() => mockLocalAssetRepo.upsertAll(any())); - verifyNever( - () => mockLocalAlbumAssetRepo.linkAssetsToAlbum(any(), any()), + verify(() => mockAlbumMediaRepo.getAssetsForAlbum(newAlbum.id)) + .called(1); + + final captured = + verify(() => mockLocalAlbumRepo.insert(captureAny(), captureAny())) + .captured; + final capturedAlbum = captured.first as LocalAlbum; + final capturedAssets = captured[1] as List; + + expect(capturedAlbum.id, newAlbum.id); + expect(capturedAlbum.assetCount, refreshedAlbum.assetCount); + expect(capturedAlbum.updatedAt, refreshedAlbum.updatedAt); + expect(capturedAlbum.thumbnailId, assets.first.localId); + expect(listEquals(capturedAssets, assets), isTrue); + }, + ); + + test( + 'refreshes, skips assets, sets null thumbnail, and inserts for empty album', + () async { + final newAlbum = LocalAlbumStub.album1.copyWith(assetCount: 0); + final refreshedAlbum = + newAlbum.copyWith(updatedAt: DateTime(2024), assetCount: 0); + + when( + () => mockAlbumMediaRepo.refresh( + newAlbum.id, + withModifiedTime: true, + withAssetCount: true, + ), + ).thenAnswer((_) async => refreshedAlbum); + + await sut.addLocalAlbum(newAlbum); + + verify( + () => mockAlbumMediaRepo.refresh( + newAlbum.id, + withModifiedTime: true, + withAssetCount: true, + ), + ).called(1); + verifyNever(() => mockAlbumMediaRepo.getAssetsForAlbum(newAlbum.id)); + + final captured = + verify(() => mockLocalAlbumRepo.insert(captureAny(), captureAny())) + .captured; + final capturedAlbum = captured.first as LocalAlbum; + final capturedAssets = captured[1] as List; + + expect(capturedAlbum.id, newAlbum.id); + expect(capturedAlbum.assetCount, 0); + expect(capturedAlbum.thumbnailId, isNull); + expect(capturedAssets, isEmpty); + }, + ); + + test('handles error during refresh', () async { + final newAlbum = LocalAlbumStub.album1; + when( + () => mockAlbumMediaRepo.refresh( + any(), + withModifiedTime: any(named: 'withModifiedTime'), + withAssetCount: any(named: 'withAssetCount'), + ), + ).thenThrow(Exception("Refresh failed")); + + await sut.addLocalAlbum(newAlbum); + + verify( + () => mockAlbumMediaRepo.refresh( + newAlbum.id, + withModifiedTime: true, + withAssetCount: true, + ), + ).called(1); + verifyNever(() => mockAlbumMediaRepo.getAssetsForAlbum(any())); + verifyNever(() => mockLocalAlbumRepo.insert(any(), any())); + }); + + test('handles error during getAssetsForAlbum', () async { + final newAlbum = LocalAlbumStub.album1.copyWith(assetCount: 0); + final refreshedAlbum = + newAlbum.copyWith(updatedAt: DateTime(2024), assetCount: 2); + + when( + () => mockAlbumMediaRepo.refresh( + newAlbum.id, + withModifiedTime: true, + withAssetCount: true, + ), + ).thenAnswer((_) async => refreshedAlbum); + when(() => mockAlbumMediaRepo.getAssetsForAlbum(newAlbum.id)) + .thenThrow(Exception("Get assets failed")); + + await sut.addLocalAlbum(newAlbum); + + verify( + () => mockAlbumMediaRepo.refresh( + newAlbum.id, + withModifiedTime: true, + withAssetCount: true, + ), + ).called(1); + verify(() => mockAlbumMediaRepo.getAssetsForAlbum(newAlbum.id)).called(1); + verifyNever(() => mockLocalAlbumRepo.insert(any(), any())); + }); + }); + + group('removeLocalAlbum', () { + test('calls localAlbumRepository.delete', () async { + final albumToDelete = LocalAlbumStub.album1; + await sut.removeLocalAlbum(albumToDelete); + verify(() => mockLocalAlbumRepo.delete(albumToDelete.id)).called(1); + }); + + test('handles error during delete', () async { + final albumToDelete = LocalAlbumStub.album1; + when(() => mockLocalAlbumRepo.delete(any())) + .thenThrow(Exception("Delete failed")); + + await expectLater(sut.removeLocalAlbum(albumToDelete), completes); + verify(() => mockLocalAlbumRepo.delete(albumToDelete.id)).called(1); + }); + }); + + group('syncLocalAlbum', () { + final dbAlbum = LocalAlbumStub.album1.copyWith( + updatedAt: DateTime(2024, 1, 1), + assetCount: 1, + backupSelection: BackupSelection.selected, + ); + + test('returns false if refresh shows no changes', () async { + final refreshedAlbum = dbAlbum; // Same updatedAt and assetCount + when( + () => mockAlbumMediaRepo.refresh( + dbAlbum.id, + withModifiedTime: true, + withAssetCount: true, + ), + ).thenAnswer((_) async => refreshedAlbum); + + final result = await sut.syncLocalAlbum(dbAlbum, LocalAlbumStub.album1); + + expect(result, isFalse); + verify( + () => mockAlbumMediaRepo.refresh( + dbAlbum.id, + withModifiedTime: true, + withAssetCount: true, + ), + ).called(1); + verifyNever(() => mockAlbumMediaRepo.getAssetsForAlbum(any())); + verifyNever(() => mockLocalAlbumRepo.getAssetsForAlbum(any())); + verifyNever(() => mockLocalAlbumRepo.transaction(any())); + }); + + test( + 'updates metadata and returns true for empty albums with no changes needed', + () async { + final emptyDbAlbum = + dbAlbum.copyWith(updatedAt: DateTime(2024, 1, 1), assetCount: 0); + final refreshedEmptyAlbum = emptyDbAlbum.copyWith( + updatedAt: DateTime(2024, 1, 2), ); - final capturedAlbum = - albumUpsertCall.captured.singleOrNull as LocalAlbum?; - expect(capturedAlbum?.id, equals(albumId)); - expect(capturedAlbum?.name, equals('Test Album')); - expect(capturedAlbum?.assetCount, equals(0)); - expect(capturedAlbum?.thumbnailId, isNull); - }); + when( + () => mockAlbumMediaRepo.refresh( + emptyDbAlbum.id, + withModifiedTime: true, + withAssetCount: true, + ), + ).thenAnswer((_) async => refreshedEmptyAlbum); - test( - 'adding an album with multiple assets works correctly', - () async { - await sut.addLocalAlbum(deviceAlbum); + final result = + await sut.syncLocalAlbum(emptyDbAlbum, LocalAlbumStub.album1); - final albumUpsertCall = - verify(() => mockLocalAlbumRepo.upsert(captureAny())); - albumUpsertCall.called(1); - verify(() => mockLocalAssetRepo.upsertAll(any())).called(1); - verify(() => - mockLocalAlbumAssetRepo.linkAssetsToAlbum(albumId, any())) - .called(1); + expect(result, isTrue); + verify( + () => mockAlbumMediaRepo.refresh( + emptyDbAlbum.id, + withModifiedTime: true, + withAssetCount: true, + ), + ).called(1); + verify( + () => mockLocalAlbumRepo.update( + refreshedEmptyAlbum.copyWith( + backupSelection: emptyDbAlbum.backupSelection, + ), + ), + ).called(1); + verifyNever(() => mockAlbumMediaRepo.getAssetsForAlbum(any())); + verifyNever(() => mockLocalAlbumRepo.getAssetsForAlbum(any())); + verifyNever(() => mockLocalAlbumRepo.transaction(any())); + }, + ); - final capturedAlbum = - albumUpsertCall.captured.singleOrNull as LocalAlbum?; - expect(capturedAlbum?.assetCount, deviceAssets.length); + test('calls tryFastSync and returns true if it succeeds', () async { + final refreshedAlbum = dbAlbum.copyWith( + updatedAt: DateTime(2024, 1, 2), + assetCount: 2, + ); // Time and count increased + when( + () => mockAlbumMediaRepo.refresh( + dbAlbum.id, + withModifiedTime: true, + withAssetCount: true, + ), + ).thenAnswer((_) async => refreshedAlbum); - expect(capturedAlbum?.thumbnailId, deviceAssets.firstOrNull?.localId); - }, - ); + final newAsset = LocalAssetStub.image2.copyWith(localId: "new_asset"); + when( + () => mockAlbumMediaRepo.getAssetsForAlbum( + dbAlbum.id, + updateTimeCond: any(named: 'updateTimeCond'), + ), + ).thenAnswer((_) async => [newAsset]); + + final result = await sut.syncLocalAlbum(dbAlbum, LocalAlbumStub.album1); + + expect(result, isTrue); + verify( + () => mockAlbumMediaRepo.refresh( + dbAlbum.id, + withModifiedTime: true, + withAssetCount: true, + ), + ).called(1); + verify( + () => mockAlbumMediaRepo.getAssetsForAlbum( + dbAlbum.id, + updateTimeCond: any(named: 'updateTimeCond'), + ), + ).called(1); + + verify(() => mockLocalAlbumRepo.transaction(any())).called(1); + verify(() => mockLocalAlbumRepo.addAssets(dbAlbum.id, [newAsset])) + .called(1); + verify( + () => mockLocalAlbumRepo.update( + any( + that: predicate( + (a) => a.id == dbAlbum.id && a.assetCount == 2, + ), + ), + ), + ).called(1); + verifyNever(() => mockLocalAlbumRepo.removeAssets(any(), any())); + + verifyNever(() => mockAlbumMediaRepo.getAssetsForAlbum(dbAlbum.id)); + verifyNever(() => mockLocalAlbumRepo.getAssetsForAlbum(dbAlbum.id)); }); - group('removeLocalAlbum: ', () { - test( - 'removing album with no assets correctly calls delete', - () async { - when(() => mockLocalAlbumAssetRepo.getAssetsForAlbum(albumId)) - .thenAnswer((_) async => []); - when(() => mockLocalAlbumRepo.getAssetIdsOnlyInAlbum(albumId)) - .thenAnswer((_) async => []); + test( + 'calls fullSync and returns true if tryFastSync returns false', + () async { + final refreshedAlbum = dbAlbum.copyWith( + updatedAt: DateTime(2024, 1, 2), + assetCount: 0, + ); // Count decreased -> fast sync fails + when( + () => mockAlbumMediaRepo.refresh( + dbAlbum.id, + withModifiedTime: true, + withAssetCount: true, + ), + ).thenAnswer((_) async => refreshedAlbum); - await sut.removeLocalAlbum(deviceAlbum); + when(() => mockAlbumMediaRepo.getAssetsForAlbum(dbAlbum.id)) + .thenAnswer((_) async => []); + when(() => mockLocalAlbumRepo.getAssetsForAlbum(dbAlbum.id)).thenAnswer( + (_) async => [LocalAssetStub.image1], + ); - verify(() => mockLocalAssetRepo.deleteIds([])).called(1); - verify(() => mockLocalAlbumRepo.delete(albumId)).called(1); - verify( - () => mockLocalAlbumAssetRepo.unlinkAssetsFromAlbum(albumId, {}), - ).called(1); - }, - ); + final result = await sut.syncLocalAlbum(dbAlbum, LocalAlbumStub.album1); - test( - 'removing album with assets unique to that album deletes those assets', - () async { - final uniqueAssetIds = deviceAssets.map((a) => a.localId).toList(); - when(() => mockLocalAlbumRepo.getAssetIdsOnlyInAlbum(albumId)) - .thenAnswer((_) async => uniqueAssetIds); - - await sut.removeLocalAlbum(deviceAlbum); - - verify(() => mockLocalAssetRepo.deleteIds(uniqueAssetIds)).called(1); - verify(() => mockLocalAlbumRepo.delete(albumId)).called(1); - verify(() => mockLocalAlbumAssetRepo.unlinkAssetsFromAlbum(any(), {})) - .called(1); - }, - ); - - test( - 'removing album with shared assets unlinks those assets', - () async { - final assetIds = deviceAssets.map((a) => a.localId).toList(); - when(() => mockLocalAlbumRepo.getAssetIdsOnlyInAlbum(albumId)) - .thenAnswer((_) async => []); - - await sut.removeLocalAlbum(deviceAlbum); - - verify(() => mockLocalAssetRepo.deleteIds([])).called(1); - verify( - () => mockLocalAlbumAssetRepo.unlinkAssetsFromAlbum( - albumId, - assetIds, + expect(result, isTrue); + verify( + () => mockAlbumMediaRepo.refresh( + dbAlbum.id, + withModifiedTime: true, + withAssetCount: true, + ), + ).called(1); + // Verify tryFastSync path was attempted but failed (no getAssetsForAlbum with timeCond) + verifyNever( + () => mockAlbumMediaRepo.getAssetsForAlbum( + dbAlbum.id, + updateTimeCond: any(named: 'updateTimeCond'), + ), + ); + // Verify fullSync path was taken + verifyNever(() => mockAlbumMediaRepo.getAssetsForAlbum(dbAlbum.id)); + verify(() => mockLocalAlbumRepo.getAssetsForAlbum(dbAlbum.id)) + .called(1); + // Verify _handleUpdate was called via fullSync + verifyNever(() => mockLocalAlbumRepo.addAssets(any(), any())); + verify( + () => mockLocalAlbumRepo.update( + any( + that: predicate( + (a) => + a.id == dbAlbum.id && + a.assetCount == 0 && + a.thumbnailId == null, + ), ), - ).called(1); - verify(() => mockLocalAlbumRepo.delete(albumId)).called(1); - }, + ), + ).called(1); + verify( + () => mockLocalAlbumRepo + .removeAssets(dbAlbum.id, [LocalAssetStub.image1.localId]), + ).called(1); + }, + ); + + test('handles error during refresh', () async { + when( + () => mockAlbumMediaRepo.refresh( + any(), + withModifiedTime: any(named: 'withModifiedTime'), + withAssetCount: any(named: 'withAssetCount'), + ), + ).thenThrow(Exception("Refresh failed")); + + final result = await sut.syncLocalAlbum(dbAlbum, LocalAlbumStub.album1); + + expect(result, isTrue); + verify( + () => mockAlbumMediaRepo.refresh( + dbAlbum.id, + withModifiedTime: true, + withAssetCount: true, + ), + ).called(1); + verifyNever(() => mockAlbumMediaRepo.getAssetsForAlbum(any())); + verifyNever(() => mockLocalAlbumRepo.transaction(any())); + }); + }); + + group('tryFastSync', () { + final dbAlbum = LocalAlbumStub.album1.copyWith( + updatedAt: DateTime(2024, 1, 1, 10, 0, 0), + assetCount: 1, + thumbnailId: const NullableValue.value("thumb1"), + ); + final refreshedAlbum = dbAlbum.copyWith( + updatedAt: DateTime(2024, 1, 1, 11, 0, 0), + assetCount: 2, + ); + + test('returns true and updates assets/metadata on success', () async { + final newAsset = LocalAssetStub.image2.copyWith( + localId: "asset2", + createdAt: DateTime(2024, 1, 1, 10, 30, 0), ); + when( + () => mockAlbumMediaRepo.getAssetsForAlbum( + dbAlbum.id, + updateTimeCond: any(named: 'updateTimeCond'), + ), + ).thenAnswer((_) async => [newAsset]); - test( - 'removing album with mixed assets (some unique, some shared)', - () async { - final uniqueAssetIds = ['asset-1', 'asset-2']; - final sharedAssetIds = ['asset-0', 'asset-3', 'asset-4']; + when(() => mockLocalAssetRepo.get("thumb1")).thenAnswer( + (_) async => LocalAssetStub.image1.copyWith( + localId: "thumb1", + createdAt: DateTime(2024, 1, 1, 9, 0, 0), + ), + ); // Old thumb is older - when(() => mockLocalAlbumRepo.getAssetIdsOnlyInAlbum(albumId)) - .thenAnswer((_) async => uniqueAssetIds); + final result = await sut.tryFastSync(dbAlbum, refreshedAlbum); - await sut.removeLocalAlbum(deviceAlbum); - - verify(() => mockLocalAssetRepo.deleteIds(uniqueAssetIds)).called(1); - verify( - () => mockLocalAlbumAssetRepo.unlinkAssetsFromAlbum( - albumId, - sharedAssetIds, + expect(result, isTrue); + verify( + () => mockAlbumMediaRepo.getAssetsForAlbum( + dbAlbum.id, + updateTimeCond: any(named: 'updateTimeCond'), + ), + ).called(1); + verify(() => mockLocalAssetRepo.get("thumb1")).called(1); + verify(() => mockLocalAlbumRepo.addAssets(dbAlbum.id, [newAsset])) + .called(1); + verify( + () => mockLocalAlbumRepo.update( + any( + that: predicate( + (a) => + a.id == dbAlbum.id && + a.assetCount == 2 && + a.updatedAt == refreshedAlbum.updatedAt && + a.thumbnailId == newAsset.localId, // Thumbnail updated ), - ).called(1); - verify(() => mockLocalAlbumRepo.delete(albumId)).called(1); - }, + ), + ), + ).called(1); + verifyNever(() => mockLocalAlbumRepo.removeAssets(any(), any())); + }); + + test('returns true and keeps old thumbnail if newer', () async { + final newAsset = LocalAssetStub.image2.copyWith( + localId: "asset2", + createdAt: DateTime(2024, 1, 1, 8, 0, 0), ); + when( + () => mockAlbumMediaRepo.getAssetsForAlbum( + dbAlbum.id, + updateTimeCond: any(named: 'updateTimeCond'), + ), + ).thenAnswer((_) async => [newAsset]); + + when(() => mockLocalAssetRepo.get("thumb1")).thenAnswer( + (_) async => LocalAssetStub.image1.copyWith( + localId: "thumb1", + createdAt: DateTime(2024, 1, 1, 9, 0, 0), + ), + ); // Old thumb is newer + + final result = await sut.tryFastSync(dbAlbum, refreshedAlbum); + + expect(result, isTrue); + verify( + () => mockAlbumMediaRepo.getAssetsForAlbum( + dbAlbum.id, + updateTimeCond: any(named: 'updateTimeCond'), + ), + ).called(1); + verify(() => mockLocalAssetRepo.get("thumb1")).called(1); + verify(() => mockLocalAlbumRepo.addAssets(dbAlbum.id, [newAsset])) + .called(1); + verify( + () => mockLocalAlbumRepo.update( + any( + that: predicate( + (a) => + a.id == dbAlbum.id && + a.assetCount == 2 && + a.updatedAt == refreshedAlbum.updatedAt && + a.thumbnailId == "thumb1", // Thumbnail NOT updated + ), + ), + ), + ).called(1); + }); + + test('returns false if updatedAt is not after', () async { + final notUpdatedAlbum = + refreshedAlbum.copyWith(updatedAt: dbAlbum.updatedAt); // Same time + final result = await sut.tryFastSync(dbAlbum, notUpdatedAlbum); + expect(result, isFalse); + verifyNever( + () => mockAlbumMediaRepo.getAssetsForAlbum( + any(), + updateTimeCond: any(named: 'updateTimeCond'), + ), + ); + verifyNever(() => mockLocalAlbumRepo.transaction(any())); + }); + + test('returns false if assetCount decreased', () async { + final decreasedCountAlbum = refreshedAlbum.copyWith(assetCount: 0); + final result = await sut.tryFastSync(dbAlbum, decreasedCountAlbum); + expect(result, isFalse); + verifyNever( + () => mockAlbumMediaRepo.getAssetsForAlbum( + any(), + updateTimeCond: any(named: 'updateTimeCond'), + ), + ); + verifyNever(() => mockLocalAlbumRepo.transaction(any())); + }); + + test('returns false if assetCount is same', () async { + final sameCountAlbum = + refreshedAlbum.copyWith(assetCount: dbAlbum.assetCount); + final result = await sut.tryFastSync(dbAlbum, sameCountAlbum); + expect(result, isFalse); + verifyNever( + () => mockAlbumMediaRepo.getAssetsForAlbum( + any(), + updateTimeCond: any(named: 'updateTimeCond'), + ), + ); + verifyNever(() => mockLocalAlbumRepo.transaction(any())); + }); + + test('returns false if no new assets found', () async { + when( + () => mockAlbumMediaRepo.getAssetsForAlbum( + dbAlbum.id, + updateTimeCond: any(named: 'updateTimeCond'), + ), + ).thenAnswer((_) async => []); + final result = await sut.tryFastSync(dbAlbum, refreshedAlbum); + expect(result, isFalse); + verify( + () => mockAlbumMediaRepo.getAssetsForAlbum( + dbAlbum.id, + updateTimeCond: any(named: 'updateTimeCond'), + ), + ).called(1); + verifyNever(() => mockLocalAlbumRepo.transaction(any())); + }); + + test('returns false if deletions occurred (count mismatch)', () async { + final newAssets = [LocalAssetStub.image2]; + final mismatchCountAlbum = refreshedAlbum.copyWith( + assetCount: 3, + ); // Expected 1 + 1 = 2, but got 3 + when( + () => mockAlbumMediaRepo.getAssetsForAlbum( + dbAlbum.id, + updateTimeCond: any(named: 'updateTimeCond'), + ), + ).thenAnswer((_) async => newAssets); + final result = await sut.tryFastSync(dbAlbum, mismatchCountAlbum); + expect(result, isFalse); + verify( + () => mockAlbumMediaRepo.getAssetsForAlbum( + dbAlbum.id, + updateTimeCond: any(named: 'updateTimeCond'), + ), + ).called(1); + verifyNever(() => mockLocalAlbumRepo.transaction(any())); + }); + + test('handles error during getAssetsForAlbum', () async { + when( + () => mockAlbumMediaRepo.getAssetsForAlbum( + dbAlbum.id, + updateTimeCond: any(named: 'updateTimeCond'), + ), + ).thenThrow(Exception("Get assets failed")); + + final result = await sut.tryFastSync(dbAlbum, refreshedAlbum); + + expect(result, isFalse); + verify( + () => mockAlbumMediaRepo.getAssetsForAlbum( + dbAlbum.id, + updateTimeCond: any(named: 'updateTimeCond'), + ), + ).called(1); + verifyNever(() => mockLocalAlbumRepo.transaction(any())); + }); + }); + + group('fullSync', () { + final dbAlbum = LocalAlbumStub.album1.copyWith( + updatedAt: DateTime(2024, 1, 1), + assetCount: 2, + thumbnailId: const NullableValue.value("asset1"), + ); + final refreshedAlbum = dbAlbum.copyWith( + updatedAt: DateTime(2024, 1, 2), + assetCount: 2, + ); + + final dbAsset1 = LocalAssetStub.image1 + .copyWith(localId: "asset1", updatedAt: DateTime(2024)); + final dbAsset2 = LocalAssetStub.image2.copyWith( + localId: "asset2", + updatedAt: DateTime(2024), + ); // To be deleted + final deviceAsset1 = LocalAssetStub.image1 + .copyWith(localId: "asset1", updatedAt: DateTime(2025)); // Updated + final deviceAsset3 = LocalAssetStub.video1 + .copyWith(localId: "asset3", updatedAt: DateTime(2024)); // Added + + test('handles empty device album -> deletes all DB assets', () async { + final emptyRefreshedAlbum = refreshedAlbum.copyWith(assetCount: 0); + when(() => mockLocalAlbumRepo.getAssetsForAlbum(dbAlbum.id)) + .thenAnswer((_) async => [dbAsset1, dbAsset2]); + + final result = await sut.fullSync(dbAlbum, emptyRefreshedAlbum); + + expect(result, isTrue); + verifyNever(() => mockAlbumMediaRepo.getAssetsForAlbum(dbAlbum.id)); + verify(() => mockLocalAlbumRepo.getAssetsForAlbum(dbAlbum.id)).called(1); + verifyNever(() => mockLocalAlbumRepo.addAssets(any(), any())); + verify( + () => mockLocalAlbumRepo.update( + any( + that: predicate( + (a) => + a.id == dbAlbum.id && + a.assetCount == 0 && + a.updatedAt == emptyRefreshedAlbum.updatedAt && + a.thumbnailId == null, // Thumbnail cleared + ), + ), + ), + ).called(1); + verify( + () => mockLocalAlbumRepo.removeAssets(dbAlbum.id, ["asset1", "asset2"]), + ).called(1); + }); + + test('handles empty DB album -> adds all device assets', () async { + final emptyDbAlbum = dbAlbum.copyWith( + assetCount: 0, + thumbnailId: const NullableValue.empty(), + ); + final deviceAssets = [deviceAsset1, deviceAsset3]; + final refreshedWithAssets = + refreshedAlbum.copyWith(assetCount: deviceAssets.length); + + when(() => mockAlbumMediaRepo.getAssetsForAlbum(emptyDbAlbum.id)) + .thenAnswer((_) async => deviceAssets); + when(() => mockLocalAlbumRepo.getAssetsForAlbum(emptyDbAlbum.id)) + .thenAnswer((_) async => []); + + final result = await sut.fullSync(emptyDbAlbum, refreshedWithAssets); + + expect(result, isTrue); + verify(() => mockAlbumMediaRepo.getAssetsForAlbum(emptyDbAlbum.id)) + .called(1); + verifyNever(() => mockLocalAlbumRepo.getAssetsForAlbum(emptyDbAlbum.id)); + verify(() => mockLocalAlbumRepo.addAssets(emptyDbAlbum.id, deviceAssets)) + .called(1); + verify( + () => mockLocalAlbumRepo.update( + any( + that: predicate( + (a) => + a.id == emptyDbAlbum.id && + a.assetCount == deviceAssets.length && + a.updatedAt == refreshedWithAssets.updatedAt && + a.thumbnailId == deviceAssets.first.localId, + ), + ), + ), + ).called(1); + verifyNever(() => mockLocalAlbumRepo.removeAssets(any(), any())); + }); + + test('handles mix of additions, updates, and deletions', () async { + final currentRefreshedAlbum = refreshedAlbum.copyWith( + assetCount: 2, + ); // asset1 updated, asset3 added, asset2 deleted + when(() => mockAlbumMediaRepo.getAssetsForAlbum(dbAlbum.id)).thenAnswer( + (_) async => [deviceAsset1, deviceAsset3], + ); // Device has asset1 (updated), asset3 (new) + when(() => mockLocalAlbumRepo.getAssetsForAlbum(dbAlbum.id)).thenAnswer( + (_) async => [dbAsset1, dbAsset2], + ); // DB has asset1 (old), asset2 (to delete) + + final result = await sut.fullSync(dbAlbum, currentRefreshedAlbum); + + expect(result, isTrue); + verify(() => mockAlbumMediaRepo.getAssetsForAlbum(dbAlbum.id)).called(1); + verify(() => mockLocalAlbumRepo.getAssetsForAlbum(dbAlbum.id)).called(1); + + // Verify assets to upsert (updated asset1, new asset3) + verify( + () => mockLocalAlbumRepo.addAssets( + dbAlbum.id, + any( + that: predicate>((list) { + return list.length == 2 && + list.any( + (a) => + a.localId == "asset1" && + a.updatedAt == deviceAsset1.updatedAt, + ) && + list.any((a) => a.localId == "asset3"); + }), + ), + ), + ).called(1); + + // Verify metadata update (thumbnail should be asset1 as it's first in sorted device list) + verify( + () => mockLocalAlbumRepo.update( + any( + that: predicate( + (a) => + a.id == dbAlbum.id && + a.assetCount == 2 && + a.updatedAt == currentRefreshedAlbum.updatedAt && + a.thumbnailId == "asset1", + ), + ), + ), + ).called(1); + + // Verify assets to delete (asset2) + verify(() => mockLocalAlbumRepo.removeAssets(dbAlbum.id, ["asset2"])) + .called(1); + }); + + test('handles no asset changes, only metadata update', () async { + final currentRefreshedAlbum = refreshedAlbum.copyWith( + updatedAt: DateTime(2025), + assetCount: 1, + ); // Only updatedAt changed + final dbAssets = [dbAsset1]; + final deviceAssets = [ + dbAsset1, + ]; // Assets are identical based on _assetsEqual + + when(() => mockAlbumMediaRepo.getAssetsForAlbum(dbAlbum.id)) + .thenAnswer((_) async => deviceAssets); + when(() => mockLocalAlbumRepo.getAssetsForAlbum(dbAlbum.id)) + .thenAnswer((_) async => dbAssets); + + final result = await sut.fullSync(dbAlbum, currentRefreshedAlbum); + + expect(result, isTrue); + verify(() => mockAlbumMediaRepo.getAssetsForAlbum(dbAlbum.id)).called(1); + verify(() => mockLocalAlbumRepo.getAssetsForAlbum(dbAlbum.id)).called(1); + // Transaction is NOT called because _handleUpdate isn't called if lists are empty + verifyNever(() => mockLocalAlbumRepo.transaction(any())); + // Only metadata update is called directly + verify( + () => mockLocalAlbumRepo.update( + any( + that: predicate( + (a) => + a.id == dbAlbum.id && + a.assetCount == 1 && + a.updatedAt == currentRefreshedAlbum.updatedAt && + a.thumbnailId == "asset1", // Thumbnail remains + ), + ), + ), + ).called(1); + verifyNever(() => mockLocalAlbumRepo.addAssets(any(), any())); + verifyNever(() => mockLocalAlbumRepo.removeAssets(any(), any())); + }); + + test('handles error during getAssetsForAlbum (device)', () async { + when(() => mockAlbumMediaRepo.getAssetsForAlbum(dbAlbum.id)) + .thenThrow(Exception("Get device assets failed")); + + final result = await sut.fullSync(dbAlbum, refreshedAlbum); + + expect(result, isFalse); + verify(() => mockAlbumMediaRepo.getAssetsForAlbum(dbAlbum.id)).called(1); + verifyNever(() => mockLocalAlbumRepo.getAssetsForAlbum(any())); + verifyNever(() => mockLocalAlbumRepo.transaction(any())); + }); + + test('handles error during getAssetsForAlbum (db)', () async { + when(() => mockAlbumMediaRepo.getAssetsForAlbum(dbAlbum.id)) + .thenAnswer((_) async => [deviceAsset1]); + when(() => mockLocalAlbumRepo.getAssetsForAlbum(dbAlbum.id)) + .thenThrow(Exception("Get db assets failed")); + + final result = await sut.fullSync(dbAlbum, refreshedAlbum); + + expect(result, isFalse); + verify(() => mockAlbumMediaRepo.getAssetsForAlbum(dbAlbum.id)).called(1); + verify(() => mockLocalAlbumRepo.getAssetsForAlbum(dbAlbum.id)).called(1); + verifyNever(() => mockLocalAlbumRepo.transaction(any())); }); }); } diff --git a/mobile/test/external.mock.dart b/mobile/test/external.mock.dart deleted file mode 100644 index 3c49386163..0000000000 --- a/mobile/test/external.mock.dart +++ /dev/null @@ -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 {} diff --git a/mobile/test/fixtures/local_album.stub.dart b/mobile/test/fixtures/local_album.stub.dart index c58e43417c..76cc3a570a 100644 --- a/mobile/test/fixtures/local_album.stub.dart +++ b/mobile/test/fixtures/local_album.stub.dart @@ -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, + ); } diff --git a/mobile/test/fixtures/local_asset.stub.dart b/mobile/test/fixtures/local_asset.stub.dart index f1a0e17118..a399dfdc22 100644 --- a/mobile/test/fixtures/local_asset.stub.dart +++ b/mobile/test/fixtures/local_asset.stub.dart @@ -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, + ); } diff --git a/mobile/test/infrastructure/repository.mock.dart b/mobile/test/infrastructure/repository.mock.dart index 6fe68d432a..6398e85d18 100644 --- a/mobile/test/infrastructure/repository.mock.dart +++ b/mobile/test/infrastructure/repository.mock.dart @@ -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 {}