diff --git a/mobile/lib/infrastructure/entities/merged_asset.drift b/mobile/lib/infrastructure/entities/merged_asset.drift index d0321ab1ef..bfcad0cd52 100644 --- a/mobile/lib/infrastructure/entities/merged_asset.drift +++ b/mobile/lib/infrastructure/entities/merged_asset.drift @@ -28,7 +28,11 @@ SELECT NULL as adjustmentTime, rae.is_edited, 0 as playback_style, - rae.uploaded_at + rae.uploaded_at, + COALESCE( + STRFTIME('%Y-%m-%d %H:%M:%f', rae.local_date_time), + STRFTIME('%Y-%m-%d %H:%M:%f', rae.created_at, 'localtime') + ) as group_date FROM remote_asset_entity rae LEFT JOIN @@ -67,7 +71,8 @@ SELECT lae.adjustment_time, 0 as is_edited, lae.playback_style, - NULL as uploaded_at + NULL as uploaded_at, + STRFTIME('%Y-%m-%d %H:%M:%f', lae.created_at, 'localtime') as group_date FROM local_asset_entity lae WHERE NOT EXISTS ( @@ -83,7 +88,7 @@ AND NOT EXISTS ( INNER JOIN local_album_entity la on laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2 -- excluded ) -ORDER BY created_at DESC +ORDER BY group_date DESC LIMIT $limit; mergedBucket(:group_by AS INTEGER): diff --git a/mobile/lib/infrastructure/entities/merged_asset.drift.dart b/mobile/lib/infrastructure/entities/merged_asset.drift.dart index 2d05ef6ceb..d06ff5c4ae 100644 --- a/mobile/lib/infrastructure/entities/merged_asset.drift.dart +++ b/mobile/lib/infrastructure/entities/merged_asset.drift.dart @@ -29,7 +29,7 @@ class MergedAssetDrift extends i1.ModularAccessor { ); $arrayStartIndex += generatedlimit.amountOfVariables; return customSelect( - 'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_ms, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited, 0 AS playback_style, rae.uploaded_at FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_ms, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited, lae.playback_style, NULL AS uploaded_at FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) ORDER BY created_at DESC ${generatedlimit.sql}', + 'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_ms, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited, 0 AS playback_style, rae.uploaded_at, COALESCE(STRFTIME(\'%Y-%m-%d %H:%M:%f\', rae.local_date_time), STRFTIME(\'%Y-%m-%d %H:%M:%f\', rae.created_at, \'localtime\')) AS group_date FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_ms, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited, lae.playback_style, NULL AS uploaded_at, STRFTIME(\'%Y-%m-%d %H:%M:%f\', lae.created_at, \'localtime\') AS group_date FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) ORDER BY group_date DESC ${generatedlimit.sql}', variables: [ for (var $ in userIds) i0.Variable($), ...generatedlimit.introducedVariables, @@ -69,6 +69,7 @@ class MergedAssetDrift extends i1.ModularAccessor { isEdited: row.read('is_edited'), playbackStyle: row.read('playback_style'), uploadedAt: row.readNullable('uploaded_at'), + groupDate: row.read('group_date'), ), ); } @@ -143,6 +144,7 @@ class MergedAssetResult { final bool isEdited; final int playbackStyle; final DateTime? uploadedAt; + final String groupDate; MergedAssetResult({ this.remoteId, this.localId, @@ -167,6 +169,7 @@ class MergedAssetResult { required this.isEdited, required this.playbackStyle, this.uploadedAt, + required this.groupDate, }); } diff --git a/mobile/test/infrastructure/repositories/merged_asset_drift_test.dart b/mobile/test/infrastructure/repositories/merged_asset_drift_test.dart index a5fe6f35c4..1aa16aa428 100644 --- a/mobile/test/infrastructure/repositories/merged_asset_drift_test.dart +++ b/mobile/test/infrastructure/repositories/merged_asset_drift_test.dart @@ -1,52 +1,222 @@ import 'package:drift/drift.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/local_asset.entity.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'; +const _userId = 'user-1'; +const _albumId = 'album-1'; + void main() { late Drift db; - setUp(() { + setUp(() async { db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); + await db + .into(db.userEntity) + .insert(UserEntityCompanion.insert(id: _userId, email: 'user-1@test.dev', name: 'User 1')); + await db + .into(db.localAlbumEntity) + .insert( + LocalAlbumEntityCompanion.insert(id: _albumId, name: 'Camera', backupSelection: BackupSelection.selected), + ); }); tearDown(() async { await db.close(); }); - test('mergedBucket falls back to createdAt when localDateTime is null', () async { - const userId = 'user-1'; - final createdAt = DateTime(2024, 1, 1, 12); + Future insertRemote(String id, {required DateTime createdAt, DateTime? localDateTime}) => db + .into(db.remoteAssetEntity) + .insert( + RemoteAssetEntityCompanion.insert( + id: id, + name: '$id.jpg', + type: AssetType.image, + checksum: 'checksum-$id', + ownerId: _userId, + visibility: AssetVisibility.timeline, + createdAt: Value(createdAt), + updatedAt: Value(createdAt), + uploadedAt: Value(createdAt), + localDateTime: Value(localDateTime), + ), + ); + Future insertLocal(String id, {required DateTime createdAt}) async { await db - .into(db.userEntity) - .insert(UserEntityCompanion.insert(id: userId, email: 'user-1@test.dev', name: 'User 1')); - - await db - .into(db.remoteAssetEntity) + .into(db.localAssetEntity) .insert( - RemoteAssetEntityCompanion.insert( - id: 'asset-1', - name: 'asset-1.jpg', + LocalAssetEntityCompanion.insert( + id: id, + name: '$id.jpg', type: AssetType.image, - checksum: 'checksum-1', - ownerId: userId, - visibility: AssetVisibility.timeline, + checksum: Value('checksum-$id'), createdAt: Value(createdAt), updatedAt: Value(createdAt), - uploadedAt: Value(createdAt), - localDateTime: const Value(null), ), ); + await db + .into(db.localAlbumAssetEntity) + .insert(LocalAlbumAssetEntityCompanion.insert(assetId: id, albumId: _albumId)); + } - final buckets = await db.mergedAssetDrift.mergedBucket(groupBy: GroupAssetsBy.day.index, userIds: [userId]).get(); + // Mirrors how the timeline pairs headers with tiles: buckets only carry a count, the assets + // come from one flat list that is addressed by the running offset of the previous buckets. + Future> headerForEachAsset(GroupAssetsBy groupBy) async { + final buckets = await db.mergedAssetDrift.mergedBucket(groupBy: groupBy.index, userIds: [_userId]).get(); + final assets = await db.mergedAssetDrift.mergedAsset(userIds: [_userId], limit: (_) => Limit(1000, 0)).get(); + + final pairs = <(String, String)>[]; + var offset = 0; + for (final bucket in buckets) { + for (final asset in assets.skip(offset).take(bucket.assetCount)) { + pairs.add(((asset.remoteId ?? asset.localId)!, bucket.bucketDate)); + } + offset += bucket.assetCount; + } + return pairs; + } + + test('mergedBucket falls back to createdAt when localDateTime is null', () async { + await insertRemote('asset-1', createdAt: DateTime(2024, 1, 1, 12)); + + final buckets = await db.mergedAssetDrift.mergedBucket(groupBy: GroupAssetsBy.day.index, userIds: [_userId]).get(); expect(buckets, hasLength(1)); expect(buckets.single.assetCount, 1); - expect(buckets.single.bucketDate, isNotEmpty); + expect(buckets.single.bucketDate, '2024-01-01'); + }); + + // Regression for #29864: buckets group by localDateTime but assets were ordered by createdAt, + // so a header minted from one asset's localDateTime was rendered above a different asset. + group('mergedAsset ordering matches mergedBucket grouping', () { + Future seedGhost() async { + await insertRemote('asset-a', createdAt: DateTime(2026, 4, 26, 10)); + await insertRemote('asset-b', createdAt: DateTime(2026, 4, 26, 12)); + await insertRemote('ghost', createdAt: DateTime(2026, 4, 26, 11), localDateTime: DateTime.utc(2027, 3, 4, 12)); + } + + test('asset under each header belongs to that header date', () async { + await seedGhost(); + + expect(await headerForEachAsset(GroupAssetsBy.day), [ + ('ghost', '2027-03-04'), + ('asset-b', '2026-04-26'), + ('asset-a', '2026-04-26'), + ]); + }); + + test('deleting the asset under a header removes the empty header', () async { + await seedGhost(); + + await db.remoteAssetEntity.deleteWhere((row) => row.id.equals('ghost')); + + final buckets = await db.mergedAssetDrift + .mergedBucket(groupBy: GroupAssetsBy.day.index, userIds: [_userId]) + .get(); + expect(buckets.map((b) => b.bucketDate), ['2026-04-26']); + expect(buckets.single.assetCount, 2); + + expect(await headerForEachAsset(GroupAssetsBy.day), [('asset-b', '2026-04-26'), ('asset-a', '2026-04-26')]); + }); + + test('orders by createdAt when localDateTime is null', () async { + await insertRemote('asset-a', createdAt: DateTime(2026, 4, 26, 10)); + await insertRemote('asset-b', createdAt: DateTime(2026, 4, 26, 12)); + + final assets = await db.mergedAssetDrift.mergedAsset(userIds: [_userId], limit: (_) => Limit(2, 0)).get(); + expect(assets.map((a) => a.remoteId), ['asset-b', 'asset-a']); + }); + }); + + test('assets taken in different timezones stay under their own day header', () async { + // taken 2024-04-27 08:00 in UTC+9 + await insertRemote('tokyo', createdAt: DateTime.utc(2024, 4, 26, 23), localDateTime: DateTime.utc(2024, 4, 27, 8)); + // taken 2024-04-26 20:00 in UTC-5, uploaded from the same trip + await insertRemote( + 'new-york', + createdAt: DateTime.utc(2024, 4, 27, 1), + localDateTime: DateTime.utc(2024, 4, 26, 20), + ); + + expect(await headerForEachAsset(GroupAssetsBy.day), [('tokyo', '2024-04-27'), ('new-york', '2024-04-26')]); + }); + + test('deleting an asset does not pull the next one under a header that is not its own', () async { + await insertRemote('tokyo', createdAt: DateTime.utc(2024, 4, 26, 23), localDateTime: DateTime.utc(2024, 4, 27, 8)); + await insertRemote( + 'new-york-late', + createdAt: DateTime.utc(2024, 4, 27, 1), + localDateTime: DateTime.utc(2024, 4, 26, 20), + ); + await insertRemote( + 'new-york-early', + createdAt: DateTime.utc(2024, 4, 27), + localDateTime: DateTime.utc(2024, 4, 26, 18), + ); + + await db.remoteAssetEntity.deleteWhere((row) => row.id.equals('new-york-late')); + + expect(await headerForEachAsset(GroupAssetsBy.day), [('tokyo', '2024-04-27'), ('new-york-early', '2024-04-26')]); + }); + + test('remote assets without a localDateTime keep their place among the others', () async { + await insertRemote('newest', createdAt: DateTime.utc(2024, 4, 26, 3), localDateTime: DateTime.utc(2024, 4, 26, 10)); + await insertRemote('no-local-date', createdAt: DateTime(2024, 4, 24, 9)); + await insertRemote('oldest', createdAt: DateTime.utc(2024, 4, 20, 3), localDateTime: DateTime.utc(2024, 4, 20, 10)); + + expect(await headerForEachAsset(GroupAssetsBy.day), [ + ('newest', '2024-04-26'), + ('no-local-date', '2024-04-24'), + ('oldest', '2024-04-20'), + ]); + }); + + test('device only assets are interleaved with remote assets by the date shown', () async { + await insertRemote('remote', createdAt: DateTime.utc(2024, 4, 26, 3), localDateTime: DateTime.utc(2024, 4, 26, 10)); + await insertLocal('device', createdAt: DateTime(2024, 4, 26, 14)); + await insertRemote('older', createdAt: DateTime.utc(2024, 4, 25, 3), localDateTime: DateTime.utc(2024, 4, 25, 10)); + + expect(await headerForEachAsset(GroupAssetsBy.day), [ + ('device', '2024-04-26'), + ('remote', '2024-04-26'), + ('older', '2024-04-25'), + ]); + }); + + test('local evening photo sorts with remote same-day rows by shown time', () async { + // local wall time, mirrors a UTC+7 evening shot among synced rows from the same day + await insertLocal('local-evening', createdAt: DateTime(2026, 7, 24, 23, 30)); + await insertRemote( + 'remote-evening', + createdAt: DateTime.utc(2026, 7, 24, 15), + localDateTime: DateTime.utc(2026, 7, 24, 22), + ); + await insertRemote( + 'remote-morning', + createdAt: DateTime.utc(2026, 7, 24, 2), + localDateTime: DateTime.utc(2026, 7, 24, 9), + ); + + expect(await headerForEachAsset(GroupAssetsBy.day), [ + ('local-evening', '2026-07-24'), + ('remote-evening', '2026-07-24'), + ('remote-morning', '2026-07-24'), + ]); + }); + + test('month grouping keeps every asset under its own month header', () async { + await insertRemote('may', createdAt: DateTime.utc(2024, 4, 30, 23), localDateTime: DateTime.utc(2024, 5, 1, 8)); + await insertRemote('april', createdAt: DateTime.utc(2024, 5, 1, 1), localDateTime: DateTime.utc(2024, 4, 30, 20)); + + expect(await headerForEachAsset(GroupAssetsBy.month), [('may', '2024-05'), ('april', '2024-04')]); }); }