From 055f1fc72fd1b3cecd00a5110e1f830a71a41945 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 5 Dec 2024 09:11:48 -0600 Subject: [PATCH] feat(mobile): Auto switching server URLs (#14437) --- mobile/analysis_options.yaml | 2 + .../android/app/src/main/AndroidManifest.xml | 2 + mobile/assets/i18n/en-US.json | 38 ++- mobile/ios/Podfile | 7 + mobile/ios/Podfile.lock | 8 +- mobile/ios/Runner.xcodeproj/project.pbxproj | 6 +- mobile/ios/Runner/AppDelegate.swift | 22 +- mobile/ios/Runner/Info.plist | 6 +- mobile/ios/Runner/Runner.entitlements | 5 +- mobile/ios/Runner/RunnerProfile.entitlements | 2 + mobile/lib/entities/store.entity.dart | 6 + .../extensions/build_context_extensions.dart | 4 + mobile/lib/interfaces/auth.interface.dart | 6 + mobile/lib/interfaces/network.interface.dart | 4 + .../models/auth/auxilary_endpoint.model.dart | 105 +++++++ mobile/lib/pages/common/settings.page.dart | 113 ++++++-- .../lib/pages/common/splash_screen.page.dart | 106 ++++--- .../providers/app_life_cycle.provider.dart | 40 ++- mobile/lib/providers/auth.provider.dart | 41 +++ mobile/lib/providers/network.provider.dart | 38 +++ .../lib/providers/server_info.provider.dart | 2 +- mobile/lib/repositories/auth.repository.dart | 39 +++ .../lib/repositories/network.repository.dart | 37 +++ .../repositories/permission.repository.dart | 45 +++ mobile/lib/services/api.service.dart | 4 +- mobile/lib/services/app_settings.service.dart | 1 + mobile/lib/services/auth.service.dart | 93 ++++++ mobile/lib/services/background.service.dart | 26 ++ mobile/lib/services/network.service.dart | 47 ++++ .../networking_settings/endpoint_input.dart | 155 ++++++++++ .../external_network_preference.dart | 189 +++++++++++++ .../local_network_preference.dart | 256 +++++++++++++++++ .../networking_settings.dart | 266 ++++++++++++++++++ mobile/openapi/devtools_options.yaml | 3 + mobile/pubspec.lock | 16 ++ mobile/pubspec.yaml | 1 + mobile/test/service.mocks.dart | 3 + mobile/test/services/auth.service_test.dart | 192 ++++++++++++- 38 files changed, 1828 insertions(+), 108 deletions(-) create mode 100644 mobile/lib/interfaces/network.interface.dart create mode 100644 mobile/lib/models/auth/auxilary_endpoint.model.dart create mode 100644 mobile/lib/providers/network.provider.dart create mode 100644 mobile/lib/repositories/network.repository.dart create mode 100644 mobile/lib/repositories/permission.repository.dart create mode 100644 mobile/lib/services/network.service.dart create mode 100644 mobile/lib/widgets/settings/networking_settings/endpoint_input.dart create mode 100644 mobile/lib/widgets/settings/networking_settings/external_network_preference.dart create mode 100644 mobile/lib/widgets/settings/networking_settings/local_network_preference.dart create mode 100644 mobile/lib/widgets/settings/networking_settings/networking_settings.dart create mode 100644 mobile/openapi/devtools_options.yaml diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 2b4b810f2a..9cb03f6758 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -104,6 +104,8 @@ custom_lint: - lib/widgets/album/album_thumbnail_listtile.dart - lib/widgets/forms/login/login_form.dart - lib/widgets/search/search_filter/{camera_picker,location_picker,people_picker}.dart + - lib/services/auth.service.dart # on ApiException + - test/services/auth.service_test.dart # on ApiException dart_code_metrics: metrics: diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 8f239015dd..bbc562c103 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -16,6 +16,8 @@ + + diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index d588507a07..121e3e4982 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -1,4 +1,35 @@ { + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", + "current_server_address": "Current server address", + "grant_permission": "Grant permission", + "automatic_endpoint_switching_title": "Automatic URL switching", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", + "cancel": "Cancel", + "save": "Save", + "wifi_name": "WiFi Name", + "enter_wifi_name": "Enter WiFi name", + "your_wifi_name": "Your WiFi name", + "server_endpoint": "Server Endpoint", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "use_current_connection": "use current connection", + "add_endpoint": "Add endpoint", + "validate_endpoint_error": "Please enter a valid URL", + "advanced_settings_tile_subtitle": "Manage advanced settings", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", + "backup_setting_subtitle": "Manage background and foreground upload settings", + "setting_languages_subtitle": "Change the app's language", + "setting_notifications_subtitle": "Manage your notification settings", + "preferences_settings_subtitle": "Manage the app's preferences", + "asset_list_settings_subtitle": "Manage the look of the timeline", "action_common_back": "Back", "action_common_cancel": "Cancel", "action_common_clear": "Clear", @@ -16,7 +47,6 @@ "advanced_settings_proxy_headers_title": "Proxy Headers", "advanced_settings_self_signed_ssl_subtitle": "Skips SSL certificate verification for the server endpoint. Required for self-signed certificates.", "advanced_settings_self_signed_ssl_title": "Allow self-signed SSL certificates", - "advanced_settings_tile_subtitle": "Advanced user's settings", "advanced_settings_tile_title": "Advanced", "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", "advanced_settings_troubleshooting_title": "Troubleshooting", @@ -56,7 +86,6 @@ "asset_list_layout_settings_group_by_month": "Month", "asset_list_layout_settings_group_by_month_day": "Month + day", "asset_list_layout_sub_title": "Layout", - "asset_list_settings_subtitle": "Photo grid layout settings", "asset_list_settings_title": "Photo Grid", "asset_restored_successfully": "Asset restored successfully", "assets_deleted_permanently": "{} asset(s) deleted permanently", @@ -65,7 +94,7 @@ "assets_restored_successfully": "{} asset(s) restored successfully", "assets_trashed": "{} asset(s) trashed", "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", - "asset_viewer_settings_title": "Asset Viewer", + "asset_viewer_settings_title": "Gallery Viewer", "backup_album_selection_page_albums_device": "Albums on device ({})", "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", "backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.", @@ -492,7 +521,6 @@ "setting_notifications_notify_seconds": "{} seconds", "setting_notifications_single_progress_subtitle": "Detailed upload progress information per asset", "setting_notifications_single_progress_title": "Show background backup detail progress", - "setting_notifications_subtitle": "Adjust your notification preferences", "setting_notifications_title": "Notifications", "setting_notifications_total_progress_subtitle": "Overall upload progress (done/total assets)", "setting_notifications_total_progress_title": "Show background backup total progress", @@ -625,4 +653,4 @@ "viewer_remove_from_stack": "Remove from Stack", "viewer_stack_use_as_main_asset": "Use as Main Asset", "viewer_unstack": "Un-Stack" -} \ No newline at end of file +} diff --git a/mobile/ios/Podfile b/mobile/ios/Podfile index f38ac9619b..b048c0bb0c 100644 --- a/mobile/ios/Podfile +++ b/mobile/ios/Podfile @@ -102,6 +102,13 @@ post_install do |installer| ## dart: PermissionGroup.criticalAlerts # 'PERMISSION_CRITICAL_ALERTS=1' + + ## The 'PERMISSION_LOCATION' macro enables the `locationWhenInUse` and `locationAlways` permission. If + ## the application only requires `locationWhenInUse`, only specify the `PERMISSION_LOCATION_WHENINUSE` + ## macro. + ## + ## dart: [PermissionGroup.location, PermissionGroup.locationAlways, PermissionGroup.locationWhenInUse] + 'PERMISSION_LOCATION=1', ] end diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 2e71937a84..bc65bd4b7f 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -67,6 +67,8 @@ PODS: - MapLibre (= 5.14.0-pre3) - native_video_player (1.0.0): - Flutter + - network_info_plus (0.0.1): + - Flutter - package_info_plus (0.4.5): - Flutter - path_provider_foundation (0.0.1): @@ -115,6 +117,7 @@ DEPENDENCIES: - isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`) - maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`) - native_video_player (from `.symlinks/plugins/native_video_player/ios`) + - network_info_plus (from `.symlinks/plugins/network_info_plus/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) @@ -169,6 +172,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/maplibre_gl/ios" native_video_player: :path: ".symlinks/plugins/native_video_player/ios" + network_info_plus: + :path: ".symlinks/plugins/network_info_plus/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" path_provider_foundation: @@ -210,6 +215,7 @@ SPEC CHECKSUMS: MapLibre: 620fc933c1d6029b33738c905c1490d024e5d4ef maplibre_gl: a2efec727dd340e4c65e26d2b03b584f14881fd9 native_video_player: d12af78a1a4a8cf09775a5177d5b392def6fd23c + network_info_plus: 6613d9d7cdeb0e6f366ed4dbe4b3c51c52d567a9 package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02 @@ -225,6 +231,6 @@ SPEC CHECKSUMS: url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1 -PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d +PODFILE CHECKSUM: 2282844f7aed70427ae663932332dad1225156c8 COCOAPODS: 1.15.2 diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 45d0b7d0ef..49ac6c4cff 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -51,6 +51,7 @@ 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; E0E99CDC17B3EB7FA8BA2332 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; F7101BB0391A314774615E89 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + FA9973382CF6DF4B000EF859 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerProfile.entitlements; sourceTree = ""; }; /* End PBXFileReference section */ @@ -126,6 +127,7 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + FA9973382CF6DF4B000EF859 /* Runner.entitlements */, 65DD438629917FAD0047FFA8 /* BackgroundSync */, FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */, 97C146FA1CF9000F007C117D /* Main.storyboard */, @@ -541,6 +543,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 184; @@ -553,7 +556,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.121.0; - PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.debug; + PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.vdebug; PRODUCT_NAME = "Immich-Debug"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; @@ -569,6 +572,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 184; diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 446c82e78f..8f635bc61b 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -1,19 +1,18 @@ import BackgroundTasks import Flutter -import UIKit +import network_info_plus import path_provider_ios import permission_handler_apple import photo_manager import shared_preferences_foundation +import UIKit @main @objc class AppDelegate: FlutterAppDelegate { - override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - // Required for flutter_local_notification if #available(iOS 10.0, *) { UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate @@ -33,27 +32,26 @@ import shared_preferences_foundation BackgroundServicePlugin.setPluginRegistrantCallback { registry in if !registry.hasPlugin("org.cocoapods.path-provider-ios") { - FLTPathProviderPlugin.register( - with: registry.registrar(forPlugin: "org.cocoapods.path-provider-ios")!) + FLTPathProviderPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.path-provider-ios")!) } if !registry.hasPlugin("org.cocoapods.photo-manager") { - PhotoManagerPlugin.register( - with: registry.registrar(forPlugin: "org.cocoapods.photo-manager")!) + PhotoManagerPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.photo-manager")!) } if !registry.hasPlugin("org.cocoapods.shared-preferences-foundation") { - SharedPreferencesPlugin.register( - with: registry.registrar(forPlugin: "org.cocoapods.shared-preferences-foundation")!) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.shared-preferences-foundation")!) } if !registry.hasPlugin("org.cocoapods.permission-handler-apple") { - PermissionHandlerPlugin.register( - with: registry.registrar(forPlugin: "org.cocoapods.permission-handler-apple")!) + PermissionHandlerPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.permission-handler-apple")!) + } + + if !registry.hasPlugin("org.cocoapods.network-info-plus") { + FPPNetworkInfoPlusPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.network-info-plus")!) } } return super.application(application, didFinishLaunchingWithOptions: launchOptions) } - } diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index f4ded26c68..4389b39114 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -82,8 +82,12 @@ NSCameraUsageDescription We need to access the camera to let you take beautiful video using this app + NSLocationAlwaysAndWhenInUseUsageDescription + We require this permission to access the local WiFi name for background upload mechanism + NSLocationUsageDescription + We require this permission to access the local WiFi name NSLocationWhenInUseUsageDescription - Enable location setting to show position of assets on map + We require this permission to access the local WiFi name NSMicrophoneUsageDescription We need to access the microphone to let you take beautiful video using this app NSPhotoLibraryAddUsageDescription diff --git a/mobile/ios/Runner/Runner.entitlements b/mobile/ios/Runner/Runner.entitlements index 0c67376eba..ba21fbdaf2 100644 --- a/mobile/ios/Runner/Runner.entitlements +++ b/mobile/ios/Runner/Runner.entitlements @@ -1,5 +1,8 @@ - + + com.apple.developer.networking.wifi-info + + diff --git a/mobile/ios/Runner/RunnerProfile.entitlements b/mobile/ios/Runner/RunnerProfile.entitlements index 903def2af5..75e36a143e 100644 --- a/mobile/ios/Runner/RunnerProfile.entitlements +++ b/mobile/ios/Runner/RunnerProfile.entitlements @@ -4,5 +4,7 @@ aps-environment development + com.apple.developer.networking.wifi-info + diff --git a/mobile/lib/entities/store.entity.dart b/mobile/lib/entities/store.entity.dart index 1dda2b9a12..316859b064 100644 --- a/mobile/lib/entities/store.entity.dart +++ b/mobile/lib/entities/store.entity.dart @@ -236,6 +236,12 @@ enum StoreKey { colorfulInterface(130, type: bool), syncAlbums(131, type: bool), + + // Auto endpoint switching + autoEndpointSwitching(132, type: bool), + preferredWifiName(133, type: String), + localEndpoint(134, type: String), + externalEndpointList(135, type: String), ; const StoreKey( diff --git a/mobile/lib/extensions/build_context_extensions.dart b/mobile/lib/extensions/build_context_extensions.dart index d87ab2845f..69a9c3b347 100644 --- a/mobile/lib/extensions/build_context_extensions.dart +++ b/mobile/lib/extensions/build_context_extensions.dart @@ -54,4 +54,8 @@ extension ContextHelper on BuildContext { // Managing focus within the widget tree from the current context FocusScopeNode get focusScope => FocusScope.of(this); + + // Show SnackBars from the current context + void showSnackBar(SnackBar snackBar) => + ScaffoldMessenger.of(this).showSnackBar(snackBar); } diff --git a/mobile/lib/interfaces/auth.interface.dart b/mobile/lib/interfaces/auth.interface.dart index e37323b994..57088f4569 100644 --- a/mobile/lib/interfaces/auth.interface.dart +++ b/mobile/lib/interfaces/auth.interface.dart @@ -1,5 +1,11 @@ import 'package:immich_mobile/interfaces/database.interface.dart'; +import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; abstract interface class IAuthRepository implements IDatabaseRepository { Future clearLocalData(); + String getAccessToken(); + bool getEndpointSwitchingFeature(); + String? getPreferredWifiName(); + String? getLocalEndpoint(); + List getExternalEndpointList(); } diff --git a/mobile/lib/interfaces/network.interface.dart b/mobile/lib/interfaces/network.interface.dart new file mode 100644 index 0000000000..098d67a27b --- /dev/null +++ b/mobile/lib/interfaces/network.interface.dart @@ -0,0 +1,4 @@ +abstract interface class INetworkRepository { + Future getWifiName(); + Future getWifiIp(); +} diff --git a/mobile/lib/models/auth/auxilary_endpoint.model.dart b/mobile/lib/models/auth/auxilary_endpoint.model.dart new file mode 100644 index 0000000000..89aba60913 --- /dev/null +++ b/mobile/lib/models/auth/auxilary_endpoint.model.dart @@ -0,0 +1,105 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first +import 'dart:convert'; + +class AuxilaryEndpoint { + final String url; + final AuxCheckStatus status; + + AuxilaryEndpoint({ + required this.url, + required this.status, + }); + + AuxilaryEndpoint copyWith({ + String? url, + AuxCheckStatus? status, + }) { + return AuxilaryEndpoint( + url: url ?? this.url, + status: status ?? this.status, + ); + } + + @override + String toString() => 'AuxilaryEndpoint(url: $url, status: $status)'; + + @override + bool operator ==(covariant AuxilaryEndpoint other) { + if (identical(this, other)) return true; + + return other.url == url && other.status == status; + } + + @override + int get hashCode => url.hashCode ^ status.hashCode; + + Map toMap() { + return { + 'url': url, + 'status': status.toMap(), + }; + } + + factory AuxilaryEndpoint.fromMap(Map map) { + return AuxilaryEndpoint( + url: map['url'] as String, + status: AuxCheckStatus.fromMap(map['status'] as Map), + ); + } + + String toJson() => json.encode(toMap()); + + factory AuxilaryEndpoint.fromJson(String source) => + AuxilaryEndpoint.fromMap(json.decode(source) as Map); +} + +class AuxCheckStatus { + final String name; + AuxCheckStatus({ + required this.name, + }); + const AuxCheckStatus._(this.name); + + static const loading = AuxCheckStatus._('loading'); + static const valid = AuxCheckStatus._('valid'); + static const error = AuxCheckStatus._('error'); + static const unknown = AuxCheckStatus._('unknown'); + + @override + bool operator ==(covariant AuxCheckStatus other) { + if (identical(this, other)) return true; + + return other.name == name; + } + + @override + int get hashCode => name.hashCode; + + AuxCheckStatus copyWith({ + String? name, + }) { + return AuxCheckStatus( + name: name ?? this.name, + ); + } + + Map toMap() { + return { + 'name': name, + }; + } + + factory AuxCheckStatus.fromMap(Map map) { + return AuxCheckStatus( + name: map['name'] as String, + ); + } + + String toJson() => json.encode(toMap()); + + factory AuxCheckStatus.fromJson(String source) => + AuxCheckStatus.fromMap(json.decode(source) as Map); + + @override + String toString() => 'AuxCheckStatus(name: $name)'; +} diff --git a/mobile/lib/pages/common/settings.page.dart b/mobile/lib/pages/common/settings.page.dart index a6ca239962..ba3150c046 100644 --- a/mobile/lib/pages/common/settings.page.dart +++ b/mobile/lib/pages/common/settings.page.dart @@ -8,36 +8,69 @@ import 'package:immich_mobile/widgets/settings/asset_list_settings/asset_list_se import 'package:immich_mobile/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart'; import 'package:immich_mobile/widgets/settings/backup_settings/backup_settings.dart'; import 'package:immich_mobile/widgets/settings/language_settings.dart'; +import 'package:immich_mobile/widgets/settings/networking_settings/networking_settings.dart'; import 'package:immich_mobile/widgets/settings/notification_setting.dart'; import 'package:immich_mobile/widgets/settings/preference_settings/preference_setting.dart'; import 'package:immich_mobile/routing/router.dart'; enum SettingSection { + advanced( + 'advanced_settings_tile_title', + Icons.build_outlined, + "advanced_settings_tile_subtitle", + ), + assetViewer( + 'asset_viewer_settings_title', + Icons.image_outlined, + "asset_viewer_settings_subtitle", + ), + backup( + 'backup_controller_page_backup', + Icons.cloud_upload_outlined, + "backup_setting_subtitle", + ), + languages( + 'setting_languages_title', + Icons.language, + "setting_languages_subtitle", + ), + networking( + 'networking_settings', + Icons.wifi, + "networking_subtitle", + ), notifications( 'setting_notifications_title', Icons.notifications_none_rounded, + "setting_notifications_subtitle", ), - languages('setting_languages_title', Icons.language), - preferences('preferences_settings_title', Icons.interests_outlined), - backup('backup_controller_page_backup', Icons.cloud_upload_outlined), - timeline('asset_list_settings_title', Icons.auto_awesome_mosaic_outlined), - viewer('asset_viewer_settings_title', Icons.image_outlined), - advanced('advanced_settings_tile_title', Icons.build_outlined); + preferences( + 'preferences_settings_title', + Icons.interests_outlined, + "preferences_settings_subtitle", + ), + timeline( + 'asset_list_settings_title', + Icons.auto_awesome_mosaic_outlined, + "asset_list_settings_subtitle", + ); final String title; + final String subtitle; final IconData icon; Widget get widget => switch (this) { - SettingSection.notifications => const NotificationSetting(), - SettingSection.languages => const LanguageSettings(), - SettingSection.preferences => const PreferenceSetting(), - SettingSection.backup => const BackupSettings(), - SettingSection.timeline => const AssetListSettings(), - SettingSection.viewer => const AssetViewerSettings(), SettingSection.advanced => const AdvancedSettings(), + SettingSection.assetViewer => const AssetViewerSettings(), + SettingSection.backup => const BackupSettings(), + SettingSection.languages => const LanguageSettings(), + SettingSection.networking => const NetworkingSettings(), + SettingSection.notifications => const NotificationSetting(), + SettingSection.preferences => const PreferenceSetting(), + SettingSection.timeline => const AssetListSettings(), }; - const SettingSection(this.title, this.icon); + const SettingSection(this.title, this.icon, this.subtitle); } @RoutePage() @@ -61,22 +94,50 @@ class _MobileLayout extends StatelessWidget { @override Widget build(BuildContext context) { return ListView( + physics: const ClampingScrollPhysics(), + padding: const EdgeInsets.symmetric(vertical: 10.0), children: SettingSection.values .map( - (s) => ListTile( - contentPadding: - const EdgeInsets.symmetric(vertical: 2.0, horizontal: 16.0), - leading: Icon(s.icon), - title: Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Text( - s.title, - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - ).tr(), + (setting) => Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + ), + child: Card( + elevation: 0, + clipBehavior: Clip.antiAlias, + color: context.colorScheme.surfaceContainer, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(16)), + ), + margin: const EdgeInsets.symmetric(vertical: 4.0), + child: ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16.0, + ), + leading: Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(16)), + color: context.isDarkTheme + ? Colors.black26 + : Colors.white.withAlpha(100), + ), + padding: const EdgeInsets.all(16.0), + child: Icon(setting.icon, color: context.primaryColor), + ), + title: Text( + setting.title, + style: context.textTheme.titleMedium!.copyWith( + fontWeight: FontWeight.w600, + color: context.primaryColor, + ), + ).tr(), + subtitle: Text( + setting.subtitle, + ).tr(), + onTap: () => + context.pushRoute(SettingsSubRoute(section: setting)), + ), ), - onTap: () => context.pushRoute(SettingsSubRoute(section: s)), ), ) .toList(), diff --git a/mobile/lib/pages/common/splash_screen.page.dart b/mobile/lib/pages/common/splash_screen.page.dart index d88c6cf366..6a060e19f0 100644 --- a/mobile/lib/pages/common/splash_screen.page.dart +++ b/mobile/lib/pages/common/splash_screen.page.dart @@ -1,6 +1,5 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; @@ -10,65 +9,80 @@ import 'package:immich_mobile/entities/store.entity.dart'; import 'package:logging/logging.dart'; @RoutePage() -class SplashScreenPage extends HookConsumerWidget { +class SplashScreenPage extends StatefulHookConsumerWidget { const SplashScreenPage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + SplashScreenPageState createState() => SplashScreenPageState(); +} + +class SplashScreenPageState extends ConsumerState { + final log = Logger("SplashScreenPage"); + @override + void initState() { + super.initState(); + ref + .read(authProvider.notifier) + .setOpenApiServiceEndpoint() + .then(logConnectionInfo) + .whenComplete(() => resumeSession()); + } + + void logConnectionInfo(String? endpoint) { + if (endpoint == null) { + return; + } + + log.info("Resuming session at $endpoint"); + } + + void resumeSession() async { final serverUrl = Store.tryGet(StoreKey.serverUrl); final endpoint = Store.tryGet(StoreKey.serverEndpoint); final accessToken = Store.tryGet(StoreKey.accessToken); - final log = Logger("SplashScreenPage"); - void performLoggingIn() async { - bool isAuthSuccess = false; + bool isAuthSuccess = false; - if (accessToken != null && serverUrl != null && endpoint != null) { - try { - isAuthSuccess = await ref.read(authProvider.notifier).saveAuthInfo( - accessToken: accessToken, - ); - } catch (error, stackTrace) { - log.severe( - 'Cannot set success login info', - error, - stackTrace, - ); - } - } else { - isAuthSuccess = false; + if (accessToken != null && serverUrl != null && endpoint != null) { + try { + isAuthSuccess = await ref.read(authProvider.notifier).saveAuthInfo( + accessToken: accessToken, + ); + } catch (error, stackTrace) { log.severe( - 'Missing authentication, server, or endpoint info from the local store', + 'Cannot set success login info', + error, + stackTrace, ); } - - if (!isAuthSuccess) { - log.severe( - 'Unable to login using offline or online methods - Logging out completely', - ); - ref.read(authProvider.notifier).logout(); - context.replaceRoute(const LoginRoute()); - return; - } - - context.replaceRoute(const TabControllerRoute()); - - final hasPermission = - await ref.read(galleryPermissionNotifier.notifier).hasPermission; - if (hasPermission) { - // Resume backup (if enable) then navigate - ref.watch(backupProvider.notifier).resumeBackup(); - } + } else { + isAuthSuccess = false; + log.severe( + 'Missing authentication, server, or endpoint info from the local store', + ); } - useEffect( - () { - performLoggingIn(); - return null; - }, - [], - ); + if (!isAuthSuccess) { + log.severe( + 'Unable to login using offline or online methods - Logging out completely', + ); + ref.read(authProvider.notifier).logout(); + context.replaceRoute(const LoginRoute()); + return; + } + context.replaceRoute(const TabControllerRoute()); + + final hasPermission = + await ref.read(galleryPermissionNotifier.notifier).hasPermission; + if (hasPermission) { + // Resume backup (if enable) then navigate + ref.watch(backupProvider.notifier).resumeBackup(); + } + } + + @override + Widget build(BuildContext context) { return const Scaffold( body: Center( child: Image( diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index 8cacb70eb2..780e22b818 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/services/background.service.dart'; @@ -35,7 +36,7 @@ class AppLifeCycleNotifier extends StateNotifier { return state; } - void handleAppResume() { + void handleAppResume() async { state = AppLifeCycleEnum.resumed; // no need to resume because app was never really paused @@ -46,32 +47,49 @@ class AppLifeCycleNotifier extends StateNotifier { // Needs to be logged in if (isAuthenticated) { + // switch endpoint if needed + final endpoint = + await _ref.read(authProvider.notifier).setOpenApiServiceEndpoint(); + if (kDebugMode) { + debugPrint("Using server URL: $endpoint"); + } + final permission = _ref.watch(galleryPermissionNotifier); if (permission.isGranted || permission.isLimited) { - _ref.read(backupProvider.notifier).resumeBackup(); - _ref.read(backgroundServiceProvider).resumeServiceIfEnabled(); + await _ref.read(backupProvider.notifier).resumeBackup(); + await _ref.read(backgroundServiceProvider).resumeServiceIfEnabled(); } - _ref.read(serverInfoProvider.notifier).getServerVersion(); + + await _ref.read(serverInfoProvider.notifier).getServerVersion(); + switch (_ref.read(tabProvider)) { case TabEnum.home: - _ref.read(assetProvider.notifier).getAllAsset(); + await _ref.read(assetProvider.notifier).getAllAsset(); + break; case TabEnum.search: - // nothing to do + // nothing to do + break; + case TabEnum.albums: - _ref.read(albumProvider.notifier).refreshRemoteAlbums(); + await _ref.read(albumProvider.notifier).refreshRemoteAlbums(); + break; case TabEnum.library: - // nothing to do + // nothing to do + break; } } _ref.read(websocketProvider.notifier).connect(); - _ref + await _ref .read(notificationPermissionProvider.notifier) .getNotificationPermission(); - _ref.read(galleryPermissionNotifier.notifier).getGalleryPermissionStatus(); - _ref.read(iOSBackgroundSettingsProvider.notifier).refresh(); + await _ref + .read(galleryPermissionNotifier.notifier) + .getGalleryPermissionStatus(); + + await _ref.read(iOSBackgroundSettingsProvider.notifier).refresh(); _ref.invalidate(memoryFutureProvider); } diff --git a/mobile/lib/providers/auth.provider.dart b/mobile/lib/providers/auth.provider.dart index 5efbdab8d3..a23ffd3d68 100644 --- a/mobile/lib/providers/auth.provider.dart +++ b/mobile/lib/providers/auth.provider.dart @@ -45,6 +45,17 @@ class AuthNotifier extends StateNotifier { return _authService.validateServerUrl(url); } + /// Validating the url is the alternative connecting server url without + /// saving the infomation to the local database + Future validateAuxilaryServerUrl(String url) async { + try { + final validEndpoint = await _apiService.resolveEndpoint(url); + return await _authService.validateAuxilaryServerUrl(validEndpoint); + } catch (_) { + return false; + } + } + Future login(String email, String password) async { final response = await _authService.login(email, password); await saveAuthInfo(accessToken: response.accessToken); @@ -161,4 +172,34 @@ class AuthNotifier extends StateNotifier { return true; } + + Future saveWifiName(String wifiName) { + return Store.put(StoreKey.preferredWifiName, wifiName); + } + + Future saveLocalEndpoint(String url) { + return Store.put(StoreKey.localEndpoint, url); + } + + String? getSavedWifiName() { + return Store.tryGet(StoreKey.preferredWifiName); + } + + String? getSavedLocalEndpoint() { + return Store.tryGet(StoreKey.localEndpoint); + } + + /// Returns the current server endpoint (with /api) URL from the store + String? getServerEndpoint() { + return Store.tryGet(StoreKey.serverEndpoint); + } + + /// Returns the current server URL (input by the user) from the store + String? getServerUrl() { + return Store.tryGet(StoreKey.serverUrl); + } + + Future setOpenApiServiceEndpoint() { + return _authService.setOpenApiServiceEndpoint(); + } } diff --git a/mobile/lib/providers/network.provider.dart b/mobile/lib/providers/network.provider.dart new file mode 100644 index 0000000000..5cb2fae4b1 --- /dev/null +++ b/mobile/lib/providers/network.provider.dart @@ -0,0 +1,38 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/services/network.service.dart'; + +final networkProvider = StateNotifierProvider((ref) { + return NetworkNotifier( + ref.watch(networkServiceProvider), + ); +}); + +class NetworkNotifier extends StateNotifier { + final NetworkService _networkService; + + NetworkNotifier(this._networkService) : super(''); + + Future getWifiName() { + return _networkService.getWifiName(); + } + + Future getWifiReadPermission() { + return _networkService.getLocationWhenInUserPermission(); + } + + Future getWifiReadBackgroundPermission() { + return _networkService.getLocationAlwaysPermission(); + } + + Future requestWifiReadPermission() { + return _networkService.requestLocationWhenInUsePermission(); + } + + Future requestWifiReadBackgroundPermission() { + return _networkService.requestLocationAlwaysPermission(); + } + + Future openSettings() { + return _networkService.openSettings(); + } +} diff --git a/mobile/lib/providers/server_info.provider.dart b/mobile/lib/providers/server_info.provider.dart index 14521b06f6..a793acb3f6 100644 --- a/mobile/lib/providers/server_info.provider.dart +++ b/mobile/lib/providers/server_info.provider.dart @@ -59,7 +59,7 @@ class ServerInfoNotifier extends StateNotifier { await getServerConfig(); } - getServerVersion() async { + Future getServerVersion() async { try { final serverVersion = await _serverInfoService.getServerVersion(); diff --git a/mobile/lib/repositories/auth.repository.dart b/mobile/lib/repositories/auth.repository.dart index ababf35c9b..fa504e6ac3 100644 --- a/mobile/lib/repositories/auth.repository.dart +++ b/mobile/lib/repositories/auth.repository.dart @@ -1,10 +1,14 @@ +import 'dart:convert'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/exif_info.entity.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/auth.interface.dart'; +import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/repositories/database.repository.dart'; @@ -27,4 +31,39 @@ class AuthRepository extends DatabaseRepository implements IAuthRepository { ]); }); } + + @override + String getAccessToken() { + return Store.get(StoreKey.accessToken); + } + + @override + bool getEndpointSwitchingFeature() { + return Store.tryGet(StoreKey.autoEndpointSwitching) ?? false; + } + + @override + String? getPreferredWifiName() { + return Store.tryGet(StoreKey.preferredWifiName); + } + + @override + String? getLocalEndpoint() { + return Store.tryGet(StoreKey.localEndpoint); + } + + @override + 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; + } } diff --git a/mobile/lib/repositories/network.repository.dart b/mobile/lib/repositories/network.repository.dart new file mode 100644 index 0000000000..54f527afb1 --- /dev/null +++ b/mobile/lib/repositories/network.repository.dart @@ -0,0 +1,37 @@ +import 'dart:io'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/network.interface.dart'; +import 'package:network_info_plus/network_info_plus.dart'; + +final networkRepositoryProvider = Provider((_) { + final networkInfo = NetworkInfo(); + + return NetworkRepository(networkInfo); +}); + +class NetworkRepository implements INetworkRepository { + final NetworkInfo _networkInfo; + + NetworkRepository(this._networkInfo); + + @override + Future getWifiName() { + if (Platform.isAndroid) { + // remove quote around the return value on Android + // https://github.com/fluttercommunity/plus_plugins/tree/main/packages/network_info_plus/network_info_plus#android + return _networkInfo.getWifiName().then((value) { + if (value != null) { + return value.replaceAll(RegExp(r'"'), ''); + } + return value; + }); + } + return _networkInfo.getWifiName(); + } + + @override + Future getWifiIp() { + return _networkInfo.getWifiIP(); + } +} diff --git a/mobile/lib/repositories/permission.repository.dart b/mobile/lib/repositories/permission.repository.dart new file mode 100644 index 0000000000..f825c36075 --- /dev/null +++ b/mobile/lib/repositories/permission.repository.dart @@ -0,0 +1,45 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:permission_handler/permission_handler.dart'; + +final permissionRepositoryProvider = Provider((_) { + return PermissionRepository(); +}); + +class PermissionRepository implements IPermissionRepository { + PermissionRepository(); + + @override + Future hasLocationWhenInUsePermission() { + return Permission.locationWhenInUse.isGranted; + } + + @override + Future requestLocationWhenInUsePermission() async { + final result = await Permission.locationWhenInUse.request(); + return result.isGranted; + } + + @override + Future hasLocationAlwaysPermission() { + return Permission.locationAlways.isGranted; + } + + @override + Future requestLocationAlwaysPermission() async { + final result = await Permission.locationAlways.request(); + return result.isGranted; + } + + @override + Future openSettings() { + return openAppSettings(); + } +} + +abstract interface class IPermissionRepository { + Future hasLocationWhenInUsePermission(); + Future requestLocationWhenInUsePermission(); + Future hasLocationAlwaysPermission(); + Future requestLocationAlwaysPermission(); + Future openSettings(); +} diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index 63cd3f9f8c..0f6fe8a100 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -67,7 +67,7 @@ class ApiService implements Authentication { } Future resolveAndSetEndpoint(String serverUrl) async { - final endpoint = await _resolveEndpoint(serverUrl); + final endpoint = await resolveEndpoint(serverUrl); setEndpoint(endpoint); // Save in local database for next startup @@ -82,7 +82,7 @@ class ApiService implements Authentication { /// host - required /// port - optional (default: based on schema) /// path - optional - Future _resolveEndpoint(String serverUrl) async { + Future resolveEndpoint(String serverUrl) async { final url = sanitizeUrl(serverUrl); if (!await _isEndpointAvailable(serverUrl)) { diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index 8f773e1bb3..14d800a4ef 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -77,6 +77,7 @@ enum AppSettingsEnum { ), enableHapticFeedback(StoreKey.enableHapticFeedback, null, true), syncAlbums(StoreKey.syncAlbums, null, false), + autoEndpointSwitching(StoreKey.autoEndpointSwitching, null, false), ; const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue); diff --git a/mobile/lib/services/auth.service.dart b/mobile/lib/services/auth.service.dart index fa6e282e63..0393470098 100644 --- a/mobile/lib/services/auth.service.dart +++ b/mobile/lib/services/auth.service.dart @@ -1,19 +1,26 @@ +import 'dart:async'; +import 'dart:io'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/interfaces/auth.interface.dart'; import 'package:immich_mobile/interfaces/auth_api.interface.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'; import 'package:immich_mobile/repositories/auth.repository.dart'; import 'package:immich_mobile/repositories/auth_api.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/network.service.dart'; import 'package:logging/logging.dart'; +import 'package:openapi/api.dart'; final authServiceProvider = Provider( (ref) => AuthService( ref.watch(authApiRepositoryProvider), ref.watch(authRepositoryProvider), ref.watch(apiServiceProvider), + ref.watch(networkServiceProvider), ), ); @@ -21,6 +28,7 @@ class AuthService { final IAuthApiRepository _authApiRepository; final IAuthRepository _authRepository; final ApiService _apiService; + final NetworkService _networkService; final _log = Logger("AuthService"); @@ -28,6 +36,7 @@ class AuthService { this._authApiRepository, this._authRepository, this._apiService, + this._networkService, ); /// Validates the provided server URL by resolving and setting the endpoint. @@ -46,6 +55,28 @@ class AuthService { return validUrl; } + Future validateAuxilaryServerUrl(String url) async { + final httpclient = HttpClient(); + final accessToken = _authRepository.getAccessToken(); + bool isValid = false; + + try { + final uri = Uri.parse('$url/users/me'); + final request = await httpclient.getUrl(uri); + request.headers.add('x-immich-user-token', accessToken); + final response = await request.close(); + if (response.statusCode == 200) { + isValid = true; + } + } catch (error) { + _log.severe("Error validating auxilary endpoint", error); + } finally { + httpclient.close(); + } + + return isValid; + } + Future login(String email, String password) { return _authApiRepository.login(email, password); } @@ -84,6 +115,10 @@ class AuthService { Store.delete(StoreKey.currentUser), Store.delete(StoreKey.accessToken), Store.delete(StoreKey.assetETag), + Store.delete(StoreKey.autoEndpointSwitching), + Store.delete(StoreKey.preferredWifiName), + Store.delete(StoreKey.localEndpoint), + Store.delete(StoreKey.externalEndpointList), ]); } @@ -95,4 +130,62 @@ class AuthService { rethrow; } } + + Future setOpenApiServiceEndpoint() async { + final enable = _authRepository.getEndpointSwitchingFeature(); + if (!enable) { + return null; + } + + final wifiName = await _networkService.getWifiName(); + final savedWifiName = _authRepository.getPreferredWifiName(); + String? endpoint; + + if (wifiName == savedWifiName) { + endpoint = await _setLocalConnection(); + } + + endpoint ??= await _setRemoteConnection(); + + return endpoint; + } + + Future _setLocalConnection() async { + try { + final localEndpoint = _authRepository.getLocalEndpoint(); + if (localEndpoint != null) { + await _apiService.resolveAndSetEndpoint(localEndpoint); + return localEndpoint; + } + } catch (error, stackTrace) { + _log.severe("Cannot set local endpoint", error, stackTrace); + } + + return null; + } + + Future _setRemoteConnection() async { + List endpointList; + + try { + endpointList = _authRepository.getExternalEndpointList(); + } catch (error, stackTrace) { + _log.severe("Cannot get external endpoint", error, stackTrace); + return null; + } + + for (final endpoint in endpointList) { + try { + return await _apiService.resolveAndSetEndpoint(endpoint.url); + } on ApiException catch (error) { + _log.severe("Cannot resolve endpoint", error); + continue; + } catch (_) { + _log.severe("Auxilary server is not valid"); + continue; + } + } + + return null; + } } diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index 3959e2a6ed..27be2c046d 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -6,6 +6,7 @@ import 'dart:ui' show DartPluginRegistrant, IsolateNameServer, PluginUtilities; import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -17,15 +18,20 @@ import 'package:immich_mobile/repositories/album.repository.dart'; import 'package:immich_mobile/repositories/album_api.repository.dart'; import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; +import 'package:immich_mobile/repositories/auth.repository.dart'; +import 'package:immich_mobile/repositories/auth_api.repository.dart'; import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; import 'package:immich_mobile/repositories/etag.repository.dart'; import 'package:immich_mobile/repositories/exif_info.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; +import 'package:immich_mobile/repositories/network.repository.dart'; import 'package:immich_mobile/repositories/partner_api.repository.dart'; +import 'package:immich_mobile/repositories/permission.repository.dart'; import 'package:immich_mobile/repositories/user.repository.dart'; import 'package:immich_mobile/repositories/user_api.repository.dart'; import 'package:immich_mobile/services/album.service.dart'; +import 'package:immich_mobile/services/auth.service.dart'; import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/hash.service.dart'; import 'package:immich_mobile/services/localization.service.dart'; @@ -36,11 +42,13 @@ import 'package:immich_mobile/services/backup.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/network.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/user.service.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; +import 'package:network_info_plus/network_info_plus.dart'; import 'package:path_provider_ios/path_provider_ios.dart'; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; @@ -422,6 +430,24 @@ class BackgroundService { assetMediaRepository, ); + AuthApiRepository authApiRepository = AuthApiRepository(apiService); + AuthRepository authRepository = AuthRepository(db); + NetworkRepository networkRepository = NetworkRepository(NetworkInfo()); + PermissionRepository permissionRepository = PermissionRepository(); + NetworkService networkService = + NetworkService(networkRepository, permissionRepository); + AuthService authService = AuthService( + authApiRepository, + authRepository, + apiService, + networkService, + ); + + final endpoint = await authService.setOpenApiServiceEndpoint(); + if (kDebugMode) { + debugPrint("[BG UPLOAD] Using endpoint: $endpoint"); + } + final selectedAlbums = await backupRepository.getAllBySelection(BackupSelection.select); final excludedAlbums = diff --git a/mobile/lib/services/network.service.dart b/mobile/lib/services/network.service.dart new file mode 100644 index 0000000000..f2d2de325d --- /dev/null +++ b/mobile/lib/services/network.service.dart @@ -0,0 +1,47 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/network.interface.dart'; +import 'package:immich_mobile/repositories/network.repository.dart'; +import 'package:immich_mobile/repositories/permission.repository.dart'; + +final networkServiceProvider = Provider((ref) { + return NetworkService( + ref.watch(networkRepositoryProvider), + ref.watch(permissionRepositoryProvider), + ); +}); + +class NetworkService { + final INetworkRepository _repository; + final IPermissionRepository _permissionRepository; + + NetworkService(this._repository, this._permissionRepository); + + Future getLocationWhenInUserPermission() { + return _permissionRepository.hasLocationWhenInUsePermission(); + } + + Future requestLocationWhenInUsePermission() { + return _permissionRepository.requestLocationWhenInUsePermission(); + } + + Future getLocationAlwaysPermission() { + return _permissionRepository.hasLocationAlwaysPermission(); + } + + Future requestLocationAlwaysPermission() { + return _permissionRepository.requestLocationAlwaysPermission(); + } + + Future getWifiName() async { + final canRead = await getLocationWhenInUserPermission(); + if (!canRead) { + return null; + } + + return await _repository.getWifiName(); + } + + Future openSettings() { + return _permissionRepository.openSettings(); + } +} diff --git a/mobile/lib/widgets/settings/networking_settings/endpoint_input.dart b/mobile/lib/widgets/settings/networking_settings/endpoint_input.dart new file mode 100644 index 0000000000..6302f9422a --- /dev/null +++ b/mobile/lib/widgets/settings/networking_settings/endpoint_input.dart @@ -0,0 +1,155 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; +import 'package:immich_mobile/widgets/settings/networking_settings/networking_settings.dart'; + +class EndpointInput extends StatefulHookConsumerWidget { + const EndpointInput({ + super.key, + required this.initialValue, + required this.index, + required this.onValidated, + required this.onDismissed, + this.enabled = true, + }); + + final AuxilaryEndpoint initialValue; + final int index; + final Function(String url, int index, AuxCheckStatus status) onValidated; + final Function(int index) onDismissed; + final bool enabled; + + @override + EndpointInputState createState() => EndpointInputState(); +} + +class EndpointInputState extends ConsumerState { + late final TextEditingController controller; + late final FocusNode focusNode; + late AuxCheckStatus auxCheckStatus; + bool isInputValid = false; + + @override + void initState() { + super.initState(); + controller = TextEditingController(text: widget.initialValue.url); + focusNode = FocusNode()..addListener(_onOutFocus); + + setState(() { + auxCheckStatus = widget.initialValue.status; + }); + } + + @override + void dispose() { + focusNode.removeListener(_onOutFocus); + focusNode.dispose(); + controller.dispose(); + super.dispose(); + } + + void _onOutFocus() { + if (!focusNode.hasFocus && isInputValid) { + validateAuxilaryServerUrl(); + } + } + + Future validateAuxilaryServerUrl() async { + final url = controller.text; + setState(() => auxCheckStatus = AuxCheckStatus.loading); + + final isValid = + await ref.read(authProvider.notifier).validateAuxilaryServerUrl(url); + + setState(() { + if (mounted) { + auxCheckStatus = isValid ? AuxCheckStatus.valid : AuxCheckStatus.error; + } + }); + + widget.onValidated(url, widget.index, auxCheckStatus); + } + + String? validateUrl(String? url) { + try { + if (url == null || url.isEmpty || !Uri.parse(url).isAbsolute) { + isInputValid = false; + return 'validate_endpoint_error'.tr(); + } + } catch (_) { + isInputValid = false; + return 'validate_endpoint_error'.tr(); + } + + isInputValid = true; + return null; + } + + @override + Widget build(BuildContext context) { + return Dismissible( + key: ValueKey(widget.index.toString()), + direction: DismissDirection.endToStart, + onDismissed: (_) => widget.onDismissed(widget.index), + background: Container( + color: Colors.red, + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 16), + child: const Icon( + Icons.delete, + color: Colors.white, + ), + ), + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + trailing: ReorderableDragStartListener( + enabled: widget.enabled, + index: widget.index, + child: const Icon(Icons.drag_handle_rounded), + ), + leading: NetworkStatusIcon( + key: ValueKey('status_$auxCheckStatus'), + status: auxCheckStatus, + enabled: widget.enabled, + ), + subtitle: TextFormField( + enabled: widget.enabled, + onTapOutside: (_) => focusNode.unfocus(), + autovalidateMode: AutovalidateMode.onUserInteraction, + validator: validateUrl, + keyboardType: TextInputType.url, + style: const TextStyle( + fontFamily: 'Inconsolata', + fontWeight: FontWeight.w600, + fontSize: 14, + ), + decoration: InputDecoration( + hintText: 'http(s)://immich.domain.com', + contentPadding: const EdgeInsets.all(16), + filled: true, + fillColor: context.colorScheme.surfaceContainer, + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(16)), + ), + errorBorder: OutlineInputBorder( + borderSide: BorderSide(color: Colors.red[300]!), + borderRadius: const BorderRadius.all(Radius.circular(16)), + ), + disabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: + context.isDarkTheme ? Colors.grey[900]! : Colors.grey[300]!, + ), + borderRadius: const BorderRadius.all(Radius.circular(16)), + ), + ), + controller: controller, + focusNode: focusNode, + ), + ), + ); + } +} diff --git a/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart b/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart new file mode 100644 index 0000000000..13c109fa0e --- /dev/null +++ b/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart @@ -0,0 +1,189 @@ +import 'dart:convert'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; +import 'package:immich_mobile/entities/store.entity.dart' as db_store; +import 'package:immich_mobile/widgets/settings/networking_settings/endpoint_input.dart'; + +class ExternalNetworkPreference extends HookConsumerWidget { + const ExternalNetworkPreference({super.key, required this.enabled}); + + final bool enabled; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final entries = + useState([AuxilaryEndpoint(url: '', status: AuxCheckStatus.unknown)]); + final canSave = useState(false); + + saveEndpointList() { + canSave.value = + entries.value.every((e) => e.status == AuxCheckStatus.valid); + + final endpointList = entries.value + .where((url) => url.status == AuxCheckStatus.valid) + .toList(); + + final jsonString = jsonEncode(endpointList); + + db_store.Store.put( + db_store.StoreKey.externalEndpointList, + jsonString, + ); + } + + updateValidationStatus(String url, int index, AuxCheckStatus status) { + entries.value[index] = + entries.value[index].copyWith(url: url, status: status); + + saveEndpointList(); + } + + handleReorder(int oldIndex, int newIndex) { + if (oldIndex < newIndex) { + newIndex -= 1; + } + + final entry = entries.value.removeAt(oldIndex); + entries.value.insert(newIndex, entry); + entries.value = [...entries.value]; + + saveEndpointList(); + } + + handleDismiss(int index) { + entries.value = [...entries.value..removeAt(index)]; + + saveEndpointList(); + } + + Widget proxyDecorator( + Widget child, + int index, + Animation animation, + ) { + return AnimatedBuilder( + animation: animation, + builder: (BuildContext context, Widget? child) { + return Material( + color: context.colorScheme.surfaceContainerHighest, + shadowColor: context.colorScheme.primary.withOpacity(0.2), + child: child, + ); + }, + child: child, + ); + } + + useEffect( + () { + final jsonString = + db_store.Store.tryGet(db_store.StoreKey.externalEndpointList); + + if (jsonString == null) { + return null; + } + + final List jsonList = jsonDecode(jsonString); + entries.value = + jsonList.map((e) => AuxilaryEndpoint.fromJson(e)).toList(); + return null; + }, + const [], + ); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(16)), + color: context.colorScheme.surfaceContainerLow, + border: Border.all( + color: context.colorScheme.surfaceContainerHighest, + width: 1, + ), + ), + child: Stack( + children: [ + Positioned( + bottom: -36, + right: -36, + child: Icon( + Icons.dns_rounded, + size: 120, + color: context.primaryColor.withOpacity(0.05), + ), + ), + ListView( + padding: const EdgeInsets.symmetric(vertical: 16.0), + physics: const ClampingScrollPhysics(), + shrinkWrap: true, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + vertical: 4.0, + horizontal: 24, + ), + child: Text( + "external_network_sheet_info".tr(), + style: context.textTheme.bodyMedium, + ), + ), + const SizedBox(height: 4), + Divider(color: context.colorScheme.surfaceContainerHighest), + Form( + key: GlobalKey(), + child: ReorderableListView.builder( + buildDefaultDragHandles: false, + proxyDecorator: proxyDecorator, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: entries.value.length, + onReorder: handleReorder, + itemBuilder: (context, index) { + return EndpointInput( + key: Key(index.toString()), + index: index, + initialValue: entries.value[index], + onValidated: updateValidationStatus, + onDismissed: handleDismiss, + enabled: enabled, + ); + }, + ), + ), + const SizedBox(height: 24), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: SizedBox( + height: 48, + child: OutlinedButton.icon( + icon: const Icon(Icons.add), + label: Text('add_endpoint'.tr().toUpperCase()), + onPressed: enabled + ? () { + entries.value = [ + ...entries.value, + AuxilaryEndpoint( + url: '', + status: AuxCheckStatus.unknown, + ), + ]; + } + : null, + ), + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/widgets/settings/networking_settings/local_network_preference.dart b/mobile/lib/widgets/settings/networking_settings/local_network_preference.dart new file mode 100644 index 0000000000..0258cc3847 --- /dev/null +++ b/mobile/lib/widgets/settings/networking_settings/local_network_preference.dart @@ -0,0 +1,256 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; +import 'package:immich_mobile/providers/network.provider.dart'; + +class LocalNetworkPreference extends HookConsumerWidget { + const LocalNetworkPreference({ + super.key, + required this.enabled, + }); + + final bool enabled; + + Future _showEditDialog( + BuildContext context, + String title, + String hintText, + String initialValue, + ) { + final controller = TextEditingController(text: initialValue); + + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: TextField( + controller: controller, + autofocus: true, + decoration: InputDecoration( + border: const OutlineInputBorder(), + hintText: hintText, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + 'cancel'.tr().toUpperCase(), + style: const TextStyle(color: Colors.red), + ), + ), + TextButton( + onPressed: () => Navigator.pop(context, controller.text), + child: Text('save'.tr().toUpperCase()), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final wifiNameText = useState(""); + final localEndpointText = useState(""); + + useEffect( + () { + final wifiName = ref.read(authProvider.notifier).getSavedWifiName(); + final localEndpoint = + ref.read(authProvider.notifier).getSavedLocalEndpoint(); + + if (wifiName != null) { + wifiNameText.value = wifiName; + } + + if (localEndpoint != null) { + localEndpointText.value = localEndpoint; + } + + return null; + }, + [], + ); + + saveWifiName(String wifiName) { + wifiNameText.value = wifiName; + return ref.read(authProvider.notifier).saveWifiName(wifiName); + } + + saveLocalEndpoint(String url) { + localEndpointText.value = url; + return ref.read(authProvider.notifier).saveLocalEndpoint(url); + } + + handleEditWifiName() async { + final wifiName = await _showEditDialog( + context, + "wifi_name".tr(), + "your_wifi_name".tr(), + wifiNameText.value, + ); + + if (wifiName != null) { + await saveWifiName(wifiName); + } + } + + handleEditServerEndpoint() async { + final localEndpoint = await _showEditDialog( + context, + "server_endpoint".tr(), + "http://local-ip:2283/api", + localEndpointText.value, + ); + + if (localEndpoint != null) { + await saveLocalEndpoint(localEndpoint); + } + } + + autofillCurrentNetwork() async { + final wifiName = await ref.read(networkProvider.notifier).getWifiName(); + + if (wifiName == null) { + context.showSnackBar( + SnackBar( + content: Text( + "get_wifiname_error".tr(), + style: context.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + color: context.colorScheme.onSecondary, + ), + ), + backgroundColor: context.colorScheme.secondary, + ), + ); + } else { + saveWifiName(wifiName); + } + + final serverEndpoint = + ref.read(authProvider.notifier).getServerEndpoint(); + + if (serverEndpoint != null) { + saveLocalEndpoint(serverEndpoint); + } + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Stack( + children: [ + Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(16)), + color: context.colorScheme.surfaceContainerLow, + border: Border.all( + color: context.colorScheme.surfaceContainerHighest, + width: 1, + ), + ), + child: Stack( + children: [ + Positioned( + bottom: -36, + right: -36, + child: Icon( + Icons.home_outlined, + size: 120, + color: context.primaryColor.withOpacity(0.05), + ), + ), + ListView( + padding: const EdgeInsets.symmetric(vertical: 16.0), + physics: const ClampingScrollPhysics(), + shrinkWrap: true, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + vertical: 4.0, + horizontal: 24, + ), + child: Text( + "local_network_sheet_info".tr(), + style: context.textTheme.bodyMedium, + ), + ), + const SizedBox(height: 4), + Divider( + color: context.colorScheme.surfaceContainerHighest, + ), + ListTile( + enabled: enabled, + contentPadding: const EdgeInsets.only(left: 24, right: 8), + leading: const Icon(Icons.wifi_rounded), + title: Text("wifi_name".tr()), + subtitle: wifiNameText.value.isEmpty + ? Text("enter_wifi_name".tr()) + : Text( + wifiNameText.value, + style: context.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.bold, + color: enabled + ? context.primaryColor + : context.colorScheme.onSurface + .withAlpha(100), + fontFamily: 'Inconsolata', + ), + ), + trailing: IconButton( + onPressed: enabled ? handleEditWifiName : null, + icon: const Icon(Icons.edit_rounded), + ), + ), + ListTile( + enabled: enabled, + contentPadding: const EdgeInsets.only(left: 24, right: 8), + leading: const Icon(Icons.lan_rounded), + title: Text("server_endpoint".tr()), + subtitle: localEndpointText.value.isEmpty + ? const Text("http://local-ip:2283/api") + : Text( + localEndpointText.value, + style: context.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.bold, + color: enabled + ? context.primaryColor + : context.colorScheme.onSurface + .withAlpha(100), + fontFamily: 'Inconsolata', + ), + ), + trailing: IconButton( + onPressed: enabled ? handleEditServerEndpoint : null, + icon: const Icon(Icons.edit_rounded), + ), + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 24.0, + ), + child: SizedBox( + height: 48, + child: OutlinedButton.icon( + icon: const Icon(Icons.wifi_find_rounded), + label: + Text('use_current_connection'.tr().toUpperCase()), + onPressed: enabled ? autofillCurrentNetwork : null, + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/widgets/settings/networking_settings/networking_settings.dart b/mobile/lib/widgets/settings/networking_settings/networking_settings.dart new file mode 100644 index 0000000000..59d05fd4cf --- /dev/null +++ b/mobile/lib/widgets/settings/networking_settings/networking_settings.dart @@ -0,0 +1,266 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/models/auth/auxilary_endpoint.model.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/widgets/settings/networking_settings/external_network_preference.dart'; +import 'package:immich_mobile/widgets/settings/networking_settings/local_network_preference.dart'; +import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; + +import 'package:immich_mobile/entities/store.entity.dart' as db_store; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; + +class NetworkingSettings extends HookConsumerWidget { + const NetworkingSettings({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final currentEndpoint = + db_store.Store.get(db_store.StoreKey.serverEndpoint); + final featureEnabled = + useAppSettingsState(AppSettingsEnum.autoEndpointSwitching); + + Future checkWifiReadPermission() async { + final [hasLocationInUse, hasLocationAlways] = await Future.wait([ + ref.read(networkProvider.notifier).getWifiReadPermission(), + ref.read(networkProvider.notifier).getWifiReadBackgroundPermission(), + ]); + + bool? isGrantLocationAlwaysPermission; + + if (!hasLocationInUse) { + await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text("location_permission".tr()), + content: Text("location_permission_content".tr()), + actions: [ + TextButton( + onPressed: () async { + final isGrant = await ref + .read(networkProvider.notifier) + .requestWifiReadPermission(); + + Navigator.pop(context, isGrant); + }, + child: Text("grant_permission".tr()), + ), + ], + ); + }, + ); + } + + if (!hasLocationAlways) { + isGrantLocationAlwaysPermission = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text("background_location_permission".tr()), + content: Text("background_location_permission_content".tr()), + actions: [ + TextButton( + onPressed: () async { + final isGrant = await ref + .read(networkProvider.notifier) + .requestWifiReadBackgroundPermission(); + + Navigator.pop(context, isGrant); + }, + child: Text("grant_permission".tr()), + ), + ], + ); + }, + ); + } + + if (isGrantLocationAlwaysPermission != null && + !isGrantLocationAlwaysPermission) { + await ref.read(networkProvider.notifier).openSettings(); + } + } + + useEffect( + () { + if (featureEnabled.value == true) { + checkWifiReadPermission(); + } + return null; + }, + [featureEnabled.value], + ); + + return ListView( + padding: const EdgeInsets.only(bottom: 96), + physics: const ClampingScrollPhysics(), + children: [ + Padding( + padding: const EdgeInsets.only(top: 8, left: 16, bottom: 8), + child: NetworkPreferenceTitle( + title: "current_server_address".tr().toUpperCase(), + icon: currentEndpoint.startsWith('https') + ? Icons.https_outlined + : Icons.http_outlined, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all(Radius.circular(16)), + side: BorderSide( + color: context.colorScheme.surfaceContainerHighest, + width: 1, + ), + ), + child: ListTile( + leading: + const Icon(Icons.check_circle_rounded, color: Colors.green), + title: Text( + currentEndpoint, + style: TextStyle( + fontSize: 16, + fontFamily: 'Inconsolata', + fontWeight: FontWeight.bold, + color: context.primaryColor, + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 10.0), + child: Divider( + color: context.colorScheme.surfaceContainerHighest, + ), + ), + SettingsSwitchListTile( + enabled: true, + valueNotifier: featureEnabled, + title: "automatic_endpoint_switching_title".tr(), + subtitle: "automatic_endpoint_switching_subtitle".tr(), + ), + Padding( + padding: const EdgeInsets.only(top: 8, left: 16, bottom: 16), + child: NetworkPreferenceTitle( + title: "local_network".tr().toUpperCase(), + icon: Icons.home_outlined, + ), + ), + LocalNetworkPreference( + enabled: featureEnabled.value, + ), + Padding( + padding: const EdgeInsets.only(top: 32, left: 16, bottom: 16), + child: NetworkPreferenceTitle( + title: "external_network".tr().toUpperCase(), + icon: Icons.dns_outlined, + ), + ), + ExternalNetworkPreference( + enabled: featureEnabled.value, + ), + ], + ); + } +} + +class NetworkPreferenceTitle extends StatelessWidget { + const NetworkPreferenceTitle({ + super.key, + required this.icon, + required this.title, + }); + + final IconData icon; + final String title; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon( + icon, + color: context.colorScheme.onSurface.withAlpha(150), + ), + const SizedBox(width: 8), + Text( + title, + style: context.textTheme.displaySmall?.copyWith( + color: context.colorScheme.onSurface.withAlpha(200), + fontWeight: FontWeight.w500, + ), + ), + ], + ); + } +} + +class NetworkStatusIcon extends StatelessWidget { + const NetworkStatusIcon({ + super.key, + required this.status, + this.enabled = true, + }) : super(); + + final AuxCheckStatus status; + final bool enabled; + + @override + Widget build(BuildContext context) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: _buildIcon(context), + ); + } + + Widget _buildIcon(BuildContext context) { + switch (status) { + case AuxCheckStatus.loading: + return Padding( + padding: const EdgeInsets.only(left: 4.0), + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + color: context.primaryColor, + strokeWidth: 2, + key: const ValueKey('loading'), + ), + ), + ); + case AuxCheckStatus.valid: + return enabled + ? const Icon( + Icons.check_circle_rounded, + color: Colors.green, + key: ValueKey('success'), + ) + : Icon( + Icons.check_circle_rounded, + color: context.colorScheme.onSurface.withAlpha(100), + key: const ValueKey('success'), + ); + case AuxCheckStatus.error: + return enabled + ? const Icon( + Icons.error_rounded, + color: Colors.red, + key: ValueKey('error'), + ) + : const Icon( + Icons.error_rounded, + color: Colors.grey, + key: ValueKey('error'), + ); + default: + return const Icon(Icons.circle_outlined, key: ValueKey('unknown')); + } + } +} diff --git a/mobile/openapi/devtools_options.yaml b/mobile/openapi/devtools_options.yaml new file mode 100644 index 0000000000..fa0b357c4f --- /dev/null +++ b/mobile/openapi/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 9203dcdf82..34eb217828 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1017,6 +1017,22 @@ packages: url: "https://github.com/immich-app/native_video_player" source: git version: "1.3.1" + network_info_plus: + dependency: "direct main" + description: + name: network_info_plus + sha256: bf9e39e523e9951d741868dc33ac386b0bc24301e9b7c8a7d60dbc34879150a8 + url: "https://pub.dev" + source: hosted + version: "6.1.1" + network_info_plus_platform_interface: + dependency: transitive + description: + name: network_info_plus_platform_interface + sha256: b7f35f4a7baef511159e524499f3c15464a49faa5ec10e92ee0bce265e664906 + url: "https://pub.dev" + source: hosted + version: "2.0.1" nm: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index a037f9b947..e8bee37653 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -61,6 +61,7 @@ dependencies: async: ^2.11.0 dynamic_color: ^1.7.0 #package to apply system theme background_downloader: ^8.5.5 + network_info_plus: ^6.1.1 native_video_player: git: url: https://github.com/immich-app/native_video_player diff --git a/mobile/test/service.mocks.dart b/mobile/test/service.mocks.dart index de49a98cc4..507b4f281b 100644 --- a/mobile/test/service.mocks.dart +++ b/mobile/test/service.mocks.dart @@ -1,6 +1,7 @@ import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/hash.service.dart'; +import 'package:immich_mobile/services/network.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/user.service.dart'; import 'package:mocktail/mocktail.dart'; @@ -14,3 +15,5 @@ class MockSyncService extends Mock implements SyncService {} class MockHashService extends Mock implements HashService {} class MockEntityService extends Mock implements EntityService {} + +class MockNetworkService extends Mock implements NetworkService {} diff --git a/mobile/test/services/auth.service_test.dart b/mobile/test/services/auth.service_test.dart index b864babb14..edbf6495e3 100644 --- a/mobile/test/services/auth.service_test.dart +++ b/mobile/test/services/auth.service_test.dart @@ -1,8 +1,10 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/services/auth.service.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:openapi/api.dart'; import '../repository.mocks.dart'; import '../service.mocks.dart'; import '../test_utils.dart'; @@ -12,12 +14,22 @@ void main() { late MockAuthApiRepository authApiRepository; late MockAuthRepository authRepository; late MockApiService apiService; + late MockNetworkService networkService; setUp(() async { authApiRepository = MockAuthApiRepository(); authRepository = MockAuthRepository(); apiService = MockApiService(); - sut = AuthService(authApiRepository, authRepository, apiService); + networkService = MockNetworkService(); + + sut = AuthService( + authApiRepository, + authRepository, + apiService, + networkService, + ); + + registerFallbackValue(Uri()); }); group('validateServerUrl', () { @@ -115,4 +127,182 @@ void main() { verify(() => authRepository.clearLocalData()).called(1); }); }); + + group('setOpenApiServiceEndpoint', () { + setUp(() { + when(() => networkService.getWifiName()) + .thenAnswer((_) async => 'TestWifi'); + }); + + test('Should return null if auto endpoint switching is disabled', () async { + when(() => authRepository.getEndpointSwitchingFeature()) + .thenReturn((false)); + + final result = await sut.setOpenApiServiceEndpoint(); + + expect(result, isNull); + verify(() => authRepository.getEndpointSwitchingFeature()).called(1); + verifyNever(() => networkService.getWifiName()); + }); + + test('Should set local connection if wifi name matches', () async { + when(() => authRepository.getEndpointSwitchingFeature()).thenReturn(true); + when(() => authRepository.getPreferredWifiName()).thenReturn('TestWifi'); + when(() => authRepository.getLocalEndpoint()) + .thenReturn('http://local.endpoint'); + when(() => apiService.resolveAndSetEndpoint('http://local.endpoint')) + .thenAnswer((_) async => 'http://local.endpoint'); + + final result = await sut.setOpenApiServiceEndpoint(); + + expect(result, 'http://local.endpoint'); + verify(() => authRepository.getEndpointSwitchingFeature()).called(1); + verify(() => networkService.getWifiName()).called(1); + verify(() => authRepository.getPreferredWifiName()).called(1); + verify(() => authRepository.getLocalEndpoint()).called(1); + verify(() => apiService.resolveAndSetEndpoint('http://local.endpoint')) + .called(1); + }); + + test('Should set external endpoint if wifi name not matching', () async { + when(() => authRepository.getEndpointSwitchingFeature()).thenReturn(true); + when(() => authRepository.getPreferredWifiName()) + .thenReturn('DifferentWifi'); + when(() => authRepository.getExternalEndpointList()).thenReturn([ + AuxilaryEndpoint( + url: 'https://external.endpoint', + status: AuxCheckStatus.valid, + ), + ]); + when( + () => apiService.resolveAndSetEndpoint('https://external.endpoint'), + ).thenAnswer((_) async => 'https://external.endpoint/api'); + + final result = await sut.setOpenApiServiceEndpoint(); + + expect(result, 'https://external.endpoint/api'); + verify(() => authRepository.getEndpointSwitchingFeature()).called(1); + verify(() => networkService.getWifiName()).called(1); + verify(() => authRepository.getPreferredWifiName()).called(1); + verify(() => authRepository.getExternalEndpointList()).called(1); + verify( + () => apiService.resolveAndSetEndpoint('https://external.endpoint'), + ).called(1); + }); + + test('Should set second external endpoint if the first throw any error', + () async { + when(() => authRepository.getEndpointSwitchingFeature()).thenReturn(true); + when(() => authRepository.getPreferredWifiName()) + .thenReturn('DifferentWifi'); + when(() => authRepository.getExternalEndpointList()).thenReturn([ + AuxilaryEndpoint( + url: 'https://external.endpoint', + status: AuxCheckStatus.valid, + ), + AuxilaryEndpoint( + url: 'https://external.endpoint2', + status: AuxCheckStatus.valid, + ), + ]); + + when( + () => apiService.resolveAndSetEndpoint('https://external.endpoint'), + ).thenThrow(Exception('Invalid endpoint')); + when( + () => apiService.resolveAndSetEndpoint('https://external.endpoint2'), + ).thenAnswer((_) async => 'https://external.endpoint2/api'); + + final result = await sut.setOpenApiServiceEndpoint(); + + expect(result, 'https://external.endpoint2/api'); + verify(() => authRepository.getEndpointSwitchingFeature()).called(1); + verify(() => networkService.getWifiName()).called(1); + verify(() => authRepository.getPreferredWifiName()).called(1); + verify(() => authRepository.getExternalEndpointList()).called(1); + verify( + () => apiService.resolveAndSetEndpoint('https://external.endpoint2'), + ).called(1); + }); + + test('Should set second external endpoint if the first throw ApiException', + () async { + when(() => authRepository.getEndpointSwitchingFeature()).thenReturn(true); + when(() => authRepository.getPreferredWifiName()) + .thenReturn('DifferentWifi'); + when(() => authRepository.getExternalEndpointList()).thenReturn([ + AuxilaryEndpoint( + url: 'https://external.endpoint', + status: AuxCheckStatus.valid, + ), + AuxilaryEndpoint( + url: 'https://external.endpoint2', + status: AuxCheckStatus.valid, + ), + ]); + + when( + () => apiService.resolveAndSetEndpoint('https://external.endpoint'), + ).thenThrow(ApiException(503, 'Invalid endpoint')); + when( + () => apiService.resolveAndSetEndpoint('https://external.endpoint2'), + ).thenAnswer((_) async => 'https://external.endpoint2/api'); + + final result = await sut.setOpenApiServiceEndpoint(); + + expect(result, 'https://external.endpoint2/api'); + verify(() => authRepository.getEndpointSwitchingFeature()).called(1); + verify(() => networkService.getWifiName()).called(1); + verify(() => authRepository.getPreferredWifiName()).called(1); + verify(() => authRepository.getExternalEndpointList()).called(1); + verify( + () => apiService.resolveAndSetEndpoint('https://external.endpoint2'), + ).called(1); + }); + + test('Should handle error when setting local connection', () async { + when(() => authRepository.getEndpointSwitchingFeature()).thenReturn(true); + when(() => authRepository.getPreferredWifiName()).thenReturn('TestWifi'); + when(() => authRepository.getLocalEndpoint()) + .thenReturn('http://local.endpoint'); + when(() => apiService.resolveAndSetEndpoint('http://local.endpoint')) + .thenThrow(Exception('Local endpoint error')); + + final result = await sut.setOpenApiServiceEndpoint(); + + expect(result, isNull); + verify(() => authRepository.getEndpointSwitchingFeature()).called(1); + verify(() => networkService.getWifiName()).called(1); + verify(() => authRepository.getPreferredWifiName()).called(1); + verify(() => authRepository.getLocalEndpoint()).called(1); + verify(() => apiService.resolveAndSetEndpoint('http://local.endpoint')) + .called(1); + }); + + test('Should handle error when setting external connection', () async { + when(() => authRepository.getEndpointSwitchingFeature()).thenReturn(true); + when(() => authRepository.getPreferredWifiName()) + .thenReturn('DifferentWifi'); + when(() => authRepository.getExternalEndpointList()).thenReturn([ + AuxilaryEndpoint( + url: 'https://external.endpoint', + status: AuxCheckStatus.valid, + ), + ]); + when( + () => apiService.resolveAndSetEndpoint('https://external.endpoint'), + ).thenThrow(Exception('External endpoint error')); + + final result = await sut.setOpenApiServiceEndpoint(); + + expect(result, isNull); + verify(() => authRepository.getEndpointSwitchingFeature()).called(1); + verify(() => networkService.getWifiName()).called(1); + verify(() => authRepository.getPreferredWifiName()).called(1); + verify(() => authRepository.getExternalEndpointList()).called(1); + verify( + () => apiService.resolveAndSetEndpoint('https://external.endpoint'), + ).called(1); + }); + }); }