diff --git a/mobile/lib/utils/datetime_helpers.dart b/mobile/lib/utils/datetime_helpers.dart index c13c8ca312..36ab71ab30 100644 --- a/mobile/lib/utils/datetime_helpers.dart +++ b/mobile/lib/utils/datetime_helpers.dart @@ -1,5 +1,5 @@ -const int _maxMillisecondsSinceEpoch = 8640000000000000; // 275760-09-13 -const int _minMillisecondsSinceEpoch = -62135596800000; // 0001-01-01 +const int _maxMillisecondsSinceEpoch = 253402300799000; // 9999-12-31 (SQLite limit) +const int _minMillisecondsSinceEpoch = -62135596800000; // 0001-01-01 (Dart / SQLite limit) DateTime? tryFromSecondsSinceEpoch(int? secondsSinceEpoch, {bool isUtc = false}) { if (secondsSinceEpoch == null) { diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index 56fccf4610..6ff6969890 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -13,6 +13,7 @@ import 'package:immich_mobile/domain/models/store.model.dart'; 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/infrastructure/entities/local_asset.entity.drift.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'; @@ -20,7 +21,7 @@ import 'package:immich_mobile/infrastructure/repositories/settings.repository.da 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? storedVersion = Store.tryGet(StoreKey.version); @@ -34,6 +35,10 @@ Future migrateDatabaseIfNeeded(Drift drift) async { await _migrateTo26(drift); } + if (version < 27) { + await migrateTo27(drift); + } + if (storedVersion == null) { await FeatureMessageService(SettingsRepository.instance).markSeen(); } @@ -145,6 +150,19 @@ Future _migrateTo26(Drift drift) async { await migrator.complete(); } +@visibleForTesting +Future migrateTo27(Drift drift) async { + final now = DateTime.timestamp(); + const format = '%Y-%m-%d'; + final table = drift.localAssetEntity; + await (drift.update( + table, + )..where((t) => t.createdAt.strftime(format).isNull())).write(LocalAssetEntityCompanion(createdAt: .new(now))); + await (drift.update( + table, + )..where((t) => t.updatedAt.strftime(format).isNull())).write(LocalAssetEntityCompanion(updatedAt: .new(now))); +} + Future _migrateAlbumSortMode(_StoreMigrator migrator) async { final raw = await migrator.readLegacyStoreInt(StoreKey.legacySelectedAlbumSortOrder.id); final mode = AlbumSortMode.values.firstWhereOrNull((e) => raw != null && e.storeIndex == raw); diff --git a/mobile/test/medium/migration_test.dart b/mobile/test/medium/migration_test.dart new file mode 100644 index 0000000000..c2ebc776b1 --- /dev/null +++ b/mobile/test/medium/migration_test.dart @@ -0,0 +1,81 @@ +import 'package:drift/drift.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/utils/migration.dart'; + +import 'repository_context.dart'; + +void main() { + late MediumRepositoryContext ctx; + + setUp(() { + ctx = MediumRepositoryContext(); + }); + + tearDown(() async { + await ctx.dispose(); + }); + + group('migrateTo27', () { + Future bucketDateIsNull(String column, String id) async { + final row = await ctx.db + .customSelect( + "SELECT STRFTIME('%Y-%m-%d', $column) AS date FROM local_asset_entity WHERE id = ?", + variables: [Variable(id)], + ) + .getSingle(); + return row.readNullable('date') == null; + } + + test('resets a local asset whose year exceeds SQLite range', () async { + final asset = await ctx.newLocalAsset(createdAt: DateTime.utc(50000, 6, 15)); + expect( + await bucketDateIsNull('created_at', asset.id), + isTrue, + reason: 'precondition: row must not be parseable by SQLite', + ); + + await migrateTo27(ctx.db); + + final createdAtRow = await (ctx.db.localAssetEntity.select()..where((t) => t.id.equals(asset.id))).getSingle(); + final createdAt = createdAtRow.createdAt; + expect(createdAt.year, lessThanOrEqualTo(9999)); + expect(await bucketDateIsNull('created_at', asset.id), isFalse); + }); + + test('resets an out of range updated_at', () async { + final asset = await ctx.newLocalAsset(updatedAt: DateTime.utc(60000, 1, 1)); + expect(await bucketDateIsNull('updated_at', asset.id), isTrue); + + await migrateTo27(ctx.db); + + expect(await bucketDateIsNull('updated_at', asset.id), isFalse); + }); + + test('valid datetime values are untouched', () async { + final original = DateTime.utc(2024, 1, 2, 3, 4, 5); + final asset = await ctx.newLocalAsset(createdAt: original); + + await migrateTo27(ctx.db); + + final createdAtRow = await (ctx.db.localAssetEntity.select()..where((t) => t.id.equals(asset.id))).getSingle(); + expect(createdAtRow.createdAt, original); + }); + + test('the timeline bucket query crashes before but succeeds after the migration', () async { + final user = await ctx.newUser(); + final album = await ctx.newLocalAlbum(backupSelection: .selected); + final asset = await ctx.newLocalAsset(createdAt: .utc(50000, 6, 15)); + await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id); + + final query = ctx.db.mergedAssetDrift.mergedBucket(userIds: [user.id], groupBy: 0); + + await expectLater(query.get(), throwsA(isA())); + + await migrateTo27(ctx.db); + + final buckets = await query.get(); + expect(buckets, hasLength(1)); + expect(buckets.first.assetCount, 1); + }); + }); +} diff --git a/mobile/test/modules/utils/datetime_helpers_test.dart b/mobile/test/modules/utils/datetime_helpers_test.dart index dfe83b4925..12e70df5ee 100644 --- a/mobile/test/modules/utils/datetime_helpers_test.dart +++ b/mobile/test/modules/utils/datetime_helpers_test.dart @@ -16,8 +16,14 @@ void main() { }); test('returns null for value above maximum allowed range', () { - // _maxMillisecondsSinceEpoch = 8640000000000000 - final seconds = 8640000000000000 ~/ 1000 + 1; // One second after max allowed + // _maxMillisecondsSinceEpoch = 253402300799000 + final seconds = 253402300799000 ~/ 1000 + 1; // One second after max allowed + final result = tryFromSecondsSinceEpoch(seconds); + expect(result, isNull); + }); + + test('returns null for a year beyond SQLite range', () { + final seconds = 8640000000000000 ~/ 1000; // Year 275760 final result = tryFromSecondsSinceEpoch(seconds); expect(result, isNull); }); @@ -29,9 +35,9 @@ void main() { }); test('returns correct DateTime for maximum allowed value', () { - final seconds = 8640000000000000 ~/ 1000; // Maximum allowed timestamp + final seconds = 253402300799000 ~/ 1000; // Maximum allowed timestamp final result = tryFromSecondsSinceEpoch(seconds); - expect(result, DateTime.fromMillisecondsSinceEpoch(8640000000000000)); + expect(result, DateTime.fromMillisecondsSinceEpoch(253402300799000)); }); test('returns correct DateTime for negative timestamp', () {