diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/platform/MediaManager.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/platform/MediaManager.kt index 7d57c13789..72068d2e6e 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/platform/MediaManager.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/platform/MediaManager.kt @@ -1,6 +1,6 @@ package app.alextran.immich.platform -import Asset +import PlatformAsset import SyncDelta import android.content.Context import android.os.Build @@ -78,7 +78,7 @@ class MediaManager(context: Context) { fun getMediaChanges(): SyncDelta { val genMap = getSavedGenerationMap(ctx) val currentVolumes = MediaStore.getExternalVolumeNames(ctx) - val changed = mutableListOf() + val changed = mutableListOf() val deleted = mutableListOf() var hasChanges = genMap.keys != currentVolumes @@ -154,7 +154,7 @@ class MediaManager(context: Context) { val bucketId = cursor.getString(bucketIdColumn) changed.add( - Asset( + PlatformAsset( id, name, mediaType.toLong(), diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/platform/Messages.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/platform/Messages.g.kt index 4553609350..7ceef58786 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/platform/Messages.g.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/platform/Messages.g.kt @@ -78,7 +78,7 @@ class FlutterError ( ) : Throwable() /** Generated class from Pigeon that represents data sent in messages. */ -data class Asset ( +data class PlatformAsset ( val id: String, val name: String, val type: Long, @@ -89,7 +89,7 @@ data class Asset ( ) { companion object { - fun fromList(pigeonVar_list: List): Asset { + fun fromList(pigeonVar_list: List): PlatformAsset { val id = pigeonVar_list[0] as String val name = pigeonVar_list[1] as String val type = pigeonVar_list[2] as Long @@ -97,7 +97,7 @@ data class Asset ( val updatedAt = pigeonVar_list[4] as Long? val durationInSeconds = pigeonVar_list[5] as Long val albumIds = pigeonVar_list[6] as List - return Asset(id, name, type, createdAt, updatedAt, durationInSeconds, albumIds) + return PlatformAsset(id, name, type, createdAt, updatedAt, durationInSeconds, albumIds) } } fun toList(): List { @@ -112,7 +112,7 @@ data class Asset ( ) } override fun equals(other: Any?): Boolean { - if (other !is Asset) { + if (other !is PlatformAsset) { return false } if (this === other) { @@ -126,14 +126,14 @@ data class Asset ( /** Generated class from Pigeon that represents data sent in messages. */ data class SyncDelta ( val hasChanges: Boolean, - val updates: List, + val updates: List, val deletes: List ) { companion object { fun fromList(pigeonVar_list: List): SyncDelta { val hasChanges = pigeonVar_list[0] as Boolean - val updates = pigeonVar_list[1] as List + val updates = pigeonVar_list[1] as List val deletes = pigeonVar_list[2] as List return SyncDelta(hasChanges, updates, deletes) } @@ -161,7 +161,7 @@ private open class MessagesPigeonCodec : StandardMessageCodec() { return when (type) { 129.toByte() -> { return (readValue(buffer) as? List)?.let { - Asset.fromList(it) + PlatformAsset.fromList(it) } } 130.toByte() -> { @@ -174,7 +174,7 @@ private open class MessagesPigeonCodec : StandardMessageCodec() { } override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { when (value) { - is Asset -> { + is PlatformAsset -> { stream.write(129) writeValue(stream, value.toList()) } diff --git a/mobile/ios/Runner/Platform/MediaManager.swift b/mobile/ios/Runner/Platform/MediaManager.swift index 4d5b19c022..6badc0e2e5 100644 --- a/mobile/ios/Runner/Platform/MediaManager.swift +++ b/mobile/ios/Runner/Platform/MediaManager.swift @@ -1,9 +1,9 @@ import Photos struct AssetWrapper: Hashable, Equatable { - let asset: Asset + let asset: PlatformAsset - init(with asset: Asset) { + init(with asset: PlatformAsset) { self.asset = asset } @@ -117,7 +117,7 @@ class MediaManager { let asset = result.object(at: i) // Asset wrapper only uses the id for comparison. Multiple change can contain the same asset, skip duplicate changes - let predicate = Asset(id: asset.localIdentifier, name: "", type: 0, createdAt: nil, updatedAt: nil, durationInSeconds: 0, albumIds: []) + let predicate = PlatformAsset(id: asset.localIdentifier, name: "", type: 0, createdAt: nil, updatedAt: nil, durationInSeconds: 0, albumIds: []) if (updatedAssets.contains(AssetWrapper(with: predicate))) { continue } @@ -129,7 +129,7 @@ class MediaManager { let updatedAt = asset.modificationDate?.timeIntervalSince1970 let durationInSeconds: Int64 = Int64(asset.duration) - let domainAsset = AssetWrapper(with: Asset( + let domainAsset = AssetWrapper(with: PlatformAsset( id: id, name: name, type: type, diff --git a/mobile/ios/Runner/Platform/Messages.g.swift b/mobile/ios/Runner/Platform/Messages.g.swift index 8f9a71340c..010e879da2 100644 --- a/mobile/ios/Runner/Platform/Messages.g.swift +++ b/mobile/ios/Runner/Platform/Messages.g.swift @@ -129,7 +129,7 @@ func deepHashMessages(value: Any?, hasher: inout Hasher) { /// Generated class from Pigeon that represents data sent in messages. -struct Asset: Hashable { +struct PlatformAsset: Hashable { var id: String var name: String var type: Int64 @@ -140,7 +140,7 @@ struct Asset: Hashable { // swift-format-ignore: AlwaysUseLowerCamelCase - static func fromList(_ pigeonVar_list: [Any?]) -> Asset? { + static func fromList(_ pigeonVar_list: [Any?]) -> PlatformAsset? { let id = pigeonVar_list[0] as! String let name = pigeonVar_list[1] as! String let type = pigeonVar_list[2] as! Int64 @@ -149,7 +149,7 @@ struct Asset: Hashable { let durationInSeconds = pigeonVar_list[5] as! Int64 let albumIds = pigeonVar_list[6] as! [String] - return Asset( + return PlatformAsset( id: id, name: name, type: type, @@ -170,7 +170,7 @@ struct Asset: Hashable { albumIds, ] } - static func == (lhs: Asset, rhs: Asset) -> Bool { + static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool { return deepEqualsMessages(lhs.toList(), rhs.toList()) } func hash(into hasher: inout Hasher) { deepHashMessages(value: toList(), hasher: &hasher) @@ -180,14 +180,14 @@ struct Asset: Hashable { /// Generated class from Pigeon that represents data sent in messages. struct SyncDelta: Hashable { var hasChanges: Bool - var updates: [Asset] + var updates: [PlatformAsset] var deletes: [String] // swift-format-ignore: AlwaysUseLowerCamelCase static func fromList(_ pigeonVar_list: [Any?]) -> SyncDelta? { let hasChanges = pigeonVar_list[0] as! Bool - let updates = pigeonVar_list[1] as! [Asset] + let updates = pigeonVar_list[1] as! [PlatformAsset] let deletes = pigeonVar_list[2] as! [String] return SyncDelta( @@ -214,7 +214,7 @@ private class MessagesPigeonCodecReader: FlutterStandardReader { override func readValue(ofType type: UInt8) -> Any? { switch type { case 129: - return Asset.fromList(self.readValue() as! [Any?]) + return PlatformAsset.fromList(self.readValue() as! [Any?]) case 130: return SyncDelta.fromList(self.readValue() as! [Any?]) default: @@ -225,7 +225,7 @@ private class MessagesPigeonCodecReader: FlutterStandardReader { private class MessagesPigeonCodecWriter: FlutterStandardWriter { override func writeValue(_ value: Any) { - if let value = value as? Asset { + if let value = value as? PlatformAsset { super.writeByte(129) super.writeValue(value.toList()) } else if let value = value as? SyncDelta { diff --git a/mobile/lib/domain/interfaces/local_album.interface.dart b/mobile/lib/domain/interfaces/local_album.interface.dart index 616e71dc4f..cf790882d7 100644 --- a/mobile/lib/domain/interfaces/local_album.interface.dart +++ b/mobile/lib/domain/interfaces/local_album.interface.dart @@ -4,27 +4,28 @@ import 'package:immich_mobile/domain/models/local_album.model.dart'; import 'package:immich_mobile/platform/messages.g.dart'; abstract interface class ILocalAlbumRepository implements IDatabaseRepository { - Future insert(LocalAlbum album, Iterable assets); - - Future addAssets(String albumId, Iterable assets); - Future> getAll({SortLocalAlbumsBy? sortBy}); Future> getAssetsForAlbum(String albumId); Future> getAssetIdsForAlbum(String albumId); - Future update(LocalAlbum album); + Future upsert( + LocalAlbum album, { + Iterable toUpsert = const [], + Iterable toDelete = const [], + }); Future updateAll(Iterable albums); - Future handleSyncDelta(SyncDelta delta); - Future delete(String albumId); - Future removeMissing(String albumId, Iterable assetIds); + Future processDelta(SyncDelta delta); - Future removeAssets(String albumId, Iterable assetIds); + Future syncAlbumDeletes( + String albumId, + Iterable assetIdsToKeep, + ); } enum SortLocalAlbumsBy { id } diff --git a/mobile/lib/domain/services/device_sync.service.dart b/mobile/lib/domain/services/device_sync.service.dart index ad619d54b6..2e524b7a6e 100644 --- a/mobile/lib/domain/services/device_sync.service.dart +++ b/mobile/lib/domain/services/device_sync.service.dart @@ -43,13 +43,13 @@ class DeviceSyncService { final deviceAlbums = await _albumMediaRepository.getAll(); await _localAlbumRepository.updateAll(deviceAlbums); - await _localAlbumRepository.handleSyncDelta(delta); + await _localAlbumRepository.processDelta(delta); if (_platform.isAndroid) { final dbAlbums = await _localAlbumRepository.getAll(); for (final album in dbAlbums) { final deviceIds = await _hostService.getAssetIdsForAlbum(album.id); - await _localAlbumRepository.removeMissing(album.id, deviceIds); + await _localAlbumRepository.syncAlbumDeletes(album.id, deviceIds); } } @@ -98,7 +98,7 @@ class DeviceSyncService { ? await _albumMediaRepository.getAssetsForAlbum(album.id) : []; - await _localAlbumRepository.insert(album, assets); + await _localAlbumRepository.upsert(album, toUpsert: assets); _log.fine("Successfully added device album ${album.name}"); } catch (e, s) { _log.warning("Error while adding device album", e, s); @@ -185,9 +185,9 @@ class DeviceSyncService { return false; } - await _updateAlbum( + await _localAlbumRepository.upsert( deviceAlbum.copyWith(backupSelection: dbAlbum.backupSelection), - assetsToUpsert: newAssets, + toUpsert: newAssets, ); return true; @@ -213,9 +213,9 @@ class DeviceSyncService { _log.fine( "Device album ${deviceAlbum.name} is empty. Removing assets from DB.", ); - await _updateAlbum( + await _localAlbumRepository.upsert( deviceAlbum.copyWith(backupSelection: dbAlbum.backupSelection), - assetIdsToDelete: assetsInDb.map((a) => a.id), + toDelete: assetsInDb.map((a) => a.id), ); return true; } @@ -228,7 +228,10 @@ class DeviceSyncService { _log.fine( "Device album ${deviceAlbum.name} is empty. Adding assets to DB.", ); - await _updateAlbum(updatedDeviceAlbum, assetsToUpsert: assetsInDevice); + await _localAlbumRepository.upsert( + updatedDeviceAlbum, + toUpsert: assetsInDevice, + ); return true; } @@ -263,14 +266,14 @@ class DeviceSyncService { _log.fine( "No asset changes detected in album ${deviceAlbum.name}. Updating metadata.", ); - _localAlbumRepository.update(updatedDeviceAlbum); + _localAlbumRepository.upsert(updatedDeviceAlbum); return true; } - await _updateAlbum( + await _localAlbumRepository.upsert( updatedDeviceAlbum, - assetsToUpsert: assetsToUpsert, - assetIdsToDelete: assetsToDelete, + toUpsert: assetsToUpsert, + toDelete: assetsToDelete, ); return true; @@ -280,17 +283,6 @@ class DeviceSyncService { return true; } - Future _updateAlbum( - LocalAlbum album, { - Iterable assetsToUpsert = const [], - Iterable assetIdsToDelete = const [], - }) => - _localAlbumRepository.transaction(() async { - await _localAlbumRepository.addAssets(album.id, assetsToUpsert); - await _localAlbumRepository.update(album); - await _localAlbumRepository.removeAssets(album.id, assetIdsToDelete); - }); - bool _assetsEqual(LocalAsset a, LocalAsset b) { return a.updatedAt.isAtSameMomentAs(b.updatedAt) && a.createdAt.isAtSameMomentAs(b.createdAt) && diff --git a/mobile/lib/infrastructure/repositories/album_media.repository.dart b/mobile/lib/infrastructure/repositories/album_media.repository.dart index 81dec4fe5b..2435c4ac7a 100644 --- a/mobile/lib/infrastructure/repositories/album_media.repository.dart +++ b/mobile/lib/infrastructure/repositories/album_media.repository.dart @@ -48,7 +48,7 @@ class AlbumMediaRepository implements IAlbumMediaRepository { if (_platform.isAndroid) { e.removeWhere((a) => a.isAll); } - return e.toDtoList(); + return Future.wait(e.map((a) => a.toDto())); }); } @@ -75,7 +75,7 @@ class AlbumMediaRepository implements IAlbumMediaRepository { lastPageCount = page.length; pageNumber++; } while (lastPageCount == kFetchLocalAssetsBatchSize); - return assets.toDtoList(); + return Future.wait(assets.map((a) => a.toDto())); } @override @@ -108,11 +108,6 @@ extension on AssetEntity { ); } -extension on List { - Future> toDtoList() => - Future.wait(map((a) => a.toDto())); -} - extension on AssetPathEntity { Future toDto({bool withAssetCount = true}) async => LocalAlbum( id: id, @@ -123,8 +118,3 @@ extension on AssetPathEntity { backupSelection: BackupSelection.none, ); } - -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 849091b357..84a1e78d3a 100644 --- a/mobile/lib/infrastructure/repositories/local_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_album.repository.dart @@ -8,7 +8,7 @@ import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.d 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:immich_mobile/platform/messages.g.dart' as platform; +import 'package:immich_mobile/platform/messages.g.dart'; import 'package:platform/platform.dart'; class DriftLocalAlbumRepository extends DriftDatabaseRepository @@ -52,7 +52,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository // That is not the case on Android since asset <-> album has one:one mapping final assetsToDelete = _platform.isIOS ? await _getUniqueAssetsInAlbum(albumId) - : await _getAssetsIdsInAlbum(albumId); + : await getAssetIdsForAlbum(albumId); await _deleteAssets(assetsToDelete); // All the other assets that are still associated will be unlinked automatically on-cascade @@ -62,28 +62,11 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository }); @override - Future insert(LocalAlbum localAlbum, Iterable assets) => - transaction(() async { - await _upsertAssets(assets); - // Needs to be after asset upsert to link the thumbnail - await update(localAlbum); - await _linkAssetsToAlbum(localAlbum.id, assets); - }); - - @override - Future addAssets(String albumId, Iterable assets) { - if (assets.isEmpty) { - return Future.value(); - } - return transaction(() async { - await _upsertAssets(assets); - await _linkAssetsToAlbum(albumId, assets); - }); - } - - @override - Future removeMissing(String albumId, Iterable assetIds) async { - if (assetIds.isEmpty) { + Future syncAlbumDeletes( + String albumId, + Iterable assetIdsToKeep, + ) async { + if (assetIdsToKeep.isEmpty) { return Future.value(); } @@ -100,45 +83,18 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository ]); subQuery.where( _db.localAlbumEntity.id.equals(albumId) & - _db.localAlbumAssetEntity.assetId.isNotIn(assetIds), + _db.localAlbumAssetEntity.assetId.isNotIn(assetIdsToKeep), ); return localAsset.id.isInQuery(subQuery); }); } @override - Future removeAssets(String albumId, Iterable assetIds) async { - if (assetIds.isEmpty) { - return Future.value(); - } - - if (_platform.isAndroid) { - return _deleteAssets(assetIds); - } - - final uniqueAssets = await _getUniqueAssetsInAlbum(albumId); - if (uniqueAssets.isEmpty) { - return _unlinkAssetsFromAlbum(albumId, assetIds); - } - // 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); - } - } - return transaction(() async { - await _unlinkAssetsFromAlbum(albumId, assetsToUnLink); - await _deleteAssets(assetsToDelete); - }); - } - - @override - Future update(LocalAlbum localAlbum) { + Future upsert( + LocalAlbum localAlbum, { + Iterable toUpsert = const [], + Iterable toDelete = const [], + }) { final companion = LocalAlbumEntityCompanion.insert( id: localAlbum.id, name: localAlbum.name, @@ -146,8 +102,12 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository backupSelection: localAlbum.backupSelection, ); - return _db.localAlbumEntity - .insertOne(companion, onConflict: DoUpdate((_) => companion)); + return _db.transaction(() async { + await _db.localAlbumEntity + .insertOne(companion, onConflict: DoUpdate((_) => companion)); + await _addAssets(localAlbum.id, toUpsert); + await _removeAssets(localAlbum.id, toDelete); + }); } @override @@ -193,6 +153,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository subQuery.where(_db.localAlbumEntity.marker_.isNotNull()); return localAsset.id.isInQuery(subQuery); }); + await deleteSmt.go(); } await _db.localAlbumEntity.deleteWhere((f) => f.marker_.isNotNull()); @@ -227,7 +188,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository } @override - Future handleSyncDelta(platform.SyncDelta delta) { + Future processDelta(SyncDelta delta) { return _db.transaction(() async { await _deleteAssets(delta.deletes); @@ -255,45 +216,64 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository }); } - Future _linkAssetsToAlbum( - String albumId, - Iterable assets, - ) { + Future _addAssets(String albumId, Iterable assets) { if (assets.isEmpty) { return Future.value(); } - - return _db.localAlbumAssetEntity.insertAll( - assets.map( - (a) => LocalAlbumAssetEntityCompanion.insert( - assetId: a.id, - albumId: albumId, + return transaction(() async { + await _upsertAssets(assets); + await _db.localAlbumAssetEntity.insertAll( + assets.map( + (a) => LocalAlbumAssetEntityCompanion.insert( + assetId: a.id, + albumId: albumId, + ), ), - ), - mode: InsertMode.insertOrIgnore, - ); + mode: InsertMode.insertOrIgnore, + ); + }); } - Future _unlinkAssetsFromAlbum( - String albumId, - Iterable assetIds, - ) { + Future _removeAssets(String albumId, Iterable assetIds) async { if (assetIds.isEmpty) { return Future.value(); } - return _db.batch( - (batch) => batch.deleteWhere( - _db.localAlbumAssetEntity, - (f) => f.assetId.isIn(assetIds) & f.albumId.equals(albumId), - ), - ); - } + if (_platform.isAndroid) { + return _deleteAssets(assetIds); + } - Future> _getAssetsIdsInAlbum(String albumId) { - final query = _db.localAlbumAssetEntity.select() - ..where((row) => row.albumId.equals(albumId)); - return query.map((row) => row.assetId).get(); + List assetsToDelete = []; + List assetsToUnLink = []; + + final uniqueAssets = await _getUniqueAssetsInAlbum(albumId); + if (uniqueAssets.isEmpty) { + assetsToUnLink = assetIds.toList(); + } else { + // Delete unique assets and unlink others + final uniqueSet = uniqueAssets.toSet(); + + for (final assetId in assetIds) { + if (uniqueSet.contains(assetId)) { + assetsToDelete.add(assetId); + } else { + assetsToUnLink.add(assetId); + } + } + } + + return transaction(() async { + if (assetsToUnLink.isNotEmpty) { + await _db.batch( + (batch) => batch.deleteWhere( + _db.localAlbumAssetEntity, + (f) => f.assetId.isIn(assetsToUnLink) & f.albumId.equals(albumId), + ), + ); + } + + await _deleteAssets(assetsToDelete); + }); } /// Get all asset ids that are only in this album and not in other albums. @@ -348,7 +328,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository } } -extension on platform.Asset { +extension on PlatformAsset { LocalAsset toLocalAsset() { return LocalAsset( id: id, diff --git a/mobile/lib/platform/messages.dart b/mobile/lib/platform/messages.dart index b535a95d84..172ac1ef13 100644 --- a/mobile/lib/platform/messages.dart +++ b/mobile/lib/platform/messages.dart @@ -12,7 +12,7 @@ import 'package:pigeon/pigeon.dart'; dartOptions: DartOptions(), ), ) -class Asset { +class PlatformAsset { final String id; final String name; final int type; // follows AssetType enum from base_asset.model.dart @@ -22,7 +22,7 @@ class Asset { final int durationInSeconds; final List albumIds; - const Asset({ + const PlatformAsset({ required this.id, required this.name, required this.type, @@ -40,7 +40,7 @@ class SyncDelta { this.deletes = const [], }); bool hasChanges; - List updates; + List updates; List deletes; } diff --git a/mobile/lib/platform/messages.g.dart b/mobile/lib/platform/messages.g.dart index 590c7cb8fd..c6e593912b 100644 --- a/mobile/lib/platform/messages.g.dart +++ b/mobile/lib/platform/messages.g.dart @@ -29,8 +29,8 @@ bool _deepEquals(Object? a, Object? b) { } -class Asset { - Asset({ +class PlatformAsset { + PlatformAsset({ required this.id, required this.name, required this.type, @@ -69,9 +69,9 @@ class Asset { Object encode() { return _toList(); } - static Asset decode(Object result) { + static PlatformAsset decode(Object result) { result as List; - return Asset( + return PlatformAsset( id: result[0]! as String, name: result[1]! as String, type: result[2]! as int, @@ -85,7 +85,7 @@ class Asset { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes bool operator ==(Object other) { - if (other is! Asset || other.runtimeType != runtimeType) { + if (other is! PlatformAsset || other.runtimeType != runtimeType) { return false; } if (identical(this, other)) { @@ -109,7 +109,7 @@ class SyncDelta { bool hasChanges; - List updates; + List updates; List deletes; @@ -128,7 +128,7 @@ class SyncDelta { result as List; return SyncDelta( hasChanges: result[0]! as bool, - updates: (result[1] as List?)!.cast(), + updates: (result[1] as List?)!.cast(), deletes: (result[2] as List?)!.cast(), ); } @@ -159,7 +159,7 @@ class _PigeonCodec extends StandardMessageCodec { if (value is int) { buffer.putUint8(4); buffer.putInt64(value); - } else if (value is Asset) { + } else if (value is PlatformAsset) { buffer.putUint8(129); writeValue(buffer, value.encode()); } else if (value is SyncDelta) { @@ -174,7 +174,7 @@ class _PigeonCodec extends StandardMessageCodec { Object? readValueOfType(int type, ReadBuffer buffer) { switch (type) { case 129: - return Asset.decode(readValue(buffer)!); + return PlatformAsset.decode(readValue(buffer)!); case 130: return SyncDelta.decode(readValue(buffer)!); default: diff --git a/mobile/test/domain/service.mock.dart b/mobile/test/domain/service.mock.dart index ba1bf1dc72..bd41248d03 100644 --- a/mobile/test/domain/service.mock.dart +++ b/mobile/test/domain/service.mock.dart @@ -3,6 +3,7 @@ import 'package:immich_mobile/domain/services/user.service.dart'; import 'package:immich_mobile/domain/utils/background_sync.dart'; import 'package:immich_mobile/platform/messages.g.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:platform/platform.dart'; class MockStoreService extends Mock implements StoreService {} @@ -11,3 +12,5 @@ class MockUserService extends Mock implements UserService {} class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {} class MockHostService extends Mock implements ImHostService {} + +class MockPlatform extends Mock implements Platform {} diff --git a/mobile/test/domain/services/device_sync_service_test.dart b/mobile/test/domain/services/device_sync_service_test.dart index 2db309ff40..988f1d8845 100644 --- a/mobile/test/domain/services/device_sync_service_test.dart +++ b/mobile/test/domain/services/device_sync_service_test.dart @@ -1,8 +1,6 @@ -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/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/local_album.model.dart'; import 'package:immich_mobile/domain/services/device_sync.service.dart'; import 'package:immich_mobile/platform/messages.g.dart'; @@ -10,6 +8,7 @@ import 'package:mocktail/mocktail.dart'; import '../../fixtures/local_album.stub.dart'; import '../../fixtures/local_asset.stub.dart'; +import '../../fixtures/platform_asset.stub.dart'; import '../../infrastructure/repository.mock.dart'; import '../service.mock.dart'; @@ -17,6 +16,7 @@ void main() { late IAlbumMediaRepository mockAlbumMediaRepo; late ILocalAlbumRepository mockLocalAlbumRepo; late ImHostService mockHostService; + late MockPlatform mockPlatformInstance; late DeviceSyncService sut; Future mockTransaction(Future Function() action) => action(); @@ -25,35 +25,27 @@ void main() { mockAlbumMediaRepo = MockAlbumMediaRepository(); mockLocalAlbumRepo = MockLocalAlbumRepository(); mockHostService = MockHostService(); + mockPlatformInstance = MockPlatform(); sut = DeviceSyncService( albumMediaRepository: mockAlbumMediaRepo, localAlbumRepository: mockLocalAlbumRepo, hostService: mockHostService, + platform: mockPlatformInstance, ); registerFallbackValue(LocalAlbumStub.album1); registerFallbackValue(LocalAssetStub.image1); - registerFallbackValue(SortLocalAlbumsBy.id); - registerFallbackValue([]); - registerFallbackValue([]); + registerFallbackValue( + SyncDelta(hasChanges: true, updates: [], deletes: []), + ); 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(() => mockAlbumMediaRepo.refresh(any())).thenAnswer( - (inv) async => - LocalAlbumStub.album1.copyWith(id: inv.positionalArguments.first), + (inv) async => LocalAlbumStub.album1.copyWith( + id: inv.positionalArguments.first as String, + assetCount: 0, + ), ); when( () => mockAlbumMediaRepo.getAssetsForAlbum( @@ -64,232 +56,361 @@ void main() { when(() => mockAlbumMediaRepo.getAssetsForAlbum(any())) .thenAnswer((_) async => []); + when(() => mockLocalAlbumRepo.getAll(sortBy: any(named: 'sortBy'))) + .thenAnswer((_) async => []); + when(() => mockLocalAlbumRepo.getAll()).thenAnswer((_) async => []); + when( + () => mockLocalAlbumRepo.upsert( + any(), + toUpsert: any(named: 'toUpsert'), + toDelete: any(named: 'toDelete'), + ), + ).thenAnswer((_) async => {}); + when(() => mockLocalAlbumRepo.delete(any())).thenAnswer((_) async => true); + when(() => mockLocalAlbumRepo.updateAll(any())).thenAnswer((_) async => {}); + when(() => mockLocalAlbumRepo.processDelta(any())) + .thenAnswer((_) async => {}); + when(() => mockLocalAlbumRepo.syncAlbumDeletes(any(), any())) + .thenAnswer((_) async => {}); + when(() => mockLocalAlbumRepo.getAssetsForAlbum(any())) + .thenAnswer((_) async => []); when(() => mockLocalAlbumRepo.transaction(any())).thenAnswer( (inv) => mockTransaction( inv.positionalArguments.first as Future Function(), ), ); + + when(() => mockHostService.shouldFullSync()).thenAnswer((_) async => true); + when(() => mockHostService.getMediaChanges()).thenAnswer( + (_) async => SyncDelta(hasChanges: false, updates: [], deletes: []), + ); + when(() => mockHostService.getAssetIdsForAlbum(any())) + .thenAnswer((_) async => []); + when(() => mockHostService.checkpointSync()).thenAnswer((_) async => {}); + + when(() => mockPlatformInstance.isAndroid).thenReturn(false); }); group('sync', () { - test('should return when no albums exist', () async { + test( + 'performs full sync and checkpoints when shouldFullSync is true', + () async { + when(() => mockHostService.shouldFullSync()) + .thenAnswer((_) async => true); + when(() => mockAlbumMediaRepo.getAll()) + .thenAnswer((_) async => [LocalAlbumStub.album1]); + when(() => mockLocalAlbumRepo.getAll(sortBy: SortLocalAlbumsBy.id)) + .thenAnswer((_) async => []); + + await sut.sync(); + + verify(() => mockHostService.shouldFullSync()).called(1); + verify(() => mockAlbumMediaRepo.getAll()).called(1); + verify(() => mockLocalAlbumRepo.getAll(sortBy: SortLocalAlbumsBy.id)) + .called(1); + verify(() => mockHostService.checkpointSync()).called(1); + verifyNever(() => mockHostService.getMediaChanges()); + }, + ); + + test( + 'skips sync and does not checkpoint when shouldFullSync is false and no media changes', + () async { + when(() => mockHostService.shouldFullSync()) + .thenAnswer((_) async => false); + when(() => mockHostService.getMediaChanges()).thenAnswer( + (_) async => SyncDelta(hasChanges: false, updates: [], deletes: []), + ); + + await sut.sync(); + + verify(() => mockHostService.shouldFullSync()).called(1); + verify(() => mockHostService.getMediaChanges()).called(1); + verifyNever(() => mockAlbumMediaRepo.getAll()); + verifyNever(() => mockLocalAlbumRepo.updateAll(any())); + verifyNever(() => mockLocalAlbumRepo.processDelta(any())); + verifyNever(() => mockHostService.checkpointSync()); + }, + ); + + test( + 'processes delta and checkpoints for non-Android when shouldFullSync is false and media changes exist', + () async { + final delta = SyncDelta( + hasChanges: true, + updates: [PlatformAssetStub.image1], + deletes: ["deleted"], + ); + final deviceAlbums = [LocalAlbumStub.album1]; + + when(() => mockHostService.shouldFullSync()) + .thenAnswer((_) async => false); + when(() => mockHostService.getMediaChanges()) + .thenAnswer((_) async => delta); + when(() => mockAlbumMediaRepo.getAll()) + .thenAnswer((_) async => deviceAlbums); + when(() => mockPlatformInstance.isAndroid).thenReturn(false); + + await sut.sync(); + + verifyInOrder([ + () => mockHostService.shouldFullSync(), + () => mockHostService.getMediaChanges(), + () => mockAlbumMediaRepo.getAll(), + () => mockLocalAlbumRepo.updateAll(deviceAlbums), + () => mockLocalAlbumRepo.processDelta(delta), + () => mockHostService.checkpointSync(), + ]); + verifyNever(() => mockHostService.getAssetIdsForAlbum(any())); + }, + ); + + test( + 'processes delta, Android logic, and checkpoints when shouldFullSync is false and media changes exist', + () async { + final delta = SyncDelta( + hasChanges: true, + updates: [PlatformAssetStub.image1], + deletes: ["deleted"], + ); + final deviceAlbums = [LocalAlbumStub.album1]; + final dbAlbums = [LocalAlbumStub.album2.copyWith(id: "dbAlbumId")]; + final assetIdsForDbAlbum = ["asset1", "asset2"]; + + when(() => mockHostService.shouldFullSync()) + .thenAnswer((_) async => false); + when(() => mockHostService.getMediaChanges()) + .thenAnswer((_) async => delta); + when(() => mockAlbumMediaRepo.getAll()) + .thenAnswer((_) async => deviceAlbums); + when(() => mockLocalAlbumRepo.getAll()) + .thenAnswer((_) async => dbAlbums); + when(() => mockPlatformInstance.isAndroid).thenReturn(true); + when(() => mockHostService.getAssetIdsForAlbum(dbAlbums.first.id)) + .thenAnswer((_) async => assetIdsForDbAlbum); + + await sut.sync(); + + verifyInOrder([ + () => mockHostService.shouldFullSync(), + () => mockHostService.getMediaChanges(), + () => mockAlbumMediaRepo.getAll(), + () => mockLocalAlbumRepo.updateAll(deviceAlbums), + () => mockLocalAlbumRepo.processDelta(delta), + () => mockPlatformInstance.isAndroid, + () => mockLocalAlbumRepo.getAll(), + () => mockHostService.getAssetIdsForAlbum(dbAlbums.first.id), + () => mockLocalAlbumRepo.syncAlbumDeletes( + dbAlbums.first.id, + assetIdsForDbAlbum, + ), + () => mockHostService.checkpointSync(), + ]); + }, + ); + + test('handles error from shouldFullSync and does not checkpoint', () async { + when(() => mockHostService.shouldFullSync()) + .thenThrow(Exception("Host error")); + await sut.sync(); - 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())); + + verify(() => mockHostService.shouldFullSync()).called(1); + verifyNever(() => mockHostService.getMediaChanges()); + verifyNever(() => mockHostService.checkpointSync()); }); - test('should call addAlbum for new device albums', () async { + test( + 'handles error from getMediaChanges and does not checkpoint', + () async { + when(() => mockHostService.shouldFullSync()) + .thenAnswer((_) async => false); + when(() => mockHostService.getMediaChanges()) + .thenThrow(Exception("Host error")); + + await sut.sync(); + + verify(() => mockHostService.shouldFullSync()).called(1); + verify(() => mockHostService.getMediaChanges()).called(1); + verifyNever(() => mockLocalAlbumRepo.updateAll(any())); + verifyNever(() => mockHostService.checkpointSync()); + }, + ); + }); + + group('fullSync', () { + test( + 'completes and checkpoints when no albums exist on device or DB', + () async { + when(() => mockAlbumMediaRepo.getAll()).thenAnswer((_) async => []); + when(() => mockLocalAlbumRepo.getAll(sortBy: SortLocalAlbumsBy.id)) + .thenAnswer((_) async => []); + + await sut.fullSync(); + + verify(() => mockAlbumMediaRepo.getAll()).called(1); + verify(() => mockLocalAlbumRepo.getAll(sortBy: SortLocalAlbumsBy.id)) + .called(1); + verifyNever( + () => mockLocalAlbumRepo.upsert( + any(), + toUpsert: any(named: 'toUpsert'), + ), + ); + verifyNever(() => mockLocalAlbumRepo.delete(any())); + verify(() => mockHostService.checkpointSync()).called(1); + }, + ); + + test('calls addAlbum for new device albums and checkpoints', () async { final deviceAlbums = [LocalAlbumStub.album1, LocalAlbumStub.album2]; when(() => mockAlbumMediaRepo.getAll()) .thenAnswer((_) async => deviceAlbums); when(() => mockLocalAlbumRepo.getAll(sortBy: SortLocalAlbumsBy.id)) .thenAnswer((_) async => []); - final refreshedAlbum1 = - deviceAlbums.first.copyWith(updatedAt: DateTime(2024), assetCount: 1); - final refreshedAlbum2 = - deviceAlbums[1].copyWith(updatedAt: DateTime(2024), assetCount: 0); - + final refreshedAlbum1 = deviceAlbums.first.copyWith(assetCount: 1); + final refreshedAlbum2 = deviceAlbums[1].copyWith(assetCount: 0); when(() => mockAlbumMediaRepo.refresh(deviceAlbums.first.id)) .thenAnswer((_) async => refreshedAlbum1); when(() => mockAlbumMediaRepo.refresh(deviceAlbums[1].id)) .thenAnswer((_) async => refreshedAlbum2); - when(() => mockAlbumMediaRepo.getAssetsForAlbum(deviceAlbums.first.id)) .thenAnswer((_) async => [LocalAssetStub.image1]); - await sut.sync(); + await sut.fullSync(); verify(() => mockAlbumMediaRepo.getAll()).called(1); verify(() => mockLocalAlbumRepo.getAll(sortBy: SortLocalAlbumsBy.id)) .called(1); - verify(() => mockAlbumMediaRepo.refresh(deviceAlbums.first.id)).called(1); verify(() => mockAlbumMediaRepo.refresh(deviceAlbums[1].id)).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())); - }); - - test('should call removeAlbum 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); - - await sut.sync(); - - 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())); + verify( + () => mockLocalAlbumRepo.upsert( + refreshedAlbum1, + toUpsert: [LocalAssetStub.image1], + toDelete: [], + ), + ).called(1); + verify( + () => mockLocalAlbumRepo.upsert( + refreshedAlbum2, + toUpsert: [], + toDelete: [], + ), + ).called(1); + verify(() => mockHostService.checkpointSync()).called(1); }); test( - 'should call updateAlbum for albums in both DB and device', + 'calls removeAlbum for DB albums not on device and checkpoints', () async { - final commonAlbum = LocalAlbumStub.album1; - final deviceAlbums = [commonAlbum]; - final dbAlbums = [commonAlbum.copyWith(updatedAt: DateTime(2023))]; - when(() => mockAlbumMediaRepo.getAll()) - .thenAnswer((_) async => deviceAlbums); + final dbAlbums = [LocalAlbumStub.album1, LocalAlbumStub.album2]; + when(() => mockAlbumMediaRepo.getAll()).thenAnswer((_) async => []); when(() => mockLocalAlbumRepo.getAll(sortBy: SortLocalAlbumsBy.id)) .thenAnswer((_) async => dbAlbums); - final refreshedAlbum = - commonAlbum.copyWith(updatedAt: DateTime(2024), assetCount: 1); - when(() => mockAlbumMediaRepo.refresh(commonAlbum.id)) - .thenAnswer((_) async => refreshedAlbum); - - when(() => mockAlbumMediaRepo.getAssetsForAlbum(commonAlbum.id)) - .thenAnswer((_) async => [LocalAssetStub.image1]); - when(() => mockLocalAlbumRepo.getAssetsForAlbum(commonAlbum.id)) - .thenAnswer((_) async => []); // DB has no assets initially - - await sut.sync(); + await sut.fullSync(); verify(() => mockAlbumMediaRepo.getAll()).called(1); verify(() => mockLocalAlbumRepo.getAll(sortBy: SortLocalAlbumsBy.id)) .called(1); - - verify(() => mockAlbumMediaRepo.refresh(commonAlbum.id)).called(1); - - // Verify fullSync path was likely taken - 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())); + verify(() => mockLocalAlbumRepo.delete(dbAlbums.first.id)).called(1); + verify(() => mockLocalAlbumRepo.delete(dbAlbums[1].id)).called(1); + verify(() => mockHostService.checkpointSync()).called(1); }, ); - test('should handle a mix of add, remove, and update', () async { - final albumToRemove = LocalAlbumStub.album1.copyWith(id: "remove_me"); - final albumToUpdate = LocalAlbumStub.album2.copyWith(id: "update_me"); - final albumToAdd = LocalAlbumStub.album3.copyWith(id: "add_me"); - + test('calls updateAlbum for common albums and checkpoints', () async { + final commonAlbum = LocalAlbumStub.album1; + final deviceAlbums = [commonAlbum]; final dbAlbums = [ - albumToRemove, - albumToUpdate.copyWith(updatedAt: DateTime(2023)), + commonAlbum.copyWith( + updatedAt: commonAlbum.updatedAt.subtract(const Duration(days: 10)), + ), ]; - final deviceAlbums = [albumToUpdate, albumToAdd]; - when(() => mockAlbumMediaRepo.getAll()) .thenAnswer((_) async => deviceAlbums); when(() => mockLocalAlbumRepo.getAll(sortBy: SortLocalAlbumsBy.id)) .thenAnswer((_) async => dbAlbums); - final refreshedUpdateAlbum = - albumToUpdate.copyWith(updatedAt: DateTime(2024), assetCount: 0); - when(() => mockAlbumMediaRepo.refresh(albumToUpdate.id)) - .thenAnswer((_) async => refreshedUpdateAlbum); + final refreshedAlbum = + commonAlbum.copyWith(updatedAt: DateTime(2024, 1, 1), assetCount: 2); + when(() => mockAlbumMediaRepo.refresh(commonAlbum.id)) + .thenAnswer((_) async => refreshedAlbum); + when( + () => mockAlbumMediaRepo.getAssetsForAlbum( + commonAlbum.id, + updateTimeCond: any(named: 'updateTimeCond'), + ), + ).thenAnswer((_) async => [LocalAssetStub.image2]); - final refreshedAddAlbum = - albumToAdd.copyWith(updatedAt: DateTime(2024), assetCount: 1); - when(() => mockAlbumMediaRepo.refresh(albumToAdd.id)) - .thenAnswer((_) async => refreshedAddAlbum); - when(() => mockAlbumMediaRepo.getAssetsForAlbum(albumToAdd.id)) - .thenAnswer((_) async => [LocalAssetStub.image1]); - - await sut.sync(); + await sut.fullSync(); verify(() => mockAlbumMediaRepo.getAll()).called(1); verify(() => mockLocalAlbumRepo.getAll(sortBy: SortLocalAlbumsBy.id)) .called(1); - - verify(() => mockLocalAlbumRepo.delete(albumToRemove.id)).called(1); - - verify(() => mockAlbumMediaRepo.refresh(albumToAdd.id)).called(1); - verify(() => mockAlbumMediaRepo.getAssetsForAlbum(albumToAdd.id)) - .called(1); + verify(() => mockAlbumMediaRepo.refresh(commonAlbum.id)).called(1); verify( - () => mockLocalAlbumRepo.insert( - any(that: predicate((a) => a.id == albumToAdd.id)), - any(), + () => mockAlbumMediaRepo.getAssetsForAlbum( + commonAlbum.id, + updateTimeCond: any(named: 'updateTimeCond'), ), ).called(1); - - verify(() => mockAlbumMediaRepo.refresh(albumToUpdate.id)).called(1); - verifyNever(() => mockAlbumMediaRepo.getAssetsForAlbum(albumToUpdate.id)); - verify(() => mockLocalAlbumRepo.getAssetsForAlbum(albumToUpdate.id)) - .called(1); verify( - () => mockLocalAlbumRepo.update( - any(that: predicate((a) => a.id == albumToUpdate.id)), + () => mockLocalAlbumRepo.upsert( + any( + that: predicate( + (a) => a.id == commonAlbum.id && a.assetCount == 2, + ), + ), + toUpsert: [LocalAssetStub.image2], ), ).called(1); + verify(() => mockHostService.checkpointSync()).called(1); }); - test('should handle errors during repository calls', () async { - when(() => mockAlbumMediaRepo.getAll()) - .thenThrow(Exception("Device error")); + test( + 'handles repository errors gracefully and does not checkpoint', + () async { + when(() => mockAlbumMediaRepo.getAll()) + .thenThrow(Exception("Repo error")); - await sut.sync(); + await sut.fullSync(); - verify(() => mockAlbumMediaRepo.getAll()).called(1); - verifyNever( - () => mockLocalAlbumRepo.getAll(sortBy: any(named: 'sortBy')), - ); - }); + verify(() => mockAlbumMediaRepo.getAll()).called(1); + verifyNever( + () => mockLocalAlbumRepo.getAll(sortBy: any(named: 'sortBy')), + ); + verifyNever(() => mockHostService.checkpointSync()); + }, + ); }); group('addAlbum', () { - test( - 'refreshes, gets assets, 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(id: "asset1", createdAt: DateTime(2024, 1, 1)), - LocalAssetStub.image2.copyWith( - id: "asset2", - createdAt: DateTime(2024, 1, 2), - ), - ]; - - when(() => mockAlbumMediaRepo.refresh(newAlbum.id)) - .thenAnswer((_) async => refreshedAlbum); - when(() => mockAlbumMediaRepo.getAssetsForAlbum(newAlbum.id)) - .thenAnswer((_) async => assets); - - await sut.addAlbum(newAlbum); - - verify(() => mockAlbumMediaRepo.refresh(newAlbum.id)).called(1); - 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(listEquals(capturedAssets, assets), isTrue); - }, - ); - - test('refreshes, skips assets, and inserts for empty album', () async { + test('refreshes, gets assets, and updates for non-empty album', () async { final newAlbum = LocalAlbumStub.album1.copyWith(assetCount: 0); - final refreshedAlbum = - newAlbum.copyWith(updatedAt: DateTime(2024), assetCount: 0); + final refreshedAlbum = newAlbum.copyWith(assetCount: 1); + final assets = [LocalAssetStub.image1]; + + when(() => mockAlbumMediaRepo.refresh(newAlbum.id)) + .thenAnswer((_) async => refreshedAlbum); + when(() => mockAlbumMediaRepo.getAssetsForAlbum(newAlbum.id)) + .thenAnswer((_) async => assets); + + await sut.addAlbum(newAlbum); + + verify(() => mockAlbumMediaRepo.refresh(newAlbum.id)).called(1); + verify(() => mockAlbumMediaRepo.getAssetsForAlbum(newAlbum.id)).called(1); + verify(() => mockLocalAlbumRepo.upsert(refreshedAlbum, toUpsert: assets)) + .called(1); + }); + + test('refreshes, skips assets, and updates for empty album', () async { + final newAlbum = LocalAlbumStub.album1.copyWith(assetCount: 0); + final refreshedAlbum = newAlbum.copyWith(assetCount: 0); when(() => mockAlbumMediaRepo.refresh(newAlbum.id)) .thenAnswer((_) async => refreshedAlbum); @@ -298,16 +419,8 @@ void main() { verify(() => mockAlbumMediaRepo.refresh(newAlbum.id)).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(capturedAssets, isEmpty); + verify(() => mockLocalAlbumRepo.upsert(refreshedAlbum, toUpsert: [])) + .called(1); }); }); @@ -320,30 +433,29 @@ void main() { }); group('updateAlbum', () { - final dbAlbum = LocalAlbumStub.album1.copyWith( - updatedAt: DateTime(2024, 1, 1), - assetCount: 1, - ); + final dbAlbum = LocalAlbumStub.album1 + .copyWith(updatedAt: DateTime(2024, 1, 1), assetCount: 1); test('returns early if refresh shows no changes', () async { final refreshedAlbum = dbAlbum; when(() => mockAlbumMediaRepo.refresh(dbAlbum.id)) .thenAnswer((_) async => refreshedAlbum); - final result = await sut.updateAlbum(dbAlbum, LocalAlbumStub.album1); + final result = await sut.updateAlbum(dbAlbum, LocalAlbumStub.album2); expect(result, false); verify(() => mockAlbumMediaRepo.refresh(dbAlbum.id)).called(1); - verifyNever(() => mockAlbumMediaRepo.getAssetsForAlbum(any())); - verifyNever(() => mockLocalAlbumRepo.getAssetsForAlbum(any())); - verifyNever(() => mockLocalAlbumRepo.transaction(any())); + verifyNever( + () => mockAlbumMediaRepo.getAssetsForAlbum( + any(), + updateTimeCond: any(named: 'updateTimeCond'), + ), + ); }); test('calls checkAddition and returns true if it succeeds', () async { - final refreshedAlbum = dbAlbum.copyWith( - updatedAt: DateTime(2024, 1, 2), - assetCount: 2, - ); + final refreshedAlbum = + dbAlbum.copyWith(updatedAt: DateTime(2024, 1, 2), assetCount: 2); when(() => mockAlbumMediaRepo.refresh(dbAlbum.id)) .thenAnswer((_) async => refreshedAlbum); @@ -354,54 +466,43 @@ void main() { updateTimeCond: any(named: 'updateTimeCond'), ), ).thenAnswer((_) async => [newAsset]); - final result = await sut.updateAlbum(dbAlbum, LocalAlbumStub.album1); + + final result = await sut.updateAlbum(dbAlbum, LocalAlbumStub.album2); expect(result, isTrue); verify(() => mockAlbumMediaRepo.refresh(dbAlbum.id)).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( + () => mockLocalAlbumRepo.upsert( any( that: predicate( (a) => a.id == dbAlbum.id && a.assetCount == 2, ), ), + toUpsert: [newAsset], ), ).called(1); - verify(() => mockLocalAlbumRepo.removeAssets(any(), any(that: isEmpty))) - .called(1); - - verifyNever(() => mockAlbumMediaRepo.getAssetsForAlbum(dbAlbum.id)); - verifyNever(() => mockLocalAlbumRepo.getAssetsForAlbum(dbAlbum.id)); }); test( - 'calls fullSync and returns true if checkAddition returns false', + 'calls fullDiff and returns true if checkAddition returns false', () async { - final refreshedAlbum = dbAlbum.copyWith( - updatedAt: DateTime(2024, 1, 2), - assetCount: 0, - ); + final refreshedAlbum = + dbAlbum.copyWith(updatedAt: DateTime(2024, 1, 2), assetCount: 0); when(() => mockAlbumMediaRepo.refresh(dbAlbum.id)) .thenAnswer((_) async => refreshedAlbum); when(() => mockAlbumMediaRepo.getAssetsForAlbum(dbAlbum.id)) .thenAnswer((_) async => []); - when(() => mockLocalAlbumRepo.getAssetsForAlbum(dbAlbum.id)).thenAnswer( - (_) async => [LocalAssetStub.image1], - ); + when(() => mockLocalAlbumRepo.getAssetsForAlbum(dbAlbum.id)) + .thenAnswer((_) async => [LocalAssetStub.image1]); - final result = await sut.updateAlbum(dbAlbum, LocalAlbumStub.album1); + final result = await sut.updateAlbum(dbAlbum, LocalAlbumStub.album2); expect(result, isTrue); verify(() => mockAlbumMediaRepo.refresh(dbAlbum.id)).called(1); @@ -415,78 +516,18 @@ void main() { verifyNever(() => mockAlbumMediaRepo.getAssetsForAlbum(dbAlbum.id)); verify(() => mockLocalAlbumRepo.getAssetsForAlbum(dbAlbum.id)) .called(1); - verify(() => mockLocalAlbumRepo.transaction(any())).called(1); - verify(() => mockLocalAlbumRepo.addAssets(any(), any(that: isEmpty))) - .called(1); verify( - () => mockLocalAlbumRepo.update( + () => mockLocalAlbumRepo.upsert( any( that: predicate( (a) => a.id == dbAlbum.id && a.assetCount == 0, ), ), + toDelete: [LocalAssetStub.image1.id], ), ).called(1); - verify( - () => mockLocalAlbumRepo - .removeAssets(dbAlbum.id, [LocalAssetStub.image1.id]), - ).called(1); }, ); - - test('handles error during checkAddition', () async { - final refreshedAlbum = dbAlbum.copyWith( - updatedAt: DateTime(2024, 1, 2), - assetCount: 2, - ); - when(() => mockAlbumMediaRepo.refresh(dbAlbum.id)) - .thenAnswer((_) async => refreshedAlbum); - when( - () => mockAlbumMediaRepo.getAssetsForAlbum( - dbAlbum.id, - updateTimeCond: any(named: 'updateTimeCond'), - ), - ).thenThrow(Exception("checkAddition failed")); - - when(() => mockAlbumMediaRepo.getAssetsForAlbum(dbAlbum.id)) - .thenAnswer((_) async => []); - when(() => mockLocalAlbumRepo.getAssetsForAlbum(dbAlbum.id)) - .thenAnswer((_) async => []); - - final result = await sut.updateAlbum(dbAlbum, LocalAlbumStub.album1); - - expect(result, isTrue); - verify(() => mockAlbumMediaRepo.refresh(dbAlbum.id)).called(1); - verify( - () => mockAlbumMediaRepo.getAssetsForAlbum( - dbAlbum.id, - updateTimeCond: any(named: 'updateTimeCond'), - ), - ).called(2); // One for checkAddition, one for fullSync - verify(() => mockLocalAlbumRepo.getAssetsForAlbum(dbAlbum.id)).called(1); - }); - - test('handles error during fullSync', () async { - final refreshedAlbum = dbAlbum.copyWith( - updatedAt: DateTime(2024, 1, 2), - assetCount: 1, - ); - when(() => mockAlbumMediaRepo.refresh(dbAlbum.id)) - .thenAnswer((_) async => refreshedAlbum); - when(() => mockAlbumMediaRepo.getAssetsForAlbum(dbAlbum.id)) - .thenThrow(Exception("fullSync failed")); - - final result = await sut.updateAlbum( - dbAlbum, - LocalAlbumStub.album1.copyWith(assetCount: 2), - ); - - expect(result, isTrue); - verify(() => mockAlbumMediaRepo.refresh(dbAlbum.id)).called(1); - verify(() => mockAlbumMediaRepo.getAssetsForAlbum(dbAlbum.id)); - verifyNever(() => mockLocalAlbumRepo.getAssetsForAlbum(any())); - verifyNever(() => mockLocalAlbumRepo.transaction(any())); - }); }); group('checkAddition', () { @@ -498,10 +539,8 @@ void main() { ); test('returns true and updates assets/metadata on success', () async { - final newAsset = LocalAssetStub.image2.copyWith( - id: "asset2", - createdAt: DateTime(2024, 1, 1, 10, 30, 0), - ); + final newAsset = LocalAssetStub.image2 + .copyWith(id: "asset2", createdAt: DateTime(2024, 1, 1, 10, 30, 0)); when( () => mockAlbumMediaRepo.getAssetsForAlbum( dbAlbum.id, @@ -518,20 +557,19 @@ void main() { updateTimeCond: any(named: 'updateTimeCond'), ), ).called(1); - verify(() => mockLocalAlbumRepo.addAssets(dbAlbum.id, [newAsset])) - .called(1); verify( - () => mockLocalAlbumRepo.update( + () => mockLocalAlbumRepo.upsert( any( - that: predicate((a) => - a.id == dbAlbum.id && - a.assetCount == 2 && - a.updatedAt == refreshedAlbum.updatedAt), + that: predicate( + (a) => + a.id == dbAlbum.id && + a.assetCount == 2 && + a.updatedAt == refreshedAlbum.updatedAt, + ), ), + toUpsert: [newAsset], ), ).called(1); - verify(() => mockLocalAlbumRepo.removeAssets(any(), any(that: isEmpty))) - .called(1); }); test('returns false if assetCount decreased', () async { @@ -544,24 +582,9 @@ void main() { 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.checkAddition(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 { + test('returns false if no new assets found by query', () async { when( () => mockAlbumMediaRepo.getAssetsForAlbum( dbAlbum.id, @@ -576,62 +599,46 @@ void main() { 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.checkAddition(dbAlbum, mismatchCountAlbum); - expect(result, isFalse); - verify( - () => mockAlbumMediaRepo.getAssetsForAlbum( - dbAlbum.id, - updateTimeCond: any(named: 'updateTimeCond'), - ), - ).called(1); - verifyNever(() => mockLocalAlbumRepo.transaction(any())); - }); + test( + 'returns false if asset count mismatch after finding new assets (implies deletion)', + () async { + final newAsset = LocalAssetStub.image2.copyWith(id: "asset2"); + when( + () => mockAlbumMediaRepo.getAssetsForAlbum( + dbAlbum.id, + updateTimeCond: any(named: 'updateTimeCond'), + ), + ).thenAnswer((_) async => [newAsset]); + // dbAlbum.assetCount = 1, newAssets.length = 1. Expected refreshedAlbum.assetCount = 2 + // But we set it to 3, indicating a mismatch. + final mismatchedCountAlbum = refreshedAlbum.copyWith(assetCount: 3); + final result = await sut.checkAddition(dbAlbum, mismatchedCountAlbum); + expect(result, isFalse); + verify( + () => mockAlbumMediaRepo.getAssetsForAlbum( + dbAlbum.id, + updateTimeCond: any(named: 'updateTimeCond'), + ), + ).called(1); + }, + ); }); - group('fullSync', () { - final dbAlbum = LocalAlbumStub.album1.copyWith( - updatedAt: DateTime(2024, 1, 1), - assetCount: 2, - ); + group('fullDiff', () { + final dbAlbum = LocalAlbumStub.album1 + .copyWith(updatedAt: DateTime(2024, 1, 1), assetCount: 2); final refreshedAlbum = dbAlbum.copyWith( updatedAt: DateTime(2024, 1, 2), assetCount: 2, ); - final dbAsset1 = LocalAssetStub.image1.copyWith( - id: "asset1", - createdAt: DateTime(2024), - updatedAt: DateTime(2024), - ); - final dbAsset2 = LocalAssetStub.image2.copyWith( - id: "asset2", - createdAt: DateTime(2024), - updatedAt: DateTime(2024), - ); // To be deleted - final deviceAsset1 = LocalAssetStub.image1.copyWith( - id: "asset1", - createdAt: DateTime(2024), - updatedAt: DateTime(2025), - ); // Updated - final deviceAsset3 = LocalAssetStub.video1.copyWith( - id: "asset3", - createdAt: DateTime(2024), - updatedAt: DateTime(2024), - ); // Added + final dbAsset1 = LocalAssetStub.image1.copyWith(id: "asset1"); + final dbAsset2 = LocalAssetStub.image2.copyWith(id: "asset2"); + final deviceAsset1Updated = + LocalAssetStub.image1.copyWith(id: "asset1", updatedAt: DateTime(2025)); + final deviceAsset3New = LocalAssetStub.video1.copyWith(id: "asset3"); test('handles empty device album -> deletes all DB assets', () async { final emptyRefreshedAlbum = refreshedAlbum.copyWith(assetCount: 0); @@ -643,33 +650,22 @@ void main() { final result = await sut.fullDiff(dbAlbum, emptyRefreshedAlbum); expect(result, isTrue); - verifyNever( - () => mockAlbumMediaRepo.getAssetsForAlbum(emptyRefreshedAlbum.id), - ); verify(() => mockLocalAlbumRepo.getAssetsForAlbum(dbAlbum.id)).called(1); - verify(() => mockLocalAlbumRepo.transaction(any())).called(1); - verify(() => mockLocalAlbumRepo.addAssets(any(), any(that: isEmpty))) - .called(1); verify( - () => mockLocalAlbumRepo.update( + () => mockLocalAlbumRepo.upsert( any( - that: predicate((a) => - a.id == dbAlbum.id && - a.assetCount == 0 && - a.updatedAt == emptyRefreshedAlbum.updatedAt), + that: predicate( + (a) => a.id == dbAlbum.id && a.assetCount == 0, + ), ), + toDelete: ["asset1", "asset2"], ), ).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); - final deviceAssets = [deviceAsset1, deviceAsset3]; - - deviceAssets.sort((a, b) => a.createdAt.compareTo(b.createdAt)); + final deviceAssets = [deviceAsset1Updated, deviceAsset3New]; final refreshedWithAssets = refreshedAlbum.copyWith(assetCount: deviceAssets.length); @@ -683,110 +679,94 @@ void main() { expect(result, isTrue); verify(() => mockAlbumMediaRepo.getAssetsForAlbum(emptyDbAlbum.id)) .called(1); - verifyNever(() => mockLocalAlbumRepo.getAssetsForAlbum(emptyDbAlbum.id)); - verify(() => mockLocalAlbumRepo.transaction(any())).called(1); - verify(() => mockLocalAlbumRepo.addAssets(emptyDbAlbum.id, deviceAssets)) - .called(1); verify( - () => mockLocalAlbumRepo.update( + () => mockLocalAlbumRepo.upsert( any( - that: predicate((a) => - a.id == emptyDbAlbum.id && - a.assetCount == deviceAssets.length && - a.updatedAt == refreshedWithAssets.updatedAt), + that: predicate( + (a) => + a.id == emptyDbAlbum.id && + a.assetCount == deviceAssets.length, + ), ), + toUpsert: + any(named: 'toUpsert', that: containsAllInOrder(deviceAssets)), ), ).called(1); - verify(() => mockLocalAlbumRepo.removeAssets(any(), any(that: isEmpty))) - .called(1); }); test('handles mix of additions, updates, and deletions', () async { - final currentRefreshedAlbum = refreshedAlbum.copyWith(assetCount: 2); - final deviceAssets = [deviceAsset1, deviceAsset3]; - deviceAssets.sort((a, b) => a.createdAt.compareTo(b.createdAt)); - final dbAssets = [dbAsset1, dbAsset2]; - dbAssets.sort((a, b) => a.id.compareTo(b.id)); + final currentDeviceAssets = [deviceAsset1Updated, deviceAsset3New]; + final currentDbAssets = [dbAsset1, dbAsset2]; - when(() => mockAlbumMediaRepo.getAssetsForAlbum(dbAlbum.id)).thenAnswer( - (_) async => deviceAssets, - ); - when(() => mockLocalAlbumRepo.getAssetsForAlbum(dbAlbum.id)).thenAnswer( - (_) async => dbAssets, - ); - - final result = await sut.fullDiff(dbAlbum, currentRefreshedAlbum); - - expect(result, isTrue); - verify(() => mockAlbumMediaRepo.getAssetsForAlbum(dbAlbum.id)).called(1); - verify(() => mockLocalAlbumRepo.getAssetsForAlbum(dbAlbum.id)).called(1); - verify(() => mockLocalAlbumRepo.transaction(any())).called(1); - - verify( - () => mockLocalAlbumRepo.addAssets( - dbAlbum.id, - any( - that: predicate>((list) { - return list.length == 2 && - list.any( - (a) => - a.id == "asset1" && - a.updatedAt == deviceAsset1.updatedAt, - ) && - list.any((a) => a.id == "asset3"); - }), - ), - ), - ).called(1); - - verify( - () => mockLocalAlbumRepo.update( - any( - that: predicate((a) => - a.id == dbAlbum.id && - a.assetCount == 2 && - a.updatedAt == currentRefreshedAlbum.updatedAt), - ), - ), - ).called(1); - - verify(() => mockLocalAlbumRepo.removeAssets(dbAlbum.id, ["asset2"])) - .called(1); - }); - - test('handles identical assets, resulting in no DB changes', () async { - final currentRefreshedAlbum = refreshedAlbum.copyWith( - updatedAt: DateTime(2025), - assetCount: 2, - ); - final dbAssets = [dbAsset1, dbAsset2]; - final deviceAssets = [dbAsset1, dbAsset2]; - deviceAssets.sort((a, b) => a.createdAt.compareTo(b.createdAt)); - dbAssets.sort((a, b) => a.id.compareTo(b.id)); + final currentRefreshedAlbum = + refreshedAlbum.copyWith(assetCount: currentDeviceAssets.length); when(() => mockAlbumMediaRepo.getAssetsForAlbum(dbAlbum.id)) - .thenAnswer((_) async => deviceAssets); + .thenAnswer((_) async => currentDeviceAssets); when(() => mockLocalAlbumRepo.getAssetsForAlbum(dbAlbum.id)) - .thenAnswer((_) async => dbAssets); + .thenAnswer((_) async => currentDbAssets); final result = await sut.fullDiff(dbAlbum, currentRefreshedAlbum); expect(result, isTrue); verify(() => mockAlbumMediaRepo.getAssetsForAlbum(dbAlbum.id)).called(1); verify(() => mockLocalAlbumRepo.getAssetsForAlbum(dbAlbum.id)).called(1); - verifyNever(() => mockLocalAlbumRepo.transaction(any())); - verifyNever(() => mockLocalAlbumRepo.addAssets(any(), any())); - verifyNever(() => mockLocalAlbumRepo.removeAssets(any(), any())); + verify( - () => mockLocalAlbumRepo.update( + () => mockLocalAlbumRepo.upsert( any( - that: predicate((a) => - a.id == dbAlbum.id && - a.assetCount == 2 && - a.updatedAt == currentRefreshedAlbum.updatedAt), + that: predicate( + (a) => + a.id == dbAlbum.id && + a.assetCount == currentDeviceAssets.length, + ), ), + toUpsert: any( + named: 'toUpsert', + that: containsAllInOrder([deviceAsset1Updated, deviceAsset3New]), + ), + toDelete: ["asset2"], ), ).called(1); }); + + test( + 'handles identical assets (only metadata update if album changed)', + () async { + final dbAssets = [dbAsset1, dbAsset2]; + final deviceAssets = [dbAsset1, dbAsset2]; + + final changedRefreshedAlbum = refreshedAlbum.copyWith( + updatedAt: DateTime(2025), + assetCount: deviceAssets.length, + ); + + when(() => mockAlbumMediaRepo.getAssetsForAlbum(dbAlbum.id)) + .thenAnswer((_) async => deviceAssets); + when(() => mockLocalAlbumRepo.getAssetsForAlbum(dbAlbum.id)) + .thenAnswer((_) async => dbAssets); + + final result = await sut.fullDiff(dbAlbum, changedRefreshedAlbum); + + expect(result, isTrue); + verify(() => mockAlbumMediaRepo.getAssetsForAlbum(dbAlbum.id)) + .called(1); + verify(() => mockLocalAlbumRepo.getAssetsForAlbum(dbAlbum.id)) + .called(1); + verify( + () => mockLocalAlbumRepo.upsert( + any( + that: predicate( + (a) => + a.id == dbAlbum.id && + a.updatedAt == changedRefreshedAlbum.updatedAt, + ), + ), + toUpsert: [], + toDelete: [], + ), + ).called(1); + }, + ); }); } diff --git a/mobile/test/fixtures/local_asset.stub.dart b/mobile/test/fixtures/local_asset.stub.dart index 1d47e7abe5..e7d33cefae 100644 --- a/mobile/test/fixtures/local_asset.stub.dart +++ b/mobile/test/fixtures/local_asset.stub.dart @@ -9,7 +9,7 @@ abstract final class LocalAssetStub { checksum: "image1-checksum", type: AssetType.image, createdAt: DateTime(2019), - updatedAt: DateTime.now(), + updatedAt: DateTime(2020), width: 1920, height: 1080, durationInSeconds: 0, diff --git a/mobile/test/fixtures/platform_asset.stub.dart b/mobile/test/fixtures/platform_asset.stub.dart new file mode 100644 index 0000000000..823113840b --- /dev/null +++ b/mobile/test/fixtures/platform_asset.stub.dart @@ -0,0 +1,23 @@ +import 'package:immich_mobile/platform/messages.g.dart'; + +abstract final class PlatformAssetStub { + static PlatformAsset get image1 => PlatformAsset( + id: "asset1", + name: "asset1.jpg", + type: 1, + createdAt: DateTime(2024, 1, 1).millisecondsSinceEpoch, + updatedAt: DateTime(2024, 1, 1).millisecondsSinceEpoch, + durationInSeconds: 0, + albumIds: ["album1"], + ); + + static PlatformAsset get video1 => PlatformAsset( + id: "asset2", + name: "asset2.mp4", + type: 2, + createdAt: DateTime(2024, 1, 2).millisecondsSinceEpoch, + updatedAt: DateTime(2024, 1, 2).millisecondsSinceEpoch, + durationInSeconds: 120, + albumIds: ["album1"], + ); +}