diff --git a/mobile/lib/infrastructure/repositories/remote_album.repository.dart b/mobile/lib/infrastructure/repositories/remote_album.repository.dart index b9cd41c960..6a1a272d35 100644 --- a/mobile/lib/infrastructure/repositories/remote_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_album.repository.dart @@ -34,7 +34,8 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository { leftOuterJoin( _db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId) & - _db.remoteAssetEntity.deletedAt.isNull(), + _db.remoteAssetEntity.deletedAt.isNull() & + _db.remoteAssetEntity.visibility.isIn([AssetVisibility.archive.index, AssetVisibility.timeline.index]), useColumns: false, ), leftOuterJoin( @@ -94,7 +95,11 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository { leftOuterJoin( _db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId) & - _db.remoteAssetEntity.deletedAt.isNull(), + _db.remoteAssetEntity.deletedAt.isNull() & + _db.remoteAssetEntity.visibility.isIn([ + AssetVisibility.archive.index, + AssetVisibility.timeline.index, + ]), useColumns: false, ), leftOuterJoin( @@ -218,16 +223,22 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository { } Stream<(DateTime, DateTime)> watchDateRange(String albumId) { + final dateExp = coalesce([_db.remoteAssetEntity.localDateTime, _db.remoteAssetEntity.createdAt]); final query = _db.remoteAlbumAssetEntity.selectOnly() ..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId)) - ..addColumns([_db.remoteAssetEntity.createdAt.min(), _db.remoteAssetEntity.createdAt.max()]) + ..addColumns([dateExp.min(), dateExp.max()]) ..join([ - innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)), + innerJoin( + _db.remoteAssetEntity, + _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId) & + _db.remoteAssetEntity.deletedAt.isNull() & + _db.remoteAssetEntity.visibility.isIn([AssetVisibility.archive.index, AssetVisibility.timeline.index]), + ), ]); return query.map((row) { - final minDate = row.read(_db.remoteAssetEntity.createdAt.min()); - final maxDate = row.read(_db.remoteAssetEntity.createdAt.max()); + final minDate = row.read(dateExp.min()); + final maxDate = row.read(dateExp.max()); return (minDate ?? DateTime.now(), maxDate ?? DateTime.now()); }).watchSingle(); } @@ -419,16 +430,22 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository { ''' SELECT raae.album_id, - $sqlAgg(rae.local_date_time) AS asset_date + $sqlAgg(COALESCE(rae.local_date_time, rae.created_at)) 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 + WHERE rae.deleted_at IS NULL + AND rae.visibility IN (?, ?) GROUP BY raae.album_id ORDER BY asset_date ASC ''', - variables: [Variable(jsonIds)], + variables: [ + Variable(jsonIds), + Variable(AssetVisibility.timeline.index), + Variable(AssetVisibility.archive.index), + ], readsFrom: {_db.remoteAlbumAssetEntity, _db.remoteAssetEntity}, ) .get(); @@ -530,7 +547,11 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository { leftOuterJoin( _db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId) & - _db.remoteAssetEntity.deletedAt.isNull(), + _db.remoteAssetEntity.deletedAt.isNull() & + _db.remoteAssetEntity.visibility.isIn([ + AssetVisibility.archive.index, + AssetVisibility.timeline.index, + ]), useColumns: false, ), leftOuterJoin( diff --git a/mobile/test/medium/repositories/remote_album_repository_test.dart b/mobile/test/medium/repositories/remote_album_repository_test.dart index 5a82ff315f..8ed3c75d14 100644 --- a/mobile/test/medium/repositories/remote_album_repository_test.dart +++ b/mobile/test/medium/repositories/remote_album_repository_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; import '../repository_context.dart'; @@ -86,6 +87,24 @@ void main() { expect(albums.first.id, album.id); expect(albums.first.assetCount, 0); }); + + test('excludes non-default visibility assets (hidden/locked) from assetCount', () async { + final user = await ctx.newUser(); + final album = await ctx.newRemoteAlbum(ownerId: user.id); + final active = await ctx.newRemoteAsset(ownerId: user.id, visibility: AssetVisibility.timeline); + final archived = await ctx.newRemoteAsset(ownerId: user.id, visibility: AssetVisibility.archive); + final hidden = await ctx.newRemoteAsset(ownerId: user.id, visibility: AssetVisibility.hidden); + final locked = await ctx.newRemoteAsset(ownerId: user.id, visibility: AssetVisibility.locked); + await ctx.newRemoteAlbumAsset(albumId: album.id, assetId: active.id); + await ctx.newRemoteAlbumAsset(albumId: album.id, assetId: archived.id); + await ctx.newRemoteAlbumAsset(albumId: album.id, assetId: hidden.id); + await ctx.newRemoteAlbumAsset(albumId: album.id, assetId: locked.id); + + final albums = await sut.getAll(); + + expect(albums, hasLength(1)); + expect(albums.first.assetCount, 2); + }); }); group('get', () { @@ -101,6 +120,20 @@ void main() { expect(result?.id, album.id); expect(result?.assetCount, 0); }); + + test('excludes non-default visibility assets (hidden/locked) from assetCount', () async { + final user = await ctx.newUser(); + final album = await ctx.newRemoteAlbum(ownerId: user.id); + final active = await ctx.newRemoteAsset(ownerId: user.id, visibility: AssetVisibility.timeline); + final hidden = await ctx.newRemoteAsset(ownerId: user.id, visibility: AssetVisibility.hidden); + await ctx.newRemoteAlbumAsset(albumId: album.id, assetId: active.id); + await ctx.newRemoteAlbumAsset(albumId: album.id, assetId: hidden.id); + + final result = await sut.get(album.id); + + expect(result, isNotNull); + expect(result?.assetCount, 1); + }); }); group('getAlbumsContainingAsset', () { @@ -130,6 +163,87 @@ void main() { expect(albums, hasLength(1)); expect(albums.first.assetCount, 0); }); + + test('excludes non-default visibility assets from assetCount', () async { + final user = await ctx.newUser(); + final album = await ctx.newRemoteAlbum(ownerId: user.id); + final active = await ctx.newRemoteAsset(ownerId: user.id); + final hidden = await ctx.newRemoteAsset(ownerId: user.id, visibility: AssetVisibility.hidden); + await ctx.newRemoteAlbumAsset(albumId: album.id, assetId: active.id); + await ctx.newRemoteAlbumAsset(albumId: album.id, assetId: hidden.id); + + final albums = await sut.getAlbumsContainingAsset(active.id); + + expect(albums, hasLength(1)); + expect(albums.first.assetCount, 1); + }); + }); + + group('watchDateRange', () { + test('excludes trashed and hidden/locked assets from date range calculation', () async { + final user = await ctx.newUser(); + final album = await ctx.newRemoteAlbum(ownerId: user.id); + + final trashed = await ctx.newRemoteAsset( + ownerId: user.id, + createdAt: DateTime(2023, 1, 1), + deletedAt: DateTime(2025, 1, 1), + ); + final active1 = await ctx.newRemoteAsset( + ownerId: user.id, + createdAt: DateTime(2024, 5, 10), + localDateTime: DateTime(2024, 5, 1), + visibility: AssetVisibility.timeline, + ); + final active2 = await ctx.newRemoteAsset( + ownerId: user.id, + createdAt: DateTime(2024, 10, 10), + localDateTime: DateTime(2024, 10, 1), + visibility: AssetVisibility.archive, + ); + final hidden = await ctx.newRemoteAsset( + ownerId: user.id, + createdAt: DateTime(2024, 12, 1), + visibility: AssetVisibility.hidden, + ); + + await ctx.newRemoteAlbumAsset(albumId: album.id, assetId: trashed.id); + await ctx.newRemoteAlbumAsset(albumId: album.id, assetId: active1.id); + await ctx.newRemoteAlbumAsset(albumId: album.id, assetId: active2.id); + await ctx.newRemoteAlbumAsset(albumId: album.id, assetId: hidden.id); + + final range = await sut.watchDateRange(album.id).first; + + expect(range.$1.toUtc(), active1.localDateTime!.toUtc()); + expect(range.$2.toUtc(), active2.localDateTime!.toUtc()); + }); + + test('prioritizes localDateTime over createdAt for date range calculation', () async { + final user = await ctx.newUser(); + final album = await ctx.newRemoteAlbum(ownerId: user.id); + + final asset1 = await ctx.newRemoteAsset( + ownerId: user.id, + createdAt: DateTime(2024, 5, 10), + localDateTime: DateTime(2024, 5, 1), + visibility: AssetVisibility.timeline, + ); + final asset2 = await ctx.newRemoteAsset( + ownerId: user.id, + createdAt: DateTime(2024, 10, 10), + localDateTime: DateTime(2024, 10, 1), + visibility: AssetVisibility.archive, + ); + + await ctx.newRemoteAlbumAsset(albumId: album.id, assetId: asset1.id); + await ctx.newRemoteAlbumAsset(albumId: album.id, assetId: asset2.id); + + final range = await sut.watchDateRange(album.id).first; + + // Dates should match localDateTime, not createdAt + expect(range.$1.toUtc(), DateTime(2024, 5, 1).toUtc()); + expect(range.$2.toUtc(), DateTime(2024, 10, 1).toUtc()); + }); }); group('getSortedAlbumIds', () { @@ -321,5 +435,51 @@ void main() { // album2 (Jan 1) should come before album1 (Jan 25) expect(resultEnd, [album2.id, album1.id]); }); + + test('ignores trashed assets when sorting albums by date', () async { + // Album 1: one active asset at Jan 10, one trashed asset at Jan 1 (older) + final album1 = await ctx.newRemoteAlbum(ownerId: userId); + final trashedEarly = await ctx.newRemoteAsset( + ownerId: userId, + createdAt: DateTime(2024, 1, 1), + deletedAt: DateTime(2025, 1, 1), + ); + final active = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 10)); + await ctx.newRemoteAlbumAsset(albumId: album1.id, assetId: trashedEarly.id); + await ctx.newRemoteAlbumAsset(albumId: album1.id, assetId: active.id); + + // Album 2: one active asset at Jan 5 (earlier than album1's active asset) + final album2 = await ctx.newRemoteAlbum(ownerId: userId); + final active2 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 5)); + await ctx.newRemoteAlbumAsset(albumId: album2.id, assetId: active2.id); + + final result = await sut.getSortedAlbumIds([album1.id, album2.id], aggregation: AssetDateAggregation.start); + + // album2 (Jan 5) should come before album1 (Jan 10, ignoring trashed Jan 1) + expect(result, [album2.id, album1.id]); + }); + + test('ignores hidden and locked assets when sorting albums by date', () async { + // Album 1: active asset at Jan 20, hidden asset at Jan 1 (should be ignored) + final album1 = await ctx.newRemoteAlbum(ownerId: userId); + final hiddenEarly = await ctx.newRemoteAsset( + ownerId: userId, + createdAt: DateTime(2024, 1, 1), + visibility: AssetVisibility.hidden, + ); + final activeAlbum1 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 20)); + await ctx.newRemoteAlbumAsset(albumId: album1.id, assetId: hiddenEarly.id); + await ctx.newRemoteAlbumAsset(albumId: album1.id, assetId: activeAlbum1.id); + + // Album 2: one active asset at Jan 10 (earlier than album1's visible start) + final album2 = await ctx.newRemoteAlbum(ownerId: userId); + final activeAlbum2 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 10)); + await ctx.newRemoteAlbumAsset(albumId: album2.id, assetId: activeAlbum2.id); + + final result = await sut.getSortedAlbumIds([album1.id, album2.id], aggregation: AssetDateAggregation.start); + + // album2 (Jan 10) before album1 (Jan 20, ignoring hidden Jan 1) + expect(result, [album2.id, album1.id]); + }); }); } diff --git a/mobile/test/medium/repository_context.dart b/mobile/test/medium/repository_context.dart index 09b8e2c7eb..392bab45a6 100644 --- a/mobile/test/medium/repository_context.dart +++ b/mobile/test/medium/repository_context.dart @@ -121,6 +121,7 @@ class MediumRepositoryContext { String? stackId, String? thumbHash, String? libraryId, + DateTime? localDateTime, }) async { id ??= TestUtils.uuid(); createdAt ??= TestUtils.date(); @@ -128,25 +129,25 @@ class MediumRepositoryContext { .into(db.remoteAssetEntity) .insertReturning( RemoteAssetEntityCompanion( - id: .new(id), - name: .new('remote_$id.jpg'), - checksum: .new(TestUtils.uuid(checksum)), - type: .new(type ?? .image), - createdAt: .new(createdAt), - updatedAt: .new(TestUtils.date(updatedAt)), - ownerId: .new(TestUtils.uuid(ownerId)), - visibility: .new(visibility ?? .timeline), - deletedAt: .new(deletedAt), - durationMs: .new(durationMs ?? 0), - width: .new(width ?? TestUtils.randInt(1000)), - height: .new(height ?? TestUtils.randInt(1000)), - isFavorite: .new(isFavorite ?? false), - isEdited: .new(isEdited ?? false), - livePhotoVideoId: .new(livePhotoVideoId), - stackId: .new(stackId), - localDateTime: .new(createdAt.toLocal()), - thumbHash: .new(TestUtils.uuid(thumbHash)), - libraryId: .new(TestUtils.uuid(libraryId)), + id: Value(id), + name: Value('remote_$id.jpg'), + checksum: Value(TestUtils.uuid(checksum)), + type: Value(type ?? AssetType.image), + createdAt: Value(createdAt), + updatedAt: Value(TestUtils.date(updatedAt)), + ownerId: Value(TestUtils.uuid(ownerId)), + visibility: Value(visibility ?? AssetVisibility.timeline), + deletedAt: Value(deletedAt), + durationMs: Value(durationMs ?? 0), + width: Value(width ?? TestUtils.randInt(1000)), + height: Value(height ?? TestUtils.randInt(1000)), + isFavorite: Value(isFavorite ?? false), + isEdited: Value(isEdited ?? false), + livePhotoVideoId: Value(livePhotoVideoId), + stackId: Value(stackId), + localDateTime: Value(localDateTime ?? createdAt.toLocal()), + thumbHash: Value(TestUtils.uuid(thumbHash)), + libraryId: Value(TestUtils.uuid(libraryId)), ), ); }