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);
+ });
+ });
}