mirror of
https://github.com/immich-app/immich.git
synced 2026-07-28 14:47:30 -07:00
fix: clamp local datetime to valid SQLite range
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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<void> migrateDatabaseIfNeeded(Drift drift) async {
|
||||
final int? storedVersion = Store.tryGet(StoreKey.version);
|
||||
@@ -34,6 +35,10 @@ Future<void> 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<void> _migrateTo26(Drift drift) async {
|
||||
await migrator.complete();
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<void> 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<void> _migrateAlbumSortMode(_StoreMigrator migrator) async {
|
||||
final raw = await migrator.readLegacyStoreInt(StoreKey.legacySelectedAlbumSortOrder.id);
|
||||
final mode = AlbumSortMode.values.firstWhereOrNull((e) => raw != null && e.storeIndex == raw);
|
||||
|
||||
@@ -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<bool> 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<String>(id)],
|
||||
)
|
||||
.getSingle();
|
||||
return row.readNullable<String>('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<TypeError>()));
|
||||
|
||||
await migrateTo27(ctx.db);
|
||||
|
||||
final buckets = await query.get();
|
||||
expect(buckets, hasLength(1));
|
||||
expect(buckets.first.assetCount, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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', () {
|
||||
|
||||
Reference in New Issue
Block a user