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 0000000000..3865620615 Binary files /dev/null and b/mobile/drift_schemas/main/drift_schema_v31.json differ 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 0000000000..0f18ca516b Binary files /dev/null and b/mobile/lib/infrastructure/entities/session.entity.drift.dart differ 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 887daea723..18efa35bf9 100644 Binary files a/mobile/lib/infrastructure/repositories/db.repository.drift.dart and b/mobile/lib/infrastructure/repositories/db.repository.drift.dart differ diff --git a/mobile/lib/infrastructure/repositories/db.repository.steps.dart b/mobile/lib/infrastructure/repositories/db.repository.steps.dart index 91f5e8c3ef..3a40fd7543 100644 Binary files a/mobile/lib/infrastructure/repositories/db.repository.steps.dart and b/mobile/lib/infrastructure/repositories/db.repository.steps.dart differ diff --git a/mobile/lib/infrastructure/repositories/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 c0302f2805..ee5900c1d1 100644 Binary files a/mobile/test/drift/main/generated/schema.dart and b/mobile/test/drift/main/generated/schema.dart differ diff --git a/mobile/test/drift/main/generated/schema_v31.dart b/mobile/test/drift/main/generated/schema_v31.dart new file mode 100644 index 0000000000..37ba27c311 Binary files /dev/null and b/mobile/test/drift/main/generated/schema_v31.dart differ 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._();