From 35fdaaeed8536d56d821ec37d9feace810dffe2f Mon Sep 17 00:00:00 2001 From: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:32:35 +0530 Subject: [PATCH] migrate session config from store --- .../drift_schemas/main/drift_schema_v31.json | Bin 0 -> 117024 bytes .../background_sync_teardown_test.dart | 6 +- mobile/lib/domain/models/session.model.dart | 63 ++++++++++ mobile/lib/domain/models/store.model.dart | 6 +- .../entities/session.entity.dart | 18 +++ .../entities/session.entity.drift.dart | Bin 0 -> 13397 bytes .../cached_key_value_repository.dart | 1 + .../repositories/db.repository.dart | 7 +- .../repositories/db.repository.drift.dart | Bin 15891 -> 16224 bytes .../repositories/db.repository.steps.dart | Bin 480553 -> 497991 bytes .../repositories/session.repository.dart | 82 ++++++++++++ .../repositories/settings.repository.dart | 4 +- .../lib/pages/common/splash_screen.page.dart | 8 +- .../open_in_browser_action_button.widget.dart | 5 +- .../asset_viewer/video_viewer.widget.dart | 5 +- .../people/partner_user_avatar.widget.dart | 10 +- mobile/lib/providers/auth.provider.dart | 10 +- .../infrastructure/session.provider.dart | 12 ++ .../infrastructure/settings.provider.dart | 2 +- mobile/lib/providers/websocket.provider.dart | 5 +- .../lib/repositories/upload.repository.dart | 5 +- mobile/lib/routing/auth_guard.dart | 16 +-- mobile/lib/services/api.service.dart | 10 +- mobile/lib/services/auth.service.dart | 8 +- .../services/background_upload.service.dart | 3 +- mobile/lib/utils/bootstrap.dart | 3 + mobile/lib/utils/image_url_builder.dart | 12 +- mobile/lib/utils/migration.dart | 104 +++++++++------ mobile/lib/utils/url_helper.dart | 5 +- mobile/lib/widgets/common/user_avatar.dart | 5 +- .../widgets/common/user_circle_avatar.dart | 5 +- .../domain/services/store_service_test.dart | 36 +++--- mobile/test/drift/main/generated/schema.dart | Bin 3256 -> 3350 bytes .../test/drift/main/generated/schema_v31.dart | Bin 0 -> 330482 bytes .../repositories/store_repository_test.dart | 12 +- .../repositories/session_repository_test.dart | 118 ++++++++++++++++++ .../settings_repository_test.dart | 4 +- mobile/test/services/auth.service_test.dart | 2 + .../background_upload.service_test.dart | 6 +- mobile/test/unit/presentation_context.dart | 6 +- 40 files changed, 473 insertions(+), 131 deletions(-) create mode 100644 mobile/drift_schemas/main/drift_schema_v31.json create mode 100644 mobile/lib/domain/models/session.model.dart create mode 100644 mobile/lib/infrastructure/entities/session.entity.dart create mode 100644 mobile/lib/infrastructure/entities/session.entity.drift.dart create mode 100644 mobile/lib/infrastructure/repositories/session.repository.dart create mode 100644 mobile/lib/providers/infrastructure/session.provider.dart create mode 100644 mobile/test/drift/main/generated/schema_v31.dart create mode 100644 mobile/test/medium/repositories/session_repository_test.dart diff --git a/mobile/drift_schemas/main/drift_schema_v31.json b/mobile/drift_schemas/main/drift_schema_v31.json new file mode 100644 index 0000000000000000000000000000000000000000..3865620615f963830b654d976ed1df83bb6ead9c GIT binary patch literal 117024 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 G&;JJw6hz_x 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 0f125b7fcc..dd81106e62 100644 --- a/mobile/integration_test/background_sync_teardown_test.dart +++ b/mobile/integration_test/background_sync_teardown_test.dart @@ -40,7 +40,7 @@ void main() { tearDown(() async { await workerManagerPatch.dispose(); await server.close(); - await Store.delete(StoreKey.serverEndpoint); + await Store.delete(StoreKey.legacyServerEndpoint); await Store.delete(StoreKey.syncMigrationStatus); }); @@ -119,7 +119,9 @@ void main() { final releaseTxn = Completer(); final txnHeld = Completer(); final txn = drift.transaction(() async { - await drift.into(drift.userEntity).insert( + await drift + .into(drift.userEntity) + .insert( UserEntityCompanion.insert( id: 'holder', name: 'holder', diff --git a/mobile/lib/domain/models/session.model.dart b/mobile/lib/domain/models/session.model.dart new file mode 100644 index 0000000000..81e9b3efd3 --- /dev/null +++ b/mobile/lib/domain/models/session.model.dart @@ -0,0 +1,63 @@ +import 'package:immich_mobile/domain/models/value_codec.dart'; +import 'package:immich_mobile/utils/option.dart'; + +enum SessionKey { + serverUrl(), + accessToken(), + serverEndpoint(); + + ValueCodec get _codec => ValueCodec.forType(T); + + String encode(T value) => _codec.encode(value); + + T decode(String raw) => _codec.decode(raw); +} + +const defaultSession = Session(); + +class Session { + final String? serverUrl; + final String? accessToken; + final String? serverEndpoint; + + const Session({this.serverUrl, this.accessToken, this.serverEndpoint}); + + Session copyWith({Option? serverUrl, Option? accessToken, Option? serverEndpoint}) => .new( + serverUrl: serverUrl.patch(this.serverUrl), + accessToken: accessToken.patch(this.accessToken), + serverEndpoint: serverEndpoint.patch(this.serverEndpoint), + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Session && + other.serverUrl == serverUrl && + other.accessToken == accessToken && + other.serverEndpoint == serverEndpoint); + + @override + int get hashCode => Object.hash(serverUrl, accessToken, serverEndpoint); + + @override + String toString() => 'Session(serverUrl: $serverUrl, accessToken: $accessToken, serverEndpoint: $serverEndpoint)'; + + T read(SessionKey key) => + (switch (key) { + .serverUrl => serverUrl, + .accessToken => accessToken, + .serverEndpoint => serverEndpoint, + }) + as T; + + factory Session.fromEntries(Map overrides) => + overrides.entries.fold(const Session(), (session, entry) => session.write(entry.key, entry.value)); + + Session write(SessionKey key, U value) { + return switch (key) { + .serverUrl => copyWith(serverUrl: .fromNullable(value as String?)), + .accessToken => copyWith(accessToken: .fromNullable(value as String?)), + .serverEndpoint => copyWith(serverEndpoint: .fromNullable(value as String?)), + }; + } +} diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index be1b0c5fb8..9a31e26f5f 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -6,9 +6,6 @@ enum StoreKey { version._(0), currentUser._(2), deviceId._(4), - serverUrl._(10), - accessToken._(11), - serverEndpoint._(12), advancedTroubleshooting._(114), enableHapticFeedback._(126), @@ -19,6 +16,9 @@ enum StoreKey { syncMigrationStatus._(1013), // Legacy keys that have been migrated to the new metadata store + legacyServerUrl._(10), + legacyAccessToken._(11), + legacyServerEndpoint._(12), legacyBackupRequireCharging._(7), legacyBackupTriggerDelay._(8), legacySyncAlbums._(131), diff --git a/mobile/lib/infrastructure/entities/session.entity.dart b/mobile/lib/infrastructure/entities/session.entity.dart new file mode 100644 index 0000000000..ddf89004fb --- /dev/null +++ b/mobile/lib/infrastructure/entities/session.entity.dart @@ -0,0 +1,18 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; + +class SessionEntity extends Table with DriftDefaultsMixin { + const SessionEntity(); + + TextColumn get key => text()(); + + TextColumn get value => text().nullable()(); + + DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); + + @override + Set get primaryKey => {key}; + + @override + String get tableName => "session"; +} diff --git a/mobile/lib/infrastructure/entities/session.entity.drift.dart b/mobile/lib/infrastructure/entities/session.entity.drift.dart new file mode 100644 index 0000000000000000000000000000000000000000..0f18ca516ba0a0f2371cf4cd97cc0f87d217aa9e GIT binary patch literal 13397 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 Uf1sFf<`quxU}%4)isE+Xe>_lv6#xJL literal 0 HcmV?d00001 diff --git a/mobile/lib/infrastructure/repositories/cached_key_value_repository.dart b/mobile/lib/infrastructure/repositories/cached_key_value_repository.dart index afeb31fa27..ed50a9dfd8 100644 --- a/mobile/lib/infrastructure/repositories/cached_key_value_repository.dart +++ b/mobile/lib/infrastructure/repositories/cached_key_value_repository.dart @@ -21,6 +21,7 @@ abstract class CachedKeyValueRepository { Future refresh() async => _snapshot = _build(await selectable().get()); + @protected Stream watchSnapshot() => selectable().watch().map((rows) => _snapshot = _build(rows)); S _build(List<({String key, String? value})> rows) => buildSnapshot( diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart index d39706b512..a8da510bf9 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.dart @@ -25,6 +25,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.d import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/session.entity.dart'; import 'package:immich_mobile/infrastructure/entities/settings.entity.dart'; import 'package:immich_mobile/infrastructure/entities/stack.entity.dart'; import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; @@ -67,6 +68,7 @@ import 'package:sqlite_async/sqlite_async.dart'; AssetEditEntity, SettingsEntity, AssetOcrEntity, + SessionEntity, ], include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'}, ) @@ -120,7 +122,7 @@ class Drift extends $Drift { } @override - int get schemaVersion => 30; + int get schemaVersion => 31; @override MigrationStrategy get migration => MigrationStrategy( @@ -311,6 +313,9 @@ class Drift extends $Drift { from29To30: (m, v30) async { await m.alterTable(TableMigration(v30.settings)); }, + from30To31: (m, v31) async { + await m.createTable(v31.session); + }, ), ); diff --git a/mobile/lib/infrastructure/repositories/db.repository.drift.dart b/mobile/lib/infrastructure/repositories/db.repository.drift.dart index 887daea723103902b967f85b9eb26e651d30eeea..18efa35bf92378f47a16c91a00d623c39f2f3253 100644 GIT binary patch delta 223 zcmbPS^Pp~n4JUhXYH@L9e%|Cj7MaPhoFbD0Sk)%ma4JnUWD=fS$f?9=ws{I?7U$%7 zLcB&MdMd#X)vkFZnI)AWiAg!B3Qz+eLJGDDnMmp;KM+!2G@Z;StixzFSx?vy$W9Oz zWi;JfChW}uvwHJZ@fbB;1&Bt74U+?`*UoJx}uIE5#l<5Xfa-TaX=i<3Fi$YkF7M2#)7Pc+y{aVvCPBIBI85>UD$jq$9Xgs~)F{2)%@%D#L znAsV(_e8O!F-_N>%_hQLoLXF*nV&aZAfMT4dHHNM!|jE#?2DPEKRCuJ441U;WpiMK zsB=8d+BqGlBzXFTY(}B&`X^W)aZI1zz$`Mo;TALNboCo-Q@8hPv7hCbUaZe7oN zVKm&n&VyZz6Ks$SR)ci7YBd#X?WQL_W)$B3CXn5nSxEuO9xEia4wr(0rVf{;H5UL` Cm|+zF delta 65 zcmX@UNp9sT*@hOz7N!>F7M2#)7Pc+y{aV{?a@o&vY!54Aj}qOU-NP=&$y%$aU~4x$ V@iC+D_IFd+&6(LWb+|OGxd8NE6ukfd diff --git a/mobile/lib/infrastructure/repositories/session.repository.dart b/mobile/lib/infrastructure/repositories/session.repository.dart new file mode 100644 index 0000000000..44653d12f5 --- /dev/null +++ b/mobile/lib/infrastructure/repositories/session.repository.dart @@ -0,0 +1,82 @@ +import 'package:drift/drift.dart'; +import 'package:flutter/material.dart'; +import 'package:immich_mobile/domain/models/session.model.dart'; +import 'package:immich_mobile/infrastructure/entities/session.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/repositories/cached_key_value_repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; + +class SessionRepository extends CachedKeyValueRepository { + final Drift _db; + + SessionRepository._(this._db) : super(const .new()); + + static SessionRepository? _instance; + + static SessionRepository get instance { + final instance = _instance; + if (instance == null) { + throw StateError('SessionRepository not initialized. Call ensureInitialized() first'); + } + return instance; + } + + static Future ensureInitialized(Drift db) async { + if (_instance == null) { + final instance = SessionRepository._(db); + await instance.refresh(); + _instance = instance; + } + return _instance!; + } + + @override + List get keys => SessionKey.values; + + @override + Object decodeValue(SessionKey key, String raw) => key.decode(raw); + + @override + Session buildSnapshot(Map overrides) => Session.fromEntries(overrides); + + @override + @protected + Selectable<({String key, String? value})> selectable() => + _db.select(_db.sessionEntity).map((row) => (key: row.key, value: row.value)); + + Session get session => snapshot; + + Future clear(Iterable keys) async { + if (keys.isEmpty) { + return; + } + + final names = keys.map((key) => key.name).toList(); + await (_db.delete(_db.sessionEntity)..where((row) => row.key.isIn(names))).go(); + + var session = snapshot; + for (final key in keys) { + session = session.write(key, defaultSession.read(key)); + } + snapshot = session; + } + + Future write(SessionKey key, U value) async { + if (value == snapshot.read(key)) { + return; + } + + String? resolvedValue; + if (value != null) { + resolvedValue = key.encode(value); + } + + await _db + .into(_db.sessionEntity) + .insertOnConflictUpdate( + SessionEntityCompanion.insert(key: key.name, value: .new(resolvedValue), updatedAt: .new(DateTime.now())), + ); + snapshot = snapshot.write(key, value); + } + + Stream watch() => watchSnapshot(); +} diff --git a/mobile/lib/infrastructure/repositories/settings.repository.dart b/mobile/lib/infrastructure/repositories/settings.repository.dart index 7063779336..de01e36768 100644 --- a/mobile/lib/infrastructure/repositories/settings.repository.dart +++ b/mobile/lib/infrastructure/repositories/settings.repository.dart @@ -1,4 +1,5 @@ import 'package:drift/drift.dart'; +import 'package:flutter/material.dart'; import 'package:immich_mobile/domain/models/config/app_config.dart'; import 'package:immich_mobile/domain/models/settings_key.dart'; import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart'; @@ -39,6 +40,7 @@ class SettingsRepository extends CachedKeyValueRepository overrides) => AppConfig.fromEntries(overrides); @override + @protected Selectable<({String key, String? value})> selectable() => _db.select(_db.settingsEntity).map((row) => (key: row.key, value: row.value)); @@ -81,5 +83,5 @@ class SettingsRepository extends CachedKeyValueRepository watchConfig() => watchSnapshot(); + Stream watch() => watchSnapshot(); } diff --git a/mobile/lib/pages/common/splash_screen.page.dart b/mobile/lib/pages/common/splash_screen.page.dart index de6fda5773..3ba46a53d4 100644 --- a/mobile/lib/pages/common/splash_screen.page.dart +++ b/mobile/lib/pages/common/splash_screen.page.dart @@ -16,6 +16,7 @@ import 'package:immich_mobile/infrastructure/repositories/settings.repository.da import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/session.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; @@ -306,9 +307,10 @@ class SplashScreenPageState extends ConsumerState { } void resumeSession() async { - final serverUrl = Store.tryGet(StoreKey.serverUrl); - final endpoint = Store.tryGet(StoreKey.serverEndpoint); - final accessToken = Store.tryGet(StoreKey.accessToken); + final session = ref.read(sessionProvider); + final serverUrl = session.serverUrl; + final endpoint = session.serverEndpoint; + final accessToken = session.accessToken; if (accessToken != null && serverUrl != null && endpoint != null) { final infoProvider = ref.read(serverInfoProvider.notifier); diff --git a/mobile/lib/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart index 541a9f8093..60e7e15673 100644 --- a/mobile/lib/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart @@ -1,9 +1,8 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/infrastructure/repositories/session.repository.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -22,7 +21,7 @@ class OpenInBrowserActionButton extends ConsumerWidget { }); void _onTap() async { - final serverEndpoint = Store.get(StoreKey.serverEndpoint).replaceFirst('/api', ''); + final serverEndpoint = SessionRepository.instance.session.serverEndpoint!.replaceFirst('/api', ''); String originPath = ''; switch (origin) { diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart index 2be7bb91e8..a3eb8c9055 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart @@ -4,8 +4,6 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.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/storage.repository.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; @@ -13,6 +11,7 @@ import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.pro import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/session.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:logging/logging.dart'; @@ -143,7 +142,7 @@ class _NativeVideoViewerState extends ConsumerState with Widg final remoteId = (videoAsset as RemoteAsset).id; - final serverEndpoint = Store.get(StoreKey.serverEndpoint); + final serverEndpoint = ref.read(sessionProvider).serverEndpoint!; final isOriginalVideo = ref.read(appConfigProvider).viewer.loadOriginalVideo; final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback'; final String videoUrl = videoAsset.livePhotoVideoId != null diff --git a/mobile/lib/presentation/widgets/people/partner_user_avatar.widget.dart b/mobile/lib/presentation/widgets/people/partner_user_avatar.widget.dart index 8618d78362..a07585a4b1 100644 --- a/mobile/lib/presentation/widgets/people/partner_user_avatar.widget.dart +++ b/mobile/lib/presentation/widgets/people/partner_user_avatar.widget.dart @@ -1,18 +1,18 @@ import 'package:flutter/material.dart'; -import 'package:immich_mobile/domain/models/store.model.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; +import 'package:immich_mobile/providers/infrastructure/session.provider.dart'; -class PartnerUserAvatar extends StatelessWidget { +class PartnerUserAvatar extends ConsumerWidget { const PartnerUserAvatar({super.key, required this.userId, required this.name}); final String userId; final String name; @override - Widget build(BuildContext context) { - final url = "${Store.get(StoreKey.serverEndpoint)}/users/$userId/profile-image"; + Widget build(BuildContext context, WidgetRef ref) { + final url = "${ref.read(sessionProvider).serverEndpoint}/users/$userId/profile-image"; final nameFirstLetter = name.isNotEmpty ? name[0] : ""; return CircleAvatar( radius: 16, diff --git a/mobile/lib/providers/auth.provider.dart b/mobile/lib/providers/auth.provider.dart index 8a3f503553..27e3e18db7 100644 --- a/mobile/lib/providers/auth.provider.dart +++ b/mobile/lib/providers/auth.provider.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter_udid/flutter_udid.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/domain/models/session.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/services/user.service.dart'; @@ -10,6 +11,7 @@ import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/models/auth/auth_state.model.dart'; import 'package:immich_mobile/models/auth/login_response.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/session.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; @@ -125,10 +127,10 @@ class AuthNotifier extends StateNotifier { } Future saveAuthInfo({required String accessToken}) async { - await Store.put(StoreKey.accessToken, accessToken); + await _ref.read(sessionRepository).write(SessionKey.accessToken, accessToken); await _apiService.updateHeaders(); - final serverEndpoint = Store.get(StoreKey.serverEndpoint); + final serverEndpoint = _ref.read(sessionProvider).serverEndpoint!; final headerMap = _ref.read(appConfigProvider).network.customHeaders; final customHeaders = headerMap.isEmpty ? null : jsonEncode(headerMap); await _widgetService.writeCredentials(serverEndpoint, accessToken, customHeaders); @@ -193,9 +195,9 @@ class AuthNotifier extends StateNotifier { return _ref.read(appConfigProvider).network.localEndpoint; } - /// Returns the current server endpoint (with /api) URL from the store + /// Returns the current server endpoint (with /api) URL from the session String? getServerEndpoint() { - return Store.tryGet(StoreKey.serverEndpoint); + return _ref.read(sessionProvider).serverEndpoint; } Future setOpenApiServiceEndpoint() { diff --git a/mobile/lib/providers/infrastructure/session.provider.dart b/mobile/lib/providers/infrastructure/session.provider.dart new file mode 100644 index 0000000000..9b3feb7b07 --- /dev/null +++ b/mobile/lib/providers/infrastructure/session.provider.dart @@ -0,0 +1,12 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/session.model.dart'; +import 'package:immich_mobile/infrastructure/repositories/session.repository.dart'; + +final sessionRepository = Provider.autoDispose((_) => SessionRepository.instance); + +final sessionProvider = Provider.autoDispose((ref) { + final repo = ref.watch(sessionRepository); + final subscription = repo.watch().listen((event) => ref.state = event); + ref.onDispose(subscription.cancel); + return repo.session; +}); diff --git a/mobile/lib/providers/infrastructure/settings.provider.dart b/mobile/lib/providers/infrastructure/settings.provider.dart index d2b9dce1d6..8c67a68340 100644 --- a/mobile/lib/providers/infrastructure/settings.provider.dart +++ b/mobile/lib/providers/infrastructure/settings.provider.dart @@ -6,7 +6,7 @@ final settingsProvider = Provider.autoDispose((_) => Setting final appConfigProvider = Provider.autoDispose((ref) { final repo = ref.watch(settingsProvider); - final subscription = repo.watchConfig().listen((event) => ref.state = event); + final subscription = repo.watch().listen((event) => ref.state = event); ref.onDispose(subscription.cancel); return repo.appConfig; }); diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart index 8d9bd5bfe3..30ea805d82 100644 --- a/mobile/lib/providers/websocket.provider.dart +++ b/mobile/lib/providers/websocket.provider.dart @@ -1,12 +1,11 @@ import 'dart:async'; 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/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/models/server_info/server_version.model.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/session.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/utils/debounce.dart'; @@ -68,7 +67,7 @@ class WebsocketNotifier extends StateNotifier { if (authenticationState.isAuthenticated) { try { - final endpoint = Uri.parse(Store.get(StoreKey.serverEndpoint)); + final endpoint = Uri.parse(_ref.read(sessionProvider).serverEndpoint!); dPrint(() => "Attempting to connect to websocket"); // Configure socket transports must be specified Socket socket = io( diff --git a/mobile/lib/repositories/upload.repository.dart b/mobile/lib/repositories/upload.repository.dart index 68522490d8..93dfe6f32e 100644 --- a/mobile/lib/repositories/upload.repository.dart +++ b/mobile/lib/repositories/upload.repository.dart @@ -5,9 +5,8 @@ import 'dart:io'; import 'package:background_downloader/background_downloader.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; -import 'package:immich_mobile/domain/models/store.model.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/session.repository.dart'; import 'package:logging/logging.dart'; import 'package:http/http.dart'; import 'package:immich_mobile/utils/debug_print.dart'; @@ -96,7 +95,7 @@ class UploadRepository { void Function(int bytes, int totalBytes)? onProgress, required String logContext, }) async { - final String savedEndpoint = Store.get(StoreKey.serverEndpoint); + final String savedEndpoint = SessionRepository.instance.session.serverEndpoint!; final baseRequest = ProgressMultipartRequest( 'POST', Uri.parse('$savedEndpoint/assets'), diff --git a/mobile/lib/routing/auth_guard.dart b/mobile/lib/routing/auth_guard.dart index 2fc27be4f4..59e4f0c6a7 100644 --- a/mobile/lib/routing/auth_guard.dart +++ b/mobile/lib/routing/auth_guard.dart @@ -2,9 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:auto_route/auto_route.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/session.repository.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/auth.service.dart'; @@ -23,10 +21,8 @@ class AuthGuard extends AutoRouteGuard { // guards, so we keep this function fully sync and validate the token in // the background — otherwise a slow validateAccessToken() request would // block the route transition for as long as the OS-level HTTP timeout. - try { - Store.get(StoreKey.accessToken); - } on StoreKeyNotFoundException catch (_) { - _log.warning('No access token in the store.'); + if (SessionRepository.instance.session.accessToken == null) { + _log.warning('No access token in the session.'); resolver.next(false); unawaited(router.replaceAll([const LoginRoute()])); return; @@ -40,7 +36,7 @@ class AuthGuard extends AutoRouteGuard { if (_validateInFlight) { return; } - final token = Store.tryGet(StoreKey.accessToken); + final token = SessionRepository.instance.session.accessToken; if (token == null) { return; } @@ -50,7 +46,7 @@ class AuthGuard extends AutoRouteGuard { if (res == null || res.authStatus != true) { // Token may have changed during validation (user logged out + logged in // again); only act if it still applies to the current session. - if (Store.tryGet(StoreKey.accessToken) != token) { + if (SessionRepository.instance.session.accessToken != token) { return; } _log.fine('User token is invalid. Redirecting to login'); @@ -61,7 +57,7 @@ class AuthGuard extends AutoRouteGuard { if (e.code != HttpStatus.unauthorized) { return; } - if (Store.tryGet(StoreKey.accessToken) != token) { + if (SessionRepository.instance.session.accessToken != token) { return; } _log.warning("Unauthorized access token."); diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index 59ef935f2f..cd2b485e3e 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -3,9 +3,9 @@ import 'dart:convert'; import 'dart:io'; import 'package:device_info_plus/device_info_plus.dart'; -import 'package:immich_mobile/domain/models/store.model.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/domain/models/session.model.dart'; import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/session.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart'; import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/url_helper.dart'; @@ -41,7 +41,7 @@ class ApiService { // The below line ensures that the api clients are initialized when the service is instantiated // This is required to avoid late initialization errors when the clients are access before the endpoint is resolved setEndpoint(''); - final endpoint = Store.tryGet(StoreKey.serverEndpoint); + final endpoint = SessionRepository.instance.session.serverEndpoint; if (endpoint != null && endpoint.isNotEmpty) { setEndpoint(endpoint); } @@ -84,7 +84,7 @@ class ApiService { setEndpoint(endpoint); // Save in local database for next startup - await Store.put(StoreKey.serverEndpoint, endpoint); + await SessionRepository.instance.write(SessionKey.serverEndpoint, endpoint); return endpoint; } @@ -173,7 +173,7 @@ class ApiService { static List getServerUrls() { final urls = []; - final serverEndpoint = Store.tryGet(StoreKey.serverEndpoint); + final serverEndpoint = SessionRepository.instance.session.serverEndpoint; if (serverEndpoint != null && serverEndpoint.isNotEmpty) { urls.add(serverEndpoint); } diff --git a/mobile/lib/services/auth.service.dart b/mobile/lib/services/auth.service.dart index 0de22fd124..218ea7199a 100644 --- a/mobile/lib/services/auth.service.dart +++ b/mobile/lib/services/auth.service.dart @@ -1,12 +1,14 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/session.model.dart'; import 'package:immich_mobile/domain/models/settings_key.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/utils/background_sync.dart'; import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/session.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart'; import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/models/auth/login_response.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; @@ -55,7 +57,7 @@ class AuthService { Future validateServerUrl(String url) async { final validUrl = await _apiService.resolveAndSetEndpoint(url); await _apiService.setDeviceInfoHeader(); - await Store.put(StoreKey.serverUrl, validUrl); + await SessionRepository.instance.write(SessionKey.serverUrl, validUrl); return validUrl; } @@ -119,7 +121,7 @@ class AuthService { await Future.wait([ _authRepository.clearLocalData(), Store.delete(StoreKey.currentUser), - Store.delete(StoreKey.accessToken), + SessionRepository.instance.clear([SessionKey.accessToken]), SettingsRepository.instance.clear(const [ .networkAutoEndpointSwitching, .networkPreferredWifiName, diff --git a/mobile/lib/services/background_upload.service.dart b/mobile/lib/services/background_upload.service.dart index 903fd02395..5491e5cd37 100644 --- a/mobile/lib/services/background_upload.service.dart +++ b/mobile/lib/services/background_upload.service.dart @@ -13,6 +13,7 @@ import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/session.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; @@ -386,7 +387,7 @@ class BackgroundUploadService { String? latitude, String? longitude, }) async { - final serverEndpoint = Store.get(StoreKey.serverEndpoint); + final serverEndpoint = SessionRepository.instance.session.serverEndpoint!; final url = Uri.parse('$serverEndpoint/assets').toString(); final headers = ApiService.getRequestHeaders(); final deviceId = Store.get(StoreKey.deviceId); diff --git a/mobile/lib/utils/bootstrap.dart b/mobile/lib/utils/bootstrap.dart index 119df5b804..498ebb26f9 100644 --- a/mobile/lib/utils/bootstrap.dart +++ b/mobile/lib/utils/bootstrap.dart @@ -6,6 +6,7 @@ import 'package:immich_mobile/extensions/translate_extensions.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/session.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; @@ -51,6 +52,8 @@ abstract final class Bootstrap { await StoreService.init(storeRepository: storeRepo, listenUpdates: listenStoreUpdates); + await SessionRepository.ensureInitialized(drift); + final settingsRepo = await SettingsRepository.ensureInitialized(drift); await LogService.init( diff --git a/mobile/lib/utils/image_url_builder.dart b/mobile/lib/utils/image_url_builder.dart index c562049b1d..9ead566524 100644 --- a/mobile/lib/utils/image_url_builder.dart +++ b/mobile/lib/utils/image_url_builder.dart @@ -1,9 +1,8 @@ -import 'package:immich_mobile/domain/models/store.model.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/session.repository.dart'; import 'package:openapi/api.dart'; String getOriginalUrlForRemoteId(final String id, {bool edited = true}) { - return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/original?edited=$edited'; + return '${SessionRepository.instance.session.serverEndpoint!}/assets/$id/original?edited=$edited'; } String getThumbnailUrlForRemoteId( @@ -12,14 +11,15 @@ String getThumbnailUrlForRemoteId( bool edited = true, String? thumbhash, }) { - final url = '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${type.value}&edited=$edited'; + final url = + '${SessionRepository.instance.session.serverEndpoint!}/assets/$id/thumbnail?size=${type.value}&edited=$edited'; return thumbhash != null ? '$url&c=${Uri.encodeComponent(thumbhash)}' : url; } String getPlaybackUrlForRemoteId(final String id) { - return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/video/playback?'; + return '${SessionRepository.instance.session.serverEndpoint!}/assets/$id/video/playback?'; } String getFaceThumbnailUrl(final String personId) { - return '${Store.get(StoreKey.serverEndpoint)}/people/$personId/thumbnail'; + return '${SessionRepository.instance.session.serverEndpoint!}/people/$personId/thumbnail'; } diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index d387c274ea..777dc43709 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -8,17 +8,20 @@ import 'package:immich_mobile/constants/colors.dart'; import 'package:immich_mobile/constants/enums.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'; 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/session.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/session.repository.dart'; import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; -const int targetVersion = 26; +const int targetVersion = 27; Future migrateDatabaseIfNeeded(Drift drift) async { final int version = Store.get(StoreKey.version, targetVersion); @@ -31,18 +34,22 @@ Future migrateDatabaseIfNeeded(Drift drift) async { await _migrateTo26(drift); } + if (version < 27) { + await _migrateTo27(drift); + } + await Store.put(StoreKey.version, targetVersion); return; } Future _migrateTo25() async { - final accessToken = Store.tryGet(StoreKey.accessToken); + final accessToken = Store.tryGet(StoreKey.legacyAccessToken); if (accessToken == null || accessToken.isEmpty) { return; } final urls = []; - final serverEndpoint = Store.tryGet(StoreKey.serverEndpoint); + final serverEndpoint = Store.tryGet(StoreKey.legacyServerEndpoint); if (serverEndpoint != null && serverEndpoint.isNotEmpty) { urls.add(serverEndpoint); } @@ -73,7 +80,7 @@ Future _migrateTo25() async { } Future _migrateTo26(Drift drift) async { - final migrator = _StoreMigrator(drift); + final migrator = _StoreMigrator.settings(drift); await migrator.migrateEnumIndex(StoreKey.legacyLogLevel, SettingsKey.logLevel, LogLevel.values); // Theme await migrator.migrateEnumName(StoreKey.legacyThemeMode, SettingsKey.themeMode, ThemeMode.values); @@ -138,7 +145,17 @@ Future _migrateTo26(Drift drift) async { await migrator.complete(); } -Future _migrateAlbumSortMode(_StoreMigrator migrator) async { +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.complete(); + + await SessionRepository.instance.refresh(); +} + +Future _migrateAlbumSortMode(_StoreMigrator migrator) async { final raw = await migrator.readLegacyStoreInt(StoreKey.legacySelectedAlbumSortOrder.id); final mode = AlbumSortMode.values.firstWhereOrNull((e) => raw != null && e.storeIndex == raw); if (mode == null) { @@ -148,7 +165,7 @@ Future _migrateAlbumSortMode(_StoreMigrator migrator) async { migrator.stage(StoreKey.legacySelectedAlbumSortOrder, SettingsKey.albumSortMode, mode); } -Future _migrateExternalEndpointList(_StoreMigrator migrator) async { +Future _migrateExternalEndpointList(_StoreMigrator migrator) async { final raw = await migrator.readLegacyStoreString(StoreKey.legacyExternalEndpointList.id); if (raw == null) { return; @@ -172,7 +189,7 @@ Future _migrateExternalEndpointList(_StoreMigrator migrator) async { migrator.stage(StoreKey.legacyExternalEndpointList, SettingsKey.networkExternalEndpointList, urls); } -Future _migrateCustomHeaders(_StoreMigrator migrator) async { +Future _migrateCustomHeaders(_StoreMigrator migrator) async { final raw = await migrator.readLegacyStoreString(StoreKey.legacyCustomHeaders.id); if (raw == null) { return; @@ -195,14 +212,39 @@ Future _migrateCustomHeaders(_StoreMigrator migrator) async { migrator.stage(StoreKey.legacyCustomHeaders, SettingsKey.networkCustomHeaders, headers); } -class _StoreMigrator { +class _StoreMigrator { + _StoreMigrator._(this._db, {required this.encode, required this.readDefault, required this.insertRow}); + + static _StoreMigrator settings(Drift db) => _StoreMigrator._( + db, + encode: (key, value) => key.encode(value), + readDefault: (key) => defaultConfig.read(key), + insertRow: (batch, name, value) => batch.insert( + db.settingsEntity, + SettingsEntityCompanion(key: Value(name), value: Value(value)), + mode: InsertMode.insertOrReplace, + ), + ); + + static _StoreMigrator session(Drift db) => _StoreMigrator._( + db, + encode: (key, value) => key.encode(value), + readDefault: (key) => defaultSession.read(key), + insertRow: (batch, name, value) => batch.insert( + db.sessionEntity, + SessionEntityCompanion(key: Value(name), value: Value(value)), + mode: InsertMode.insertOrReplace, + ), + ); + final Drift _db; - final Map _cache = {}; + final String Function(K key, Object value) encode; + final Object? Function(K key) readDefault; + final void Function(Batch batch, String name, String? value) insertRow; + final Map _cache = {}; final List _migratedStoreIds = []; - _StoreMigrator(this._db); - - Future migrateEnumIndex(StoreKey legacyKey, SettingsKey newKey, List values) async { + Future migrateEnumIndex(StoreKey legacyKey, K newKey, List values) async { final index = await readLegacyStoreInt(legacyKey.id); if (index == null) { return; @@ -217,11 +259,7 @@ class _StoreMigrator { _migratedStoreIds.add(legacyKey.id); } - Future migrateEnumName( - StoreKey legacyKey, - SettingsKey newKey, - List values, - ) async { + Future migrateEnumName(StoreKey legacyKey, K newKey, List values) async { final name = await readLegacyStoreString(legacyKey.id); if (name == null) { return; @@ -236,18 +274,17 @@ class _StoreMigrator { _migratedStoreIds.add(legacyKey.id); } - Future migrateBool(StoreKey legacyKey, SettingsKey newKey) async { + Future migrateBool(StoreKey legacyKey, K newKey) async { final intValue = await readLegacyStoreInt(legacyKey.id); if (intValue == null) { return; } - final boolValue = intValue != 0; - _cache[newKey] = boolValue; + _cache[newKey] = intValue != 0; _migratedStoreIds.add(legacyKey.id); } - Future migrateInt(StoreKey legacyKey, SettingsKey newKey) async { + Future migrateInt(StoreKey legacyKey, K newKey) async { final intValue = await readLegacyStoreInt(legacyKey.id); if (intValue == null) { return; @@ -257,9 +294,9 @@ class _StoreMigrator { _migratedStoreIds.add(legacyKey.id); } - Future migrateString(StoreKey legacyKey, SettingsKey newKey) async { + Future migrateString(StoreKey legacyKey, K newKey) async { final value = await readLegacyStoreString(legacyKey.id); - if (value == null) { + if (value == null || value.isEmpty) { return; } @@ -267,7 +304,12 @@ class _StoreMigrator { _migratedStoreIds.add(legacyKey.id); } - void stage(StoreKey legacyKey, SettingsKey newKey, U value) { + Future migrateNullableString(StoreKey legacyKey, K newKey) async { + _cache[newKey] = await readLegacyStoreString(legacyKey.id); + _migratedStoreIds.add(legacyKey.id); + } + + void stage(StoreKey legacyKey, K newKey, Object? value) { _cache[newKey] = value; _migratedStoreIds.add(legacyKey.id); } @@ -275,20 +317,12 @@ class _StoreMigrator { Future complete() async { await _db.batch((batch) { for (final entry in _cache.entries) { - if (entry.value == defaultConfig.read(entry.key)) { + if (entry.value == readDefault(entry.key)) { continue; } - String? resolvedValue; - if (entry.value != null) { - resolvedValue = entry.key.encode(entry.value); - } - - batch.insert( - _db.settingsEntity, - SettingsEntityCompanion(key: Value(entry.key.name), value: Value(resolvedValue)), - mode: InsertMode.insertOrReplace, - ); + final value = entry.value; + insertRow(batch, entry.key.name, value == null ? null : encode(entry.key, value)); } }); await deleteLegacyStoreRows(_migratedStoreIds); diff --git a/mobile/lib/utils/url_helper.dart b/mobile/lib/utils/url_helper.dart index b7dc41c4cf..4fed30b69b 100644 --- a/mobile/lib/utils/url_helper.dart +++ b/mobile/lib/utils/url_helper.dart @@ -1,5 +1,4 @@ -import 'package:immich_mobile/domain/models/store.model.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/session.repository.dart'; import 'package:punycode/punycode.dart'; String sanitizeUrl(String url) { @@ -11,7 +10,7 @@ String sanitizeUrl(String url) { } String? getServerUrl() { - final serverUrl = punycodeDecodeUrl(Store.tryGet(StoreKey.serverEndpoint)); + final serverUrl = punycodeDecodeUrl(SessionRepository.instance.session.serverEndpoint); final serverUri = serverUrl != null ? Uri.tryParse(serverUrl) : null; if (serverUri == null) { return null; diff --git a/mobile/lib/widgets/common/user_avatar.dart b/mobile/lib/widgets/common/user_avatar.dart index 911d6a9f10..6dfb2b0d04 100644 --- a/mobile/lib/widgets/common/user_avatar.dart +++ b/mobile/lib/widgets/common/user_avatar.dart @@ -1,12 +1,11 @@ import 'package:flutter/material.dart'; -import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/user.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/session.repository.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; Widget userAvatar(BuildContext context, UserDto u, {double? radius}) { - final url = "${Store.get(StoreKey.serverEndpoint)}/users/${u.id}/profile-image"; + final url = "${SessionRepository.instance.session.serverEndpoint!}/users/${u.id}/profile-image"; final nameFirstLetter = u.name.isNotEmpty ? u.name[0] : ""; return CircleAvatar( radius: radius, diff --git a/mobile/lib/widgets/common/user_circle_avatar.dart b/mobile/lib/widgets/common/user_circle_avatar.dart index c6e4f4719e..4e6090c15e 100644 --- a/mobile/lib/widgets/common/user_circle_avatar.dart +++ b/mobile/lib/widgets/common/user_circle_avatar.dart @@ -1,9 +1,8 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; +import 'package:immich_mobile/providers/infrastructure/session.provider.dart'; // ignore: must_be_immutable class UserCircleAvatar extends ConsumerWidget { @@ -18,7 +17,7 @@ class UserCircleAvatar extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final userAvatarColor = user.avatarColor.toColor().withValues(alpha: opacity); final profileImageUrl = - '${Store.get(StoreKey.serverEndpoint)}/users/${user.id}/profile-image?d=${user.profileChangedAt.millisecondsSinceEpoch}'; + '${ref.read(sessionProvider).serverEndpoint}/users/${user.id}/profile-image?d=${user.profileChangedAt.millisecondsSinceEpoch}'; final textColor = (user.avatarColor.toColor().computeLuminance() > 0.5 ? Colors.black : Colors.white).withValues( alpha: opacity, diff --git a/mobile/test/domain/services/store_service_test.dart b/mobile/test/domain/services/store_service_test.dart index bb439b3d72..090faed0dc 100644 --- a/mobile/test/domain/services/store_service_test.dart +++ b/mobile/test/domain/services/store_service_test.dart @@ -21,13 +21,13 @@ void main() { controller = StreamController>>.broadcast(); mockDriftStoreRepo = MockDriftStoreRepository(); // For generics, we need to provide fallback to each concrete type to avoid runtime errors - registerFallbackValue(StoreKey.accessToken); + registerFallbackValue(StoreKey.legacyAccessToken); registerFallbackValue(StoreKey.version); registerFallbackValue(StoreKey.advancedTroubleshooting); when(() => mockDriftStoreRepo.getAll()).thenAnswer( (_) async => [ - const StoreDto(StoreKey.accessToken, _kAccessToken), + const StoreDto(StoreKey.legacyAccessToken, _kAccessToken), const StoreDto(StoreKey.advancedTroubleshooting, _kAdvancedTroubleshooting), const StoreDto(StoreKey.version, _kVersion), ], @@ -45,7 +45,7 @@ void main() { group("Store Service Init:", () { test('Populates the internal cache on init', () { verify(() => mockDriftStoreRepo.getAll()).called(1); - expect(sut.tryGet(StoreKey.accessToken), _kAccessToken); + expect(sut.tryGet(StoreKey.legacyAccessToken), _kAccessToken); expect(sut.tryGet(StoreKey.advancedTroubleshooting), _kAdvancedTroubleshooting); expect(sut.tryGet(StoreKey.version), _kVersion); // Other keys should be null @@ -53,19 +53,19 @@ void main() { }); test('Listens to stream of store updates', () async { - final event = StoreDto(StoreKey.accessToken, _kAccessToken.toUpperCase()); + final event = StoreDto(StoreKey.legacyAccessToken, _kAccessToken.toUpperCase()); controller.add([event]); await pumpEventQueue(); verify(() => mockDriftStoreRepo.watchAll()).called(1); - expect(sut.tryGet(StoreKey.accessToken), _kAccessToken.toUpperCase()); + expect(sut.tryGet(StoreKey.legacyAccessToken), _kAccessToken.toUpperCase()); }); }); group('Store Service get:', () { test('Returns the stored value for the given key', () { - expect(sut.get(StoreKey.accessToken), _kAccessToken); + expect(sut.get(StoreKey.legacyAccessToken), _kAccessToken); }); test('Throws StoreKeyNotFoundException for nonexistent keys', () { @@ -83,15 +83,15 @@ void main() { }); test('Skip insert when value is not modified', () async { - await sut.put(StoreKey.accessToken, _kAccessToken); - verifyNever(() => mockDriftStoreRepo.upsert(StoreKey.accessToken, any())); + await sut.put(StoreKey.legacyAccessToken, _kAccessToken); + verifyNever(() => mockDriftStoreRepo.upsert(StoreKey.legacyAccessToken, any())); }); test('Insert value when modified', () async { final newAccessToken = _kAccessToken.toUpperCase(); - await sut.put(StoreKey.accessToken, newAccessToken); - verify(() => mockDriftStoreRepo.upsert(StoreKey.accessToken, newAccessToken)).called(1); - expect(sut.tryGet(StoreKey.accessToken), newAccessToken); + await sut.put(StoreKey.legacyAccessToken, newAccessToken); + verify(() => mockDriftStoreRepo.upsert(StoreKey.legacyAccessToken, newAccessToken)).called(1); + expect(sut.tryGet(StoreKey.legacyAccessToken), newAccessToken); }); }); @@ -108,7 +108,7 @@ void main() { }); test('Watches a specific key for changes', () async { - final stream = sut.watch(StoreKey.accessToken); + final stream = sut.watch(StoreKey.legacyAccessToken); final events = [_kAccessToken, _kAccessToken.toUpperCase(), null, _kAccessToken.toLowerCase()]; unawaited(expectLater(stream, emitsInOrder(events))); @@ -118,7 +118,7 @@ void main() { } await pumpEventQueue(); - verify(() => mockDriftStoreRepo.watch(StoreKey.accessToken)).called(1); + verify(() => mockDriftStoreRepo.watch(StoreKey.legacyAccessToken)).called(1); }); }); @@ -128,13 +128,13 @@ void main() { }); test('Removes the value from the DB', () async { - await sut.delete(StoreKey.accessToken); - verify(() => mockDriftStoreRepo.delete(StoreKey.accessToken)).called(1); + await sut.delete(StoreKey.legacyAccessToken); + verify(() => mockDriftStoreRepo.delete(StoreKey.legacyAccessToken)).called(1); }); test('Removes the value from the cache', () async { - await sut.delete(StoreKey.accessToken); - expect(sut.tryGet(StoreKey.accessToken), isNull); + await sut.delete(StoreKey.legacyAccessToken); + expect(sut.tryGet(StoreKey.legacyAccessToken), isNull); }); }); @@ -146,7 +146,7 @@ void main() { test('Clears all values from the store', () async { await sut.clear(); verify(() => mockDriftStoreRepo.deleteAll()).called(1); - expect(sut.tryGet(StoreKey.accessToken), isNull); + expect(sut.tryGet(StoreKey.legacyAccessToken), isNull); expect(sut.tryGet(StoreKey.advancedTroubleshooting), isNull); expect(sut.tryGet(StoreKey.version), isNull); }); diff --git a/mobile/test/drift/main/generated/schema.dart b/mobile/test/drift/main/generated/schema.dart index c0302f2805e2d4ba74ac3c3e41d62419d3302657..ee5900c1d1bcdb37eeb4a40446e6f501dea18744 100644 GIT binary patch delta 92 zcmdlXIZbNAM3%|SjDmcIdMSxTCF%-^#R_G{hMW6Y&ai1FCl;qF7#mt~DL_F{YDsBP k9!PVc;Bn+tBpF4$-R{^M22dFmInyZ!z0N@)LPXGV_ delta 23 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 IU;pd>1K)^K^#A|> literal 0 HcmV?d00001 diff --git a/mobile/test/infrastructure/repositories/store_repository_test.dart b/mobile/test/infrastructure/repositories/store_repository_test.dart index 3e160c29ca..b8baab614c 100644 --- a/mobile/test/infrastructure/repositories/store_repository_test.dart +++ b/mobile/test/infrastructure/repositories/store_repository_test.dart @@ -29,7 +29,7 @@ Future _populateStore(Drift db) async { batch.insert( db.storeEntity, StoreEntityCompanion( - id: Value(StoreKey.accessToken.id), + id: Value(StoreKey.legacyAccessToken.id), intValue: const Value(null), stringValue: const Value(_kTestAccessToken), ), @@ -68,10 +68,10 @@ void main() { }); test('converts string', () async { - String? accessToken = await sut.tryGet(StoreKey.accessToken); + String? accessToken = await sut.tryGet(StoreKey.legacyAccessToken); expect(accessToken, isNull); - await sut.upsert(StoreKey.accessToken, _kTestAccessToken); - accessToken = await sut.tryGet(StoreKey.accessToken); + await sut.upsert(StoreKey.legacyAccessToken, _kTestAccessToken); + accessToken = await sut.tryGet(StoreKey.legacyAccessToken); expect(accessToken, _kTestAccessToken); }); @@ -147,12 +147,12 @@ void main() { emitsInOrder([ [ const StoreDto(StoreKey.version, _kTestVersion), - const StoreDto(StoreKey.accessToken, _kTestAccessToken), + const StoreDto(StoreKey.legacyAccessToken, _kTestAccessToken), const StoreDto(StoreKey.advancedTroubleshooting, _kTestAdvancedTroubleshooting), ], [ const StoreDto(StoreKey.version, _kTestVersion + 10), - const StoreDto(StoreKey.accessToken, _kTestAccessToken), + const StoreDto(StoreKey.legacyAccessToken, _kTestAccessToken), const StoreDto(StoreKey.advancedTroubleshooting, _kTestAdvancedTroubleshooting), ], ]), diff --git a/mobile/test/medium/repositories/session_repository_test.dart b/mobile/test/medium/repositories/session_repository_test.dart new file mode 100644 index 0000000000..d645556b98 --- /dev/null +++ b/mobile/test/medium/repositories/session_repository_test.dart @@ -0,0 +1,118 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/session.model.dart'; +import 'package:immich_mobile/infrastructure/entities/session.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/repositories/session.repository.dart'; + +import '../repository_context.dart'; + +void main() { + late MediumRepositoryContext ctx; + late SessionRepository sut; + + setUpAll(() async { + ctx = MediumRepositoryContext(); + sut = await SessionRepository.ensureInitialized(ctx.db); + }); + + tearDownAll(() async { + await ctx.dispose(); + }); + + setUp(() async { + await ctx.db.delete(ctx.db.sessionEntity).go(); + await SessionRepository.instance.refresh(); + }); + + group('defaults', () { + test('session returns null fields when DB is empty', () { + expect(sut.session.serverUrl, isNull); + expect(sut.session.accessToken, isNull); + expect(sut.session.serverEndpoint, isNull); + }); + }); + + group('write', () { + test('persists a value and reflects it in the composed view', () async { + await sut.write(.serverEndpoint, 'https://demo.immich.app/api'); + expect(sut.session.serverEndpoint, 'https://demo.immich.app/api'); + }); + + test('persists across keys independently', () async { + await sut.write(.serverUrl, 'https://demo.immich.app'); + await sut.write(.accessToken, 'token-123'); + expect(sut.session.serverUrl, 'https://demo.immich.app'); + expect(sut.session.accessToken, 'token-123'); + expect(sut.session.serverEndpoint, isNull); + }); + }); + + group('null values', () { + test('a stored NULL value column decodes to null on refresh', () async { + await ctx.db + .into(ctx.db.sessionEntity) + .insert( + SessionEntityCompanion.insert( + key: SessionKey.accessToken.name, + value: const .new(null), + updatedAt: .new(DateTime.now()), + ), + ); + + await SessionRepository.instance.refresh(); + expect(sut.session.accessToken, isNull); + }); + }); + + group('sync', () { + test('picks up rows that were inserted directly into the DB', () async { + await ctx.db + .into(ctx.db.sessionEntity) + .insert( + SessionEntityCompanion.insert( + key: SessionKey.serverEndpoint.name, + value: const .new('https://demo.immich.app/api'), + updatedAt: .new(DateTime.now()), + ), + ); + expect(sut.session.serverEndpoint, isNull); + + await SessionRepository.instance.refresh(); + expect(sut.session.serverEndpoint, 'https://demo.immich.app/api'); + }); + + test('drops cached values for rows that were deleted out from under the repo', () async { + await sut.write(.serverEndpoint, 'https://demo.immich.app/api'); + await ctx.db.delete(ctx.db.sessionEntity).go(); + expect(sut.session.serverEndpoint, 'https://demo.immich.app/api'); + + await SessionRepository.instance.refresh(); + expect(sut.session.serverEndpoint, isNull); + }); + + test('skips rows whose key is unknown to SessionKey', () async { + await ctx.db + .into(ctx.db.sessionEntity) + .insert( + SessionEntityCompanion.insert( + key: 'session.unknown.future-key', + value: const .new('unknown'), + updatedAt: .new(DateTime.now()), + ), + ); + + await SessionRepository.instance.refresh(); + expect(sut.session.serverEndpoint, isNull); + }); + }); + + group('watch', () { + test('watchSession emits the new value after a write', () async { + final expectation = expectLater( + sut.watch().map((s) => s.serverEndpoint), + emitsThrough('https://demo.immich.app/api/watch'), + ); + await sut.write(SessionKey.serverEndpoint, 'https://demo.immich.app/api/watch'); + await expectation; + }); + }); +} diff --git a/mobile/test/medium/repositories/settings_repository_test.dart b/mobile/test/medium/repositories/settings_repository_test.dart index 087b3aece4..c63f59e6a3 100644 --- a/mobile/test/medium/repositories/settings_repository_test.dart +++ b/mobile/test/medium/repositories/settings_repository_test.dart @@ -142,13 +142,13 @@ void main() { group('watch', () { test('watchAppConfig emits the new value after a write', () async { - final expectation = expectLater(sut.watchConfig().map((c) => c.theme.mode), emitsThrough(ThemeMode.dark)); + final expectation = expectLater(sut.watch().map((c) => c.theme.mode), emitsThrough(ThemeMode.dark)); await sut.write(SettingsKey.themeMode, ThemeMode.dark); await expectation; }); test('watchConfig emits the new value after a write', () async { - final expectation = expectLater(sut.watchConfig().map((c) => c.logLevel), emitsThrough(LogLevel.warning)); + final expectation = expectLater(sut.watch().map((c) => c.logLevel), emitsThrough(LogLevel.warning)); await sut.write(SettingsKey.logLevel, LogLevel.warning); await expectation; }); diff --git a/mobile/test/services/auth.service_test.dart b/mobile/test/services/auth.service_test.dart index 584ea57027..82eec5b52f 100644 --- a/mobile/test/services/auth.service_test.dart +++ b/mobile/test/services/auth.service_test.dart @@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/session.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/services/auth.service.dart'; @@ -44,6 +45,7 @@ void main() { WidgetsFlutterBinding.ensureInitialized(); db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); await StoreService.init(storeRepository: DriftStoreRepository(db)); + await SessionRepository.ensureInitialized(db); }); tearDownAll(() async { diff --git a/mobile/test/services/background_upload.service_test.dart b/mobile/test/services/background_upload.service_test.dart index 310f2f4d49..4a918eb69f 100644 --- a/mobile/test/services/background_upload.service_test.dart +++ b/mobile/test/services/background_upload.service_test.dart @@ -7,10 +7,12 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/session.model.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'; +import 'package:immich_mobile/infrastructure/repositories/session.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/services/background_upload.service.dart'; @@ -39,8 +41,8 @@ void main() { db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); await StoreService.init(storeRepository: DriftStoreRepository(db)); await SettingsRepository.ensureInitialized(db); - - await Store.put(StoreKey.serverEndpoint, 'http://test-server.com'); + await SessionRepository.ensureInitialized(db); + await SessionRepository.instance.write(SessionKey.serverEndpoint, 'https://demo.immich.app'); await Store.put(StoreKey.deviceId, 'test-device-id'); }); diff --git a/mobile/test/unit/presentation_context.dart b/mobile/test/unit/presentation_context.dart index 97b09ba85e..3e99cdd9c0 100644 --- a/mobile/test/unit/presentation_context.dart +++ b/mobile/test/unit/presentation_context.dart @@ -5,10 +5,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/locales.dart'; -import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/models/session.model.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/generated/codegen_loader.g.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/session.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import '../test_utils.dart'; @@ -25,7 +26,8 @@ class PresentationContext { if (_db == null) { final db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); await StoreService.init(storeRepository: DriftStoreRepository(db), listenUpdates: false); - await StoreService.I.put(StoreKey.serverEndpoint, serverEndpoint); + await SessionRepository.ensureInitialized(db); + await SessionRepository.instance.write(SessionKey.serverEndpoint, serverEndpoint); _db = db; } return const PresentationContext._();