diff --git a/mobile/drift_schemas/main/drift_schema_v8.json b/mobile/drift_schemas/main/drift_schema_v8.json new file mode 100644 index 000000000..6a4fe73fc Binary files /dev/null and b/mobile/drift_schemas/main/drift_schema_v8.json differ diff --git a/mobile/integration_test/test_utils/general_helper.dart b/mobile/integration_test/test_utils/general_helper.dart index 550f44b50..d6065170e 100644 --- a/mobile/integration_test/test_utils/general_helper.dart +++ b/mobile/integration_test/test_utils/general_helper.dart @@ -4,7 +4,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart'; import 'package:immich_mobile/main.dart' as app; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; @@ -40,15 +39,18 @@ class ImmichTestHelper { static Future loadApp(WidgetTester tester) async { await EasyLocalization.ensureInitialized(); // Clear all data from Isar (reuse existing instance if available) - final db = await Bootstrap.initIsar(); - final logDb = DriftLogger(); - await Bootstrap.initDomain(db, logDb); + final (isar, drift, logDb) = await Bootstrap.initDB(); + await Bootstrap.initDomain(isar, drift, logDb); await Store.clear(); - await db.writeTxn(() => db.clear()); + await isar.writeTxn(() => isar.clear()); // Load main Widget await tester.pumpWidget( ProviderScope( - overrides: [dbProvider.overrideWithValue(db), isarProvider.overrideWithValue(db)], + overrides: [ + dbProvider.overrideWithValue(isar), + isarProvider.overrideWithValue(isar), + driftProvider.overrideWith(driftOverride(drift)), + ], child: const app.MainWidget(), ), ); diff --git a/mobile/lib/domain/services/log.service.dart b/mobile/lib/domain/services/log.service.dart index 98cb24d9c..1053d5e54 100644 --- a/mobile/lib/domain/services/log.service.dart +++ b/mobile/lib/domain/services/log.service.dart @@ -15,7 +15,7 @@ import 'package:logging/logging.dart'; /// via [IStoreRepository] class LogService { final LogRepository _logRepository; - final IsarStoreRepository _storeRepository; + final IStoreRepository _storeRepository; final List _msgBuffer = []; @@ -38,7 +38,7 @@ class LogService { static Future init({ required LogRepository logRepository, - required IsarStoreRepository storeRepository, + required IStoreRepository storeRepository, bool shouldBuffer = true, }) async { _instance ??= await create( @@ -51,7 +51,7 @@ class LogService { static Future create({ required LogRepository logRepository, - required IsarStoreRepository storeRepository, + required IStoreRepository storeRepository, bool shouldBuffer = true, }) async { final instance = LogService._(logRepository, storeRepository, shouldBuffer); @@ -92,7 +92,7 @@ class LogService { } Future setLogLevel(LogLevel level) async { - await _storeRepository.insert(StoreKey.logLevel, level.index); + await _storeRepository.upsert(StoreKey.logLevel, level.index); Logger.root.level = level.toLevel(); } diff --git a/mobile/lib/domain/services/store.service.dart b/mobile/lib/domain/services/store.service.dart index dc845b70f..3347134ae 100644 --- a/mobile/lib/domain/services/store.service.dart +++ b/mobile/lib/domain/services/store.service.dart @@ -6,13 +6,13 @@ import 'package:immich_mobile/infrastructure/repositories/store.repository.dart' /// Provides access to a persistent key-value store with an in-memory cache. /// Listens for repository changes to keep the cache updated. class StoreService { - final IsarStoreRepository _storeRepository; + final IStoreRepository _storeRepository; /// In-memory cache. Keys are [StoreKey.id] final Map _cache = {}; - late final StreamSubscription _storeUpdateSubscription; + late final StreamSubscription> _storeUpdateSubscription; - StoreService._({required IsarStoreRepository storeRepository}) : _storeRepository = storeRepository; + StoreService._({required IStoreRepository isarStoreRepository}) : _storeRepository = isarStoreRepository; // TODO: Temporary typedef to make minimal changes. Remove this and make the presentation layer access store through a provider static StoreService? _instance; @@ -24,27 +24,29 @@ class StoreService { } // TODO: Replace the implementation with the one from create after removing the typedef - static Future init({required IsarStoreRepository storeRepository}) async { + static Future init({required IStoreRepository storeRepository}) async { _instance ??= await create(storeRepository: storeRepository); return _instance!; } - static Future create({required IsarStoreRepository storeRepository}) async { - final instance = StoreService._(storeRepository: storeRepository); - await instance._populateCache(); + static Future create({required IStoreRepository storeRepository}) async { + final instance = StoreService._(isarStoreRepository: storeRepository); + await instance.populateCache(); instance._storeUpdateSubscription = instance._listenForChange(); return instance; } - Future _populateCache() async { + Future populateCache() async { final storeValues = await _storeRepository.getAll(); for (StoreDto storeValue in storeValues) { _cache[storeValue.key.id] = storeValue.value; } } - StreamSubscription _listenForChange() => _storeRepository.watchAll().listen((event) { - _cache[event.key.id] = event.value; + StreamSubscription> _listenForChange() => _storeRepository.watchAll().listen((events) { + for (final event in events) { + _cache[event.key.id] = event.value; + } }); /// Disposes the store and cancels the subscription. To reuse the store call init() again @@ -69,7 +71,7 @@ class StoreService { /// Stores the [value] for the [key]. Skips write if value hasn't changed. Future put, T>(U key, T value) async { if (_cache[key.id] == value) return; - await _storeRepository.insert(key, value); + await _storeRepository.upsert(key, value); _cache[key.id] = value; } diff --git a/mobile/lib/infrastructure/entities/store.entity.dart b/mobile/lib/infrastructure/entities/store.entity.dart index 8d6d9a7d1..d4b3eec84 100644 --- a/mobile/lib/infrastructure/entities/store.entity.dart +++ b/mobile/lib/infrastructure/entities/store.entity.dart @@ -1,3 +1,5 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; import 'package:isar/isar.dart'; part 'store.entity.g.dart'; @@ -11,3 +13,13 @@ class StoreValue { const StoreValue(this.id, {this.intValue, this.strValue}); } + +class StoreEntity extends Table with DriftDefaultsMixin { + IntColumn get id => integer()(); + + TextColumn get stringValue => text().nullable()(); + IntColumn get intValue => integer().nullable()(); + + @override + Set get primaryKey => {id}; +} diff --git a/mobile/lib/infrastructure/entities/store.entity.drift.dart b/mobile/lib/infrastructure/entities/store.entity.drift.dart new file mode 100644 index 000000000..327b0e95d Binary files /dev/null and b/mobile/lib/infrastructure/entities/store.entity.drift.dart differ diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart index c829b7c58..386de2269 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.dart @@ -18,6 +18,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity. import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/stack.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart'; @@ -58,6 +59,7 @@ class IsarDatabaseRepository implements IDatabaseRepository { StackEntity, PersonEntity, AssetFaceEntity, + StoreEntity, ], include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'}, ) @@ -66,7 +68,7 @@ class Drift extends $Drift implements IDatabaseRepository { : super(executor ?? driftDatabase(name: 'immich', native: const DriftNativeOptions(shareAcrossIsolates: true))); @override - int get schemaVersion => 7; + int get schemaVersion => 8; @override MigrationStrategy get migration => MigrationStrategy( @@ -118,6 +120,9 @@ class Drift extends $Drift implements IDatabaseRepository { from6To7: (m, v7) async { await m.createIndex(v7.idxLatLng); }, + from7To8: (m, v8) async { + await m.create(v8.storeEntity); + }, ), ); diff --git a/mobile/lib/infrastructure/repositories/db.repository.drift.dart b/mobile/lib/infrastructure/repositories/db.repository.drift.dart index fd170fc22..456296e2d 100644 Binary files a/mobile/lib/infrastructure/repositories/db.repository.drift.dart and b/mobile/lib/infrastructure/repositories/db.repository.drift.dart differ diff --git a/mobile/lib/infrastructure/repositories/db.repository.steps.dart b/mobile/lib/infrastructure/repositories/db.repository.steps.dart index a5e8c010c..68c54174b 100644 Binary files a/mobile/lib/infrastructure/repositories/db.repository.steps.dart and b/mobile/lib/infrastructure/repositories/db.repository.steps.dart differ diff --git a/mobile/lib/infrastructure/repositories/store.repository.dart b/mobile/lib/infrastructure/repositories/store.repository.dart index 6467767aa..5aea63117 100644 --- a/mobile/lib/infrastructure/repositories/store.repository.dart +++ b/mobile/lib/infrastructure/repositories/store.repository.dart @@ -1,16 +1,30 @@ +import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/user.repository.dart'; import 'package:isar/isar.dart'; -class IsarStoreRepository extends IsarDatabaseRepository { +// Temporary interface until Isar is removed to make the service work with both Isar and Sqlite +abstract class IStoreRepository { + Future deleteAll(); + Stream>> watchAll(); + Future delete(StoreKey key); + Future upsert(StoreKey key, T value); + Future tryGet(StoreKey key); + Stream watch(StoreKey key); + Future>> getAll(); +} + +class IsarStoreRepository extends IsarDatabaseRepository implements IStoreRepository { final Isar _db; final validStoreKeys = StoreKey.values.map((e) => e.id).toSet(); IsarStoreRepository(super.db) : _db = db; + @override Future deleteAll() async { return await transaction(() async { await _db.storeValues.clear(); @@ -18,25 +32,29 @@ class IsarStoreRepository extends IsarDatabaseRepository { }); } - Stream> watchAll() { + @override + Stream>> watchAll() { return _db.storeValues .filter() .anyOf(validStoreKeys, (query, id) => query.idEqualTo(id)) .watch(fireImmediately: true) - .asyncExpand((entities) => Stream.fromFutures(entities.map((e) async => _toUpdateEvent(e)))); + .asyncMap((entities) => Future.wait(entities.map((entity) => _toUpdateEvent(entity)))); } + @override Future delete(StoreKey key) async { return await transaction(() async => await _db.storeValues.delete(key.id)); } - Future insert(StoreKey key, T value) async { + @override + Future upsert(StoreKey key, T value) async { return await transaction(() async { await _db.storeValues.put(await _fromValue(key, value)); return true; }); } + @override Future tryGet(StoreKey key) async { final entity = (await _db.storeValues.get(key.id)); if (entity == null) { @@ -45,13 +63,7 @@ class IsarStoreRepository extends IsarDatabaseRepository { return await _toValue(key, entity); } - Future update(StoreKey key, T value) async { - return await transaction(() async { - await _db.storeValues.put(await _fromValue(key, value)); - return true; - }); - } - + @override Stream watch(StoreKey key) async* { yield* _db.storeValues .watchObject(key.id, fireImmediately: true) @@ -88,8 +100,93 @@ class IsarStoreRepository extends IsarDatabaseRepository { return StoreValue(key.id, intValue: intValue, strValue: strValue); } + @override Future>> getAll() async { final entities = await _db.storeValues.filter().anyOf(validStoreKeys, (query, id) => query.idEqualTo(id)).findAll(); return Future.wait(entities.map((e) => _toUpdateEvent(e)).toList()); } } + +class DriftStoreRepository extends DriftDatabaseRepository implements IStoreRepository { + final Drift _db; + final validStoreKeys = StoreKey.values.map((e) => e.id).toSet(); + + DriftStoreRepository(super.db) : _db = db; + + @override + Future deleteAll() async { + await _db.storeEntity.deleteAll(); + return true; + } + + @override + Future>> getAll() async { + final query = _db.storeEntity.select()..where((entity) => entity.id.isIn(validStoreKeys)); + return query.asyncMap((entity) => _toUpdateEvent(entity)).get(); + } + + @override + Stream>> watchAll() { + final query = _db.storeEntity.select()..where((entity) => entity.id.isIn(validStoreKeys)); + + return query.asyncMap((entity) => _toUpdateEvent(entity)).watch(); + } + + @override + Future delete(StoreKey key) async { + await _db.storeEntity.deleteWhere((entity) => entity.id.equals(key.id)); + return; + } + + @override + Future upsert(StoreKey key, T value) async { + await _db.storeEntity.insertOnConflictUpdate(await _fromValue(key, value)); + return true; + } + + @override + Future tryGet(StoreKey key) async { + final entity = await _db.managers.storeEntity.filter((entity) => entity.id.equals(key.id)).getSingleOrNull(); + if (entity == null) { + return null; + } + return await _toValue(key, entity); + } + + @override + Stream watch(StoreKey key) async* { + final query = _db.storeEntity.select()..where((entity) => entity.id.equals(key.id)); + + yield* query.watchSingleOrNull().asyncMap((e) async => e == null ? null : await _toValue(key, e)); + } + + Future> _toUpdateEvent(StoreEntityData entity) async { + final key = StoreKey.values.firstWhere((e) => e.id == entity.id) as StoreKey; + final value = await _toValue(key, entity); + return StoreDto(key, value); + } + + Future _toValue(StoreKey key, StoreEntityData entity) async => + switch (key.type) { + const (int) => entity.intValue, + const (String) => entity.stringValue, + const (bool) => entity.intValue == 1, + const (DateTime) => entity.intValue == null ? null : DateTime.fromMillisecondsSinceEpoch(entity.intValue!), + const (UserDto) => + entity.stringValue == null ? null : await DriftUserRepository(_db).get(entity.stringValue!), + _ => null, + } + as T?; + + Future _fromValue(StoreKey key, T value) async { + final (int? intValue, String? strValue) = switch (key.type) { + const (int) => (value as int, null), + const (String) => (null, value as String), + const (bool) => ((value as bool) ? 1 : 0, null), + const (DateTime) => ((value as DateTime).millisecondsSinceEpoch, null), + const (UserDto) => (null, (await DriftUserRepository(_db).upsert(value as UserDto)).id), + _ => throw UnsupportedError("Unsupported primitive type: ${key.type} for key: ${key.name}"), + }; + return StoreEntityCompanion(id: Value(key.id), intValue: Value(intValue), stringValue: Value(strValue)); + } +} diff --git a/mobile/lib/infrastructure/repositories/user.repository.dart b/mobile/lib/infrastructure/repositories/user.repository.dart index 2c6d72139..1caab462c 100644 --- a/mobile/lib/infrastructure/repositories/user.repository.dart +++ b/mobile/lib/infrastructure/repositories/user.repository.dart @@ -1,6 +1,8 @@ +import 'package:drift/drift.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart' as entity; +import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:isar/isar.dart'; @@ -63,3 +65,40 @@ class IsarUserRepository extends IsarDatabaseRepository { return true; } } + +class DriftUserRepository extends DriftDatabaseRepository { + final Drift _db; + const DriftUserRepository(super.db) : _db = db; + + Future get(String id) => + _db.managers.userEntity.filter((user) => user.id.equals(id)).getSingleOrNull().then((user) => user?.toDto()); + + Future upsert(UserDto user) async { + await _db.userEntity.insertOnConflictUpdate( + UserEntityCompanion( + id: Value(user.id), + isAdmin: Value(user.isAdmin), + updatedAt: Value(user.updatedAt), + name: Value(user.name), + email: Value(user.email), + hasProfileImage: Value(user.hasProfileImage), + profileChangedAt: Value(user.profileChangedAt), + ), + ); + return user; + } +} + +extension on UserEntityData { + UserDto toDto() { + return UserDto( + id: id, + email: email, + name: name, + isAdmin: isAdmin, + updatedAt: updatedAt, + profileChangedAt: profileChangedAt, + hasProfileImage: hasProfileImage, + ); + } +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 8ac989183..0cab21748 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -14,7 +14,6 @@ import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/locales.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/generated/codegen_loader.g.dart'; -import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.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/db.provider.dart'; @@ -41,18 +40,21 @@ import 'package:worker_manager/worker_manager.dart'; void main() async { ImmichWidgetsBinding(); - final db = await Bootstrap.initIsar(); - final logDb = DriftLogger(); - await Bootstrap.initDomain(db, logDb); + final (isar, drift, logDb) = await Bootstrap.initDB(); + await Bootstrap.initDomain(isar, drift, logDb); await initApp(); // Warm-up isolate pool for worker manager await workerManager.init(dynamicSpawning: true); - await migrateDatabaseIfNeeded(db); + await migrateDatabaseIfNeeded(isar, drift); HttpSSLOptions.apply(); runApp( ProviderScope( - overrides: [dbProvider.overrideWithValue(db), isarProvider.overrideWithValue(db)], + overrides: [ + dbProvider.overrideWithValue(isar), + isarProvider.overrideWithValue(isar), + driftProvider.overrideWith(driftOverride(drift)), + ], child: const MainWidget(), ), ); diff --git a/mobile/lib/pages/common/change_experience.page.dart b/mobile/lib/pages/common/change_experience.page.dart index 45392a38f..3e9747ce3 100644 --- a/mobile/lib/pages/common/change_experience.page.dart +++ b/mobile/lib/pages/common/change_experience.page.dart @@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; @@ -13,8 +14,8 @@ import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; -import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/migration.dart'; +import 'package:logging/logging.dart'; import 'package:permission_handler/permission_handler.dart'; @RoutePage() @@ -28,7 +29,7 @@ class ChangeExperiencePage extends ConsumerStatefulWidget { } class _ChangeExperiencePageState extends ConsumerState { - bool hasMigrated = false; + AsyncValue hasMigrated = const AsyncValue.loading(); @override void initState() { @@ -37,46 +38,60 @@ class _ChangeExperiencePageState extends ConsumerState { } Future _handleMigration() async { - if (widget.switchingToBeta) { - final assetNotifier = ref.read(assetProvider.notifier); - if (assetNotifier.mounted) { - assetNotifier.dispose(); - } - final albumNotifier = ref.read(albumProvider.notifier); - if (albumNotifier.mounted) { - albumNotifier.dispose(); + try { + if (widget.switchingToBeta) { + final assetNotifier = ref.read(assetProvider.notifier); + if (assetNotifier.mounted) { + assetNotifier.dispose(); + } + final albumNotifier = ref.read(albumProvider.notifier); + if (albumNotifier.mounted) { + albumNotifier.dispose(); + } + + // Cancel uploads + await Store.put(StoreKey.backgroundBackup, false); + ref + .read(backupProvider.notifier) + .configureBackgroundBackup(enabled: false, onBatteryInfo: () {}, onError: (_) {}); + ref.read(backupProvider.notifier).setAutoBackup(false); + ref.read(backupProvider.notifier).cancelBackup(); + ref.read(manualUploadProvider.notifier).cancelBackup(); + // Start listening to new websocket events + ref.read(websocketProvider.notifier).stopListenToOldEvents(); + ref.read(websocketProvider.notifier).startListeningToBetaEvents(); + + final permission = await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission(); + + if (permission.isGranted) { + await ref.read(backgroundSyncProvider).syncLocal(full: true); + await migrateDeviceAssetToSqlite(ref.read(isarProvider), ref.read(driftProvider)); + await migrateBackupAlbumsToSqlite(ref.read(isarProvider), ref.read(driftProvider)); + await migrateStoreToSqlite(ref.read(isarProvider), ref.read(driftProvider)); + } + } else { + await ref.read(backgroundSyncProvider).cancel(); + ref.read(websocketProvider.notifier).stopListeningToBetaEvents(); + ref.read(websocketProvider.notifier).startListeningToOldEvents(); + await migrateStoreToIsar(ref.read(isarProvider), ref.read(driftProvider)); } - // Cancel uploads - await Store.put(StoreKey.backgroundBackup, false); - ref - .read(backupProvider.notifier) - .configureBackgroundBackup(enabled: false, onBatteryInfo: () {}, onError: (_) {}); - ref.read(backupProvider.notifier).setAutoBackup(false); - ref.read(backupProvider.notifier).cancelBackup(); - ref.read(manualUploadProvider.notifier).cancelBackup(); - // Start listening to new websocket events - ref.read(websocketProvider.notifier).stopListenToOldEvents(); - ref.read(websocketProvider.notifier).startListeningToBetaEvents(); + await IsarStoreRepository(ref.read(isarProvider)).upsert(StoreKey.betaTimeline, widget.switchingToBeta); + await DriftStoreRepository(ref.read(driftProvider)).upsert(StoreKey.betaTimeline, widget.switchingToBeta); - final permission = await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission(); - - if (permission.isGranted) { - await ref.read(backgroundSyncProvider).syncLocal(full: true); - await migrateDeviceAssetToSqlite(ref.read(isarProvider), ref.read(driftProvider)); - await migrateBackupAlbumsToSqlite(ref.read(isarProvider), ref.read(driftProvider)); + if (mounted) { + setState(() { + HapticFeedback.heavyImpact(); + hasMigrated = const AsyncValue.data(true); + }); + } + } catch (e, s) { + Logger("ChangeExperiencePage").severe("Error during migration", e, s); + if (mounted) { + setState(() { + hasMigrated = AsyncValue.error(e, s); + }); } - } else { - await ref.read(backgroundSyncProvider).cancel(); - ref.read(websocketProvider.notifier).stopListeningToBetaEvents(); - ref.read(websocketProvider.notifier).startListeningToOldEvents(); - } - - if (mounted) { - setState(() { - HapticFeedback.heavyImpact(); - hasMigrated = true; - }); } } @@ -89,44 +104,34 @@ class _ChangeExperiencePageState extends ConsumerState { children: [ AnimatedSwitcher( duration: Durations.long4, - child: hasMigrated - ? const Icon(Icons.check_circle_rounded, color: Colors.green, size: 48.0) - : const SizedBox(width: 50.0, height: 50.0, child: CircularProgressIndicator()), + child: hasMigrated.when( + data: (data) => const Icon(Icons.check_circle_rounded, color: Colors.green, size: 48.0), + error: (error, stackTrace) => const Icon(Icons.error, color: Colors.red, size: 48.0), + loading: () => const SizedBox(width: 50.0, height: 50.0, child: CircularProgressIndicator()), + ), ), const SizedBox(height: 16.0), - Center( - child: Column( - children: [ - SizedBox( - width: 300.0, - child: AnimatedSwitcher( - duration: Durations.long4, - child: hasMigrated - ? Text( - "Migration success!", - style: context.textTheme.titleMedium, - textAlign: TextAlign.center, - ) - : Text( - "Data migration in progress...\nPlease wait and don't close this page", - style: context.textTheme.titleMedium, - textAlign: TextAlign.center, - ), - ), + SizedBox( + width: 300.0, + child: AnimatedSwitcher( + duration: Durations.long4, + child: hasMigrated.when( + data: (data) => Text( + "Migration success!\nPlease close and reopen the app to apply changes", + style: context.textTheme.titleMedium, + textAlign: TextAlign.center, ), - if (hasMigrated) - Padding( - padding: const EdgeInsets.only(top: 16.0), - child: ElevatedButton( - onPressed: () { - context.replaceRoute( - widget.switchingToBeta ? const TabShellRoute() : const TabControllerRoute(), - ); - }, - child: const Text("Continue"), - ), - ), - ], + error: (error, stackTrace) => Text( + "Migration failed!\nError: $error", + style: context.textTheme.titleMedium, + textAlign: TextAlign.center, + ), + loading: () => Text( + "Data migration in progress...\nPlease wait and don't close this page", + style: context.textTheme.titleMedium, + textAlign: TextAlign.center, + ), + ), ), ), ], diff --git a/mobile/lib/providers/infrastructure/db.provider.dart b/mobile/lib/providers/infrastructure/db.provider.dart index cdf934e50..d38bcbfb5 100644 --- a/mobile/lib/providers/infrastructure/db.provider.dart +++ b/mobile/lib/providers/infrastructure/db.provider.dart @@ -10,9 +10,12 @@ part 'db.provider.g.dart'; @Riverpod(keepAlive: true) Isar isar(Ref ref) => throw UnimplementedError('isar'); -final driftProvider = Provider((ref) { - final drift = Drift(); +Drift Function(Ref ref) driftOverride(Drift drift) => (ref) { ref.onDispose(() => unawaited(drift.close())); ref.keepAlive(); return drift; -}); +}; + +final driftProvider = Provider( + (ref) => throw UnimplementedError("driftProvider must be overridden in the isolate's ProviderContainer before use"), +); diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index 3bcc93f19..e6436df24 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -14,7 +14,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; @@ -331,11 +330,16 @@ class BackgroundService { } Future _onAssetsChanged() async { - final db = await Bootstrap.initIsar(); - final logDb = DriftLogger(); - await Bootstrap.initDomain(db, logDb); + final (isar, drift, logDb) = await Bootstrap.initDB(); + await Bootstrap.initDomain(isar, drift, logDb); - final ref = ProviderContainer(overrides: [dbProvider.overrideWithValue(db), isarProvider.overrideWithValue(db)]); + final ref = ProviderContainer( + overrides: [ + dbProvider.overrideWithValue(isar), + isarProvider.overrideWithValue(isar), + driftProvider.overrideWith(driftOverride(drift)), + ], + ); HttpSSLOptions.apply(); ref.read(apiServiceProvider).setAccessToken(Store.get(StoreKey.accessToken)); diff --git a/mobile/lib/services/backup_verification.service.dart b/mobile/lib/services/backup_verification.service.dart index 8f39fd17e..94c4721cc 100644 --- a/mobile/lib/services/backup_verification.service.dart +++ b/mobile/lib/services/backup_verification.service.dart @@ -11,7 +11,6 @@ import 'package:immich_mobile/domain/services/user.service.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/exif.repository.dart'; -import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart'; import 'package:immich_mobile/infrastructure/utils/exif.converter.dart'; import 'package:immich_mobile/providers/infrastructure/exif.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; @@ -116,9 +115,8 @@ class BackupVerificationService { assert(tuple.deleteCandidates.length == tuple.originals.length); final List result = []; BackgroundIsolateBinaryMessenger.ensureInitialized(tuple.rootIsolateToken); - final db = await Bootstrap.initIsar(); - final logDb = DriftLogger(); - await Bootstrap.initDomain(db, logDb); + final (isar, drift, logDb) = await Bootstrap.initDB(); + await Bootstrap.initDomain(isar, drift, logDb); await tuple.fileMediaRepository.enableBackgroundAccess(); final ApiService apiService = ApiService(); apiService.setEndpoint(tuple.endpoint); diff --git a/mobile/lib/utils/bootstrap.dart b/mobile/lib/utils/bootstrap.dart index d2ad5ea16..480d918b4 100644 --- a/mobile/lib/utils/bootstrap.dart +++ b/mobile/lib/utils/bootstrap.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/entities/album.entity.dart'; @@ -14,6 +15,7 @@ import 'package:immich_mobile/infrastructure/entities/device_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'; import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/log.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; @@ -21,18 +23,23 @@ import 'package:isar/isar.dart'; import 'package:path_provider/path_provider.dart'; abstract final class Bootstrap { - static Future initIsar() async { - if (Isar.getInstance() != null) { - return Isar.getInstance()!; + static Future<(Isar isar, Drift drift, DriftLogger logDb)> initDB() async { + final drift = Drift(); + final logDb = DriftLogger(); + + Isar? isar = Isar.getInstance(); + + if (isar != null) { + return (isar, drift, logDb); } final dir = await getApplicationDocumentsDirectory(); - return await Isar.open( + isar = await Isar.open( [ StoreValueSchema, - ExifInfoSchema, AssetSchema, AlbumSchema, + ExifInfoSchema, UserSchema, BackupAlbumSchema, DuplicatedAssetSchema, @@ -45,14 +52,19 @@ abstract final class Bootstrap { maxSizeMiB: 2048, inspector: kDebugMode, ); + + return (isar, drift, logDb); } - static Future initDomain(Isar db, DriftLogger logDb, {bool shouldBufferLogs = true}) async { - await StoreService.init(storeRepository: IsarStoreRepository(db)); + static Future initDomain(Isar db, Drift drift, DriftLogger logDb, {bool shouldBufferLogs = true}) async { + final isBeta = await IsarStoreRepository(db).tryGet(StoreKey.betaTimeline) ?? false; + final IStoreRepository storeRepo = isBeta ? DriftStoreRepository(drift) : IsarStoreRepository(db); + + await StoreService.init(storeRepository: storeRepo); await LogService.init( logRepository: LogRepository(logDb), - storeRepository: IsarStoreRepository(db), + storeRepository: storeRepo, shouldBuffer: shouldBufferLogs, ); } diff --git a/mobile/lib/utils/isolate.dart b/mobile/lib/utils/isolate.dart index 2dfd9d4f5..58e7ad7f2 100644 --- a/mobile/lib/utils/isolate.dart +++ b/mobile/lib/utils/isolate.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/services/log.service.dart'; -import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; @@ -35,15 +34,15 @@ Cancelable runInIsolateGentle({ BackgroundIsolateBinaryMessenger.ensureInitialized(token); DartPluginRegistrant.ensureInitialized(); - final db = await Bootstrap.initIsar(); - final logDb = DriftLogger(); - await Bootstrap.initDomain(db, logDb, shouldBufferLogs: false); + final (isar, drift, logDb) = await Bootstrap.initDB(); + await Bootstrap.initDomain(isar, drift, logDb, shouldBufferLogs: false); final ref = ProviderContainer( overrides: [ // TODO: Remove once isar is removed - dbProvider.overrideWithValue(db), - isarProvider.overrideWithValue(db), + dbProvider.overrideWithValue(isar), + isarProvider.overrideWithValue(isar), cancellationProvider.overrideWithValue(cancelledChecker), + driftProvider.overrideWith(driftOverride(drift)), ], ); diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index e5c4bf6ef..9816986b9 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -20,6 +20,7 @@ import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'; import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; @@ -30,9 +31,10 @@ import 'package:logging/logging.dart'; // ignore: import_rule_photo_manager import 'package:photo_manager/photo_manager.dart'; -const int targetVersion = 13; +const int targetVersion = 14; -Future migrateDatabaseIfNeeded(Isar db) async { +Future migrateDatabaseIfNeeded(Isar db, Drift drift) async { + final hasVersion = Store.tryGet(StoreKey.version) != null; final int version = Store.get(StoreKey.version, targetVersion); if (version < 9) { @@ -58,6 +60,12 @@ Future migrateDatabaseIfNeeded(Isar db) async { await Store.put(StoreKey.photoManagerCustomFilter, true); } + // This means that the SQLite DB is just created and has no version + if (version < 14 || !hasVersion) { + await migrateStoreToSqlite(db, drift); + await Store.populateCache(); + } + if (targetVersion >= 12) { await Store.put(StoreKey.version, targetVersion); return; @@ -215,6 +223,39 @@ Future migrateBackupAlbumsToSqlite(Isar db, Drift drift) async { } } +Future migrateStoreToSqlite(Isar db, Drift drift) async { + try { + final isarStoreValues = await db.storeValues.where().findAll(); + await drift.batch((batch) { + for (final storeValue in isarStoreValues) { + final companion = StoreEntityCompanion( + id: Value(storeValue.id), + stringValue: Value(storeValue.strValue), + intValue: Value(storeValue.intValue), + ); + batch.insert(drift.storeEntity, companion, onConflict: DoUpdate((_) => companion)); + } + }); + } catch (error) { + debugPrint("[MIGRATION] Error while migrating store values to SQLite: $error"); + } +} + +Future migrateStoreToIsar(Isar db, Drift drift) async { + try { + final driftStoreValues = await drift.storeEntity + .select() + .map((entity) => StoreValue(entity.id, intValue: entity.intValue, strValue: entity.stringValue)) + .get(); + + await db.writeTxn(() async { + await db.storeValues.putAll(driftStoreValues); + }); + } catch (error) { + debugPrint("[MIGRATION] Error while migrating store values to Isar: $error"); + } +} + class _DeviceAsset { final String assetId; final List? hash; diff --git a/mobile/lib/widgets/settings/beta_timeline_list_tile.dart b/mobile/lib/widgets/settings/beta_timeline_list_tile.dart index 3d41094b7..a49814527 100644 --- a/mobile/lib/widgets/settings/beta_timeline_list_tile.dart +++ b/mobile/lib/widgets/settings/beta_timeline_list_tile.dart @@ -89,7 +89,6 @@ class _BetaTimelineListTileState extends ConsumerState wit ElevatedButton( onPressed: () async { Navigator.of(context).pop(); - await ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.betaTimeline, value); context.router.replaceAll([ChangeExperienceRoute(switchingToBeta: value)]); }, child: Text("ok".t(context: context)), diff --git a/mobile/test/domain/services/log_service_test.dart b/mobile/test/domain/services/log_service_test.dart index b4feac4e2..95f677ba9 100644 --- a/mobile/test/domain/services/log_service_test.dart +++ b/mobile/test/domain/services/log_service_test.dart @@ -64,12 +64,12 @@ void main() { group("Log Service Set Level:", () { setUp(() async { - when(() => mockStoreRepo.insert(StoreKey.logLevel, any())).thenAnswer((_) async => true); + when(() => mockStoreRepo.upsert(StoreKey.logLevel, any())).thenAnswer((_) async => true); await sut.setLogLevel(LogLevel.shout); }); test('Updates the log level in store', () { - final index = verify(() => mockStoreRepo.insert(StoreKey.logLevel, captureAny())).captured.firstOrNull; + final index = verify(() => mockStoreRepo.upsert(StoreKey.logLevel, captureAny())).captured.firstOrNull; expect(index, LogLevel.shout.index); }); diff --git a/mobile/test/domain/services/store_service_test.dart b/mobile/test/domain/services/store_service_test.dart index d23913991..d03e49384 100644 --- a/mobile/test/domain/services/store_service_test.dart +++ b/mobile/test/domain/services/store_service_test.dart @@ -16,11 +16,13 @@ final _kBackupFailedSince = DateTime.utc(2023); void main() { late StoreService sut; late IsarStoreRepository mockStoreRepo; - late StreamController> controller; + late DriftStoreRepository mockDriftStoreRepo; + late StreamController>> controller; setUp(() async { - controller = StreamController>.broadcast(); + controller = StreamController>>.broadcast(); mockStoreRepo = MockStoreRepository(); + mockDriftStoreRepo = MockDriftStoreRepository(); // For generics, we need to provide fallback to each concrete type to avoid runtime errors registerFallbackValue(StoreKey.accessToken); registerFallbackValue(StoreKey.backupTriggerDelay); @@ -37,6 +39,16 @@ void main() { ); when(() => mockStoreRepo.watchAll()).thenAnswer((_) => controller.stream); + when(() => mockDriftStoreRepo.getAll()).thenAnswer( + (_) async => [ + const StoreDto(StoreKey.accessToken, _kAccessToken), + const StoreDto(StoreKey.backgroundBackup, _kBackgroundBackup), + const StoreDto(StoreKey.groupAssetsBy, _kGroupAssetsBy), + StoreDto(StoreKey.backupFailedSince, _kBackupFailedSince), + ], + ); + when(() => mockDriftStoreRepo.watchAll()).thenAnswer((_) => controller.stream); + sut = await StoreService.create(storeRepository: mockStoreRepo); }); @@ -58,7 +70,7 @@ void main() { test('Listens to stream of store updates', () async { final event = StoreDto(StoreKey.accessToken, _kAccessToken.toUpperCase()); - controller.add(event); + controller.add([event]); await pumpEventQueue(); @@ -83,18 +95,19 @@ void main() { group('Store Service put:', () { setUp(() { - when(() => mockStoreRepo.insert(any>(), any())).thenAnswer((_) async => true); + when(() => mockStoreRepo.upsert(any>(), any())).thenAnswer((_) async => true); + when(() => mockDriftStoreRepo.upsert(any>(), any())).thenAnswer((_) async => true); }); test('Skip insert when value is not modified', () async { await sut.put(StoreKey.accessToken, _kAccessToken); - verifyNever(() => mockStoreRepo.insert(StoreKey.accessToken, any())); + verifyNever(() => mockStoreRepo.upsert(StoreKey.accessToken, any())); }); test('Insert value when modified', () async { final newAccessToken = _kAccessToken.toUpperCase(); await sut.put(StoreKey.accessToken, newAccessToken); - verify(() => mockStoreRepo.insert(StoreKey.accessToken, newAccessToken)).called(1); + verify(() => mockStoreRepo.upsert(StoreKey.accessToken, newAccessToken)).called(1); expect(sut.tryGet(StoreKey.accessToken), newAccessToken); }); }); @@ -105,6 +118,7 @@ void main() { setUp(() { valueController = StreamController.broadcast(); when(() => mockStoreRepo.watch(any>())).thenAnswer((_) => valueController.stream); + when(() => mockDriftStoreRepo.watch(any>())).thenAnswer((_) => valueController.stream); }); tearDown(() async { @@ -129,6 +143,7 @@ void main() { group('Store Service delete:', () { setUp(() { when(() => mockStoreRepo.delete(any>())).thenAnswer((_) async => true); + when(() => mockDriftStoreRepo.delete(any>())).thenAnswer((_) async => true); }); test('Removes the value from the DB', () async { @@ -145,6 +160,7 @@ void main() { group('Store Service clear:', () { setUp(() { when(() => mockStoreRepo.deleteAll()).thenAnswer((_) async => true); + when(() => mockDriftStoreRepo.deleteAll()).thenAnswer((_) async => true); }); test('Clears all values from the store', () async { diff --git a/mobile/test/drift/main/generated/schema.dart b/mobile/test/drift/main/generated/schema.dart index 87de9194d..746206e45 100644 Binary files a/mobile/test/drift/main/generated/schema.dart and b/mobile/test/drift/main/generated/schema.dart differ diff --git a/mobile/test/drift/main/generated/schema_v8.dart b/mobile/test/drift/main/generated/schema_v8.dart new file mode 100644 index 000000000..13520e637 Binary files /dev/null and b/mobile/test/drift/main/generated/schema_v8.dart differ diff --git a/mobile/test/infrastructure/repositories/store_repository_test.dart b/mobile/test/infrastructure/repositories/store_repository_test.dart index 84d18ad95..f6424beab 100644 --- a/mobile/test/infrastructure/repositories/store_repository_test.dart +++ b/mobile/test/infrastructure/repositories/store_repository_test.dart @@ -44,7 +44,7 @@ void main() { test('converts int', () async { int? version = await sut.tryGet(StoreKey.version); expect(version, isNull); - await sut.insert(StoreKey.version, _kTestVersion); + await sut.upsert(StoreKey.version, _kTestVersion); version = await sut.tryGet(StoreKey.version); expect(version, _kTestVersion); }); @@ -52,7 +52,7 @@ void main() { test('converts string', () async { String? accessToken = await sut.tryGet(StoreKey.accessToken); expect(accessToken, isNull); - await sut.insert(StoreKey.accessToken, _kTestAccessToken); + await sut.upsert(StoreKey.accessToken, _kTestAccessToken); accessToken = await sut.tryGet(StoreKey.accessToken); expect(accessToken, _kTestAccessToken); }); @@ -60,7 +60,7 @@ void main() { test('converts datetime', () async { DateTime? backupFailedSince = await sut.tryGet(StoreKey.backupFailedSince); expect(backupFailedSince, isNull); - await sut.insert(StoreKey.backupFailedSince, _kTestBackupFailed); + await sut.upsert(StoreKey.backupFailedSince, _kTestBackupFailed); backupFailedSince = await sut.tryGet(StoreKey.backupFailedSince); expect(backupFailedSince, _kTestBackupFailed); }); @@ -68,7 +68,7 @@ void main() { test('converts bool', () async { bool? colorfulInterface = await sut.tryGet(StoreKey.colorfulInterface); expect(colorfulInterface, isNull); - await sut.insert(StoreKey.colorfulInterface, _kTestColorfulInterface); + await sut.upsert(StoreKey.colorfulInterface, _kTestColorfulInterface); colorfulInterface = await sut.tryGet(StoreKey.colorfulInterface); expect(colorfulInterface, _kTestColorfulInterface); }); @@ -76,7 +76,7 @@ void main() { test('converts user', () async { UserDto? user = await sut.tryGet(StoreKey.currentUser); expect(user, isNull); - await sut.insert(StoreKey.currentUser, _kTestUser); + await sut.upsert(StoreKey.currentUser, _kTestUser); user = await sut.tryGet(StoreKey.currentUser); expect(user, _kTestUser); }); @@ -108,10 +108,10 @@ void main() { await _populateStore(db); }); - test('update()', () async { + test('upsert()', () async { int? version = await sut.tryGet(StoreKey.version); expect(version, _kTestVersion); - await sut.update(StoreKey.version, _kTestVersion + 10); + await sut.upsert(StoreKey.version, _kTestVersion + 10); version = await sut.tryGet(StoreKey.version); expect(version, _kTestVersion + 10); }); @@ -126,22 +126,29 @@ void main() { final stream = sut.watch(StoreKey.version); expectLater(stream, emitsInOrder([_kTestVersion, _kTestVersion + 10])); await pumpEventQueue(); - await sut.update(StoreKey.version, _kTestVersion + 10); + await sut.upsert(StoreKey.version, _kTestVersion + 10); }); test('watchAll()', () async { final stream = sut.watchAll(); expectLater( stream, - emitsInAnyOrder([ - emits(const StoreDto(StoreKey.version, _kTestVersion)), - emits(StoreDto(StoreKey.backupFailedSince, _kTestBackupFailed)), - emits(const StoreDto(StoreKey.accessToken, _kTestAccessToken)), - emits(const StoreDto(StoreKey.colorfulInterface, _kTestColorfulInterface)), - emits(const StoreDto(StoreKey.version, _kTestVersion + 10)), + emitsInOrder([ + [ + const StoreDto(StoreKey.version, _kTestVersion), + StoreDto(StoreKey.backupFailedSince, _kTestBackupFailed), + const StoreDto(StoreKey.accessToken, _kTestAccessToken), + const StoreDto(StoreKey.colorfulInterface, _kTestColorfulInterface), + ], + [ + const StoreDto(StoreKey.version, _kTestVersion + 10), + StoreDto(StoreKey.backupFailedSince, _kTestBackupFailed), + const StoreDto(StoreKey.accessToken, _kTestAccessToken), + const StoreDto(StoreKey.colorfulInterface, _kTestColorfulInterface), + ], ]), ); - await sut.update(StoreKey.version, _kTestVersion + 10); + await sut.upsert(StoreKey.version, _kTestVersion + 10); }); }); } diff --git a/mobile/test/infrastructure/repository.mock.dart b/mobile/test/infrastructure/repository.mock.dart index 1fe3af689..1b66451dd 100644 --- a/mobile/test/infrastructure/repository.mock.dart +++ b/mobile/test/infrastructure/repository.mock.dart @@ -14,6 +14,8 @@ import 'package:mocktail/mocktail.dart'; class MockStoreRepository extends Mock implements IsarStoreRepository {} +class MockDriftStoreRepository extends Mock implements DriftStoreRepository {} + class MockLogRepository extends Mock implements LogRepository {} class MockIsarUserRepository extends Mock implements IsarUserRepository {}