diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt index 33b99bf621..f52dc60279 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt @@ -174,12 +174,16 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> 2L else -> 0L } - // Date taken is in ms; date added/modified in seconds. No-EXIF (date taken <= 0) - // falls back to the earliest of modified/added to match the server + iOS. - val createdAt = (c.getLong(dateTakenColumn).takeIf { it > 0 }?.div(1000)) - ?: minOf(c.getLong(dateModifiedColumn), c.getLong(dateAddedColumn)) - // Date modified is seconds since epoch + // Date taken is in ms; added/modified are in seconds. No-EXIF assets use the earliest + // positive modified/added date, or added if neither is positive. val modifiedAt = c.getLong(dateModifiedColumn) + val addedAt = c.getLong(dateAddedColumn) + val createdAt = (c.getLong(dateTakenColumn).takeIf { it > 0 }?.div(1000)) + ?: when { + modifiedAt <= 0 -> addedAt + addedAt <= 0 -> modifiedAt + else -> minOf(modifiedAt, addedAt) + } val width = c.getInt(widthColumn).toLong() val height = c.getInt(heightColumn).toLong() // Duration is milliseconds diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index fc3441cdeb..c2b1dd0433 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -54,7 +54,16 @@ Future _migrateTo27(Drift drift) async { return true; } try { - await drift.customStatement('UPDATE local_asset_entity SET created_at = updated_at WHERE created_at > updated_at'); + await drift.customStatement( + "UPDATE local_asset_entity SET created_at = updated_at " + "WHERE julianday(updated_at) > julianday('1970-01-01T00:00:00Z') " + "AND julianday(created_at) > julianday(updated_at)", + ); + await drift.customStatement( + "UPDATE trashed_local_asset_entity SET created_at = updated_at " + "WHERE julianday(updated_at) > julianday('1970-01-01T00:00:00Z') " + "AND julianday(created_at) > julianday(updated_at)", + ); return true; } catch (_) { return false; diff --git a/mobile/test/modules/utils/migration_test.dart b/mobile/test/modules/utils/migration_test.dart index 141ab0841d..8c050bf02f 100644 --- a/mobile/test/modules/utils/migration_test.dart +++ b/mobile/test/modules/utils/migration_test.dart @@ -7,6 +7,8 @@ import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/utils/migration.dart'; @@ -24,6 +26,13 @@ void main() { await StoreService.init(storeRepository: storeRepository, listenUpdates: false); }); + setUp(() async { + await Store.clear(); + await db.delete(db.localAssetEntity).go(); + await db.delete(db.trashedLocalAssetEntity).go(); + await db.customStatement('DROP TRIGGER IF EXISTS fail_migration'); + }); + tearDownAll(() async { debugDefaultTargetPlatformOverride = null; await Store.clear(); @@ -52,4 +61,56 @@ void main() { expect(await storeRepository.tryGet(StoreKey.version), 26); }); + + test('fixes dates in active and trashed assets', () async { + final createdAt = DateTime(2026); + final updatedAt = DateTime(2025); + final epoch = DateTime.fromMillisecondsSinceEpoch(0); + await Store.put(StoreKey.version, 26); + await db + .into(db.localAssetEntity) + .insert( + LocalAssetEntityCompanion.insert( + id: 'local', + name: 'local.jpg', + type: AssetType.image, + createdAt: drift.Value(createdAt), + updatedAt: drift.Value(updatedAt), + ), + ); + await db + .into(db.localAssetEntity) + .insert( + LocalAssetEntityCompanion.insert( + id: 'epoch', + name: 'epoch.jpg', + type: AssetType.image, + createdAt: drift.Value(createdAt), + updatedAt: drift.Value(epoch), + ), + ); + await db + .into(db.trashedLocalAssetEntity) + .insert( + TrashedLocalAssetEntityCompanion.insert( + id: 'trashed', + albumId: 'album', + name: 'trashed.jpg', + type: AssetType.image, + createdAt: drift.Value(createdAt), + updatedAt: drift.Value(updatedAt), + source: TrashOrigin.localSync, + ), + ); + + await migrateDatabaseIfNeeded(db); + + final local = await (db.select(db.localAssetEntity)..where((row) => row.id.equals('local'))).getSingle(); + final unchanged = await (db.select(db.localAssetEntity)..where((row) => row.id.equals('epoch'))).getSingle(); + final trashed = await db.select(db.trashedLocalAssetEntity).getSingle(); + expect(local.createdAt, updatedAt); + expect(unchanged.createdAt, createdAt); + expect(trashed.createdAt, updatedAt); + expect(await storeRepository.tryGet(StoreKey.version), 27); + }); }