diff --git a/mobile/lib/domain/models/config/network_config.dart b/mobile/lib/domain/models/config/network_config.dart new file mode 100644 index 0000000000..78f0482a1a --- /dev/null +++ b/mobile/lib/domain/models/config/network_config.dart @@ -0,0 +1,54 @@ +import 'package:flutter/foundation.dart'; + +class NetworkConfig { + final bool autoEndpointSwitching; + final String? preferredWifiName; + final String? localEndpoint; + final List externalEndpointList; + final Map customHeaders; + + const NetworkConfig({ + this.autoEndpointSwitching = false, + this.preferredWifiName, + this.localEndpoint, + this.externalEndpointList = const [], + this.customHeaders = const {}, + }); + + NetworkConfig copyWith({ + bool? autoEndpointSwitching, + String? preferredWifiName, + String? localEndpoint, + List? externalEndpointList, + Map? customHeaders, + }) => NetworkConfig( + autoEndpointSwitching: autoEndpointSwitching ?? this.autoEndpointSwitching, + preferredWifiName: preferredWifiName ?? this.preferredWifiName, + localEndpoint: localEndpoint ?? this.localEndpoint, + externalEndpointList: externalEndpointList ?? this.externalEndpointList, + customHeaders: customHeaders ?? this.customHeaders, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is NetworkConfig && + other.autoEndpointSwitching == autoEndpointSwitching && + other.preferredWifiName == preferredWifiName && + other.localEndpoint == localEndpoint && + listEquals(other.externalEndpointList, externalEndpointList) && + mapEquals(other.customHeaders, customHeaders)); + + @override + int get hashCode => Object.hash( + autoEndpointSwitching, + preferredWifiName, + localEndpoint, + Object.hashAll(externalEndpointList), + Object.hashAllUnordered(customHeaders.entries.map((e) => Object.hash(e.key, e.value))), + ); + + @override + String toString() => + 'NetworkConfig(autoEndpointSwitching: $autoEndpointSwitching, preferredWifiName: $preferredWifiName, localEndpoint: $localEndpoint, externalEndpointList: $externalEndpointList, customHeaders: $customHeaders)'; +} diff --git a/mobile/lib/domain/models/config/system_config.dart b/mobile/lib/domain/models/config/system_config.dart index cbad77695d..7d8fef6dd8 100644 --- a/mobile/lib/domain/models/config/system_config.dart +++ b/mobile/lib/domain/models/config/system_config.dart @@ -1,18 +1,22 @@ +import 'package:immich_mobile/domain/models/config/network_config.dart'; import 'package:immich_mobile/domain/models/log.model.dart'; class SystemConfig { final LogLevel logLevel; + final NetworkConfig network; - const SystemConfig({this.logLevel = .info}); + const SystemConfig({this.logLevel = .info, this.network = const .new()}); - SystemConfig copyWith({LogLevel? logLevel}) => SystemConfig(logLevel: logLevel ?? this.logLevel); + SystemConfig copyWith({LogLevel? logLevel, NetworkConfig? network}) => + SystemConfig(logLevel: logLevel ?? this.logLevel, network: network ?? this.network); @override - bool operator ==(Object other) => identical(this, other) || (other is SystemConfig && other.logLevel == logLevel); + bool operator ==(Object other) => + identical(this, other) || (other is SystemConfig && other.logLevel == logLevel && other.network == network); @override - int get hashCode => logLevel.hashCode; + int get hashCode => Object.hash(logLevel, network); @override - String toString() => 'SystemConfig(logLevel: $logLevel)'; + String toString() => 'SystemConfig(logLevel: $logLevel, network: $network)'; } diff --git a/mobile/lib/domain/models/metadata_key.dart b/mobile/lib/domain/models/metadata_key.dart index 04ef506f89..f221bf6abf 100644 --- a/mobile/lib/domain/models/metadata_key.dart +++ b/mobile/lib/domain/models/metadata_key.dart @@ -34,6 +34,23 @@ enum MetadataKey { viewerAutoPlayVideo(.appConfig, 'viewer.autoPlayVideo', true), viewerTapToNavigate(.appConfig, 'viewer.tapToNavigate', false), + // Network + networkAutoEndpointSwitching(.systemConfig, 'network.autoEndpointSwitching', false), + networkPreferredWifiName(.systemConfig, 'network.preferredWifiName', ''), + networkLocalEndpoint(.systemConfig, 'network.localEndpoint', ''), + networkExternalEndpointList>( + .systemConfig, + 'network.externalEndpointList', + [], + _ListCodec(_PrimitiveCodec.string), + ), + networkCustomHeaders>( + .systemConfig, + 'network.customHeaders', + {}, + _MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string), + ), + // Timeline timelineTilesPerRow(.appConfig, 'timeline.tilesPerRow', 4), timelineGroupAssetsBy( @@ -143,6 +160,47 @@ final class _DateTimeCodec extends _MetadataCodec { DateTime? decode(String raw) => DateTime.tryParse(raw); } +final class _MapCodec extends _MetadataCodec> { + final _MetadataCodec _keyCodec; + final _MetadataCodec _valueCodec; + + const _MapCodec(this._keyCodec, this._valueCodec); + + @override + String encode(Map value) { + final entries = {}; + value.forEach((k, v) => entries[_keyCodec.encode(k)] = _valueCodec.encode(v)); + return jsonEncode(entries); + } + + @override + Map? decode(String raw) { + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) { + return null; + } + final result = {}; + for (final entry in decoded.entries) { + final rawKey = entry.key; + final rawValue = entry.value; + if (rawKey is! String || rawValue is! String) { + return null; + } + final k = _keyCodec.decode(rawKey); + final v = _valueCodec.decode(rawValue); + if (k == null || v == null) { + return null; + } + result[k] = v; + } + return result; + } on FormatException { + return null; + } + } +} + final class _ListCodec extends _MetadataCodec> { final _MetadataCodec _elementCodec; diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index f2a3fcc2c0..2d753dfa78 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -15,23 +15,13 @@ enum StoreKey { advancedTroubleshooting._(114), selectedAlbumSortReverse._(123), enableHapticFeedback._(126), - customHeaders._(127), syncAlbums._(131), - // Auto endpoint switching - autoEndpointSwitching._(132), - preferredWifiName._(133), - localEndpoint._(134), - externalEndpointList._(135), - manageLocalMediaAndroid._(137), // Read-only Mode settings readonlyModeEnabled._(138), albumGridView._(140), - // Image viewer navigation settings - tapToNavigate._(141), - // Experimental stuff enableBackup._(1003), useWifiForUploadVideos._(1004), @@ -39,6 +29,11 @@ enum StoreKey { syncMigrationStatus._(1013), // Legacy keys that have been migrated to the new metadata store + legacyAutoEndpointSwitching._(132), + legacyPreferredWifiName._(133), + legacyLocalEndpoint._(134), + legacyExternalEndpointList._(135), + legacyCustomHeaders._(127), legacyLoopVideo._(117), legacyLoadOriginalVideo._(136), legacyAutoPlayVideo._(139), diff --git a/mobile/lib/extensions/string_extensions.dart b/mobile/lib/extensions/string_extensions.dart index d30c221f96..792b932df2 100644 --- a/mobile/lib/extensions/string_extensions.dart +++ b/mobile/lib/extensions/string_extensions.dart @@ -4,6 +4,8 @@ extension StringExtension on String { String capitalize() { return split(" ").map((str) => str.isEmpty ? str : str[0].toUpperCase() + str.substring(1)).join(" "); } + + String? get nullIfEmpty => isEmpty ? null : this; } extension DurationExtension on String { diff --git a/mobile/lib/infrastructure/repositories/metadata.repository.dart b/mobile/lib/infrastructure/repositories/metadata.repository.dart index b5801b9b9c..d8575cd9a8 100644 --- a/mobile/lib/infrastructure/repositories/metadata.repository.dart +++ b/mobile/lib/infrastructure/repositories/metadata.repository.dart @@ -2,6 +2,7 @@ import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/models/config/app_config.dart'; import 'package:immich_mobile/domain/models/config/system_config.dart'; import 'package:immich_mobile/domain/models/metadata_key.dart'; +import 'package:immich_mobile/extensions/string_extensions.dart'; import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; @@ -148,7 +149,16 @@ extension on MetadataDomain { ), ); case .systemConfig: - repo._systemConfig = .new(logLevel: repo._read(.logLevel)); + repo._systemConfig = .new( + logLevel: repo._read(.logLevel), + network: .new( + autoEndpointSwitching: repo._read(.networkAutoEndpointSwitching), + preferredWifiName: repo._read(.networkPreferredWifiName).nullIfEmpty, + localEndpoint: repo._read(.networkLocalEndpoint).nullIfEmpty, + externalEndpointList: repo._read(.networkExternalEndpointList), + customHeaders: repo._read(.networkCustomHeaders), + ), + ); } } } diff --git a/mobile/lib/pages/common/headers_settings.page.dart b/mobile/lib/pages/common/headers_settings.page.dart index 1fa183456c..e599286dcf 100644 --- a/mobile/lib/pages/common/headers_settings.page.dart +++ b/mobile/lib/pages/common/headers_settings.page.dart @@ -1,14 +1,12 @@ -import 'dart:convert'; - import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart' hide Store; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/store.model.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/domain/models/metadata_key.dart'; import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; class SettingsHeader { String key = ""; @@ -24,17 +22,14 @@ class HeaderSettingsPage extends HookConsumerWidget { final headers = useState>([]); final setInitialHeaders = useState(false); - var headersStr = Store.get(StoreKey.customHeaders, ""); + final storedHeaders = ref.read(metadataProvider).systemConfig.network.customHeaders; if (!setInitialHeaders.value) { - if (headersStr.isNotEmpty) { - var customHeaders = jsonDecode(headersStr) as Map; - customHeaders.forEach((k, v) { - final header = SettingsHeader(); - header.key = k; - header.value = v; - headers.value.add(header); - }); - } + storedHeaders.forEach((k, v) { + final header = SettingsHeader(); + header.key = k; + header.value = v; + headers.value.add(header); + }); // add first one to help the user if (headers.value.isEmpty) { @@ -88,8 +83,8 @@ class HeaderSettingsPage extends HookConsumerWidget { } saveHeaders(WidgetRef ref, List headers) async { - final headersMap = {}; - for (var header in headers) { + final headersMap = {}; + for (final header in headers) { final key = header.key.trim(); final value = header.value.trim(); @@ -99,8 +94,7 @@ class HeaderSettingsPage extends HookConsumerWidget { headersMap[key] = value; } - var encoded = jsonEncode(headersMap); - await Store.put(StoreKey.customHeaders, encoded); + await ref.read(metadataProvider).write(MetadataKey.networkCustomHeaders, headersMap); await ref.read(apiServiceProvider).updateHeaders(); } } diff --git a/mobile/lib/providers/auth.provider.dart b/mobile/lib/providers/auth.provider.dart index 4c2a110fde..ae97909349 100644 --- a/mobile/lib/providers/auth.provider.dart +++ b/mobile/lib/providers/auth.provider.dart @@ -1,6 +1,9 @@ +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/metadata_key.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'; @@ -8,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/metadata.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/auth.service.dart'; @@ -126,7 +130,8 @@ class AuthNotifier extends StateNotifier { await _apiService.updateHeaders(); final serverEndpoint = Store.get(StoreKey.serverEndpoint); - final customHeaders = Store.tryGet(StoreKey.customHeaders); + final headerMap = _ref.read(metadataProvider).systemConfig.network.customHeaders; + final customHeaders = headerMap.isEmpty ? null : jsonEncode(headerMap); await _widgetService.writeCredentials(serverEndpoint, accessToken, customHeaders); // Get the deviceid from the store if it exists, otherwise generate a new one @@ -174,19 +179,19 @@ class AuthNotifier extends StateNotifier { } Future saveWifiName(String wifiName) async { - await Store.put(StoreKey.preferredWifiName, wifiName); + await _ref.read(metadataProvider).write(MetadataKey.networkPreferredWifiName, wifiName); } Future saveLocalEndpoint(String url) async { - await Store.put(StoreKey.localEndpoint, url); + await _ref.read(metadataProvider).write(MetadataKey.networkLocalEndpoint, url); } String? getSavedWifiName() { - return Store.tryGet(StoreKey.preferredWifiName); + return _ref.read(metadataProvider).systemConfig.network.preferredWifiName; } String? getSavedLocalEndpoint() { - return Store.tryGet(StoreKey.localEndpoint); + return _ref.read(metadataProvider).systemConfig.network.localEndpoint; } /// Returns the current server endpoint (with /api) URL from the store diff --git a/mobile/lib/repositories/auth.repository.dart b/mobile/lib/repositories/auth.repository.dart index c16b728ae5..e71c752f44 100644 --- a/mobile/lib/repositories/auth.repository.dart +++ b/mobile/lib/repositories/auth.repository.dart @@ -1,46 +1,40 @@ -import 'dart:convert'; - 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/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart'; import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; -final authRepositoryProvider = Provider((ref) => AuthRepository(ref.watch(driftProvider))); +final authRepositoryProvider = Provider( + (ref) => AuthRepository(ref.watch(driftProvider), ref.watch(metadataProvider)), +); class AuthRepository { final Drift _drift; + final MetadataRepository _metadata; - const AuthRepository(this._drift); + const AuthRepository(this._drift, this._metadata); Future clearLocalData() async { await SyncStreamRepository(_drift).reset(); } bool getEndpointSwitchingFeature() { - return Store.tryGet(StoreKey.autoEndpointSwitching) ?? false; + return _metadata.systemConfig.network.autoEndpointSwitching; } String? getPreferredWifiName() { - return Store.tryGet(StoreKey.preferredWifiName); + return _metadata.systemConfig.network.preferredWifiName; } String? getLocalEndpoint() { - return Store.tryGet(StoreKey.localEndpoint); + return _metadata.systemConfig.network.localEndpoint; } List getExternalEndpointList() { - final jsonString = Store.tryGet(StoreKey.externalEndpointList); - - if (jsonString == null) { - return []; - } - - final List jsonList = jsonDecode(jsonString); - final endpointList = jsonList.map((e) => AuxilaryEndpoint.fromJson(e)).toList(); - - return endpointList; + return _metadata.systemConfig.network.externalEndpointList + .map((url) => AuxilaryEndpoint(url: url, status: .valid)) + .toList(); } } diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index 33c87798a1..bc36c98768 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -5,8 +5,8 @@ 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/infrastructure/repositories/metadata.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; -import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/url_helper.dart'; import 'package:logging/logging.dart'; @@ -177,30 +177,21 @@ class ApiService { if (serverEndpoint != null && serverEndpoint.isNotEmpty) { urls.add(serverEndpoint); } - final localEndpoint = Store.tryGet(StoreKey.localEndpoint); - if (localEndpoint != null && localEndpoint.isNotEmpty) { + final network = MetadataRepository.instance.systemConfig.network; + final localEndpoint = network.localEndpoint; + if (localEndpoint != null) { urls.add(localEndpoint); } - final externalJson = Store.tryGet(StoreKey.externalEndpointList); - if (externalJson != null) { - final List list = jsonDecode(externalJson); - for (final entry in list) { - final url = AuxilaryEndpoint.fromJson(entry).url; - if (url.isNotEmpty) { - urls.add(url); - } + for (final url in network.externalEndpointList) { + if (url.isNotEmpty) { + urls.add(url); } } return urls; } static Map getRequestHeaders() { - var customHeadersStr = Store.get(StoreKey.customHeaders, ""); - if (customHeadersStr.isEmpty) { - return const {}; - } - - return (jsonDecode(customHeadersStr) as Map).cast(); + return MetadataRepository.instance.systemConfig.network.customHeaders; } ApiClient get apiClient => _apiClient; diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index 1b9a38bc19..c5a316e655 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -8,7 +8,6 @@ enum AppSettingsEnum { selectedAlbumSortReverse(StoreKey.selectedAlbumSortReverse, null, true), enableHapticFeedback(StoreKey.enableHapticFeedback, null, true), syncAlbums(StoreKey.syncAlbums, null, false), - autoEndpointSwitching(StoreKey.autoEndpointSwitching, null, false), enableBackup(StoreKey.enableBackup, null, false), useCellularForUploadVideos(StoreKey.useWifiForUploadVideos, null, false), useCellularForUploadPhotos(StoreKey.useWifiForUploadPhotos, null, false), diff --git a/mobile/lib/services/auth.service.dart b/mobile/lib/services/auth.service.dart index 1b5eaab715..14f67972fa 100644 --- a/mobile/lib/services/auth.service.dart +++ b/mobile/lib/services/auth.service.dart @@ -123,10 +123,6 @@ class AuthService { _authRepository.clearLocalData(), Store.delete(StoreKey.currentUser), Store.delete(StoreKey.accessToken), - Store.delete(StoreKey.autoEndpointSwitching), - Store.delete(StoreKey.preferredWifiName), - Store.delete(StoreKey.localEndpoint), - Store.delete(StoreKey.externalEndpointList), ]); } diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index 8f0eb00b16..f0d82d6c06 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:drift/drift.dart'; import 'package:flutter/material.dart'; @@ -12,7 +13,7 @@ import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/entities/metadata.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/services/api.service.dart'; +import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; const int targetVersion = 26; @@ -37,12 +38,35 @@ Future _migrateTo25() async { return; } - final serverUrls = ApiService.getServerUrls(); - if (serverUrls.isEmpty) { + final urls = []; + final serverEndpoint = Store.tryGet(StoreKey.serverEndpoint); + if (serverEndpoint != null && serverEndpoint.isNotEmpty) { + urls.add(serverEndpoint); + } + final localEndpoint = Store.tryGet(StoreKey.legacyLocalEndpoint); + if (localEndpoint != null && localEndpoint.isNotEmpty) { + urls.add(localEndpoint); + } + final externalJson = Store.tryGet(StoreKey.legacyExternalEndpointList); + if (externalJson != null) { + final List list = jsonDecode(externalJson); + for (final entry in list) { + final url = AuxilaryEndpoint.fromJson(entry).url; + if (url.isNotEmpty) { + urls.add(url); + } + } + } + if (urls.isEmpty) { return; } - await NetworkRepository.setHeaders(ApiService.getRequestHeaders(), serverUrls, token: accessToken); + final customHeadersStr = Store.get(StoreKey.legacyCustomHeaders, ""); + final headers = customHeadersStr.isEmpty + ? const {} + : (jsonDecode(customHeadersStr) as Map).cast(); + + await NetworkRepository.setHeaders(headers, urls, token: accessToken); } Future _migrateTo26(Drift drift) async { @@ -96,9 +120,76 @@ Future _migrateTo26(Drift drift) async { await migrator.migrateBool(StoreKey.legacyLoadOriginalVideo, MetadataKey.viewerLoadOriginalVideo); await migrator.migrateBool(StoreKey.legacyAutoPlayVideo, MetadataKey.viewerAutoPlayVideo); await migrator.migrateBool(StoreKey.legacyTapToNavigate, MetadataKey.viewerTapToNavigate); + // Network + await migrator.migrateBool(StoreKey.legacyAutoEndpointSwitching, MetadataKey.networkAutoEndpointSwitching); + await migrator.migrateString(StoreKey.legacyPreferredWifiName, MetadataKey.networkPreferredWifiName); + await migrator.migrateString(StoreKey.legacyLocalEndpoint, MetadataKey.networkLocalEndpoint); + await _migrateExternalEndpointList(drift, migrator); + await _migrateCustomHeaders(drift, migrator); await migrator.complete(); } +Future _migrateExternalEndpointList(Drift drift, _StoreMigrator migrator) async { + final raw = await migrator.readLegacyStoreString(StoreKey.legacyExternalEndpointList.id); + if (raw == null) { + return; + } + + final urls = []; + try { + final decoded = jsonDecode(raw); + if (decoded is List) { + for (final entry in decoded) { + final url = AuxilaryEndpoint.fromJson(entry).url; + if (url.isNotEmpty) { + urls.add(url); + } + } + } + } on FormatException { + // ignore invalid entries + } + + await drift.metadataEntity.insertOnConflictUpdate( + MetadataEntityCompanion.insert( + key: MetadataKey.networkExternalEndpointList.key, + value: MetadataKey.networkExternalEndpointList.encode(urls), + updatedAt: Value(DateTime.now()), + ), + ); + await migrator.deleteLegacyStoreRows([StoreKey.legacyExternalEndpointList.id]); +} + +Future _migrateCustomHeaders(Drift drift, _StoreMigrator migrator) async { + final raw = await migrator.readLegacyStoreString(StoreKey.legacyCustomHeaders.id); + if (raw == null) { + return; + } + + final headers = {}; + try { + final decoded = jsonDecode(raw); + if (decoded is Map) { + decoded.forEach((key, value) { + if (key is String && value is String) { + headers[key] = value; + } + }); + } + } on FormatException { + // ignore invalid entries + } + + await drift.metadataEntity.insertOnConflictUpdate( + MetadataEntityCompanion.insert( + key: MetadataKey.networkCustomHeaders.key, + value: MetadataKey.networkCustomHeaders.encode(headers), + updatedAt: Value(DateTime.now()), + ), + ); + await migrator.deleteLegacyStoreRows([StoreKey.legacyCustomHeaders.id]); +} + class _StoreMigrator { final Drift _db; final Map, Object> _cache = {}; @@ -153,6 +244,16 @@ class _StoreMigrator { _migratedStoreIds.add(legacyKey.id); } + Future migrateString(StoreKey legacyKey, MetadataKey newKey) async { + final value = await readLegacyStoreString(legacyKey.id); + if (value == null) { + return; + } + + _cache[newKey] = value; + _migratedStoreIds.add(legacyKey.id); + } + Future complete() async { await _db.batch((batch) { for (final entry in _cache.entries) { diff --git a/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart b/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart index ba21acf49c..7900747055 100644 --- a/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart +++ b/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart @@ -1,13 +1,11 @@ -import 'dart:convert'; - import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart' hide Store; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/store.model.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/domain/models/metadata_key.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; +import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/widgets/settings/networking_settings/endpoint_input.dart'; class ExternalNetworkPreference extends HookConsumerWidget { @@ -23,11 +21,12 @@ class ExternalNetworkPreference extends HookConsumerWidget { saveEndpointList() { canSave.value = entries.value.every((e) => e.status == AuxCheckStatus.valid); - final endpointList = entries.value.where((url) => url.status == AuxCheckStatus.valid).toList(); + final urls = entries.value + .where((e) => e.status == AuxCheckStatus.valid && e.url.isNotEmpty) + .map((e) => e.url) + .toList(); - final jsonString = jsonEncode(endpointList); - - Store.put(StoreKey.externalEndpointList, jsonString); + ref.read(metadataProvider).write(MetadataKey.networkExternalEndpointList, urls); } updateValidationStatus(String url, int index, AuxCheckStatus status) { @@ -69,14 +68,13 @@ class ExternalNetworkPreference extends HookConsumerWidget { } useEffect(() { - final jsonString = Store.tryGet(StoreKey.externalEndpointList); + final urls = ref.read(metadataProvider).systemConfig.network.externalEndpointList; - if (jsonString == null) { + if (urls.isEmpty) { return null; } - final List jsonList = jsonDecode(jsonString); - entries.value = jsonList.map((e) => AuxilaryEndpoint.fromJson(e)).toList(); + entries.value = urls.map((url) => AuxilaryEndpoint(url: url, status: .valid)).toList(); return null; }, const []); diff --git a/mobile/lib/widgets/settings/networking_settings/networking_settings.dart b/mobile/lib/widgets/settings/networking_settings/networking_settings.dart index 981bec2c0c..f232f41a5d 100644 --- a/mobile/lib/widgets/settings/networking_settings/networking_settings.dart +++ b/mobile/lib/widgets/settings/networking_settings/networking_settings.dart @@ -1,13 +1,12 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart' hide Store; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; +import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/providers/network.provider.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/utils/url_helper.dart'; import 'package:immich_mobile/widgets/settings/networking_settings/external_network_preference.dart'; import 'package:immich_mobile/widgets/settings/networking_settings/local_network_preference.dart'; @@ -20,7 +19,10 @@ class NetworkingSettings extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final currentEndpoint = getServerUrl(); - final featureEnabled = useAppSettingsState(AppSettingsEnum.autoEndpointSwitching); + final featureEnabled = useState(ref.read(systemConfigProvider).network.autoEndpointSwitching); + useValueChanged(featureEnabled.value, (_, __) { + ref.read(metadataProvider).write(.networkAutoEndpointSwitching, featureEnabled.value); + }); Future checkWifiReadPermission() async { final [hasLocationInUse, hasLocationAlways] = await Future.wait([ diff --git a/mobile/test/services/background_upload.service_test.dart b/mobile/test/services/background_upload.service_test.dart index 585ffcb499..c6d2f64c12 100644 --- a/mobile/test/services/background_upload.service_test.dart +++ b/mobile/test/services/background_upload.service_test.dart @@ -11,6 +11,7 @@ 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/metadata.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/background_upload.service.dart'; @@ -42,6 +43,7 @@ void main() { ); db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); await StoreService.init(storeRepository: DriftStoreRepository(db)); + await MetadataRepository.ensureInitialized(db); await Store.put(StoreKey.serverEndpoint, 'http://test-server.com'); await Store.put(StoreKey.deviceId, 'test-device-id'); diff --git a/mobile/test/unit/repositories/metadata_repository_test.dart b/mobile/test/unit/repositories/metadata_repository_test.dart index 4c29ce3a01..75b34da7cb 100644 --- a/mobile/test/unit/repositories/metadata_repository_test.dart +++ b/mobile/test/unit/repositories/metadata_repository_test.dart @@ -13,6 +13,10 @@ void main() { test('decode falls back to the default value when the raw input is unparseable', () { for (final key in MetadataKey.values) { + // String keys can decode any string. So skip them + if (key.defaultValue is String) { + continue; + } expect( key.decode('not a valid encoding for any key'), key.defaultValue,