fix: use proper updatedAt value in local assets (#24137)

* fix: incorrect updatedAt value in local assets

* add test

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
feat/sync-adjustment-time
shenlong 2025-11-24 21:19:27 +07:00 committed by GitHub
parent aecf064ec9
commit 24e5dabb51
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 68 additions and 13 deletions

@ -363,14 +363,14 @@ extension on Iterable<PlatformAsset> {
}
}
extension on PlatformAsset {
extension PlatformToLocalAsset on PlatformAsset {
LocalAsset toLocalAsset() => LocalAsset(
id: id,
name: name,
checksum: null,
type: AssetType.values.elementAtOrNull(type) ?? AssetType.other,
createdAt: tryFromSecondsSinceEpoch(createdAt, isUtc: true) ?? DateTime.timestamp(),
updatedAt: tryFromSecondsSinceEpoch(createdAt, isUtc: true) ?? DateTime.timestamp(),
updatedAt: tryFromSecondsSinceEpoch(updatedAt, isUtc: true) ?? DateTime.timestamp(),
width: width,
height: height,
durationInSeconds: durationInSeconds,

@ -22,14 +22,16 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/datetime_helpers.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart';
// ignore: import_rule_photo_manager
import 'package:photo_manager/photo_manager.dart';
const int targetVersion = 18;
const int targetVersion = 19;
Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
final hasVersion = Store.tryGet(StoreKey.version) != null;
@ -78,6 +80,12 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
await Store.put(StoreKey.shouldResetSync, true);
}
if (version < 19 && Store.isBetaTimelineEnabled) {
if (!await _populateUpdatedAtTime(drift)) {
return;
}
}
if (targetVersion >= 12) {
await Store.put(StoreKey.version, targetVersion);
return;
@ -221,6 +229,32 @@ Future<void> _migrateDeviceAsset(Isar db) async {
});
}
Future<bool> _populateUpdatedAtTime(Drift db) async {
try {
final nativeApi = NativeSyncApi();
final albums = await nativeApi.getAlbums();
for (final album in albums) {
final assets = await nativeApi.getAssetsForAlbum(album.id);
await db.batch((batch) async {
for (final asset in assets) {
batch.update(
db.localAssetEntity,
LocalAssetEntityCompanion(
updatedAt: Value(tryFromSecondsSinceEpoch(asset.updatedAt, isUtc: true) ?? DateTime.timestamp()),
),
where: (t) => t.id.equals(asset.id),
);
}
});
}
return true;
} catch (error) {
dPrint(() => "[MIGRATION] Error while populating updatedAt time: $error");
return false;
}
}
Future<void> migrateDeviceAssetToSqlite(Isar db, Drift drift) async {
try {
final isarDeviceAssets = await db.deviceAssetEntitys.where().findAll();

@ -54,12 +54,7 @@ void main() {
when(() => mockNativeSyncApi.shouldFullSync()).thenAnswer((_) async => false);
when(() => mockNativeSyncApi.getMediaChanges()).thenAnswer(
(_) async => SyncDelta(
hasChanges: false,
updates: const [],
deletes: const [],
assetAlbums: const {},
),
(_) async => SyncDelta(hasChanges: false, updates: const [], deletes: const [], assetAlbums: const {}),
);
when(() => mockNativeSyncApi.getTrashedAssets()).thenAnswer((_) async => {});
when(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).thenAnswer((_) async {});
@ -144,13 +139,19 @@ void main() {
});
final localAssetToTrash = LocalAssetStub.image2.copyWith(id: 'local-trash', checksum: 'checksum-trash');
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {'album-a': [localAssetToTrash]});
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer(
(_) async => {
'album-a': [localAssetToTrash],
},
);
final assetEntity = MockAssetEntity();
when(() => assetEntity.getMediaUrl()).thenAnswer((_) async => 'content://local-trash');
when(() => mockStorageRepository.getAssetEntityForAsset(localAssetToTrash)).thenAnswer((_) async => assetEntity);
await sut.processTrashedAssets({'album-a': [platformAsset]});
await sut.processTrashedAssets({
'album-a': [platformAsset],
});
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).called(1);
verify(() => mockTrashedLocalAssetRepository.getToTrash()).called(1);
@ -159,8 +160,7 @@ void main() {
verify(() => mockTrashedLocalAssetRepository.applyRestoredAssets(restoredIds)).called(1);
verify(() => mockStorageRepository.getAssetEntityForAsset(localAssetToTrash)).called(1);
final moveArgs =
verify(() => mockLocalFilesManager.moveToTrash(captureAny())).captured.single as List<String>;
final moveArgs = verify(() => mockLocalFilesManager.moveToTrash(captureAny())).captured.single as List<String>;
expect(moveArgs, ['content://local-trash']);
final trashArgs =
verify(() => mockTrashedLocalAssetRepository.trashLocalAsset(captureAny())).captured.single
@ -187,4 +187,25 @@ void main() {
verifyNever(() => mockTrashedLocalAssetRepository.trashLocalAsset(any()));
});
});
group('LocalSyncService - PlatformAsset conversion', () {
test('toLocalAsset uses correct updatedAt timestamp', () {
final platformAsset = PlatformAsset(
id: 'test-id',
name: 'test.jpg',
type: AssetType.image.index,
durationInSeconds: 0,
orientation: 0,
isFavorite: false,
createdAt: 1700000000,
updatedAt: 1732000000,
);
final localAsset = platformAsset.toLocalAsset();
expect(localAsset.createdAt.millisecondsSinceEpoch ~/ 1000, 1700000000);
expect(localAsset.updatedAt.millisecondsSinceEpoch ~/ 1000, 1732000000);
expect(localAsset.updatedAt, isNot(localAsset.createdAt));
});
});
}