From 5ca2c5effc7411585b7ce107e06fe2f4e9d5d8b4 Mon Sep 17 00:00:00 2001 From: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Date: Wed, 17 Jun 2026 18:51:01 +0530 Subject: [PATCH] refactor: migrate app metadata --- .../drift_schemas/main/drift_schema_v32.json | Bin 0 -> 118667 bytes .../background_sync_teardown_test.dart | 4 +- .../lib/domain/models/app_metadata_key.dart | 20 ++ mobile/lib/domain/models/store.model.dart | 8 +- mobile/lib/domain/models/value_codec.dart | 6 +- .../domain/services/local_sync.service.dart | 7 +- .../domain/services/sync_stream.service.dart | 39 ++-- .../entities/app_metadata.entity.dart | 18 ++ .../entities/app_metadata.entity.drift.dart | Bin 0 -> 13776 bytes .../repositories/app_metadata.repository.dart | 28 +++ .../repositories/db.repository.dart | 12 +- .../repositories/db.repository.drift.dart | Bin 16224 -> 16592 bytes .../repositories/db.repository.steps.dart | Bin 497991 -> 515769 bytes .../presentation/widgets/map/map.state.dart | 3 - .../lib/providers/app_settings.provider.dart | 4 - .../infrastructure/app_metadata.provider.dart | 7 + .../infrastructure/sync.provider.dart | 3 + mobile/lib/services/action.service.dart | 9 +- mobile/lib/services/app_settings.service.dart | 23 --- .../utils/hooks/app_settings_update_hook.dart | 13 -- mobile/lib/utils/migration.dart | 174 ++++++++++-------- .../lib/widgets/forms/login/login_form.dart | 11 +- .../widgets/settings/advanced_settings.dart | 12 +- .../asset_list_group_settings.dart | 2 - .../asset_list_layout_settings.dart | 4 - .../asset_list_settings.dart | 2 - .../image_viewer_quality_setting.dart | 2 - .../sync_status_and_actions.dart | 30 +-- mobile/test/domain/service.mock.dart | 3 - .../services/local_sync_service_test.dart | 15 +- .../domain/services/store_service_test.dart | 8 +- .../services/sync_stream_service_test.dart | 35 ++-- mobile/test/drift/main/generated/schema.dart | Bin 3350 -> 3444 bytes .../test/drift/main/generated/schema_v32.dart | Bin 0 -> 336844 bytes mobile/test/drift/main/migration_test.dart | 38 +++- .../repositories/store_repository_test.dart | 24 +-- .../test/infrastructure/repository.mock.dart | 3 + .../app_metadata_repository_test.dart | 90 +++++++++ mobile/test/service.mocks.dart | 3 - mobile/test/services/action.service_test.dart | 12 +- 40 files changed, 440 insertions(+), 232 deletions(-) create mode 100644 mobile/drift_schemas/main/drift_schema_v32.json create mode 100644 mobile/lib/domain/models/app_metadata_key.dart create mode 100644 mobile/lib/infrastructure/entities/app_metadata.entity.dart create mode 100644 mobile/lib/infrastructure/entities/app_metadata.entity.drift.dart create mode 100644 mobile/lib/infrastructure/repositories/app_metadata.repository.dart delete mode 100644 mobile/lib/providers/app_settings.provider.dart create mode 100644 mobile/lib/providers/infrastructure/app_metadata.provider.dart delete mode 100644 mobile/lib/services/app_settings.service.dart delete mode 100644 mobile/lib/utils/hooks/app_settings_update_hook.dart create mode 100644 mobile/test/drift/main/generated/schema_v32.dart create mode 100644 mobile/test/medium/repositories/app_metadata_repository_test.dart diff --git a/mobile/drift_schemas/main/drift_schema_v32.json b/mobile/drift_schemas/main/drift_schema_v32.json new file mode 100644 index 0000000000000000000000000000000000000000..b204ce1b03521b5b1f6cd1c6f312954652c33a61 GIT binary patch literal 118667 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 F|37*N=Q{uZ literal 0 HcmV?d00001 diff --git a/mobile/integration_test/background_sync_teardown_test.dart b/mobile/integration_test/background_sync_teardown_test.dart index dd81106e62..843fad96b6 100644 --- a/mobile/integration_test/background_sync_teardown_test.dart +++ b/mobile/integration_test/background_sync_teardown_test.dart @@ -34,14 +34,14 @@ void main() { server = await FakeImmichServer.start(); await ApiService().resolveAndSetEndpoint(server.endpoint); await drift.delete(drift.userEntity).go(); - await Store.delete(StoreKey.syncMigrationStatus); + await Store.delete(StoreKey.legacySyncMigrationStatus); }); tearDown(() async { await workerManagerPatch.dispose(); await server.close(); await Store.delete(StoreKey.legacyServerEndpoint); - await Store.delete(StoreKey.syncMigrationStatus); + await Store.delete(StoreKey.legacySyncMigrationStatus); }); void sendUser(SyncStream stream, String id, String name) { diff --git a/mobile/lib/domain/models/app_metadata_key.dart b/mobile/lib/domain/models/app_metadata_key.dart new file mode 100644 index 0000000000..c557683ff7 --- /dev/null +++ b/mobile/lib/domain/models/app_metadata_key.dart @@ -0,0 +1,20 @@ +import 'package:immich_mobile/domain/models/value_codec.dart'; + +const int kCurrentVersion = 29; + +enum AppMetadataKey { + version(kCurrentVersion), + syncMigrationStatus>([], codec: ListCodec(PrimitiveCodec.string)), + manageLocalMediaAndroid(false); + + const AppMetadataKey(this.defaultValue, {ValueCodec? codec}) : _codecOverride = codec; + + final T defaultValue; + + final ValueCodec? _codecOverride; + ValueCodec get _codec => _codecOverride ?? ValueCodec.forType(T); + + String encode(T value) => _codec.encode(value); + + T decode(String raw) => _codec.decode(raw); +} diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index 2c4042b044..80934d7d46 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -1,14 +1,12 @@ /// Key for each possible value in the `Store`. /// Defines the data type for each value enum StoreKey { - version._(0), deviceId._(4), - manageLocalMediaAndroid._(137), - - syncMigrationStatus._(1013), - // Legacy keys that have been migrated to the new metadata store + legacyVersion._(0), + legacyManageLocalMediaAndroid._(137), + legacySyncMigrationStatus._(1013), legacyAdvancedTroubleshooting._(114), legacyEnableHapticFeedback._(126), legacyReadonlyModeEnabled._(138), diff --git a/mobile/lib/domain/models/value_codec.dart b/mobile/lib/domain/models/value_codec.dart index afc1b86e91..73496f184b 100644 --- a/mobile/lib/domain/models/value_codec.dart +++ b/mobile/lib/domain/models/value_codec.dart @@ -104,19 +104,19 @@ final class ListCodec extends ValueCodec> { try { final decoded = jsonDecode(raw); if (decoded is! List) { - return []; + return const []; } final result = []; for (final item in decoded) { if (item is! String) { - return []; + return const []; } final element = _elementCodec.decode(item); result.add(element); } return result; } on FormatException { - return []; + return const []; } } } diff --git a/mobile/lib/domain/services/local_sync.service.dart b/mobile/lib/domain/services/local_sync.service.dart index feb104f90d..76f6b0a0d0 100644 --- a/mobile/lib/domain/services/local_sync.service.dart +++ b/mobile/lib/domain/services/local_sync.service.dart @@ -5,9 +5,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.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/entities/store.entity.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; +import 'package:immich_mobile/infrastructure/repositories/app_metadata.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; @@ -28,6 +27,7 @@ class LocalSyncService { final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository; final AssetMediaRepository _assetMediaRepository; final IPermissionRepository _permissionRepository; + final AppMetadataRepository _appMetadataRepository; final Completer? _cancellation; final Logger _log = Logger("DeviceSyncService"); @@ -38,6 +38,7 @@ class LocalSyncService { required this._trashedLocalAssetRepository, required this._assetMediaRepository, required this._permissionRepository, + required this._appMetadataRepository, this._cancellation, }) { _cancellation?.future.then((_) => _nativeSyncApi.cancelSync().onError(_log.warning)); @@ -48,7 +49,7 @@ class LocalSyncService { Future sync({bool full = false}) async { final Stopwatch stopwatch = Stopwatch()..start(); try { - if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) { + if (CurrentPlatform.isAndroid && await _appMetadataRepository.get(.manageLocalMediaAndroid)) { final hasPermission = await _permissionRepository.hasManageMediaPermission(); if (hasPermission) { await _syncTrashedAssets(); diff --git a/mobile/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart index 5a197dc88a..37bbdce5aa 100644 --- a/mobile/lib/domain/services/sync_stream.service.dart +++ b/mobile/lib/domain/services/sync_stream.service.dart @@ -1,13 +1,11 @@ // ignore_for_file: constant_identifier_names import 'dart:async'; -import 'dart:convert'; 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/models/sync_event.model.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; +import 'package:immich_mobile/infrastructure/repositories/app_metadata.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart'; @@ -38,6 +36,7 @@ class SyncStreamService { final IPermissionRepository _permissionRepository; final SyncMigrationRepository _syncMigrationRepository; final ApiService _api; + final AppMetadataRepository _appMetadataRepository; final Completer? _cancellation; SyncStreamService({ @@ -49,10 +48,12 @@ class SyncStreamService { required this._permissionRepository, required this._syncMigrationRepository, required this._api, + required this._appMetadataRepository, this._cancellation, }); bool get isCancelled => _cancellation?.isCompleted ?? false; + bool _manageLocalMediaAndroid = false; Future sync() async { _logger.info("Remote sync request for user"); @@ -64,16 +65,17 @@ class SyncStreamService { final serverSemVer = SemVer(major: serverVersion.major, minor: serverVersion.minor, patch: serverVersion.patch_); - final value = Store.get(StoreKey.syncMigrationStatus, "[]"); - final migrations = (jsonDecode(value) as List).cast(); + final migrations = (await _appMetadataRepository.get(.syncMigrationStatus)).toList(); int previousLength = migrations.length; await _runPreSyncTasks(migrations, serverSemVer); if (migrations.length != previousLength) { _logger.info("Updated pre-sync migration status: $migrations"); - await Store.put(StoreKey.syncMigrationStatus, jsonEncode(migrations)); + await _appMetadataRepository.set(.syncMigrationStatus, migrations); } + _manageLocalMediaAndroid = CurrentPlatform.isAndroid && await _appMetadataRepository.get(.manageLocalMediaAndroid); + // Start the sync stream and handle events bool shouldReset = false; await _syncApiRepository.streamChanges( @@ -96,7 +98,7 @@ class SyncStreamService { if (migrations.length != previousLength) { _logger.info("Updated pre-sync migration status: $migrations"); - await Store.put(StoreKey.syncMigrationStatus, jsonEncode(migrations)); + await _appMetadataRepository.set(.syncMigrationStatus, migrations); } return true; @@ -106,10 +108,10 @@ class SyncStreamService { if (!migrations.contains(SyncMigrationTask.v20260128_ResetExifV1.name)) { _logger.info("Running pre-sync task: v20260128_ResetExifV1"); await _syncApiRepository.deleteSyncAck([ - SyncEntityType.assetExifV1, - SyncEntityType.partnerAssetExifV1, - SyncEntityType.albumAssetExifCreateV1, - SyncEntityType.albumAssetExifUpdateV1, + .assetExifV1, + .partnerAssetExifV1, + .albumAssetExifCreateV1, + .albumAssetExifUpdateV1, ]); migrations.add(SyncMigrationTask.v20260128_ResetExifV1.name); } @@ -117,12 +119,7 @@ class SyncStreamService { if (!migrations.contains(SyncMigrationTask.v20260128_ResetAssetV1.name) && semVer >= const SemVer(major: 2, minor: 5, patch: 0)) { _logger.info("Running pre-sync task: v20260128_ResetAssetV1"); - await _syncApiRepository.deleteSyncAck([ - SyncEntityType.assetV1, - SyncEntityType.partnerAssetV1, - SyncEntityType.albumAssetCreateV1, - SyncEntityType.albumAssetUpdateV1, - ]); + await _syncApiRepository.deleteSyncAck([.assetV1, .partnerAssetV1, .albumAssetCreateV1, .albumAssetUpdateV1]); migrations.add(SyncMigrationTask.v20260128_ResetAssetV1.name); @@ -134,7 +131,7 @@ class SyncStreamService { if (!migrations.contains(SyncMigrationTask.v20260597_ResetAssetV1AssetV2.name) && semVer > const SemVer(major: 2, minor: 7, patch: 5)) { _logger.info("Running pre-sync task: v20260597_ResetAssetV1AssetV2"); - await _syncApiRepository.deleteSyncAck([SyncEntityType.assetV1, SyncEntityType.assetV2]); + await _syncApiRepository.deleteSyncAck([.assetV1, .assetV2]); migrations.add(SyncMigrationTask.v20260597_ResetAssetV1AssetV2.name); } } @@ -197,20 +194,20 @@ class SyncStreamService { case SyncEntityType.assetV1: final remoteSyncAssets = data.cast(); await _syncStreamRepository.updateAssetsV1(remoteSyncAssets); - if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) { + if (_manageLocalMediaAndroid) { await _syncAssetTrashStatus(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id).toList()); } return; case SyncEntityType.assetV2: final remoteSyncAssets = data.cast(); await _syncStreamRepository.updateAssetsV2(remoteSyncAssets); - if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) { + if (_manageLocalMediaAndroid) { await _syncAssetTrashStatus(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id).toList()); } return; case SyncEntityType.assetDeleteV1: final remoteSyncAssets = data.cast(); - if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) { + if (_manageLocalMediaAndroid) { await _syncAssetDeletion(remoteSyncAssets.map((e) => e.assetId).toList()); } return _syncStreamRepository.deleteAssetsV1(remoteSyncAssets); diff --git a/mobile/lib/infrastructure/entities/app_metadata.entity.dart b/mobile/lib/infrastructure/entities/app_metadata.entity.dart new file mode 100644 index 0000000000..0f22d4d7fe --- /dev/null +++ b/mobile/lib/infrastructure/entities/app_metadata.entity.dart @@ -0,0 +1,18 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; + +class AppMetadataEntity extends Table with DriftDefaultsMixin { + const AppMetadataEntity(); + + TextColumn get key => text()(); + + TextColumn get value => text().nullable()(); + + DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); + + @override + Set get primaryKey => {key}; + + @override + String get tableName => "app_metadata"; +} diff --git a/mobile/lib/infrastructure/entities/app_metadata.entity.drift.dart b/mobile/lib/infrastructure/entities/app_metadata.entity.drift.dart new file mode 100644 index 0000000000000000000000000000000000000000..d3fa90bd0ce5c29d00656f392b525a6a945711c4 GIT binary patch literal 13776 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 Kf2urg_x=m&K=jrC literal 0 HcmV?d00001 diff --git a/mobile/lib/infrastructure/repositories/app_metadata.repository.dart b/mobile/lib/infrastructure/repositories/app_metadata.repository.dart new file mode 100644 index 0000000000..7f8faa6bb5 --- /dev/null +++ b/mobile/lib/infrastructure/repositories/app_metadata.repository.dart @@ -0,0 +1,28 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/domain/models/app_metadata_key.dart'; +import 'package:immich_mobile/infrastructure/entities/app_metadata.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; + +class AppMetadataRepository { + final Drift _db; + + const AppMetadataRepository(this._db); + + Future get(AppMetadataKey key) async { + final row = await (_db.select(_db.appMetadataEntity)..where((row) => row.key.equals(key.name))).getSingleOrNull(); + final value = row?.value; + return value == null ? key.defaultValue : key.decode(value); + } + + Future set(AppMetadataKey key, U value) async { + await _db + .into(_db.appMetadataEntity) + .insertOnConflictUpdate( + AppMetadataEntityCompanion.insert( + key: key.name, + value: Value(key.encode(value)), + updatedAt: Value(DateTime.now()), + ), + ); + } +} diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart index a8da510bf9..3532003799 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.dart @@ -6,6 +6,7 @@ import 'package:drift/drift.dart'; import 'package:drift/src/runtime/executor/stream_queries.dart' show StreamQueryStore; import 'package:drift_sqlite_async/drift_sqlite_async.dart'; import 'package:flutter/foundation.dart'; +import 'package:immich_mobile/infrastructure/entities/app_metadata.entity.dart'; import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart'; import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart'; import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.dart'; @@ -69,6 +70,7 @@ import 'package:sqlite_async/sqlite_async.dart'; SettingsEntity, AssetOcrEntity, SessionEntity, + AppMetadataEntity, ], include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'}, ) @@ -122,7 +124,7 @@ class Drift extends $Drift { } @override - int get schemaVersion => 31; + int get schemaVersion => 32; @override MigrationStrategy get migration => MigrationStrategy( @@ -316,6 +318,14 @@ class Drift extends $Drift { from30To31: (m, v31) async { await m.createTable(v31.session); }, + from31To32: (m, v32) async { + await m.createTable(v32.appMetadata); + await customStatement( + "INSERT INTO app_metadata (key, value) " + "SELECT 'version', CAST(int_value AS TEXT) FROM store_entity " + "WHERE id = 0 AND int_value IS NOT NULL", + ); + }, ), ); diff --git a/mobile/lib/infrastructure/repositories/db.repository.drift.dart b/mobile/lib/infrastructure/repositories/db.repository.drift.dart index 18efa35bf92378f47a16c91a00d623c39f2f3253..8298e52fe687a8f8696ddcd2906ee29bca114789 100644 GIT binary patch delta 275 zcmaD*ccGDS!%R+|#DaqO+|-i9l*E$6$$=~~lXr27Ob%dGn>>?KY4SuS;mPMXl^D%8 zf8;#QIoW}a$IZx8PsOpIz!$2;HLoPIq%tHiDJN9{XcVfjf~`WPktvq~7@%mIe1Th( zIn&5&@@`=rM)S!pgbl^b^n6o`(o<6$i;GiBT#7Q&O6)X%DmEL4#IbNA8NInl;*J`( p0?1@N6_8UV`o_NKrjhC777-mrv&kDp48=|Ld{c|k iQ&Sv^i&INniZatm>@F7M2#)7Pc+yHo4PzjG2U(jE$ymWM)rdQhw zqS%_4rt8mU6PaGE$Ii==SWw`*y>L3KFXQ&ww`|jmraw5wD!jcYiCu#c%mgc#euACN z6~v04&db3zar&Nk_TcFkvKfW8>z`nK#4&w-1GC8VhFi?6+n49CujiZ|Qp|29YHXxX zoSc!Go2aRfR+OJ>Y#5SnY_vV3j9pxeEjh6`Rl#`rhB9^qM&s!h%GeXxAo9}}RROMCq8Br-u`YXdoZ(-0+KyeNNgQ01qDqVE=_AL0D2T+ AQvd(} delta 54 zcmdnFSN`}WxrP?T7N!>F7M2#)7Pc+yHo4nvH?m7{ZeOyCT~us)$wl^T&h0w4*|VA1 KG { } void switchTheme(ThemeMode mode) { - // TODO: Remove this line when map theme provider is removed - // Until then, keep both in sync as MapThemeOverride uses map state provider - // ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapThemeMode, mode.index); ref.read(mapStateNotifierProvider.notifier).switchTheme(mode); state = state.copyWith(themeMode: mode); } diff --git a/mobile/lib/providers/app_settings.provider.dart b/mobile/lib/providers/app_settings.provider.dart deleted file mode 100644 index 3d3947a931..0000000000 --- a/mobile/lib/providers/app_settings.provider.dart +++ /dev/null @@ -1,4 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; - -final appSettingsServiceProvider = Provider((_) => const AppSettingsService()); diff --git a/mobile/lib/providers/infrastructure/app_metadata.provider.dart b/mobile/lib/providers/infrastructure/app_metadata.provider.dart new file mode 100644 index 0000000000..9830ee9c53 --- /dev/null +++ b/mobile/lib/providers/infrastructure/app_metadata.provider.dart @@ -0,0 +1,7 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/infrastructure/repositories/app_metadata.repository.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; + +final appMetadataRepositoryProvider = Provider( + (ref) => AppMetadataRepository(ref.watch(driftProvider)), +); diff --git a/mobile/lib/providers/infrastructure/sync.provider.dart b/mobile/lib/providers/infrastructure/sync.provider.dart index 700b51f12d..f7b3ff01f1 100644 --- a/mobile/lib/providers/infrastructure/sync.provider.dart +++ b/mobile/lib/providers/infrastructure/sync.provider.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/infrastructure/repositories/sync_migration.reposit import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/app_metadata.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; @@ -26,6 +27,7 @@ final syncStreamServiceProvider = Provider( permissionRepository: ref.watch(permissionRepositoryProvider), syncMigrationRepository: ref.watch(syncMigrationRepositoryProvider), api: ref.watch(apiServiceProvider), + appMetadataRepository: ref.watch(appMetadataRepositoryProvider), cancellation: ref.watch(cancellationProvider), ), ); @@ -42,6 +44,7 @@ final localSyncServiceProvider = Provider( assetMediaRepository: ref.watch(assetMediaRepositoryProvider), permissionRepository: ref.watch(permissionRepositoryProvider), nativeSyncApi: ref.watch(nativeSyncApiProvider), + appMetadataRepository: ref.watch(appMetadataRepositoryProvider), cancellation: ref.watch(cancellationProvider), ), ); diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index 8e01777c5d..93fe7ec915 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -6,15 +6,15 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset_edit.model.dart'; -import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/services/tag.service.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; +import 'package:immich_mobile/infrastructure/repositories/app_metadata.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/app_metadata.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/repositories/asset_api.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; @@ -38,6 +38,7 @@ final actionServiceProvider = Provider( ref.watch(assetMediaRepositoryProvider), ref.watch(downloadRepositoryProvider), ref.watch(tagServiceProvider), + ref.watch(appMetadataRepositoryProvider), ), ); @@ -51,6 +52,7 @@ class ActionService { final AssetMediaRepository _assetMediaRepository; final DownloadRepository _downloadRepository; final TagService _tagService; + final AppMetadataRepository _appMetadataRepository; const ActionService( this._assetApiRepository, @@ -62,6 +64,7 @@ class ActionService { this._assetMediaRepository, this._downloadRepository, this._tagService, + this._appMetadataRepository, ); Future shareLink(List remoteIds, BuildContext context) async { @@ -318,7 +321,7 @@ class ActionService { if (deletedIds.isEmpty) { return 0; } - if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) { + if (CurrentPlatform.isAndroid && await _appMetadataRepository.get(.manageLocalMediaAndroid)) { await _trashedLocalAssetRepository.applyTrashedAssets(deletedIds); } else { await _localAssetRepository.delete(deletedIds); diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart deleted file mode 100644 index 4a8b76b1ce..0000000000 --- a/mobile/lib/services/app_settings.service.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:immich_mobile/domain/models/store.model.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; - -enum AppSettingsEnum { - manageLocalMediaAndroid(StoreKey.manageLocalMediaAndroid, null, false); - - const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue); - - final StoreKey storeKey; - final String? hiveKey; - final T defaultValue; -} - -class AppSettingsService { - const AppSettingsService(); - T getSetting(AppSettingsEnum setting) { - return Store.get(setting.storeKey, setting.defaultValue); - } - - Future setSetting(AppSettingsEnum setting, T value) { - return Store.put(setting.storeKey, value); - } -} diff --git a/mobile/lib/utils/hooks/app_settings_update_hook.dart b/mobile/lib/utils/hooks/app_settings_update_hook.dart deleted file mode 100644 index c498b60b06..0000000000 --- a/mobile/lib/utils/hooks/app_settings_update_hook.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:flutter_hooks/flutter_hooks.dart' hide Store; -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; - -ValueNotifier useAppSettingsState(AppSettingsEnum key) { - final notifier = useState(Store.get(key.storeKey, key.defaultValue)); - - // Listen to changes to the notifier and update app settings - useValueChanged(notifier.value, (_, __) => Store.put(key.storeKey, notifier.value)); - - return notifier; -} diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index 6ce1751ba9..a755c30247 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -6,6 +6,7 @@ import 'package:drift/drift.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/constants/colors.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/app_metadata_key.dart'; import 'package:immich_mobile/domain/models/config/app_config.dart'; import 'package:immich_mobile/domain/models/log.model.dart'; import 'package:immich_mobile/domain/models/session.model.dart'; @@ -13,8 +14,10 @@ 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/infrastructure/entities/app_metadata.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/session.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/repositories/app_metadata.repository.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/session.repository.dart'; @@ -22,10 +25,10 @@ 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 = 28; - Future migrateDatabaseIfNeeded(Drift drift) async { - final int version = Store.get(StoreKey.version, targetVersion); + final metadataRepository = AppMetadataRepository(drift); + + final int version = await metadataRepository.get(AppMetadataKey.version); if (version < 25) { await _migrateTo25(); @@ -43,26 +46,30 @@ Future migrateDatabaseIfNeeded(Drift drift) async { await _migrateTo28(drift); } - await Store.put(StoreKey.version, targetVersion); + if (version < 29) { + await _migrateTo29(drift); + } + + await metadataRepository.set(AppMetadataKey.version, kCurrentVersion); return; } Future _migrateTo25() async { - final accessToken = Store.tryGet(StoreKey.legacyAccessToken); + final accessToken = Store.tryGet(.legacyAccessToken); if (accessToken == null || accessToken.isEmpty) { return; } final urls = []; - final serverEndpoint = Store.tryGet(StoreKey.legacyServerEndpoint); + final serverEndpoint = Store.tryGet(.legacyServerEndpoint); if (serverEndpoint != null && serverEndpoint.isNotEmpty) { urls.add(serverEndpoint); } - final localEndpoint = Store.tryGet(StoreKey.legacyLocalEndpoint); + final localEndpoint = Store.tryGet(.legacyLocalEndpoint); if (localEndpoint != null && localEndpoint.isNotEmpty) { urls.add(localEndpoint); } - final externalJson = Store.tryGet(StoreKey.legacyExternalEndpointList); + final externalJson = Store.tryGet(.legacyExternalEndpointList); if (externalJson != null) { final List list = jsonDecode(externalJson); for (final entry in list) { @@ -76,7 +83,7 @@ Future _migrateTo25() async { return; } - final customHeadersStr = Store.get(StoreKey.legacyCustomHeaders, ""); + final customHeadersStr = Store.get(.legacyCustomHeaders, ""); final headers = customHeadersStr.isEmpty ? const {} : (jsonDecode(customHeadersStr) as Map).cast(); @@ -86,75 +93,67 @@ Future _migrateTo25() async { Future _migrateTo26(Drift drift) async { final migrator = _StoreMigrator.settings(drift); - await migrator.migrateEnumIndex(StoreKey.legacyLogLevel, SettingsKey.logLevel, LogLevel.values); + await migrator.migrateEnumIndex(.legacyLogLevel, .logLevel, LogLevel.values); // Theme - await migrator.migrateEnumName(StoreKey.legacyThemeMode, SettingsKey.themeMode, ThemeMode.values); - await migrator.migrateEnumName(StoreKey.legacyPrimaryColor, SettingsKey.themePrimaryColor, ImmichColorPreset.values); - await migrator.migrateBool(StoreKey.legacyDynamicTheme, SettingsKey.themeDynamic); - await migrator.migrateBool(StoreKey.legacyColorfulInterface, SettingsKey.themeColorfulInterface); + await migrator.migrateEnumName(.legacyThemeMode, .themeMode, ThemeMode.values); + await migrator.migrateEnumName(.legacyPrimaryColor, .themePrimaryColor, ImmichColorPreset.values); + await migrator.migrateBool(.legacyDynamicTheme, .themeDynamic); + await migrator.migrateBool(.legacyColorfulInterface, .themeColorfulInterface); // Cleanup - final cleanupKeepAlbumIds = await migrator.readLegacyStoreString(StoreKey.legacyCleanupKeepAlbumIds.id); + final cleanupKeepAlbumIds = await migrator.readLegacyStoreString(.legacyCleanupKeepAlbumIds); if (cleanupKeepAlbumIds != null) { final ids = cleanupKeepAlbumIds.split(',').where((id) => id.isNotEmpty).toList(); - migrator.stage(StoreKey.legacyCleanupKeepAlbumIds, SettingsKey.cleanupKeepAlbumIds, ids); + migrator.stage(.legacyCleanupKeepAlbumIds, .cleanupKeepAlbumIds, ids); } - await migrator.migrateBool(StoreKey.legacyCleanupKeepFavorites, SettingsKey.cleanupKeepFavorites); - await migrator.migrateEnumIndex( - StoreKey.legacyCleanupKeepMediaType, - SettingsKey.cleanupKeepMediaType, - AssetKeepType.values, - ); - await migrator.migrateInt(StoreKey.legacyCleanupCutoffDaysAgo, SettingsKey.cleanupCutoffDaysAgo); - await migrator.migrateBool(StoreKey.legacyCleanupDefaultsInitialized, SettingsKey.cleanupDefaultsInitialized); + await migrator.migrateBool(.legacyCleanupKeepFavorites, .cleanupKeepFavorites); + await migrator.migrateEnumIndex(.legacyCleanupKeepMediaType, .cleanupKeepMediaType, AssetKeepType.values); + await migrator.migrateInt(.legacyCleanupCutoffDaysAgo, .cleanupCutoffDaysAgo); + await migrator.migrateBool(.legacyCleanupDefaultsInitialized, .cleanupDefaultsInitialized); // Map - await migrator.migrateBool(StoreKey.legacyMapShowFavoriteOnly, SettingsKey.mapShowFavoriteOnly); - await migrator.migrateInt(StoreKey.legacyMapRelativeDate, SettingsKey.mapRelativeDate); - await migrator.migrateBool(StoreKey.legacyMapIncludeArchived, SettingsKey.mapIncludeArchived); - await migrator.migrateEnumIndex(StoreKey.legacyMapThemeMode, SettingsKey.mapThemeMode, ThemeMode.values); - await migrator.migrateBool(StoreKey.legacyMapwithPartners, SettingsKey.mapWithPartners); + await migrator.migrateBool(.legacyMapShowFavoriteOnly, .mapShowFavoriteOnly); + await migrator.migrateInt(.legacyMapRelativeDate, .mapRelativeDate); + await migrator.migrateBool(.legacyMapIncludeArchived, .mapIncludeArchived); + await migrator.migrateEnumIndex(.legacyMapThemeMode, .mapThemeMode, ThemeMode.values); + await migrator.migrateBool(.legacyMapwithPartners, .mapWithPartners); // Timeline - await migrator.migrateInt(StoreKey.legacyTilesPerRow, SettingsKey.timelineTilesPerRow); - await migrator.migrateEnumIndex( - StoreKey.legacyGroupAssetsBy, - SettingsKey.timelineGroupAssetsBy, - GroupAssetsBy.values, - ); - await migrator.migrateBool(StoreKey.legacyStorageIndicator, SettingsKey.timelineStorageIndicator); + await migrator.migrateInt(.legacyTilesPerRow, .timelineTilesPerRow); + await migrator.migrateEnumIndex(.legacyGroupAssetsBy, .timelineGroupAssetsBy, GroupAssetsBy.values); + await migrator.migrateBool(.legacyStorageIndicator, .timelineStorageIndicator); // Image - await migrator.migrateBool(StoreKey.legacyPreferRemoteImage, SettingsKey.imagePreferRemote); - await migrator.migrateBool(StoreKey.legacyLoadOriginal, SettingsKey.imageLoadOriginal); + await migrator.migrateBool(.legacyPreferRemoteImage, .imagePreferRemote); + await migrator.migrateBool(.legacyLoadOriginal, .imageLoadOriginal); // Viewer - await migrator.migrateBool(StoreKey.legacyLoopVideo, SettingsKey.viewerLoopVideo); - await migrator.migrateBool(StoreKey.legacyLoadOriginalVideo, SettingsKey.viewerLoadOriginalVideo); - await migrator.migrateBool(StoreKey.legacyAutoPlayVideo, SettingsKey.viewerAutoPlayVideo); - await migrator.migrateBool(StoreKey.legacyTapToNavigate, SettingsKey.viewerTapToNavigate); + await migrator.migrateBool(.legacyLoopVideo, .viewerLoopVideo); + await migrator.migrateBool(.legacyLoadOriginalVideo, .viewerLoadOriginalVideo); + await migrator.migrateBool(.legacyAutoPlayVideo, .viewerAutoPlayVideo); + await migrator.migrateBool(.legacyTapToNavigate, .viewerTapToNavigate); // Network - await migrator.migrateBool(StoreKey.legacyAutoEndpointSwitching, SettingsKey.networkAutoEndpointSwitching); - final preferredWifiName = await migrator.readLegacyStoreString(StoreKey.legacyPreferredWifiName.id); - migrator.stage(StoreKey.legacyPreferredWifiName, SettingsKey.networkPreferredWifiName, preferredWifiName); - final localEndpoint = await migrator.readLegacyStoreString(StoreKey.legacyLocalEndpoint.id); - migrator.stage(StoreKey.legacyLocalEndpoint, SettingsKey.networkLocalEndpoint, localEndpoint); + await migrator.migrateBool(.legacyAutoEndpointSwitching, .networkAutoEndpointSwitching); + final preferredWifiName = await migrator.readLegacyStoreString(.legacyPreferredWifiName); + migrator.stage(.legacyPreferredWifiName, .networkPreferredWifiName, preferredWifiName); + final localEndpoint = await migrator.readLegacyStoreString(.legacyLocalEndpoint); + migrator.stage(.legacyLocalEndpoint, .networkLocalEndpoint, localEndpoint); await _migrateExternalEndpointList(migrator); await _migrateCustomHeaders(migrator); // Album await _migrateAlbumSortMode(migrator); - await migrator.migrateBool(StoreKey.legacySelectedAlbumSortReverse, SettingsKey.albumIsReverse); - await migrator.migrateBool(StoreKey.legacyAlbumGridView, SettingsKey.albumIsGrid); + await migrator.migrateBool(.legacySelectedAlbumSortReverse, .albumIsReverse); + await migrator.migrateBool(.legacyAlbumGridView, .albumIsGrid); // Backup - await migrator.migrateBool(StoreKey.legacyEnableBackup, SettingsKey.backupEnabled); - await migrator.migrateBool(StoreKey.legacyUseWifiForUploadVideos, SettingsKey.backupUseCellularForVideos); - await migrator.migrateBool(StoreKey.legacyUseWifiForUploadPhotos, SettingsKey.backupUseCellularForPhotos); - await migrator.migrateBool(StoreKey.legacyBackupRequireCharging, SettingsKey.backupRequireCharging); - await migrator.migrateInt(StoreKey.legacyBackupTriggerDelay, SettingsKey.backupTriggerDelay); - await migrator.migrateBool(StoreKey.legacySyncAlbums, SettingsKey.backupSyncAlbums); + await migrator.migrateBool(.legacyEnableBackup, .backupEnabled); + await migrator.migrateBool(.legacyUseWifiForUploadVideos, .backupUseCellularForVideos); + await migrator.migrateBool(.legacyUseWifiForUploadPhotos, .backupUseCellularForPhotos); + await migrator.migrateBool(.legacyBackupRequireCharging, .backupRequireCharging); + await migrator.migrateInt(.legacyBackupTriggerDelay, .backupTriggerDelay); + await migrator.migrateBool(.legacySyncAlbums, .backupSyncAlbums); await migrator.complete(); } Future _migrateTo27(Drift drift) async { final migrator = _StoreMigrator.session(drift); - await migrator.migrateString(StoreKey.legacyServerUrl, SessionKey.serverUrl); - await migrator.migrateString(StoreKey.legacyAccessToken, SessionKey.accessToken); - await migrator.migrateString(StoreKey.legacyServerEndpoint, SessionKey.serverEndpoint); + await migrator.migrateString(.legacyServerUrl, .serverUrl); + await migrator.migrateString(.legacyAccessToken, .accessToken); + await migrator.migrateString(.legacyServerEndpoint, .serverEndpoint); await migrator.complete(); await SessionRepository.instance.refresh(); @@ -162,26 +161,40 @@ Future _migrateTo27(Drift drift) async { Future _migrateTo28(Drift drift) async { final migrator = _StoreMigrator.settings(drift); - await migrator.migrateBool(StoreKey.legacyAdvancedTroubleshooting, SettingsKey.advancedTroubleshooting); - await migrator.migrateBool(StoreKey.legacyEnableHapticFeedback, SettingsKey.advancedEnableHapticFeedback); - await migrator.migrateBool(StoreKey.legacyReadonlyModeEnabled, SettingsKey.advancedReadonlyModeEnabled); + await migrator.migrateBool(.legacyAdvancedTroubleshooting, .advancedTroubleshooting); + await migrator.migrateBool(.legacyEnableHapticFeedback, .advancedEnableHapticFeedback); + await migrator.migrateBool(.legacyReadonlyModeEnabled, .advancedReadonlyModeEnabled); await migrator.complete(); await SettingsRepository.instance.refresh(); } +Future _migrateTo29(Drift drift) async { + final migrator = _StoreMigrator.appMetadata(drift); + + final rawStatus = await migrator.readLegacyStoreString(.legacySyncMigrationStatus); + if (rawStatus != null) { + final decoded = jsonDecode(rawStatus); + final migrations = decoded is List ? decoded.whereType().toList() : []; + migrator.stage(.legacySyncMigrationStatus, .syncMigrationStatus, migrations); + } + + await migrator.migrateBool(.legacyManageLocalMediaAndroid, .manageLocalMediaAndroid); + await migrator.complete(); +} + Future _migrateAlbumSortMode(_StoreMigrator migrator) async { - final raw = await migrator.readLegacyStoreInt(StoreKey.legacySelectedAlbumSortOrder.id); + final raw = await migrator.readLegacyStoreInt(.legacySelectedAlbumSortOrder); final mode = AlbumSortMode.values.firstWhereOrNull((e) => raw != null && e.storeIndex == raw); if (mode == null) { return; } - migrator.stage(StoreKey.legacySelectedAlbumSortOrder, SettingsKey.albumSortMode, mode); + migrator.stage(.legacySelectedAlbumSortOrder, .albumSortMode, mode); } Future _migrateExternalEndpointList(_StoreMigrator migrator) async { - final raw = await migrator.readLegacyStoreString(StoreKey.legacyExternalEndpointList.id); + final raw = await migrator.readLegacyStoreString(.legacyExternalEndpointList); if (raw == null) { return; } @@ -205,7 +218,7 @@ Future _migrateExternalEndpointList(_StoreMigrator migrator) } Future _migrateCustomHeaders(_StoreMigrator migrator) async { - final raw = await migrator.readLegacyStoreString(StoreKey.legacyCustomHeaders.id); + final raw = await migrator.readLegacyStoreString(.legacyCustomHeaders); if (raw == null) { return; } @@ -252,6 +265,17 @@ class _StoreMigrator { ), ); + static _StoreMigrator appMetadata(Drift db) => _StoreMigrator._( + db, + encode: (key, value) => key.encode(value), + readDefault: (_) => null, + insertRow: (batch, name, value) => batch.insert( + db.appMetadataEntity, + AppMetadataEntityCompanion(key: Value(name), value: Value(value)), + mode: InsertMode.insertOrReplace, + ), + ); + final Drift _db; final String Function(K key, Object value) encode; final Object? Function(K key) readDefault; @@ -260,7 +284,7 @@ class _StoreMigrator { final List _migratedStoreIds = []; Future migrateEnumIndex(StoreKey legacyKey, K newKey, List values) async { - final index = await readLegacyStoreInt(legacyKey.id); + final index = await readLegacyStoreInt(legacyKey); if (index == null) { return; } @@ -275,7 +299,7 @@ class _StoreMigrator { } Future migrateEnumName(StoreKey legacyKey, K newKey, List values) async { - final name = await readLegacyStoreString(legacyKey.id); + final name = await readLegacyStoreString(legacyKey); if (name == null) { return; } @@ -290,7 +314,7 @@ class _StoreMigrator { } Future migrateBool(StoreKey legacyKey, K newKey) async { - final intValue = await readLegacyStoreInt(legacyKey.id); + final intValue = await readLegacyStoreInt(legacyKey); if (intValue == null) { return; } @@ -300,7 +324,7 @@ class _StoreMigrator { } Future migrateInt(StoreKey legacyKey, K newKey) async { - final intValue = await readLegacyStoreInt(legacyKey.id); + final intValue = await readLegacyStoreInt(legacyKey); if (intValue == null) { return; } @@ -310,7 +334,7 @@ class _StoreMigrator { } Future migrateString(StoreKey legacyKey, K newKey) async { - final value = await readLegacyStoreString(legacyKey.id); + final value = await readLegacyStoreString(legacyKey); if (value == null || value.isEmpty) { return; } @@ -320,7 +344,7 @@ class _StoreMigrator { } Future migrateNullableString(StoreKey legacyKey, K newKey) async { - _cache[newKey] = await readLegacyStoreString(legacyKey.id); + _cache[newKey] = await readLegacyStoreString(legacyKey); _migratedStoreIds.add(legacyKey.id); } @@ -343,13 +367,13 @@ class _StoreMigrator { await deleteLegacyStoreRows(_migratedStoreIds); } - Future readLegacyStoreString(int id) async { - final row = await (_db.storeEntity.select()..where((t) => t.id.equals(id))).getSingleOrNull(); + Future readLegacyStoreString(StoreKey key) async { + final row = await (_db.storeEntity.select()..where((t) => t.id.equals(key.id))).getSingleOrNull(); return row?.stringValue; } - Future readLegacyStoreInt(int id) async { - final row = await (_db.storeEntity.select()..where((t) => t.id.equals(id))).getSingleOrNull(); + Future readLegacyStoreInt(StoreKey key) async { + final row = await (_db.storeEntity.select()..where((t) => t.id.equals(key.id))).getSingleOrNull(); return row?.intValue; } diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index 7615218159..56cd53ac3e 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -11,14 +11,14 @@ import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:fluttertoast/fluttertoast.dart'; 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/domain/models/app_metadata_key.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/app_metadata.provider.dart'; import 'package:immich_mobile/providers/oauth.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart'; @@ -242,7 +242,8 @@ class LoginForm extends HookConsumerWidget { } } - bool isSyncRemoteDeletionsMode() => Platform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false); + Future isSyncRemoteDeletionsMode() async => + Platform.isAndroid && await ref.read(appMetadataRepositoryProvider).get(AppMetadataKey.manageLocalMediaAndroid); login() async { TextInput.finishAutofillContext(); @@ -257,7 +258,7 @@ class LoginForm extends HookConsumerWidget { unawaited(context.pushRoute(const ChangePasswordRoute())); } else { await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission(); - if (isSyncRemoteDeletionsMode()) { + if (await isSyncRemoteDeletionsMode()) { await getManageMediaPermission(); } unawaited(handleSyncFlow()); @@ -345,7 +346,7 @@ class LoginForm extends HookConsumerWidget { if (isSuccess) { await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission(); - if (isSyncRemoteDeletionsMode()) { + if (await isSyncRemoteDeletionsMode()) { await getManageMediaPermission(); } unawaited(handleSyncFlow()); diff --git a/mobile/lib/widgets/settings/advanced_settings.dart b/mobile/lib/widgets/settings/advanced_settings.dart index 9051a4d4bb..55e4c9f24f 100644 --- a/mobile/lib/widgets/settings/advanced_settings.dart +++ b/mobile/lib/widgets/settings/advanced_settings.dart @@ -5,15 +5,15 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/app_metadata_key.dart'; import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/infrastructure/app_metadata.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/repositories/permission.repository.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; -import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/widgets/settings/custom_proxy_headers_settings/custom_proxy_headers_settings.dart'; import 'package:immich_mobile/widgets/settings/settings_action_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart'; @@ -32,7 +32,7 @@ class AdvancedSettings extends HookConsumerWidget { advancedTroubleshooting.value, (_, __) => ref.read(settingsProvider).write(.advancedTroubleshooting, advancedTroubleshooting.value), ); - final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid); + final manageLocalMediaAndroid = useState(false); final isManageMediaSupported = useState(false); final manageMediaAndroidPermission = useState(false); final levelId = useState(ref.read(appConfigProvider).logLevel.index); @@ -61,6 +61,9 @@ class AdvancedSettings extends HookConsumerWidget { () async { isManageMediaSupported.value = await checkAndroidVersion(); if (isManageMediaSupported.value) { + manageLocalMediaAndroid.value = await ref + .read(appMetadataRepositoryProvider) + .get(AppMetadataKey.manageLocalMediaAndroid); manageMediaAndroidPermission.value = await ref.read(permissionRepositoryProvider).hasManageMediaPermission(); } }(); @@ -87,6 +90,9 @@ class AdvancedSettings extends HookConsumerWidget { final result = await ref.read(permissionRepositoryProvider).requestManageMediaPermission(); manageLocalMediaAndroid.value = result; manageMediaAndroidPermission.value = result; + await ref.read(appMetadataRepositoryProvider).set(AppMetadataKey.manageLocalMediaAndroid, result); + } else { + await ref.read(appMetadataRepositoryProvider).set(AppMetadataKey.manageLocalMediaAndroid, false); } }, ), diff --git a/mobile/lib/widgets/settings/asset_list_settings/asset_list_group_settings.dart b/mobile/lib/widgets/settings/asset_list_settings/asset_list_group_settings.dart index a5ced2ef5f..f00f3bbd75 100644 --- a/mobile/lib/widgets/settings/asset_list_settings/asset_list_group_settings.dart +++ b/mobile/lib/widgets/settings/asset_list_settings/asset_list_group_settings.dart @@ -5,7 +5,6 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; -import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/widgets/settings/setting_group_title.dart'; @@ -20,7 +19,6 @@ class GroupSettings extends HookConsumerWidget { Future updateAppSettings(GroupAssetsBy groupBy) async { await ref.read(settingsProvider).write(.timelineGroupAssetsBy, groupBy); - ref.invalidate(appSettingsServiceProvider); ref.invalidate(timelineServiceProvider); } diff --git a/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart b/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart index f915df04f8..82c0a7ac22 100644 --- a/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart +++ b/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; -import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/widgets/settings/setting_group_title.dart'; import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart'; @@ -32,9 +31,6 @@ class LayoutSettings extends HookConsumerWidget { maxValue: 6, minValue: 2, noDivisons: 4, - onChangeEnd: (value) { - ref.invalidate(appSettingsServiceProvider); - }, ), ], ); diff --git a/mobile/lib/widgets/settings/asset_list_settings/asset_list_settings.dart b/mobile/lib/widgets/settings/asset_list_settings/asset_list_settings.dart index 3ac72d6612..2efb7e467f 100644 --- a/mobile/lib/widgets/settings/asset_list_settings/asset_list_settings.dart +++ b/mobile/lib/widgets/settings/asset_list_settings/asset_list_settings.dart @@ -2,7 +2,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/widgets/settings/asset_list_settings/asset_list_group_settings.dart'; import 'package:immich_mobile/widgets/settings/asset_list_settings/asset_list_layout_settings.dart'; @@ -22,7 +21,6 @@ class AssetListSettings extends HookConsumerWidget { title: 'theme_setting_asset_list_storage_indicator_title'.tr(), onChanged: (value) { ref.read(settingsProvider).write(.timelineStorageIndicator, value); - ref.invalidate(appSettingsServiceProvider); ref.invalidate(settingsProvider); }, ), diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart b/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart index f65af6af9d..b47b970e37 100644 --- a/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart +++ b/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; -import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/widgets/settings/setting_group_title.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; @@ -29,7 +28,6 @@ class ImageViewerQualitySetting extends HookConsumerWidget { valueNotifier: isOriginal, title: "setting_image_viewer_original_title".t(context: context), subtitle: "setting_image_viewer_original_subtitle".t(context: context), - onChanged: (_) => ref.invalidate(appSettingsServiceProvider), ), ], ); diff --git a/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart b/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart index 92787077a1..98c98721ae 100644 --- a/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart +++ b/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart @@ -6,10 +6,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/domain/models/app_metadata_key.dart'; import 'package:immich_mobile/generated/translations.g.dart'; -import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/app_metadata.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; @@ -17,7 +18,6 @@ import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/sync_status.provider.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/widgets/settings/beta_sync_settings/entity_count_tile.dart'; import 'package:immich_mobile/widgets/settings/setting_group_title.dart'; import 'package:immich_mobile/widgets/settings/setting_list_tile.dart'; @@ -219,7 +219,7 @@ class _SyncStatsCounts extends ConsumerWidget { final localAlbumService = ref.watch(localAlbumServiceProvider); final remoteAlbumService = ref.watch(remoteAlbumServiceProvider); final memoryService = ref.watch(driftMemoryServiceProvider); - final appSettingsService = ref.watch(appSettingsServiceProvider); + final appMetadataRepository = ref.watch(appMetadataRepositoryProvider); Future> loadCounts() async { final assetCounts = assetService.getAssetCounts(); @@ -227,8 +227,16 @@ class _SyncStatsCounts extends ConsumerWidget { final remoteAlbumCounts = remoteAlbumService.getCount(); final memoryCount = memoryService.getCount(); final getLocalHashedCount = assetService.getLocalHashedCount(); + final manageLocalMediaAndroid = appMetadataRepository.get(AppMetadataKey.manageLocalMediaAndroid); - return await Future.wait([assetCounts, localAlbumCounts, remoteAlbumCounts, memoryCount, getLocalHashedCount]); + return await Future.wait([ + assetCounts, + localAlbumCounts, + remoteAlbumCounts, + memoryCount, + getLocalHashedCount, + manageLocalMediaAndroid, + ]); } return FutureBuilder( @@ -254,14 +262,15 @@ class _SyncStatsCounts extends ConsumerWidget { ); } - final assetCounts = snapshot.data![0]! as (int, int); + final assetCounts = snapshot.data![0] as (int, int); final localAssetCount = assetCounts.$1; final remoteAssetCount = assetCounts.$2; - final localAlbumCount = snapshot.data![1]! as int; - final remoteAlbumCount = snapshot.data![2]! as int; - final memoryCount = snapshot.data![3]! as int; - final localHashedCount = snapshot.data![4]! as int; + final localAlbumCount = snapshot.data![1] as int; + final remoteAlbumCount = snapshot.data![2] as int; + final memoryCount = snapshot.data![3] as int; + final localHashedCount = snapshot.data![4] as int; + final manageLocalMediaAndroid = snapshot.data![5] as bool; return Column( mainAxisAlignment: MainAxisAlignment.start, @@ -354,8 +363,7 @@ class _SyncStatsCounts extends ConsumerWidget { ), ), // To be removed once the experimental feature is stable - if (CurrentPlatform.isAndroid && - appSettingsService.getSetting(AppSettingsEnum.manageLocalMediaAndroid)) ...[ + if (CurrentPlatform.isAndroid && manageLocalMediaAndroid) ...[ SettingGroupTitle(title: "trash".t(context: context)), Consumer( builder: (context, ref, _) { diff --git a/mobile/test/domain/service.mock.dart b/mobile/test/domain/service.mock.dart index 743d75f1bf..1bd5968566 100644 --- a/mobile/test/domain/service.mock.dart +++ b/mobile/test/domain/service.mock.dart @@ -2,7 +2,6 @@ import 'package:immich_mobile/domain/services/partner.service.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/domain/utils/background_sync.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:mocktail/mocktail.dart'; class MockStoreService extends Mock implements StoreService {} @@ -11,6 +10,4 @@ class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {} class MockNativeSyncApi extends Mock implements NativeSyncApi {} -class MockAppSettingsService extends Mock implements AppSettingsService {} - class MockPartnerService extends Mock implements PartnerService {} diff --git a/mobile/test/domain/services/local_sync_service_test.dart b/mobile/test/domain/services/local_sync_service_test.dart index e0e6b663a7..cb9cdc1458 100644 --- a/mobile/test/domain/services/local_sync_service_test.dart +++ b/mobile/test/domain/services/local_sync_service_test.dart @@ -2,8 +2,8 @@ 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/app_metadata_key.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/local_sync.service.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; @@ -29,6 +29,7 @@ void main() { late AssetMediaRepository mockAssetMediaRepository; late MockPermissionRepository mockPermissionRepository; late MockNativeSyncApi mockNativeSyncApi; + late MockAppMetadataRepository mockAppMetadataRepository; late Drift db; setUpAll(() async { @@ -52,6 +53,7 @@ void main() { mockAssetMediaRepository = MockAssetMediaRepository(); mockPermissionRepository = MockPermissionRepository(); mockNativeSyncApi = MockNativeSyncApi(); + mockAppMetadataRepository = MockAppMetadataRepository(); when(() => mockNativeSyncApi.shouldFullSync()).thenAnswer((_) async => false); when(() => mockNativeSyncApi.getMediaChanges()).thenAnswer( @@ -75,15 +77,16 @@ void main() { assetMediaRepository: mockAssetMediaRepository, permissionRepository: mockPermissionRepository, nativeSyncApi: mockNativeSyncApi, + appMetadataRepository: mockAppMetadataRepository, ); - await Store.put(StoreKey.manageLocalMediaAndroid, false); + when(() => mockAppMetadataRepository.get(AppMetadataKey.manageLocalMediaAndroid)).thenAnswer((_) async => false); when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => false); }); group('LocalSyncService - syncTrashedAssets gating', () { test('invokes syncTrashedAssets when Android flag enabled and permission granted', () async { - await Store.put(StoreKey.manageLocalMediaAndroid, true); + when(() => mockAppMetadataRepository.get(AppMetadataKey.manageLocalMediaAndroid)).thenAnswer((_) async => true); when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true); await sut.sync(); @@ -93,7 +96,7 @@ void main() { }); test('skips syncTrashedAssets when store flag disabled', () async { - await Store.put(StoreKey.manageLocalMediaAndroid, false); + when(() => mockAppMetadataRepository.get(AppMetadataKey.manageLocalMediaAndroid)).thenAnswer((_) async => false); when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true); await sut.sync(); @@ -102,7 +105,7 @@ void main() { }); test('skips syncTrashedAssets when MANAGE_MEDIA permission absent', () async { - await Store.put(StoreKey.manageLocalMediaAndroid, true); + when(() => mockAppMetadataRepository.get(AppMetadataKey.manageLocalMediaAndroid)).thenAnswer((_) async => true); when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => false); await sut.sync(); @@ -114,7 +117,7 @@ void main() { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.android); - await Store.put(StoreKey.manageLocalMediaAndroid, true); + when(() => mockAppMetadataRepository.get(AppMetadataKey.manageLocalMediaAndroid)).thenAnswer((_) async => true); when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true); await sut.sync(); diff --git a/mobile/test/domain/services/store_service_test.dart b/mobile/test/domain/services/store_service_test.dart index f1616aa2eb..ca84fe04a2 100644 --- a/mobile/test/domain/services/store_service_test.dart +++ b/mobile/test/domain/services/store_service_test.dart @@ -22,14 +22,14 @@ void main() { mockDriftStoreRepo = MockDriftStoreRepository(); // For generics, we need to provide fallback to each concrete type to avoid runtime errors registerFallbackValue(StoreKey.legacyAccessToken); - registerFallbackValue(StoreKey.version); + registerFallbackValue(StoreKey.legacyVersion); registerFallbackValue(StoreKey.legacyAdvancedTroubleshooting); when(() => mockDriftStoreRepo.getAll()).thenAnswer( (_) async => [ const StoreDto(StoreKey.legacyAccessToken, _kAccessToken), const StoreDto(StoreKey.legacyAdvancedTroubleshooting, _kAdvancedTroubleshooting), - const StoreDto(StoreKey.version, _kVersion), + const StoreDto(StoreKey.legacyVersion, _kVersion), ], ); when(() => mockDriftStoreRepo.watchAll()).thenAnswer((_) => controller.stream); @@ -47,7 +47,7 @@ void main() { verify(() => mockDriftStoreRepo.getAll()).called(1); expect(sut.tryGet(StoreKey.legacyAccessToken), _kAccessToken); expect(sut.tryGet(StoreKey.legacyAdvancedTroubleshooting), _kAdvancedTroubleshooting); - expect(sut.tryGet(StoreKey.version), _kVersion); + expect(sut.tryGet(StoreKey.legacyVersion), _kVersion); // Other keys should be null expect(sut.tryGet(StoreKey.deviceId), isNull); }); @@ -148,7 +148,7 @@ void main() { verify(() => mockDriftStoreRepo.deleteAll()).called(1); expect(sut.tryGet(StoreKey.legacyAccessToken), isNull); expect(sut.tryGet(StoreKey.legacyAdvancedTroubleshooting), isNull); - expect(sut.tryGet(StoreKey.version), isNull); + expect(sut.tryGet(StoreKey.legacyVersion), isNull); }); }); } diff --git a/mobile/test/domain/services/sync_stream_service_test.dart b/mobile/test/domain/services/sync_stream_service_test.dart index e033229408..33a21a7ad2 100644 --- a/mobile/test/domain/services/sync_stream_service_test.dart +++ b/mobile/test/domain/services/sync_stream_service_test.dart @@ -4,8 +4,8 @@ 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/app_metadata_key.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/models/sync_event.model.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/domain/services/sync_stream.service.dart'; @@ -36,7 +36,6 @@ class _AbortCallbackWrapper { class _MockAbortCallbackWrapper extends Mock implements _AbortCallbackWrapper {} - void main() { late SyncStreamService sut; late SyncStreamRepository mockSyncStreamRepo; @@ -51,6 +50,7 @@ void main() { late Future Function(List, Function(), Function()) handleEventsCallback; late _MockAbortCallbackWrapper mockAbortCallbackWrapper; late _MockAbortCallbackWrapper mockResetCallbackWrapper; + late MockAppMetadataRepository mockAppMetadataRepository; late Drift db; late bool hasManageMediaPermission; @@ -59,6 +59,8 @@ void main() { debugDefaultTargetPlatformOverride = TargetPlatform.android; registerFallbackValue(LocalAssetStub.image1); registerFallbackValue(const SemVer(major: 2, minor: 5, patch: 0)); + registerFallbackValue(AppMetadataKey.syncMigrationStatus); + registerFallbackValue(const []); db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); await StoreService.init(storeRepository: DriftStoreRepository(db)); @@ -84,6 +86,7 @@ void main() { mockApi = MockApiService(); mockServerApi = MockServerApi(); mockSyncMigrationRepo = MockSyncMigrationRepository(); + mockAppMetadataRepository = MockAppMetadataRepository(); when(() => mockAbortCallbackWrapper()).thenReturn(false); @@ -159,6 +162,7 @@ void main() { permissionRepository: mockPermissionRepo, api: mockApi, syncMigrationRepository: mockSyncMigrationRepo, + appMetadataRepository: mockAppMetadataRepository, ); when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((_) async => {}); @@ -172,7 +176,11 @@ void main() { return ids; }); when(() => mockAssetMediaRepo.restoreAssetsFromTrash(any())).thenAnswer((_) async => []); - await Store.put(StoreKey.manageLocalMediaAndroid, false); + when(() => mockAppMetadataRepository.get(AppMetadataKey.manageLocalMediaAndroid)).thenAnswer((_) async => false); + when( + () => mockAppMetadataRepository.get(AppMetadataKey.syncMigrationStatus), + ).thenAnswer((_) async => const []); + when(() => mockAppMetadataRepository.set, List>(any(), any())).thenAnswer((_) async {}); }); Future simulateEvents(List events) async { @@ -243,6 +251,7 @@ void main() { cancellation: cancellation, api: mockApi, syncMigrationRepository: mockSyncMigrationRepo, + appMetadataRepository: mockAppMetadataRepository, ); await sut.sync(); @@ -283,6 +292,7 @@ void main() { cancellation: cancellation, api: mockApi, syncMigrationRepository: mockSyncMigrationRepo, + appMetadataRepository: mockAppMetadataRepository, ); await sut.sync(); @@ -394,12 +404,12 @@ void main() { group("SyncStreamService - remote trash & restore", () { setUp(() async { - await Store.put(StoreKey.manageLocalMediaAndroid, true); + when(() => mockAppMetadataRepository.get(AppMetadataKey.manageLocalMediaAndroid)).thenAnswer((_) async => true); hasManageMediaPermission = true; }); tearDown(() async { - await Store.put(StoreKey.manageLocalMediaAndroid, false); + when(() => mockAppMetadataRepository.get(AppMetadataKey.manageLocalMediaAndroid)).thenAnswer((_) async => false); hasManageMediaPermission = false; }); @@ -552,7 +562,9 @@ void main() { group('SyncStreamService - Sync Migration', () { test('ensure that <2.5.0 migrations run', () async { - await Store.put(StoreKey.syncMigrationStatus, "[]"); + when( + () => mockAppMetadataRepository.get(AppMetadataKey.syncMigrationStatus), + ).thenAnswer((_) async => const []); when( () => mockServerApi.getServerVersion(), ).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1, prerelease: null)); @@ -580,7 +592,9 @@ void main() { ); }); test('ensure that >=2.5.0 migrations run', () async { - await Store.put(StoreKey.syncMigrationStatus, "[]"); + when( + () => mockAppMetadataRepository.get(AppMetadataKey.syncMigrationStatus), + ).thenAnswer((_) async => const []); when( () => mockServerApi.getServerVersion(), ).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 5, patch_: 0, prerelease: null)); @@ -606,10 +620,9 @@ void main() { }); test('ensure that migrations do not re-run', () async { - await Store.put( - StoreKey.syncMigrationStatus, - '["${SyncMigrationTask.v20260128_CopyExifWidthHeightToAsset.name}"]', - ); + when( + () => mockAppMetadataRepository.get(AppMetadataKey.syncMigrationStatus), + ).thenAnswer((_) async => [SyncMigrationTask.v20260128_CopyExifWidthHeightToAsset.name]); when( () => mockServerApi.getServerVersion(), diff --git a/mobile/test/drift/main/generated/schema.dart b/mobile/test/drift/main/generated/schema.dart index ee5900c1d1bcdb37eeb4a40446e6f501dea18744..94913560da0e745537cd086c0dcb491c97036108 100644 GIT binary patch delta 92 zcmbOx^+jsKCYFg$1o(}O^imRwO4Jn+ixtX@jW%y!VPw}#PApDUFgCK{Qhn+a delta 23 fcmew&HBD;6CYH^YS^l$aX6H#@WR11vs^tOzbkqn5 diff --git a/mobile/test/drift/main/generated/schema_v32.dart b/mobile/test/drift/main/generated/schema_v32.dart new file mode 100644 index 0000000000000000000000000000000000000000..776719c0c0e516880fd09b1be4f272a55024e650 GIT binary patch literal 336844 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 literal 0 HcmV?d00001 diff --git a/mobile/test/drift/main/migration_test.dart b/mobile/test/drift/main/migration_test.dart index 74467492ae..9369d30921 100644 --- a/mobile/test/drift/main/migration_test.dart +++ b/mobile/test/drift/main/migration_test.dart @@ -1,13 +1,11 @@ -// dart format width=80 -// ignore_for_file: unused_local_variable, unused_import import 'package:drift/drift.dart'; import 'package:drift_dev/api/migrations_native.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/app_metadata_key.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'generated/schema.dart'; -import 'generated/schema_v1.dart' as v1; -import 'generated/schema_v2.dart' as v2; +import 'generated/schema_v31.dart' as v31; void main() { driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; @@ -35,4 +33,36 @@ void main() { }); } }); + + group('data migrations', () { + test('v31->v32 backfills the migration', () async { + final schema = await verifier.schemaAt(31); + + final oldDb = v31.DatabaseAtV31(schema.newConnection()); + await oldDb.into(oldDb.storeEntity).insert(v31.StoreEntityCompanion.insert(id: 0, intValue: const Value(28))); + await oldDb.close(); + + final db = Drift(schema.newConnection()); + await verifier.migrateAndValidate(db, 32); + + final cursor = await (db.appMetadataEntity.select()..where((tbl) => tbl.key.equals(AppMetadataKey.version.name))) + .map((row) => row.value) + .getSingleOrNull(); + expect(cursor, '28'); + + await db.close(); + }); + + test('v31->v32 writes no row when the legacy store has none', () async { + final schema = await verifier.schemaAt(31); + + final db = Drift(schema.newConnection()); + await verifier.migrateAndValidate(db, 32); + + final rows = await db.appMetadataEntity.select().get(); + expect(rows, isEmpty); + + await db.close(); + }); + }); } diff --git a/mobile/test/infrastructure/repositories/store_repository_test.dart b/mobile/test/infrastructure/repositories/store_repository_test.dart index 9962f34314..2d3bda5fab 100644 --- a/mobile/test/infrastructure/repositories/store_repository_test.dart +++ b/mobile/test/infrastructure/repositories/store_repository_test.dart @@ -33,7 +33,7 @@ Future _populateStore(Drift db) async { batch.insert( db.storeEntity, StoreEntityCompanion( - id: Value(StoreKey.version.id), + id: Value(StoreKey.legacyVersion.id), intValue: const Value(_kTestVersion), stringValue: const Value(null), ), @@ -56,10 +56,10 @@ void main() { group('Store Repository converters:', () { test('converts int', () async { - int? version = await sut.tryGet(StoreKey.version); + int? version = await sut.tryGet(StoreKey.legacyVersion); expect(version, isNull); - await sut.upsert(StoreKey.version, _kTestVersion); - version = await sut.tryGet(StoreKey.version); + await sut.upsert(StoreKey.legacyVersion, _kTestVersion); + version = await sut.tryGet(StoreKey.legacyVersion); expect(version, _kTestVersion); }); @@ -107,10 +107,10 @@ void main() { }); test('upsert()', () async { - int? version = await sut.tryGet(StoreKey.version); + int? version = await sut.tryGet(StoreKey.legacyVersion); expect(version, _kTestVersion); - await sut.upsert(StoreKey.version, _kTestVersion + 10); - version = await sut.tryGet(StoreKey.version); + await sut.upsert(StoreKey.legacyVersion, _kTestVersion + 10); + version = await sut.tryGet(StoreKey.legacyVersion); expect(version, _kTestVersion + 10); }); }); @@ -121,10 +121,10 @@ void main() { }); test('watch()', () async { - final stream = sut.watch(StoreKey.version); + final stream = sut.watch(StoreKey.legacyVersion); unawaited(expectLater(stream, emitsInOrder([_kTestVersion, _kTestVersion + 10]))); await pumpEventQueue(); - await sut.upsert(StoreKey.version, _kTestVersion + 10); + await sut.upsert(StoreKey.legacyVersion, _kTestVersion + 10); }); test('watchAll()', () async { @@ -134,19 +134,19 @@ void main() { stream, emitsInOrder([ [ - const StoreDto(StoreKey.version, _kTestVersion), + const StoreDto(StoreKey.legacyVersion, _kTestVersion), const StoreDto(StoreKey.legacyAccessToken, _kTestAccessToken), const StoreDto(StoreKey.legacyAdvancedTroubleshooting, _kTestAdvancedTroubleshooting), ], [ - const StoreDto(StoreKey.version, _kTestVersion + 10), + const StoreDto(StoreKey.legacyVersion, _kTestVersion + 10), const StoreDto(StoreKey.legacyAccessToken, _kTestAccessToken), const StoreDto(StoreKey.legacyAdvancedTroubleshooting, _kTestAdvancedTroubleshooting), ], ]), ), ); - await sut.upsert(StoreKey.version, _kTestVersion + 10); + await sut.upsert(StoreKey.legacyVersion, _kTestVersion + 10); }); }); } diff --git a/mobile/test/infrastructure/repository.mock.dart b/mobile/test/infrastructure/repository.mock.dart index 5f047cba47..e5136dec6e 100644 --- a/mobile/test/infrastructure/repository.mock.dart +++ b/mobile/test/infrastructure/repository.mock.dart @@ -1,3 +1,4 @@ +import 'package:immich_mobile/infrastructure/repositories/app_metadata.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; @@ -22,6 +23,8 @@ class MockDriftStoreRepository extends Mock implements DriftStoreRepository {} class MockSettingsRepository extends Mock implements SettingsRepository {} +class MockAppMetadataRepository extends Mock implements AppMetadataRepository {} + class MockLogRepository extends Mock implements LogRepository {} class MockSyncStreamRepository extends Mock implements SyncStreamRepository {} diff --git a/mobile/test/medium/repositories/app_metadata_repository_test.dart b/mobile/test/medium/repositories/app_metadata_repository_test.dart new file mode 100644 index 0000000000..d47cabc9d0 --- /dev/null +++ b/mobile/test/medium/repositories/app_metadata_repository_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/app_metadata_key.dart'; +import 'package:immich_mobile/infrastructure/entities/app_metadata.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/repositories/app_metadata.repository.dart'; + +import '../repository_context.dart'; + +void main() { + late MediumRepositoryContext ctx; + late AppMetadataRepository sut; + + setUpAll(() { + ctx = MediumRepositoryContext(); + sut = AppMetadataRepository(ctx.db); + }); + + tearDownAll(() async { + await ctx.dispose(); + }); + + setUp(() async { + await ctx.db.delete(ctx.db.appMetadataEntity).go(); + }); + + group('get', () { + test('a stored NULL value column resolves to the key default', () async { + await ctx.db + .into(ctx.db.appMetadataEntity) + .insert( + AppMetadataEntityCompanion.insert( + key: AppMetadataKey.manageLocalMediaAndroid.name, + value: const .new(null), + updatedAt: .new(DateTime.now()), + ), + ); + + expect(await sut.get(.manageLocalMediaAndroid), false); + }); + }); + + group('defaults', () { + test('falls back to the key default when the value is absent', () async { + expect(await sut.get(.version), kCurrentVersion); + expect(await sut.get(.syncMigrationStatus), const []); + expect(await sut.get(.manageLocalMediaAndroid), false); + }); + + test('a stored value takes precedence over the default', () async { + await sut.set(.version, 5); + await sut.set(.syncMigrationStatus, const ['task']); + await sut.set(.manageLocalMediaAndroid, true); + + expect(await sut.get(.version), 5); + expect(await sut.get(.syncMigrationStatus), const ['task']); + expect(await sut.get(.manageLocalMediaAndroid), true); + }); + }); + + group('set', () { + test('round-trips int, List and bool values to their typed form', () async { + await sut.set(.version, 42); + await sut.set(.syncMigrationStatus, const ['task']); + await sut.set(.manageLocalMediaAndroid, true); + + expect(await sut.get(.version), 42); + expect(await sut.get(.syncMigrationStatus), const ['task']); + expect(await sut.get(.manageLocalMediaAndroid), true); + }); + + test('overwrites the existing value and keeps a single row per key', () async { + await sut.set(.version, 1); + await sut.set(.version, 2); + + expect(await sut.get(.version), 2); + expect(await ctx.db.select(ctx.db.appMetadataEntity).get(), hasLength(1)); + }); + }); + + group('cache-less reads', () { + test('observes a value mutated directly in the DB', () async { + await sut.set(.version, 10); + + await (ctx.db.update(ctx.db.appMetadataEntity)..where((r) => r.key.equals(AppMetadataKey.version.name))).write( + AppMetadataEntityCompanion(value: .new(AppMetadataKey.version.encode(99))), + ); + + expect(await sut.get(.version), 99); + }); + }); +} diff --git a/mobile/test/service.mocks.dart b/mobile/test/service.mocks.dart index 4591dd845d..2f59c5fe1c 100644 --- a/mobile/test/service.mocks.dart +++ b/mobile/test/service.mocks.dart @@ -1,10 +1,7 @@ import 'package:immich_mobile/services/api.service.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/network.service.dart'; import 'package:mocktail/mocktail.dart'; class MockApiService extends Mock implements ApiService {} class MockNetworkService extends Mock implements NetworkService {} - -class MockAppSettingService extends Mock implements AppSettingsService {} diff --git a/mobile/test/services/action.service_test.dart b/mobile/test/services/action.service_test.dart index ef5ece38e9..9145dd37b0 100644 --- a/mobile/test/services/action.service_test.dart +++ b/mobile/test/services/action.service_test.dart @@ -2,7 +2,6 @@ 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/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/repositories/db.repository.dart'; @@ -28,6 +27,7 @@ void main() { late MockAssetMediaRepository assetMediaRepository; late MockDownloadRepository downloadRepository; late MockTagService tagService; + late MockAppMetadataRepository appMetadataRepository; late Drift db; @@ -55,6 +55,7 @@ void main() { assetMediaRepository = MockAssetMediaRepository(); downloadRepository = MockDownloadRepository(); tagService = MockTagService(); + appMetadataRepository = MockAppMetadataRepository(); sut = ActionService( assetApiRepository, @@ -66,7 +67,10 @@ void main() { assetMediaRepository, downloadRepository, tagService, + appMetadataRepository, ); + + when(() => appMetadataRepository.get(.manageLocalMediaAndroid)).thenAnswer((_) async => false); }); tearDown(() async { @@ -144,7 +148,7 @@ void main() { group('ActionService.deleteLocal', () { test('routes deleted ids to trashed repository when Android trash handling is enabled', () async { - await Store.put(StoreKey.manageLocalMediaAndroid, true); + when(() => appMetadataRepository.get(.manageLocalMediaAndroid)).thenAnswer((_) async => true); const ids = ['a', 'b']; when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids); @@ -159,7 +163,7 @@ void main() { }); test('deletes locally when Android trash handling is disabled', () async { - await Store.put(StoreKey.manageLocalMediaAndroid, false); + when(() => appMetadataRepository.get(.manageLocalMediaAndroid)).thenAnswer((_) async => false); const ids = ['c']; when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids); @@ -174,7 +178,7 @@ void main() { }); test('short-circuits when nothing was deleted', () async { - await Store.put(StoreKey.manageLocalMediaAndroid, true); + when(() => appMetadataRepository.get(.manageLocalMediaAndroid)).thenAnswer((_) async => true); const ids = ['x']; when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => []);