From 215559d86aa1177798fcf2042a9e37855c5799f9 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Thu, 18 Jun 2026 20:48:24 +0600 Subject: [PATCH 1/6] fix(mobile): show the real date for android local photos with no exif photos with no exif date showed their copy-to-phone date in the "on this device" albums instead of the real date. fall back to the earlier of date_modified/date_added (matches the server + ios), plus a migration to fix the rows already saved wrong. --- .../alextran/immich/sync/MessagesImplBase.kt | 5 ++-- mobile/lib/utils/migration.dart | 26 ++++++++++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) 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 18b771a613..33b99bf621 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,9 +174,10 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> 2L else -> 0L } - // Date taken is milliseconds since epoch, Date added is seconds since epoch + // 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)) - ?: c.getLong(dateAddedColumn) + ?: minOf(c.getLong(dateModifiedColumn), c.getLong(dateAddedColumn)) // Date modified is seconds since epoch val modifiedAt = c.getLong(dateModifiedColumn) val width = c.getInt(widthColumn).toLong() diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index d387c274ea..e62e2a83d8 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -12,13 +12,14 @@ import 'package:immich_mobile/domain/models/settings_key.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; -const int targetVersion = 26; +const int targetVersion = 27; Future migrateDatabaseIfNeeded(Drift drift) async { final int version = Store.get(StoreKey.version, targetVersion); @@ -31,10 +32,33 @@ Future migrateDatabaseIfNeeded(Drift drift) async { await _migrateTo26(drift); } + if (version < 27) { + if (!await _migrateTo27(drift)) { + return; + } + } + await Store.put(StoreKey.version, targetVersion); return; } +Future _migrateTo27(Drift drift) async { + // Android-only: no-EXIF photos got a wrong createdAt (DATE_ADDED copy-time instead + // of the real DATE_MODIFIED). Those rows can't self-heal -- the local sync only + // updates an asset when its updatedAt (DATE_MODIFIED) changes, which it never does + // here. A createdAt later than updatedAt is the copy-time signature, so clamp it + // back to updatedAt (the real date, == the new minOf(DATE_MODIFIED, DATE_ADDED)). + if (!CurrentPlatform.isAndroid) { + return true; + } + try { + await drift.customStatement('UPDATE local_asset_entity SET created_at = updated_at WHERE created_at > updated_at'); + return true; + } catch (_) { + return false; + } +} + Future _migrateTo25() async { final accessToken = Store.tryGet(StoreKey.accessToken); if (accessToken == null || accessToken.isEmpty) { From 7095d5771f24d35b908e566b246802656424a57d Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Fri, 24 Jul 2026 02:14:31 +0600 Subject: [PATCH 2/6] persist migration progress when the date backfill fails --- mobile/lib/utils/migration.dart | 13 ++--- mobile/test/modules/utils/migration_test.dart | 55 +++++++++++++++++++ 2 files changed, 59 insertions(+), 9 deletions(-) create mode 100644 mobile/test/modules/utils/migration_test.dart diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index 0787032d31..fc3441cdeb 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -35,10 +35,9 @@ Future migrateDatabaseIfNeeded(Drift drift) async { await _migrateTo26(drift); } - if (version < 27) { - if (!await _migrateTo27(drift)) { - return; - } + if (version < 27 && !await _migrateTo27(drift)) { + await Store.put(StoreKey.version, 26); + return; } if (storedVersion == null) { @@ -50,11 +49,7 @@ Future migrateDatabaseIfNeeded(Drift drift) async { } Future _migrateTo27(Drift drift) async { - // Android-only: no-EXIF photos got a wrong createdAt (DATE_ADDED copy-time instead - // of the real DATE_MODIFIED). Those rows can't self-heal -- the local sync only - // updates an asset when its updatedAt (DATE_MODIFIED) changes, which it never does - // here. A createdAt later than updatedAt is the copy-time signature, so clamp it - // back to updatedAt (the real date, == the new minOf(DATE_MODIFIED, DATE_ADDED)). + // DATE_ADDED can be later than DATE_MODIFIED after a file is copied. if (!CurrentPlatform.isAndroid) { return true; } diff --git a/mobile/test/modules/utils/migration_test.dart b/mobile/test/modules/utils/migration_test.dart new file mode 100644 index 0000000000..141ab0841d --- /dev/null +++ b/mobile/test/modules/utils/migration_test.dart @@ -0,0 +1,55 @@ +import 'package:drift/drift.dart' as drift; +import 'package:drift/native.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +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/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; +import 'package:immich_mobile/utils/migration.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Drift db; + late DriftStoreRepository storeRepository; + + setUpAll(() async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); + storeRepository = DriftStoreRepository(db); + await StoreService.init(storeRepository: storeRepository, listenUpdates: false); + }); + + tearDownAll(() async { + debugDefaultTargetPlatformOverride = null; + await Store.clear(); + await db.close(); + }); + + test('stores version 26 when migration 27 fails', () async { + await Store.put(StoreKey.version, 25); + await db + .into(db.localAssetEntity) + .insert( + LocalAssetEntityCompanion.insert( + id: 'asset', + name: 'asset.jpg', + type: AssetType.image, + createdAt: drift.Value(DateTime(2026)), + updatedAt: drift.Value(DateTime(2025)), + ), + ); + await db.customStatement( + "CREATE TRIGGER fail_migration BEFORE UPDATE OF created_at ON local_asset_entity " + "BEGIN SELECT RAISE(FAIL, 'migration failed'); END", + ); + + await migrateDatabaseIfNeeded(db); + + expect(await storeRepository.tryGet(StoreKey.version), 26); + }); +} From f400d5eb132707defa7f8d082156e2c5cee37020 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Fri, 24 Jul 2026 02:31:11 +0600 Subject: [PATCH 3/6] fix local date migration for trash and epoch rows --- .../alextran/immich/sync/MessagesImplBase.kt | 14 +++-- mobile/lib/utils/migration.dart | 11 +++- mobile/test/modules/utils/migration_test.dart | 61 +++++++++++++++++++ 3 files changed, 80 insertions(+), 6 deletions(-) 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); + }); } From b35fe5debfb3e3ac7d43f6236704d005010f04b6 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Fri, 24 Jul 2026 02:35:21 +0600 Subject: [PATCH 4/6] cover epoch dates in both local asset tables --- .../alextran/immich/sync/MessagesImplBase.kt | 4 ++-- mobile/test/modules/utils/migration_test.dart | 19 ++++++++++++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) 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 f52dc60279..fc4eb8365a 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,8 +174,8 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> 2L else -> 0L } - // 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. + // Date taken is in ms; added/modified are in seconds, and modified can be 0 when unset. + // No-EXIF assets use the earliest positive added/modified date, or raw 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)) diff --git a/mobile/test/modules/utils/migration_test.dart b/mobile/test/modules/utils/migration_test.dart index 8c050bf02f..fb7999d5c2 100644 --- a/mobile/test/modules/utils/migration_test.dart +++ b/mobile/test/modules/utils/migration_test.dart @@ -102,15 +102,32 @@ void main() { source: TrashOrigin.localSync, ), ); + await db + .into(db.trashedLocalAssetEntity) + .insert( + TrashedLocalAssetEntityCompanion.insert( + id: 'trashed-epoch', + albumId: 'album', + name: 'trashed-epoch.jpg', + type: AssetType.image, + createdAt: drift.Value(createdAt), + updatedAt: drift.Value(epoch), + 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(); + final trashed = await (db.select(db.trashedLocalAssetEntity)..where((row) => row.id.equals('trashed'))).getSingle(); + final trashedUnchanged = await (db.select( + db.trashedLocalAssetEntity, + )..where((row) => row.id.equals('trashed-epoch'))).getSingle(); expect(local.createdAt, updatedAt); expect(unchanged.createdAt, createdAt); expect(trashed.createdAt, updatedAt); + expect(trashedUnchanged.createdAt, createdAt); expect(await storeRepository.tryGet(StoreKey.version), 27); }); } From 849a4886f70d60a1eed0a8cfb988fbbd5fc3ac80 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Wed, 5 Aug 2026 18:55:17 +0600 Subject: [PATCH 5/6] simplify the created date pick and rework the migration test --- .../alextran/immich/sync/MessagesImplBase.kt | 8 +- mobile/test/medium/repository_context.dart | 2 + mobile/test/modules/utils/migration_test.dart | 124 ++++++------------ 3 files changed, 44 insertions(+), 90 deletions(-) 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 450f748156..d6d8e2283d 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 @@ -176,15 +176,11 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa else -> 0L } // Date taken is in ms; added/modified are in seconds, and modified can be 0 when unset. - // No-EXIF assets use the earliest positive added/modified date, or raw added if neither is positive. + // If EXIF date taken exists use it, else if modified is empty use added, else the earliest of the two. 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) - } + ?: if (modifiedAt <= 0) addedAt else minOf(modifiedAt, addedAt) val width = c.getInt(widthColumn).toLong() val height = c.getInt(heightColumn).toLong() // Duration is milliseconds diff --git a/mobile/test/medium/repository_context.dart b/mobile/test/medium/repository_context.dart index 09b8e2c7eb..494de75808 100644 --- a/mobile/test/medium/repository_context.dart +++ b/mobile/test/medium/repository_context.dart @@ -313,6 +313,7 @@ class MediumRepositoryContext { TrashOrigin? source, AssetType? type, DateTime? createdAt, + DateTime? updatedAt, bool? isFavorite, }) async { id ??= TestUtils.uuid(); @@ -328,6 +329,7 @@ class MediumRepositoryContext { source: .new(source ?? TrashOrigin.remoteSync), isFavorite: .new(isFavorite ?? false), createdAt: .new(TestUtils.date(createdAt)), + updatedAt: .new(TestUtils.date(updatedAt)), ), ); } diff --git a/mobile/test/modules/utils/migration_test.dart b/mobile/test/modules/utils/migration_test.dart index fb7999d5c2..dba05dff5f 100644 --- a/mobile/test/modules/utils/migration_test.dart +++ b/mobile/test/modules/utils/migration_test.dart @@ -1,65 +1,49 @@ -import 'package:drift/drift.dart' as drift; -import 'package:drift/native.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; 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'; +import '../../medium/repository_context.dart'; + void main() { TestWidgetsFlutterBinding.ensureInitialized(); - late Drift db; - late DriftStoreRepository storeRepository; + late MediumRepositoryContext ctx; setUpAll(() async { debugDefaultTargetPlatformOverride = TargetPlatform.android; - db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); - storeRepository = DriftStoreRepository(db); - await StoreService.init(storeRepository: storeRepository, listenUpdates: false); + ctx = MediumRepositoryContext(); + await StoreService.init(storeRepository: DriftStoreRepository(ctx.db), 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'); + await ctx.db.delete(ctx.db.localAssetEntity).go(); + await ctx.db.delete(ctx.db.trashedLocalAssetEntity).go(); + await ctx.db.customStatement('DROP TRIGGER IF EXISTS fail_migration'); }); tearDownAll(() async { debugDefaultTargetPlatformOverride = null; await Store.clear(); - await db.close(); + await ctx.dispose(); }); test('stores version 26 when migration 27 fails', () async { await Store.put(StoreKey.version, 25); - await db - .into(db.localAssetEntity) - .insert( - LocalAssetEntityCompanion.insert( - id: 'asset', - name: 'asset.jpg', - type: AssetType.image, - createdAt: drift.Value(DateTime(2026)), - updatedAt: drift.Value(DateTime(2025)), - ), - ); - await db.customStatement( + await ctx.newLocalAsset(id: 'asset', createdAt: DateTime(2026), updatedAt: DateTime(2025)); + await ctx.db.customStatement( "CREATE TRIGGER fail_migration BEFORE UPDATE OF created_at ON local_asset_entity " "BEGIN SELECT RAISE(FAIL, 'migration failed'); END", ); - await migrateDatabaseIfNeeded(db); + await migrateDatabaseIfNeeded(ctx.db); - expect(await storeRepository.tryGet(StoreKey.version), 26); + expect(Store.tryGet(StoreKey.version), 26); }); test('fixes dates in active and trashed assets', () async { @@ -67,67 +51,39 @@ void main() { 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 db - .into(db.trashedLocalAssetEntity) - .insert( - TrashedLocalAssetEntityCompanion.insert( - id: 'trashed-epoch', - albumId: 'album', - name: 'trashed-epoch.jpg', - type: AssetType.image, - createdAt: drift.Value(createdAt), - updatedAt: drift.Value(epoch), - source: TrashOrigin.localSync, - ), - ); + await ctx.newLocalAsset(id: 'local', createdAt: createdAt, updatedAt: updatedAt); + await ctx.newLocalAsset(id: 'epoch', createdAt: createdAt, updatedAt: epoch); + await ctx.newTrashedLocalAsset( + id: 'trashed', + albumId: 'album', + createdAt: createdAt, + updatedAt: updatedAt, + source: TrashOrigin.localSync, + ); + await ctx.newTrashedLocalAsset( + id: 'trashed-epoch', + albumId: 'album', + createdAt: createdAt, + updatedAt: epoch, + source: TrashOrigin.localSync, + ); - await migrateDatabaseIfNeeded(db); + await migrateDatabaseIfNeeded(ctx.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)..where((row) => row.id.equals('trashed'))).getSingle(); - final trashedUnchanged = await (db.select( - db.trashedLocalAssetEntity, + final local = await (ctx.db.select(ctx.db.localAssetEntity)..where((row) => row.id.equals('local'))).getSingle(); + final unchanged = await (ctx.db.select( + ctx.db.localAssetEntity, + )..where((row) => row.id.equals('epoch'))).getSingle(); + final trashed = await (ctx.db.select( + ctx.db.trashedLocalAssetEntity, + )..where((row) => row.id.equals('trashed'))).getSingle(); + final trashedUnchanged = await (ctx.db.select( + ctx.db.trashedLocalAssetEntity, )..where((row) => row.id.equals('trashed-epoch'))).getSingle(); expect(local.createdAt, updatedAt); expect(unchanged.createdAt, createdAt); expect(trashed.createdAt, updatedAt); expect(trashedUnchanged.createdAt, createdAt); - expect(await storeRepository.tryGet(StoreKey.version), 27); + expect(Store.tryGet(StoreKey.version), 27); }); } From 0d0096d55330f2508ced2fdd89512dd0421c32d8 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Thu, 6 Aug 2026 02:02:59 +0600 Subject: [PATCH 6/6] heal no-exif dates from mediastore in the v27 migration --- .../alextran/immich/sync/MessagesImpl30.kt | 3 +- .../alextran/immich/sync/MessagesImplBase.kt | 18 +- mobile/ios/Runner/Sync/MessagesImpl.swift | 2 + mobile/lib/main.dart | 4 +- mobile/lib/utils/migration.dart | 78 +++++++-- mobile/pigeon/native_sync_api.dart | 2 + mobile/test/modules/utils/migration_test.dart | 156 +++++++++++++----- 7 files changed, 205 insertions(+), 58 deletions(-) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl30.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl30.kt index 4785b751c0..4b09d97ea9 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl30.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl30.kt @@ -113,7 +113,8 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY) } - getCursor(volume, queryArgs).use { cursor -> + val cursor = getCursor(volume, queryArgs) ?: error("MediaStore trash query failed") + cursor.use { getAssets(cursor).forEach { res -> if (res is AssetResult.ValidAsset) { result.getOrPut(res.albumId) { mutableListOf() }.add(res.asset) 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 d6d8e2283d..e4284dd148 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 @@ -1,8 +1,10 @@ package app.alextran.immich.sync +import android.Manifest import android.annotation.SuppressLint import android.content.ContentUris import android.content.Context +import android.content.pm.PackageManager import android.database.Cursor import android.os.Build import android.os.Bundle @@ -10,6 +12,7 @@ import android.os.ext.SdkExtensions import android.provider.MediaStore import android.util.Base64 import android.util.Log +import androidx.core.content.ContextCompat import androidx.core.database.getStringOrNull import app.alextran.immich.core.ImmichPlugin import com.bumptech.glide.Glide @@ -108,6 +111,12 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa SdkExtensions.getExtensionVersion(Build.VERSION_CODES.S) >= 21) } + fun hasMediaReadPermission(): Boolean = + (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + arrayOf(Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VIDEO) + } else arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE)) + .all { ContextCompat.checkSelfPermission(ctx, it) == PackageManager.PERMISSION_GRANTED } + protected fun getCursor( volume: String, selection: String, @@ -315,13 +324,14 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa val selection = "(${MediaStore.Files.FileColumns.BUCKET_ID} IS NOT NULL) AND $MEDIA_SELECTION" - getCursor( + val cursor = getCursor( MediaStore.VOLUME_EXTERNAL, selection, MEDIA_SELECTION_ARGS, projection, "${MediaStore.Files.FileColumns.DATE_MODIFIED} DESC" - )?.use { cursor -> + ) ?: error("MediaStore album query failed") + cursor.use { val bucketIdColumn = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.BUCKET_ID) val bucketNameColumn = @@ -396,7 +406,9 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa selectionArgs.addAll(listOf(updatedTimeCond.toString(), updatedTimeCond.toString())) } - return getAssets(getCursor(MediaStore.VOLUME_EXTERNAL, selection, selectionArgs.toTypedArray())) + val cursor = getCursor(MediaStore.VOLUME_EXTERNAL, selection, selectionArgs.toTypedArray()) + ?: error("MediaStore asset query failed") + return getAssets(cursor) .mapNotNull { result -> (result as? AssetResult.ValidAsset)?.asset } .toList() } diff --git a/mobile/ios/Runner/Sync/MessagesImpl.swift b/mobile/ios/Runner/Sync/MessagesImpl.swift index ddfd023690..c7de43a439 100644 --- a/mobile/ios/Runner/Sync/MessagesImpl.swift +++ b/mobile/ios/Runner/Sync/MessagesImpl.swift @@ -46,6 +46,8 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin { init(with defaults: UserDefaults = .standard) { self.defaults = defaults } + + func hasMediaReadPermission() throws -> Bool { PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized } @available(iOS 16, *) private func getChangeToken() -> PHPersistentChangeToken? { diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 317733f1de..11fec728ba 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -22,6 +22,8 @@ import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/pages/common/splash_screen.page.dart'; import 'package:immich_mobile/platform/background_worker_lock_api.g.dart'; +import 'package:immich_mobile/platform/native_sync_api.g.dart'; +import 'package:immich_mobile/platform/permission_api.g.dart'; import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; @@ -56,7 +58,7 @@ void main() async { await initApp(); // Warm-up isolate pool for worker manager await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5)); - await migrateDatabaseIfNeeded(drift); + await migrateDatabaseIfNeeded(drift, NativeSyncApi(), PermissionApi()); runApp(ProviderScope(overrides: [driftProvider.overrideWith(driftOverride(drift))], child: const MainWidget())); } catch (error, stack) { diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index c2b1dd0433..a5988c9f4a 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -5,6 +5,7 @@ import 'package:collection/collection.dart'; import 'package:drift/drift.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/constants/colors.dart'; +import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/config/app_config.dart'; import 'package:immich_mobile/domain/models/log.model.dart'; @@ -14,16 +15,21 @@ import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/feature_message.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; +import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.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/network.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart'; import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; +import 'package:immich_mobile/platform/native_sync_api.g.dart'; +import 'package:immich_mobile/platform/permission_api.g.dart'; import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; +import 'package:immich_mobile/utils/datetime_helpers.dart'; const int targetVersion = 27; -Future migrateDatabaseIfNeeded(Drift drift) async { +Future migrateDatabaseIfNeeded(Drift drift, NativeSyncApi nativeSyncApi, PermissionApi permissionApi) async { final int? storedVersion = Store.tryGet(StoreKey.version); final version = storedVersion ?? targetVersion; @@ -35,7 +41,7 @@ Future migrateDatabaseIfNeeded(Drift drift) async { await _migrateTo26(drift); } - if (version < 27 && !await _migrateTo27(drift)) { + if (version < 27 && !await _migrateTo27(drift, nativeSyncApi, permissionApi)) { await Store.put(StoreKey.version, 26); return; } @@ -48,22 +54,66 @@ Future migrateDatabaseIfNeeded(Drift drift) async { return; } -Future _migrateTo27(Drift drift) async { - // DATE_ADDED can be later than DATE_MODIFIED after a file is copied. +Future _migrateTo27(Drift drift, NativeSyncApi nativeSyncApi, PermissionApi permissionApi) async { if (!CurrentPlatform.isAndroid) { return true; } try { - 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)", - ); + if (!await nativeSyncApi.hasMediaReadPermission()) { + return false; + } + + final dates = {}; + void addDates(Iterable assets) { + for (final asset in assets) { + dates[asset.id] = tryFromSecondsSinceEpoch(asset.createdAt, isUtc: true) ?? DateTime.timestamp(); + } + } + + for (final album in await nativeSyncApi.getAlbums()) { + addDates(await nativeSyncApi.getAssetsForAlbum(album.id)); + } + if (await permissionApi.hasManageMediaPermission()) { + final trashed = await nativeSyncApi.getTrashedAssets(); + addDates(trashed.values.flattened); + } + + await drift.transaction(() async { + final localDates = { + for (final row in await (drift.selectOnly( + drift.localAssetEntity, + )..addColumns([drift.localAssetEntity.id, drift.localAssetEntity.createdAt])).get()) + row.read(drift.localAssetEntity.id)!: row.read(drift.localAssetEntity.createdAt)!, + }; + final trashedDates = { + for (final row in await (drift.selectOnly( + drift.trashedLocalAssetEntity, + )..addColumns([drift.trashedLocalAssetEntity.id, drift.trashedLocalAssetEntity.createdAt])).get()) + row.read(drift.trashedLocalAssetEntity.id)!: row.read(drift.trashedLocalAssetEntity.createdAt)!, + }; + for (final chunk in dates.entries.slices(kDriftMaxChunk)) { + await drift.batch((batch) { + for (final entry in chunk) { + final localDate = localDates[entry.key]; + if (localDate != null && !localDate.isAtSameMomentAs(entry.value)) { + batch.update( + drift.localAssetEntity, + LocalAssetEntityCompanion(createdAt: Value(entry.value)), + where: (row) => row.id.equals(entry.key), + ); + } + final trashedDate = trashedDates[entry.key]; + if (trashedDate != null && !trashedDate.isAtSameMomentAs(entry.value)) { + batch.update( + drift.trashedLocalAssetEntity, + TrashedLocalAssetEntityCompanion(createdAt: Value(entry.value)), + where: (row) => row.id.equals(entry.key), + ); + } + } + }); + } + }); return true; } catch (_) { return false; diff --git a/mobile/pigeon/native_sync_api.dart b/mobile/pigeon/native_sync_api.dart index 433b154cd1..2e80a18492 100644 --- a/mobile/pigeon/native_sync_api.dart +++ b/mobile/pigeon/native_sync_api.dart @@ -105,6 +105,8 @@ class CloudIdResult { @HostApi() abstract class NativeSyncApi { + bool hasMediaReadPermission(); + @async bool shouldFullSync(); diff --git a/mobile/test/modules/utils/migration_test.dart b/mobile/test/modules/utils/migration_test.dart index dba05dff5f..258024f150 100644 --- a/mobile/test/modules/utils/migration_test.dart +++ b/mobile/test/modules/utils/migration_test.dart @@ -5,14 +5,20 @@ import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; +import 'package:immich_mobile/platform/native_sync_api.g.dart'; +import 'package:immich_mobile/platform/permission_api.g.dart'; import 'package:immich_mobile/utils/migration.dart'; +import 'package:mocktail/mocktail.dart'; import '../../medium/repository_context.dart'; +import '../../service.mocks.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); late MediumRepositoryContext ctx; + late MockNativeSyncApi nativeSyncApi; + late MockPermissionApi permissionApi; setUpAll(() async { debugDefaultTargetPlatformOverride = TargetPlatform.android; @@ -21,10 +27,18 @@ void main() { }); setUp(() async { + nativeSyncApi = MockNativeSyncApi(); + permissionApi = MockPermissionApi(); await Store.clear(); await ctx.db.delete(ctx.db.localAssetEntity).go(); await ctx.db.delete(ctx.db.trashedLocalAssetEntity).go(); - await ctx.db.customStatement('DROP TRIGGER IF EXISTS fail_migration'); + when(() => nativeSyncApi.hasMediaReadPermission()).thenAnswer((_) async => true); + when(() => permissionApi.hasManageMediaPermission()).thenAnswer((_) async => true); + when( + () => nativeSyncApi.getAlbums(), + ).thenAnswer((_) async => [PlatformAlbum(id: 'album', name: 'album', isCloud: false, assetCount: 1)]); + when(() => nativeSyncApi.getAssetsForAlbum('album')).thenAnswer((_) async => []); + when(() => nativeSyncApi.getTrashedAssets()).thenAnswer((_) async => {}); }); tearDownAll(() async { @@ -33,57 +47,121 @@ void main() { await ctx.dispose(); }); - test('stores version 26 when migration 27 fails', () async { - await Store.put(StoreKey.version, 25); - await ctx.newLocalAsset(id: 'asset', createdAt: DateTime(2026), updatedAt: DateTime(2025)); - await ctx.db.customStatement( - "CREATE TRIGGER fail_migration BEFORE UPDATE OF created_at ON local_asset_entity " - "BEGIN SELECT RAISE(FAIL, 'migration failed'); END", - ); - - await migrateDatabaseIfNeeded(ctx.db); - - expect(Store.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); + test('heals dates from MediaStore', () async { + final wrongDate = DateTime.utc(2026); + final platformDate = DateTime.utc(2019, 3, 15, 10, 30); await Store.put(StoreKey.version, 26); - await ctx.newLocalAsset(id: 'local', createdAt: createdAt, updatedAt: updatedAt); - await ctx.newLocalAsset(id: 'epoch', createdAt: createdAt, updatedAt: epoch); + await ctx.newLocalAsset(id: 'local', createdAt: wrongDate, updatedAt: platformDate); await ctx.newTrashedLocalAsset( id: 'trashed', albumId: 'album', - createdAt: createdAt, - updatedAt: updatedAt, + createdAt: wrongDate, + updatedAt: platformDate, source: TrashOrigin.localSync, ); - await ctx.newTrashedLocalAsset( - id: 'trashed-epoch', - albumId: 'album', - createdAt: createdAt, - updatedAt: epoch, - source: TrashOrigin.localSync, + when(() => nativeSyncApi.getAssetsForAlbum('album')).thenAnswer((_) async => [_asset('local', platformDate)]); + when(() => nativeSyncApi.getTrashedAssets()).thenAnswer( + (_) async => { + 'album': [_asset('trashed', platformDate)], + }, ); - await migrateDatabaseIfNeeded(ctx.db); + await migrateDatabaseIfNeeded(ctx.db, nativeSyncApi, permissionApi); final local = await (ctx.db.select(ctx.db.localAssetEntity)..where((row) => row.id.equals('local'))).getSingle(); - final unchanged = await (ctx.db.select( - ctx.db.localAssetEntity, - )..where((row) => row.id.equals('epoch'))).getSingle(); final trashed = await (ctx.db.select( ctx.db.trashedLocalAssetEntity, )..where((row) => row.id.equals('trashed'))).getSingle(); - final trashedUnchanged = await (ctx.db.select( - ctx.db.trashedLocalAssetEntity, - )..where((row) => row.id.equals('trashed-epoch'))).getSingle(); - expect(local.createdAt, updatedAt); - expect(unchanged.createdAt, createdAt); - expect(trashed.createdAt, updatedAt); - expect(trashedUnchanged.createdAt, createdAt); + expect(local.createdAt, platformDate); + expect(trashed.createdAt, platformDate); + expect(Store.tryGet(StoreKey.version), 27); + }); + + test('keeps EXIF date without trash access', () async { + final modifiedAt = DateTime.utc(2025); + final takenAt = DateTime.utc(2026); + await Store.put(StoreKey.version, 26); + await ctx.newLocalAsset(id: 'exif', createdAt: takenAt, updatedAt: modifiedAt); + when( + () => nativeSyncApi.getAssetsForAlbum('album'), + ).thenAnswer((_) async => [_asset('exif', takenAt, updatedAt: modifiedAt)]); + when(() => permissionApi.hasManageMediaPermission()).thenAnswer((_) async => false); + + await migrateDatabaseIfNeeded(ctx.db, nativeSyncApi, permissionApi); + + final asset = await (ctx.db.select(ctx.db.localAssetEntity)..where((row) => row.id.equals('exif'))).getSingle(); + expect(asset.createdAt, takenAt); + expect(Store.tryGet(StoreKey.version), 27); + verifyNever(() => nativeSyncApi.getTrashedAssets()); + }); + + test('retries after permission is granted', () async { + final wrongDate = DateTime.utc(2026); + final platformDate = DateTime.utc(2019); + await Store.put(StoreKey.version, 26); + await ctx.newLocalAsset(id: 'local', createdAt: wrongDate, updatedAt: platformDate); + when(() => nativeSyncApi.hasMediaReadPermission()).thenAnswer((_) async => false); + + await migrateDatabaseIfNeeded(ctx.db, nativeSyncApi, permissionApi); + + expect(Store.tryGet(StoreKey.version), 26); + verifyNever(() => nativeSyncApi.getAlbums()); + verifyNever(() => nativeSyncApi.getTrashedAssets()); + var asset = await (ctx.db.select(ctx.db.localAssetEntity)..where((row) => row.id.equals('local'))).getSingle(); + expect(asset.createdAt, wrongDate); + + when(() => nativeSyncApi.hasMediaReadPermission()).thenAnswer((_) async => true); + when(() => nativeSyncApi.getAssetsForAlbum('album')).thenAnswer((_) async => [_asset('local', platformDate)]); + + await migrateDatabaseIfNeeded(ctx.db, nativeSyncApi, permissionApi); + + asset = await (ctx.db.select(ctx.db.localAssetEntity)..where((row) => row.id.equals('local'))).getSingle(); + expect(asset.createdAt, platformDate); + expect(Store.tryGet(StoreKey.version), 27); + }); + + test('keeps version 26 when MediaStore read fails', () async { + final wrongDate = DateTime.utc(2026); + await Store.put(StoreKey.version, 26); + await ctx.newLocalAsset(id: 'local', createdAt: wrongDate); + when(() => nativeSyncApi.getAlbums()).thenThrow(StateError('query failed')); + + await migrateDatabaseIfNeeded(ctx.db, nativeSyncApi, permissionApi); + + final asset = await (ctx.db.select(ctx.db.localAssetEntity)..where((row) => row.id.equals('local'))).getSingle(); + expect(asset.createdAt, wrongDate); + expect(Store.tryGet(StoreKey.version), 26); + }); + + test('heals out-of-range date and completes migration', () async { + final wrongDate = DateTime.utc(2026); + final before = DateTime.timestamp().subtract(const Duration(seconds: 1)); + await Store.put(StoreKey.version, 26); + await ctx.newLocalAsset(id: 'local', createdAt: wrongDate); + when( + () => nativeSyncApi.getAssetsForAlbum('album'), + ).thenAnswer((_) async => [_asset('local', wrongDate, createdAtSeconds: 8640000000001)]); + + await migrateDatabaseIfNeeded(ctx.db, nativeSyncApi, permissionApi); + + final after = DateTime.timestamp().add(const Duration(seconds: 1)); + final asset = await (ctx.db.select(ctx.db.localAssetEntity)..where((row) => row.id.equals('local'))).getSingle(); + expect(asset.createdAt.isBefore(before), isFalse); + expect(asset.createdAt.isAfter(after), isFalse); expect(Store.tryGet(StoreKey.version), 27); }); } + +class MockPermissionApi extends Mock implements PermissionApi {} + +PlatformAsset _asset(String id, DateTime createdAt, {DateTime? updatedAt, int? createdAtSeconds}) => PlatformAsset( + id: id, + name: '$id.jpg', + type: 1, + createdAt: createdAtSeconds ?? createdAt.millisecondsSinceEpoch ~/ 1000, + updatedAt: (updatedAt ?? createdAt).millisecondsSinceEpoch ~/ 1000, + durationMs: 0, + orientation: 0, + isFavorite: false, + playbackStyle: PlatformAssetPlaybackStyle.image, +);