diff --git a/mobile/lib/constants/enums.dart b/mobile/lib/constants/enums.dart index 350f6b80fa..32ef9bbbed 100644 --- a/mobile/lib/constants/enums.dart +++ b/mobile/lib/constants/enums.dart @@ -18,3 +18,5 @@ enum ActionSource { timeline, viewer } enum CleanupStep { selectDate, scan, delete } enum AssetKeepType { none, photosOnly, videosOnly } + +enum AssetDateAggregation { start, end } diff --git a/mobile/lib/domain/services/remote_album.service.dart b/mobile/lib/domain/services/remote_album.service.dart index 0cf3f3e1c1..945ba8eb3f 100644 --- a/mobile/lib/domain/services/remote_album.service.dart +++ b/mobile/lib/domain/services/remote_album.service.dart @@ -43,8 +43,8 @@ class RemoteAlbumService { AlbumSortMode.title => albums.sortedBy((album) => album.name), AlbumSortMode.lastModified => albums.sortedBy((album) => album.updatedAt), AlbumSortMode.assetCount => albums.sortedBy((album) => album.assetCount), - AlbumSortMode.mostRecent => await _sortByNewestAsset(albums), - AlbumSortMode.mostOldest => await _sortByOldestAsset(albums), + AlbumSortMode.mostRecent => await _sortByAssetDate(albums, aggregation: AssetDateAggregation.end), + AlbumSortMode.mostOldest => await _sortByAssetDate(albums, aggregation: AssetDateAggregation.start), }; final effectiveOrder = isReverse ? sortMode.defaultOrder.reverse() : sortMode.defaultOrder; @@ -172,46 +172,25 @@ class RemoteAlbumService { return _repository.getAlbumsContainingAsset(assetId); } - Future> _sortByNewestAsset(List albums) async { - // map album IDs to their newest asset dates - final Map> assetTimestampFutures = {}; - for (final album in albums) { - assetTimestampFutures[album.id] = _repository.getNewestAssetTimestamp(album.id); + Future> _sortByAssetDate( + List albums, { + required AssetDateAggregation aggregation, + }) async { + if (albums.isEmpty) return []; + + final albumIds = albums.map((e) => e.id).toList(); + final sortedIds = await _repository.getSortedAlbumIds(albumIds, aggregation: aggregation); + + final albumMap = Map.fromEntries(albums.map((a) => MapEntry(a.id, a))); + + final sortedAlbums = sortedIds.map((id) => albumMap[id]).whereType().toList(); + + if (sortedAlbums.length < albums.length) { + final returnedIdSet = sortedIds.toSet(); + final emptyAlbums = albums.where((a) => !returnedIdSet.contains(a.id)); + sortedAlbums.addAll(emptyAlbums); } - // await all database queries - final entries = await Future.wait( - assetTimestampFutures.entries.map((entry) async => MapEntry(entry.key, await entry.value)), - ); - final assetTimestamps = Map.fromEntries(entries); - - final sorted = albums.sorted((a, b) { - final aDate = assetTimestamps[a.id] ?? DateTime.fromMillisecondsSinceEpoch(0); - final bDate = assetTimestamps[b.id] ?? DateTime.fromMillisecondsSinceEpoch(0); - return aDate.compareTo(bDate); - }); - - return sorted; - } - - Future> _sortByOldestAsset(List albums) async { - // map album IDs to their oldest asset dates - final Map> assetTimestampFutures = { - for (final album in albums) album.id: _repository.getOldestAssetTimestamp(album.id), - }; - - // await all database queries - final entries = await Future.wait( - assetTimestampFutures.entries.map((entry) async => MapEntry(entry.key, await entry.value)), - ); - final assetTimestamps = Map.fromEntries(entries); - - final sorted = albums.sorted((a, b) { - final aDate = assetTimestamps[a.id] ?? DateTime.fromMillisecondsSinceEpoch(0); - final bDate = assetTimestamps[b.id] ?? DateTime.fromMillisecondsSinceEpoch(0); - return aDate.compareTo(bDate); - }); - - return sorted; + return sortedAlbums; } } diff --git a/mobile/lib/infrastructure/repositories/remote_album.repository.dart b/mobile/lib/infrastructure/repositories/remote_album.repository.dart index d7d4a250ad..a594647f19 100644 --- a/mobile/lib/infrastructure/repositories/remote_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_album.repository.dart @@ -1,6 +1,8 @@ import 'dart:async'; +import 'dart:convert'; import 'package:drift/drift.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; @@ -321,26 +323,32 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository { }).watchSingleOrNull(); } - Future getNewestAssetTimestamp(String albumId) { - final query = _db.remoteAlbumAssetEntity.selectOnly() - ..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId)) - ..addColumns([_db.remoteAssetEntity.localDateTime.max()]) - ..join([ - innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)), - ]); + Future> getSortedAlbumIds(List albumIds, {required AssetDateAggregation aggregation}) async { + if (albumIds.isEmpty) return []; - return query.map((row) => row.read(_db.remoteAssetEntity.localDateTime.max())).getSingleOrNull(); - } + final jsonIds = jsonEncode(albumIds); + final sqlAgg = aggregation == AssetDateAggregation.start ? 'MIN' : 'MAX'; - Future getOldestAssetTimestamp(String albumId) { - final query = _db.remoteAlbumAssetEntity.selectOnly() - ..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId)) - ..addColumns([_db.remoteAssetEntity.localDateTime.min()]) - ..join([ - innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)), - ]); + final rows = await _db + .customSelect( + ''' + SELECT + raae.album_id, + $sqlAgg(rae.local_date_time) AS asset_date + FROM json_each(?) ids + INNER JOIN remote_album_asset_entity raae + ON raae.album_id = ids.value + INNER JOIN remote_asset_entity rae + ON rae.id = raae.asset_id + GROUP BY raae.album_id + ORDER BY asset_date ASC + ''', + variables: [Variable(jsonIds)], + readsFrom: {_db.remoteAlbumAssetEntity, _db.remoteAssetEntity}, + ) + .get(); - return query.map((row) => row.read(_db.remoteAssetEntity.localDateTime.min())).getSingleOrNull(); + return rows.map((row) => row.read('album_id')).toList(); } Future getCount() { diff --git a/mobile/test/domain/services/album.service_test.dart b/mobile/test/domain/services/album.service_test.dart index 1a36a811c3..9110a09471 100644 --- a/mobile/test/domain/services/album.service_test.dart +++ b/mobile/test/domain/services/album.service_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/services/remote_album.service.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; @@ -13,38 +14,6 @@ void main() { late DriftRemoteAlbumRepository mockRemoteAlbumRepo; late DriftAlbumApiRepository mockAlbumApiRepo; - setUp(() { - mockRemoteAlbumRepo = MockRemoteAlbumRepository(); - mockAlbumApiRepo = MockDriftAlbumApiRepository(); - sut = RemoteAlbumService(mockRemoteAlbumRepo, mockAlbumApiRepo); - - when(() => mockRemoteAlbumRepo.getNewestAssetTimestamp(any())).thenAnswer((invocation) { - // Simulate a timestamp for the newest asset in the album - final albumID = invocation.positionalArguments[0] as String; - - if (albumID == '1') { - return Future.value(DateTime(2023, 1, 1)); - } else if (albumID == '2') { - return Future.value(DateTime(2023, 2, 1)); - } - - return Future.value(DateTime.fromMillisecondsSinceEpoch(0)); - }); - - when(() => mockRemoteAlbumRepo.getOldestAssetTimestamp(any())).thenAnswer((invocation) { - // Simulate a timestamp for the oldest asset in the album - final albumID = invocation.positionalArguments[0] as String; - - if (albumID == '1') { - return Future.value(DateTime(2019, 1, 1)); - } else if (albumID == '2') { - return Future.value(DateTime(2019, 2, 1)); - } - - return Future.value(DateTime.fromMillisecondsSinceEpoch(0)); - }); - }); - final albumA = RemoteAlbum( id: '1', name: 'Album A', @@ -73,6 +42,21 @@ void main() { isShared: false, ); + setUp(() { + mockRemoteAlbumRepo = MockRemoteAlbumRepository(); + mockAlbumApiRepo = MockDriftAlbumApiRepository(); + + when( + () => mockRemoteAlbumRepo.getSortedAlbumIds(any(), aggregation: AssetDateAggregation.end), + ).thenAnswer((_) async => ['1', '2']); + + when( + () => mockRemoteAlbumRepo.getSortedAlbumIds(any(), aggregation: AssetDateAggregation.start), + ).thenAnswer((_) async => ['1', '2']); + + sut = RemoteAlbumService(mockRemoteAlbumRepo, mockAlbumApiRepo); + }); + group('sortAlbums', () { test('should sort correctly based on name', () async { final albums = [albumB, albumA]; diff --git a/mobile/test/infrastructure/repositories/remote_album_repository_test.dart b/mobile/test/infrastructure/repositories/remote_album_repository_test.dart new file mode 100644 index 0000000000..bc39d7bf5e --- /dev/null +++ b/mobile/test/infrastructure/repositories/remote_album_repository_test.dart @@ -0,0 +1,305 @@ +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/user.model.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; + +void main() { + late Drift db; + late DriftRemoteAlbumRepository repository; + + setUp(() { + db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); + repository = DriftRemoteAlbumRepository(db); + }); + + tearDown(() async { + await db.close(); + }); + + group('getSortedAlbumIds', () { + Future createUser(String userId, String name) async { + await db + .into(db.userEntity) + .insert( + UserEntityCompanion( + id: Value(userId), + name: Value(name), + email: Value('$userId@test.com'), + avatarColor: const Value(AvatarColor.primary), + ), + ); + } + + Future createAlbum(String albumId, String ownerId, String name) async { + await db + .into(db.remoteAlbumEntity) + .insert( + RemoteAlbumEntityCompanion( + id: Value(albumId), + name: Value(name), + ownerId: Value(ownerId), + createdAt: Value(DateTime.now()), + updatedAt: Value(DateTime.now()), + description: const Value(''), + isActivityEnabled: const Value(false), + order: const Value(AlbumAssetOrder.asc), + ), + ); + } + + Future createAsset(String assetId, String ownerId, DateTime createdAt) async { + await db + .into(db.remoteAssetEntity) + .insert( + RemoteAssetEntityCompanion( + id: Value(assetId), + checksum: Value('checksum-$assetId'), + name: Value('asset-$assetId'), + ownerId: Value(ownerId), + type: const Value(AssetType.image), + createdAt: Value(createdAt), + updatedAt: Value(createdAt), + localDateTime: Value(createdAt), + durationInSeconds: const Value(0), + height: const Value(1080), + width: const Value(1920), + visibility: const Value(AssetVisibility.timeline), + ), + ); + } + + Future linkAssetToAlbum(String albumId, String assetId) async { + await db + .into(db.remoteAlbumAssetEntity) + .insert(RemoteAlbumAssetEntityCompanion(albumId: Value(albumId), assetId: Value(assetId))); + } + + test('returns empty list when albumIds is empty', () async { + final result = await repository.getSortedAlbumIds([], aggregation: AssetDateAggregation.start); + + expect(result, isEmpty); + }); + + test('returns single album when only one album exists', () async { + const userId = 'user1'; + const albumId = 'album1'; + + await createUser(userId, 'Test User'); + await createAlbum(albumId, userId, 'Album 1'); + await createAsset('asset1', userId, DateTime(2024, 1, 1)); + await linkAssetToAlbum(albumId, 'asset1'); + + final result = await repository.getSortedAlbumIds([albumId], aggregation: AssetDateAggregation.start); + + expect(result, [albumId]); + }); + + test('sorts albums by start date (MIN) ascending', () async { + const userId = 'user1'; + + await createUser(userId, 'Test User'); + + // Album 1: Assets from Jan 10 to Jan 20 (start: Jan 10) + await createAlbum('album1', userId, 'Album 1'); + await createAsset('asset1', userId, DateTime(2024, 1, 10)); + await createAsset('asset2', userId, DateTime(2024, 1, 20)); + await linkAssetToAlbum('album1', 'asset1'); + await linkAssetToAlbum('album1', 'asset2'); + + // Album 2: Assets from Jan 5 to Jan 15 (start: Jan 5) + await createAlbum('album2', userId, 'Album 2'); + await createAsset('asset3', userId, DateTime(2024, 1, 5)); + await createAsset('asset4', userId, DateTime(2024, 1, 15)); + await linkAssetToAlbum('album2', 'asset3'); + await linkAssetToAlbum('album2', 'asset4'); + + // Album 3: Assets from Jan 25 to Jan 30 (start: Jan 25) + await createAlbum('album3', userId, 'Album 3'); + await createAsset('asset5', userId, DateTime(2024, 1, 25)); + await createAsset('asset6', userId, DateTime(2024, 1, 30)); + await linkAssetToAlbum('album3', 'asset5'); + await linkAssetToAlbum('album3', 'asset6'); + + final result = await repository.getSortedAlbumIds([ + 'album1', + 'album2', + 'album3', + ], aggregation: AssetDateAggregation.start); + + // Expected order: album2 (Jan 5), album1 (Jan 10), album3 (Jan 25) + expect(result, ['album2', 'album1', 'album3']); + }); + + test('sorts albums by end date (MAX) ascending', () async { + const userId = 'user1'; + + await createUser(userId, 'Test User'); + + // Album 1: Assets from Jan 10 to Jan 20 (end: Jan 20) + await createAlbum('album1', userId, 'Album 1'); + await createAsset('asset1', userId, DateTime(2024, 1, 10)); + await createAsset('asset2', userId, DateTime(2024, 1, 20)); + await linkAssetToAlbum('album1', 'asset1'); + await linkAssetToAlbum('album1', 'asset2'); + + // Album 2: Assets from Jan 5 to Jan 15 (end: Jan 15) + await createAlbum('album2', userId, 'Album 2'); + await createAsset('asset3', userId, DateTime(2024, 1, 5)); + await createAsset('asset4', userId, DateTime(2024, 1, 15)); + await linkAssetToAlbum('album2', 'asset3'); + await linkAssetToAlbum('album2', 'asset4'); + + // Album 3: Assets from Jan 25 to Jan 30 (end: Jan 30) + await createAlbum('album3', userId, 'Album 3'); + await createAsset('asset5', userId, DateTime(2024, 1, 25)); + await createAsset('asset6', userId, DateTime(2024, 1, 30)); + await linkAssetToAlbum('album3', 'asset5'); + await linkAssetToAlbum('album3', 'asset6'); + + final result = await repository.getSortedAlbumIds([ + 'album1', + 'album2', + 'album3', + ], aggregation: AssetDateAggregation.end); + + // Expected order: album2 (Jan 15), album1 (Jan 20), album3 (Jan 30) + expect(result, ['album2', 'album1', 'album3']); + }); + + test('handles albums with single asset', () async { + const userId = 'user1'; + + await createUser(userId, 'Test User'); + + await createAlbum('album1', userId, 'Album 1'); + await createAsset('asset1', userId, DateTime(2024, 1, 15)); + await linkAssetToAlbum('album1', 'asset1'); + + await createAlbum('album2', userId, 'Album 2'); + await createAsset('asset2', userId, DateTime(2024, 1, 10)); + await linkAssetToAlbum('album2', 'asset2'); + + final result = await repository.getSortedAlbumIds(['album1', 'album2'], aggregation: AssetDateAggregation.start); + + expect(result, ['album2', 'album1']); + }); + + test('only returns requested album IDs in the result', () async { + const userId = 'user1'; + + await createUser(userId, 'Test User'); + + // Create 3 albums + await createAlbum('album1', userId, 'Album 1'); + await createAsset('asset1', userId, DateTime(2024, 1, 10)); + await linkAssetToAlbum('album1', 'asset1'); + + await createAlbum('album2', userId, 'Album 2'); + await createAsset('asset2', userId, DateTime(2024, 1, 5)); + await linkAssetToAlbum('album2', 'asset2'); + + await createAlbum('album3', userId, 'Album 3'); + await createAsset('asset3', userId, DateTime(2024, 1, 15)); + await linkAssetToAlbum('album3', 'asset3'); + + // Only request album1 and album3 + final result = await repository.getSortedAlbumIds(['album1', 'album3'], aggregation: AssetDateAggregation.start); + + // Should only return album1 and album3, not album2 + expect(result, ['album1', 'album3']); + }); + + test('handles albums with same date correctly', () async { + const userId = 'user1'; + + await createUser(userId, 'Test User'); + + final sameDate = DateTime(2024, 1, 10); + + await createAlbum('album1', userId, 'Album 1'); + await createAsset('asset1', userId, sameDate); + await linkAssetToAlbum('album1', 'asset1'); + + await createAlbum('album2', userId, 'Album 2'); + await createAsset('asset2', userId, sameDate); + await linkAssetToAlbum('album2', 'asset2'); + + final result = await repository.getSortedAlbumIds(['album1', 'album2'], aggregation: AssetDateAggregation.start); + + // Both albums have the same date, so both should be returned + expect(result, hasLength(2)); + expect(result, containsAll(['album1', 'album2'])); + }); + + test('handles albums across different years', () async { + const userId = 'user1'; + + await createUser(userId, 'Test User'); + + await createAlbum('album1', userId, 'Album 1'); + await createAsset('asset1', userId, DateTime(2023, 12, 25)); + await linkAssetToAlbum('album1', 'asset1'); + + await createAlbum('album2', userId, 'Album 2'); + await createAsset('asset2', userId, DateTime(2024, 1, 5)); + await linkAssetToAlbum('album2', 'asset2'); + + await createAlbum('album3', userId, 'Album 3'); + await createAsset('asset3', userId, DateTime(2025, 1, 1)); + await linkAssetToAlbum('album3', 'asset3'); + + final result = await repository.getSortedAlbumIds([ + 'album1', + 'album2', + 'album3', + ], aggregation: AssetDateAggregation.start); + + expect(result, ['album1', 'album2', 'album3']); + }); + + test('handles album with multiple assets correctly', () async { + const userId = 'user1'; + + await createUser(userId, 'Test User'); + + await createAlbum('album1', userId, 'Album 1'); + // Album 1 has 5 assets from Jan 5 to Jan 25 + await createAsset('asset1', userId, DateTime(2024, 1, 5)); + await createAsset('asset2', userId, DateTime(2024, 1, 10)); + await createAsset('asset3', userId, DateTime(2024, 1, 15)); + await createAsset('asset4', userId, DateTime(2024, 1, 20)); + await createAsset('asset5', userId, DateTime(2024, 1, 25)); + await linkAssetToAlbum('album1', 'asset1'); + await linkAssetToAlbum('album1', 'asset2'); + await linkAssetToAlbum('album1', 'asset3'); + await linkAssetToAlbum('album1', 'asset4'); + await linkAssetToAlbum('album1', 'asset5'); + + await createAlbum('album2', userId, 'Album 2'); + await createAsset('asset6', userId, DateTime(2024, 1, 1)); + await linkAssetToAlbum('album2', 'asset6'); + + final resultStart = await repository.getSortedAlbumIds([ + 'album1', + 'album2', + ], aggregation: AssetDateAggregation.start); + + // album2 (Jan 1) should come before album1 (Jan 5) + expect(resultStart, ['album2', 'album1']); + + final resultEnd = await repository.getSortedAlbumIds(['album1', 'album2'], aggregation: AssetDateAggregation.end); + + // album2 (Jan 1) should come before album1 (Jan 25) + expect(resultEnd, ['album2', 'album1']); + }); + }); +}