fix(mobile): clamp out of range datetimes so timeline queries cannot crash (#30490)

* fix(mobile): clamp out of range datetimes so timeline queries cannot crash

* replace the per column converters with a clamping custom type

* heal each table in one statement, rename the clamp type, read dates as stored

* one medium test for the clamp, drop the dart side bounds, private clamp type
This commit is contained in:
Santo Shakil
2026-09-18 17:41:15 +00:00
committed by GitHub
parent 747af325d9
commit 909b8aac93
24 changed files with 3817 additions and 52 deletions
@@ -0,0 +1,71 @@
import 'package:drift_dev/api/migrations_native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/data/db/main/database.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:intl/date_symbol_data_local.dart';
import '../../drift/main/generated/schema.dart';
import '../repository_context.dart';
void main() {
late MediumRepositoryContext ctx;
setUpAll(initializeDateFormatting);
setUp(() {
ctx = MediumRepositoryContext();
});
tearDown(() async {
await ctx.dispose();
});
Future<DateTime> storedCreatedAt(DateTime createdAt) async {
final asset = await ctx.newLocalAsset(createdAt: createdAt);
final row = await (ctx.db.select(ctx.db.localAssetEntity)..where((t) => t.id.equals(asset.id))).getSingle();
return row.createdAt;
}
test('clamps a date before year 1 to the floor', () async {
expect(await storedCreatedAt(DateTime.utc(-4712, 3, 4)), DateTime.utc(1));
});
test('clamps a date after year 9999 to the ceiling', () async {
// the reporter's date from #28524
expect(await storedCreatedAt(DateTime.utc(144769, 11, 18, 12, 38, 32)), DateTime.utc(9999, 12, 31));
});
test('stores a late hour on the last day at the midnight ceiling', () async {
expect(await storedCreatedAt(DateTime.utc(9999, 12, 31, 23, 59, 59)), DateTime.utc(9999, 12, 31));
});
test('stores a date in range unchanged', () async {
final date = DateTime.utc(2024, 1, 2, 3, 4, 5, 123);
expect(await storedCreatedAt(date), date);
});
test('v34 clamps dates already stored out of range', () async {
final verifier = SchemaVerifier(GeneratedHelper());
final schema = await verifier.schemaAt(33);
schema.rawDatabase.execute(
"INSERT INTO local_asset_entity (id, name, type, created_at, updated_at) "
"VALUES ('a', 'a.jpg', 0, '+144769-11-18T12:38:32.000Z', '-4712-03-04T05:06:07.000Z')",
);
final db = Drift(schema.newConnection());
addTearDown(db.close);
await verifier.migrateAndValidate(db, 34);
final row = await (db.select(db.localAssetEntity)..where((t) => t.id.equals('a'))).getSingle();
expect(row.createdAt, DateTime.utc(9999, 12, 31));
expect(row.updatedAt, DateTime.utc(1));
});
test('timeline buckets a clamped date instead of crashing', () async {
final user = await ctx.newUser();
await ctx.newRemoteAsset(ownerId: user.id, createdAt: DateTime.utc(144769, 11, 18, 12, 38, 32));
final buckets = await TimelineRepository(ctx.db).main([user.id], .day).bucketSource().first;
expect(buckets, [TimeBucket(date: DateTime(9999, 12, 31), assetCount: 1)]);
});
}
@@ -8,15 +8,7 @@ void main() {
expect(result, isNull);
});
test('returns null for value below minimum allowed range', () {
// _minMillisecondsSinceEpoch = -62135596800000
const seconds = -62135596800000 ~/ 1000 - 1; // One second before min allowed
final result = tryFromSecondsSinceEpoch(seconds);
expect(result, isNull);
});
test('returns null for value above maximum allowed range', () {
// _maxMillisecondsSinceEpoch = 8640000000000000
const seconds = 8640000000000000 ~/ 1000 + 1; // One second after max allowed
final result = tryFromSecondsSinceEpoch(seconds);
expect(result, isNull);
@@ -122,29 +122,29 @@ void main() {
expect(Store.tryGet(StoreKey.version), 27);
});
test('skips out-of-range date and completes migration', () async {
test('clamps out-of-range date and completes migration', () async {
final wrongDate = DateTime.utc(2026);
await Store.put(StoreKey.version, 26);
await ctx.newLocalAsset(id: 'local', createdAt: wrongDate);
when(
() => nativeSyncApi.getAssetsForAlbum('album'),
).thenAnswer((_) async => [_asset('local', wrongDate, createdAtSeconds: 8640000000001)]);
).thenAnswer((_) async => [_asset('local', DateTime.utc(144769, 11, 18, 12, 38, 32))]);
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(asset.createdAt, DateTime.utc(9999, 12, 31));
expect(Store.tryGet(StoreKey.version), 27);
});
}
class MockPermissionApi extends Mock implements PermissionApi {}
PlatformAsset _asset(String id, DateTime createdAt, {DateTime? updatedAt, int? createdAtSeconds}) => PlatformAsset(
PlatformAsset _asset(String id, DateTime createdAt, {DateTime? updatedAt}) => PlatformAsset(
id: id,
name: '$id.jpg',
type: 1,
createdAt: createdAtSeconds ?? createdAt.millisecondsSinceEpoch ~/ 1000,
createdAt: createdAt.millisecondsSinceEpoch ~/ 1000,
updatedAt: (updatedAt ?? createdAt).millisecondsSinceEpoch ~/ 1000,
durationMs: 0,
orientation: 0,