fix(mobile): sort timeline assets by the date their headers group on

This commit is contained in:
Santo Shakil
2026-07-27 13:17:29 +06:00
parent 3606144190
commit 59a7963307
3 changed files with 201 additions and 23 deletions
@@ -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):
+4 -1
View File
@@ -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<String>($),
...generatedlimit.introducedVariables,
@@ -69,6 +69,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
isEdited: row.read<bool>('is_edited'),
playbackStyle: row.read<int>('playback_style'),
uploadedAt: row.readNullable<DateTime>('uploaded_at'),
groupDate: row.read<String>('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,
});
}
@@ -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<void> 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<void> 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<List<(String, String)>> 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<void> 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')]);
});
}