mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-03 19:17:11 -05:00 
			
		
		
		
	chore(mobile): refactor authentication (#14322)
This commit is contained in:
		
							parent
							
								
									5417e34fb6
								
							
						
					
					
						commit
						21f14be949
					
				@ -93,7 +93,7 @@ custom_lint:
 | 
			
		||||
        - lib/models/server_info/server_{config,disk_info,features,version}.model.dart
 | 
			
		||||
        - lib/models/shared_link/shared_link.model.dart
 | 
			
		||||
        - lib/providers/asset_viewer/asset_people.provider.dart
 | 
			
		||||
        - lib/providers/authentication.provider.dart
 | 
			
		||||
        - lib/providers/auth.provider.dart
 | 
			
		||||
        - lib/providers/image/immich_remote_{image,thumbnail}_provider.dart
 | 
			
		||||
        - lib/providers/map/map_state.provider.dart
 | 
			
		||||
        - lib/providers/search/{search,search_filter}.provider.dart
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										5
									
								
								mobile/lib/interfaces/auth.interface.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								mobile/lib/interfaces/auth.interface.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,5 @@
 | 
			
		||||
import 'package:immich_mobile/interfaces/database.interface.dart';
 | 
			
		||||
 | 
			
		||||
abstract interface class IAuthRepository implements IDatabaseRepository {
 | 
			
		||||
  Future<void> clearLocalData();
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										9
									
								
								mobile/lib/interfaces/auth_api.interface.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								mobile/lib/interfaces/auth_api.interface.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,9 @@
 | 
			
		||||
import 'package:immich_mobile/models/auth/login_response.model.dart';
 | 
			
		||||
 | 
			
		||||
abstract interface class IAuthApiRepository {
 | 
			
		||||
  Future<LoginResponse> login(String email, String password);
 | 
			
		||||
 | 
			
		||||
  Future<void> logout();
 | 
			
		||||
 | 
			
		||||
  Future<void> changePassword(String newPassword);
 | 
			
		||||
}
 | 
			
		||||
@ -1,62 +1,58 @@
 | 
			
		||||
class AuthenticationState {
 | 
			
		||||
class AuthState {
 | 
			
		||||
  final String deviceId;
 | 
			
		||||
  final String userId;
 | 
			
		||||
  final String userEmail;
 | 
			
		||||
  final bool isAuthenticated;
 | 
			
		||||
  final String name;
 | 
			
		||||
  final bool isAdmin;
 | 
			
		||||
  final bool shouldChangePassword;
 | 
			
		||||
  final String profileImagePath;
 | 
			
		||||
  AuthenticationState({
 | 
			
		||||
 | 
			
		||||
  AuthState({
 | 
			
		||||
    required this.deviceId,
 | 
			
		||||
    required this.userId,
 | 
			
		||||
    required this.userEmail,
 | 
			
		||||
    required this.isAuthenticated,
 | 
			
		||||
    required this.name,
 | 
			
		||||
    required this.isAdmin,
 | 
			
		||||
    required this.shouldChangePassword,
 | 
			
		||||
    required this.profileImagePath,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  AuthenticationState copyWith({
 | 
			
		||||
  AuthState copyWith({
 | 
			
		||||
    String? deviceId,
 | 
			
		||||
    String? userId,
 | 
			
		||||
    String? userEmail,
 | 
			
		||||
    bool? isAuthenticated,
 | 
			
		||||
    String? name,
 | 
			
		||||
    bool? isAdmin,
 | 
			
		||||
    bool? shouldChangePassword,
 | 
			
		||||
    String? profileImagePath,
 | 
			
		||||
  }) {
 | 
			
		||||
    return AuthenticationState(
 | 
			
		||||
    return AuthState(
 | 
			
		||||
      deviceId: deviceId ?? this.deviceId,
 | 
			
		||||
      userId: userId ?? this.userId,
 | 
			
		||||
      userEmail: userEmail ?? this.userEmail,
 | 
			
		||||
      isAuthenticated: isAuthenticated ?? this.isAuthenticated,
 | 
			
		||||
      name: name ?? this.name,
 | 
			
		||||
      isAdmin: isAdmin ?? this.isAdmin,
 | 
			
		||||
      shouldChangePassword: shouldChangePassword ?? this.shouldChangePassword,
 | 
			
		||||
      profileImagePath: profileImagePath ?? this.profileImagePath,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return 'AuthenticationState(deviceId: $deviceId, userId: $userId, userEmail: $userEmail, isAuthenticated: $isAuthenticated, name: $name, isAdmin: $isAdmin, shouldChangePassword: $shouldChangePassword, profileImagePath: $profileImagePath)';
 | 
			
		||||
    return 'AuthenticationState(deviceId: $deviceId, userId: $userId, userEmail: $userEmail, isAuthenticated: $isAuthenticated, name: $name, isAdmin: $isAdmin, profileImagePath: $profileImagePath)';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(Object other) {
 | 
			
		||||
    if (identical(this, other)) return true;
 | 
			
		||||
 | 
			
		||||
    return other is AuthenticationState &&
 | 
			
		||||
    return other is AuthState &&
 | 
			
		||||
        other.deviceId == deviceId &&
 | 
			
		||||
        other.userId == userId &&
 | 
			
		||||
        other.userEmail == userEmail &&
 | 
			
		||||
        other.isAuthenticated == isAuthenticated &&
 | 
			
		||||
        other.name == name &&
 | 
			
		||||
        other.isAdmin == isAdmin &&
 | 
			
		||||
        other.shouldChangePassword == shouldChangePassword &&
 | 
			
		||||
        other.profileImagePath == profileImagePath;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -68,7 +64,6 @@ class AuthenticationState {
 | 
			
		||||
        isAuthenticated.hashCode ^
 | 
			
		||||
        name.hashCode ^
 | 
			
		||||
        isAdmin.hashCode ^
 | 
			
		||||
        shouldChangePassword.hashCode ^
 | 
			
		||||
        profileImagePath.hashCode;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										30
									
								
								mobile/lib/models/auth/login_response.model.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								mobile/lib/models/auth/login_response.model.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,30 @@
 | 
			
		||||
class LoginResponse {
 | 
			
		||||
  final String accessToken;
 | 
			
		||||
 | 
			
		||||
  final bool isAdmin;
 | 
			
		||||
 | 
			
		||||
  final String name;
 | 
			
		||||
 | 
			
		||||
  final String profileImagePath;
 | 
			
		||||
 | 
			
		||||
  final bool shouldChangePassword;
 | 
			
		||||
 | 
			
		||||
  final String userEmail;
 | 
			
		||||
 | 
			
		||||
  final String userId;
 | 
			
		||||
 | 
			
		||||
  LoginResponse({
 | 
			
		||||
    required this.accessToken,
 | 
			
		||||
    required this.isAdmin,
 | 
			
		||||
    required this.name,
 | 
			
		||||
    required this.profileImagePath,
 | 
			
		||||
    required this.shouldChangePassword,
 | 
			
		||||
    required this.userEmail,
 | 
			
		||||
    required this.userId,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return 'LoginResponse[accessToken=$accessToken, isAdmin=$isAdmin, name=$name, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, userEmail=$userEmail, userId=$userId]';
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -7,7 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
 | 
			
		||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/album/album.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/authentication.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/auth.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/utils/immich_loading_overlay.dart';
 | 
			
		||||
import 'package:immich_mobile/routing/router.dart';
 | 
			
		||||
import 'package:immich_mobile/entities/album.entity.dart';
 | 
			
		||||
@ -25,7 +25,7 @@ class AlbumOptionsPage extends HookConsumerWidget {
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    final sharedUsers = useState(album.sharedUsers.toList());
 | 
			
		||||
    final owner = album.owner.value;
 | 
			
		||||
    final userId = ref.watch(authenticationProvider).userId;
 | 
			
		||||
    final userId = ref.watch(authProvider).userId;
 | 
			
		||||
    final activityEnabled = useState(album.activityEnabled);
 | 
			
		||||
    final isProcessing = useProcessingOverlay();
 | 
			
		||||
    final isOwner = owner?.id == userId;
 | 
			
		||||
 | 
			
		||||
@ -15,7 +15,7 @@ import 'package:immich_mobile/utils/immich_loading_overlay.dart';
 | 
			
		||||
import 'package:immich_mobile/widgets/album/album_action_filled_button.dart';
 | 
			
		||||
import 'package:immich_mobile/widgets/album/album_viewer_editable_title.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/multiselect.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/authentication.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/auth.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/widgets/album/album_viewer_appbar.dart';
 | 
			
		||||
import 'package:immich_mobile/routing/router.dart';
 | 
			
		||||
import 'package:immich_mobile/entities/album.entity.dart';
 | 
			
		||||
@ -42,7 +42,7 @@ class AlbumViewerPage extends HookConsumerWidget {
 | 
			
		||||
        () => ref.read(currentAlbumProvider.notifier).set(value),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
    final userId = ref.watch(authenticationProvider).userId;
 | 
			
		||||
    final userId = ref.watch(authProvider).userId;
 | 
			
		||||
    final isProcessing = useProcessingOverlay();
 | 
			
		||||
 | 
			
		||||
    Future<bool> onRemoveFromAlbumPressed(Iterable<Asset> assets) async {
 | 
			
		||||
 | 
			
		||||
@ -3,11 +3,10 @@ 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/authentication.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/auth.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/routing/router.dart';
 | 
			
		||||
import 'package:immich_mobile/entities/store.entity.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/api.provider.dart';
 | 
			
		||||
import 'package:logging/logging.dart';
 | 
			
		||||
 | 
			
		||||
@RoutePage()
 | 
			
		||||
@ -16,7 +15,6 @@ class SplashScreenPage extends HookConsumerWidget {
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    final apiService = ref.watch(apiServiceProvider);
 | 
			
		||||
    final serverUrl = Store.tryGet(StoreKey.serverUrl);
 | 
			
		||||
    final endpoint = Store.tryGet(StoreKey.serverEndpoint);
 | 
			
		||||
    final accessToken = Store.tryGet(StoreKey.accessToken);
 | 
			
		||||
@ -26,14 +24,9 @@ class SplashScreenPage extends HookConsumerWidget {
 | 
			
		||||
      bool isAuthSuccess = false;
 | 
			
		||||
 | 
			
		||||
      if (accessToken != null && serverUrl != null && endpoint != null) {
 | 
			
		||||
        apiService.setEndpoint(endpoint);
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
          isAuthSuccess = await ref
 | 
			
		||||
              .read(authenticationProvider.notifier)
 | 
			
		||||
              .setSuccessLoginInfo(
 | 
			
		||||
          isAuthSuccess = await ref.read(authProvider.notifier).saveAuthInfo(
 | 
			
		||||
                accessToken: accessToken,
 | 
			
		||||
                serverUrl: serverUrl,
 | 
			
		||||
              );
 | 
			
		||||
        } catch (error, stackTrace) {
 | 
			
		||||
          log.severe(
 | 
			
		||||
@ -53,7 +46,7 @@ class SplashScreenPage extends HookConsumerWidget {
 | 
			
		||||
        log.severe(
 | 
			
		||||
          'Unable to login using offline or online methods - Logging out completely',
 | 
			
		||||
        );
 | 
			
		||||
        ref.read(authenticationProvider.notifier).logout();
 | 
			
		||||
        ref.read(authProvider.notifier).logout();
 | 
			
		||||
        context.replaceRoute(const LoginRoute());
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
@ -5,7 +5,7 @@ import 'package:immich_mobile/models/backup/backup_state.model.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/backup/ios_background_settings.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/authentication.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/auth.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/memory.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/notification_permission.provider.dart';
 | 
			
		||||
@ -42,7 +42,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
 | 
			
		||||
    if (!_wasPaused) return;
 | 
			
		||||
    _wasPaused = false;
 | 
			
		||||
 | 
			
		||||
    final isAuthenticated = _ref.read(authenticationProvider).isAuthenticated;
 | 
			
		||||
    final isAuthenticated = _ref.read(authProvider).isAuthenticated;
 | 
			
		||||
 | 
			
		||||
    // Needs to be logged in
 | 
			
		||||
    if (isAuthenticated) {
 | 
			
		||||
@ -85,7 +85,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
 | 
			
		||||
    state = AppLifeCycleEnum.paused;
 | 
			
		||||
    _wasPaused = true;
 | 
			
		||||
 | 
			
		||||
    if (_ref.read(authenticationProvider).isAuthenticated) {
 | 
			
		||||
    if (_ref.read(authProvider).isAuthenticated) {
 | 
			
		||||
      // Do not cancel backup if manual upload is in progress
 | 
			
		||||
      if (_ref.read(backupProvider.notifier).backupProgress !=
 | 
			
		||||
          BackUpProgressEnum.manualInProgress) {
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										164
									
								
								mobile/lib/providers/auth.provider.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										164
									
								
								mobile/lib/providers/auth.provider.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,164 @@
 | 
			
		||||
import 'package:flutter/foundation.dart';
 | 
			
		||||
import 'package:flutter_udid/flutter_udid.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/entities/store.entity.dart';
 | 
			
		||||
import 'package:immich_mobile/models/auth/login_response.model.dart';
 | 
			
		||||
import 'package:immich_mobile/models/auth/auth_state.model.dart';
 | 
			
		||||
import 'package:immich_mobile/entities/user.entity.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/api.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/services/api.service.dart';
 | 
			
		||||
import 'package:immich_mobile/services/auth.service.dart';
 | 
			
		||||
import 'package:immich_mobile/utils/hash.dart';
 | 
			
		||||
import 'package:logging/logging.dart';
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
 | 
			
		||||
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
 | 
			
		||||
  return AuthNotifier(
 | 
			
		||||
    ref.watch(authServiceProvider),
 | 
			
		||||
    ref.watch(apiServiceProvider),
 | 
			
		||||
  );
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
class AuthNotifier extends StateNotifier<AuthState> {
 | 
			
		||||
  final AuthService _authService;
 | 
			
		||||
  final ApiService _apiService;
 | 
			
		||||
  final _log = Logger("AuthenticationNotifier");
 | 
			
		||||
 | 
			
		||||
  static const Duration _timeoutDuration = Duration(seconds: 7);
 | 
			
		||||
 | 
			
		||||
  AuthNotifier(
 | 
			
		||||
    this._authService,
 | 
			
		||||
    this._apiService,
 | 
			
		||||
  ) : super(
 | 
			
		||||
          AuthState(
 | 
			
		||||
            deviceId: "",
 | 
			
		||||
            userId: "",
 | 
			
		||||
            userEmail: "",
 | 
			
		||||
            name: '',
 | 
			
		||||
            profileImagePath: '',
 | 
			
		||||
            isAdmin: false,
 | 
			
		||||
            isAuthenticated: false,
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
  Future<String> validateServerUrl(String url) {
 | 
			
		||||
    return _authService.validateServerUrl(url);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<LoginResponse> login(String email, String password) async {
 | 
			
		||||
    final response = await _authService.login(email, password);
 | 
			
		||||
    await saveAuthInfo(accessToken: response.accessToken);
 | 
			
		||||
    return response;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> logout() async {
 | 
			
		||||
    try {
 | 
			
		||||
      await _authService.logout();
 | 
			
		||||
    } finally {
 | 
			
		||||
      await _cleanUp();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _cleanUp() async {
 | 
			
		||||
    state = AuthState(
 | 
			
		||||
      deviceId: "",
 | 
			
		||||
      userId: "",
 | 
			
		||||
      userEmail: "",
 | 
			
		||||
      name: '',
 | 
			
		||||
      profileImagePath: '',
 | 
			
		||||
      isAdmin: false,
 | 
			
		||||
      isAuthenticated: false,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void updateUserProfileImagePath(String path) {
 | 
			
		||||
    state = state.copyWith(profileImagePath: path);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<bool> changePassword(String newPassword) async {
 | 
			
		||||
    try {
 | 
			
		||||
      await _authService.changePassword(newPassword);
 | 
			
		||||
      return true;
 | 
			
		||||
    } catch (_) {
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<bool> saveAuthInfo({
 | 
			
		||||
    required String accessToken,
 | 
			
		||||
  }) async {
 | 
			
		||||
    _apiService.setAccessToken(accessToken);
 | 
			
		||||
 | 
			
		||||
    // Get the deviceid from the store if it exists, otherwise generate a new one
 | 
			
		||||
    String deviceId =
 | 
			
		||||
        Store.tryGet(StoreKey.deviceId) ?? await FlutterUdid.consistentUdid;
 | 
			
		||||
 | 
			
		||||
    User? user = Store.tryGet(StoreKey.currentUser);
 | 
			
		||||
 | 
			
		||||
    UserAdminResponseDto? userResponse;
 | 
			
		||||
    UserPreferencesResponseDto? userPreferences;
 | 
			
		||||
    try {
 | 
			
		||||
      final responses = await Future.wait([
 | 
			
		||||
        _apiService.usersApi.getMyUser().timeout(_timeoutDuration),
 | 
			
		||||
        _apiService.usersApi.getMyPreferences().timeout(_timeoutDuration),
 | 
			
		||||
      ]);
 | 
			
		||||
      userResponse = responses[0] as UserAdminResponseDto;
 | 
			
		||||
      userPreferences = responses[1] as UserPreferencesResponseDto;
 | 
			
		||||
    } on ApiException catch (error, stackTrace) {
 | 
			
		||||
      if (error.code == 401) {
 | 
			
		||||
        _log.severe("Unauthorized access, token likely expired. Logging out.");
 | 
			
		||||
        return false;
 | 
			
		||||
      }
 | 
			
		||||
      _log.severe(
 | 
			
		||||
        "Error getting user information from the server [API EXCEPTION]",
 | 
			
		||||
        stackTrace,
 | 
			
		||||
      );
 | 
			
		||||
    } catch (error, stackTrace) {
 | 
			
		||||
      _log.severe(
 | 
			
		||||
        "Error getting user information from the server [CATCH ALL]",
 | 
			
		||||
        error,
 | 
			
		||||
        stackTrace,
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      if (kDebugMode) {
 | 
			
		||||
        debugPrint(
 | 
			
		||||
          "Error getting user information from the server [CATCH ALL] $error $stackTrace",
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // If the user information is successfully retrieved, update the store
 | 
			
		||||
    // Due to the flow of the code, this will always happen on first login
 | 
			
		||||
    if (userResponse != null) {
 | 
			
		||||
      Store.put(StoreKey.deviceId, deviceId);
 | 
			
		||||
      Store.put(StoreKey.deviceIdHash, fastHash(deviceId));
 | 
			
		||||
      Store.put(
 | 
			
		||||
        StoreKey.currentUser,
 | 
			
		||||
        User.fromUserDto(userResponse, userPreferences),
 | 
			
		||||
      );
 | 
			
		||||
      Store.put(StoreKey.accessToken, accessToken);
 | 
			
		||||
 | 
			
		||||
      user = User.fromUserDto(userResponse, userPreferences);
 | 
			
		||||
    } else {
 | 
			
		||||
      _log.severe("Unable to get user information from the server.");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // If the user is null, the login was not successful
 | 
			
		||||
    // and we don't have a local copy of the user from a prior successful login
 | 
			
		||||
    if (user == null) {
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    state = state.copyWith(
 | 
			
		||||
      isAuthenticated: true,
 | 
			
		||||
      userId: user.id,
 | 
			
		||||
      userEmail: user.email,
 | 
			
		||||
      name: user.name,
 | 
			
		||||
      profileImagePath: user.profileImagePath,
 | 
			
		||||
      isAdmin: user.isAdmin,
 | 
			
		||||
      deviceId: deviceId,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -1,245 +0,0 @@
 | 
			
		||||
import 'dart:io';
 | 
			
		||||
 | 
			
		||||
import 'package:device_info_plus/device_info_plus.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter_udid/flutter_udid.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/album/album.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/entities/store.entity.dart';
 | 
			
		||||
import 'package:immich_mobile/models/authentication/authentication_state.model.dart';
 | 
			
		||||
import 'package:immich_mobile/entities/user.entity.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/api.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/db.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/services/api.service.dart';
 | 
			
		||||
import 'package:immich_mobile/utils/db.dart';
 | 
			
		||||
import 'package:immich_mobile/utils/hash.dart';
 | 
			
		||||
import 'package:isar/isar.dart';
 | 
			
		||||
import 'package:logging/logging.dart';
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
 | 
			
		||||
class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
 | 
			
		||||
  AuthenticationNotifier(
 | 
			
		||||
    this._apiService,
 | 
			
		||||
    this._db,
 | 
			
		||||
    this._ref,
 | 
			
		||||
  ) : super(
 | 
			
		||||
          AuthenticationState(
 | 
			
		||||
            deviceId: "",
 | 
			
		||||
            userId: "",
 | 
			
		||||
            userEmail: "",
 | 
			
		||||
            name: '',
 | 
			
		||||
            profileImagePath: '',
 | 
			
		||||
            isAdmin: false,
 | 
			
		||||
            shouldChangePassword: false,
 | 
			
		||||
            isAuthenticated: false,
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
  final ApiService _apiService;
 | 
			
		||||
  final Isar _db;
 | 
			
		||||
  final StateNotifierProviderRef<AuthenticationNotifier, AuthenticationState>
 | 
			
		||||
      _ref;
 | 
			
		||||
  final _log = Logger("AuthenticationNotifier");
 | 
			
		||||
 | 
			
		||||
  static const Duration _timeoutDuration = Duration(seconds: 7);
 | 
			
		||||
 | 
			
		||||
  Future<bool> login(
 | 
			
		||||
    String email,
 | 
			
		||||
    String password,
 | 
			
		||||
    String serverUrl,
 | 
			
		||||
  ) async {
 | 
			
		||||
    try {
 | 
			
		||||
      // Resolve API server endpoint from user provided serverUrl
 | 
			
		||||
      await _apiService.resolveAndSetEndpoint(serverUrl);
 | 
			
		||||
      await _apiService.serverInfoApi.pingServer();
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      debugPrint('Invalid Server Endpoint Url $e');
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Make sign-in request
 | 
			
		||||
    DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
 | 
			
		||||
 | 
			
		||||
    if (Platform.isIOS) {
 | 
			
		||||
      var iosInfo = await deviceInfoPlugin.iosInfo;
 | 
			
		||||
      _apiService.authenticationApi.apiClient
 | 
			
		||||
          .addDefaultHeader('deviceModel', iosInfo.utsname.machine);
 | 
			
		||||
      _apiService.authenticationApi.apiClient
 | 
			
		||||
          .addDefaultHeader('deviceType', 'iOS');
 | 
			
		||||
    } else {
 | 
			
		||||
      var androidInfo = await deviceInfoPlugin.androidInfo;
 | 
			
		||||
      _apiService.authenticationApi.apiClient
 | 
			
		||||
          .addDefaultHeader('deviceModel', androidInfo.model);
 | 
			
		||||
      _apiService.authenticationApi.apiClient
 | 
			
		||||
          .addDefaultHeader('deviceType', 'Android');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      var loginResponse = await _apiService.authenticationApi.login(
 | 
			
		||||
        LoginCredentialDto(
 | 
			
		||||
          email: email,
 | 
			
		||||
          password: password,
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      if (loginResponse == null) {
 | 
			
		||||
        debugPrint('Login Response is null');
 | 
			
		||||
        return false;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return setSuccessLoginInfo(
 | 
			
		||||
        accessToken: loginResponse.accessToken,
 | 
			
		||||
        serverUrl: serverUrl,
 | 
			
		||||
      );
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      debugPrint("Error logging in $e");
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> logout() async {
 | 
			
		||||
    var log = Logger('AuthenticationNotifier');
 | 
			
		||||
    try {
 | 
			
		||||
      String? userEmail = Store.tryGet(StoreKey.currentUser)?.email;
 | 
			
		||||
 | 
			
		||||
      await _apiService.authenticationApi
 | 
			
		||||
          .logout()
 | 
			
		||||
          .timeout(_timeoutDuration)
 | 
			
		||||
          .then((_) => log.info("Logout was successful for $userEmail"))
 | 
			
		||||
          .onError(
 | 
			
		||||
            (error, stackTrace) =>
 | 
			
		||||
                log.severe("Logout failed for $userEmail", error, stackTrace),
 | 
			
		||||
          );
 | 
			
		||||
    } catch (e, stack) {
 | 
			
		||||
      log.severe('Logout failed', e, stack);
 | 
			
		||||
    } finally {
 | 
			
		||||
      await Future.wait([
 | 
			
		||||
        clearAssetsAndAlbums(_db),
 | 
			
		||||
        Store.delete(StoreKey.currentUser),
 | 
			
		||||
        Store.delete(StoreKey.accessToken),
 | 
			
		||||
      ]);
 | 
			
		||||
      _ref.invalidate(albumProvider);
 | 
			
		||||
 | 
			
		||||
      state = state.copyWith(
 | 
			
		||||
        deviceId: "",
 | 
			
		||||
        userId: "",
 | 
			
		||||
        userEmail: "",
 | 
			
		||||
        name: '',
 | 
			
		||||
        profileImagePath: '',
 | 
			
		||||
        isAdmin: false,
 | 
			
		||||
        shouldChangePassword: false,
 | 
			
		||||
        isAuthenticated: false,
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  updateUserProfileImagePath(String path) {
 | 
			
		||||
    state = state.copyWith(profileImagePath: path);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<bool> changePassword(String newPassword) async {
 | 
			
		||||
    try {
 | 
			
		||||
      await _apiService.usersApi.updateMyUser(
 | 
			
		||||
        UserUpdateMeDto(
 | 
			
		||||
          password: newPassword,
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      state = state.copyWith(shouldChangePassword: false);
 | 
			
		||||
 | 
			
		||||
      return true;
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      debugPrint("Error changing password $e");
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<bool> setSuccessLoginInfo({
 | 
			
		||||
    required String accessToken,
 | 
			
		||||
    required String serverUrl,
 | 
			
		||||
  }) async {
 | 
			
		||||
    _apiService.setAccessToken(accessToken);
 | 
			
		||||
 | 
			
		||||
    // Get the deviceid from the store if it exists, otherwise generate a new one
 | 
			
		||||
    String deviceId =
 | 
			
		||||
        Store.tryGet(StoreKey.deviceId) ?? await FlutterUdid.consistentUdid;
 | 
			
		||||
 | 
			
		||||
    bool shouldChangePassword = false;
 | 
			
		||||
    User? user = Store.tryGet(StoreKey.currentUser);
 | 
			
		||||
 | 
			
		||||
    UserAdminResponseDto? userResponse;
 | 
			
		||||
    UserPreferencesResponseDto? userPreferences;
 | 
			
		||||
    try {
 | 
			
		||||
      final responses = await Future.wait([
 | 
			
		||||
        _apiService.usersApi.getMyUser().timeout(_timeoutDuration),
 | 
			
		||||
        _apiService.usersApi.getMyPreferences().timeout(_timeoutDuration),
 | 
			
		||||
      ]);
 | 
			
		||||
      userResponse = responses[0] as UserAdminResponseDto;
 | 
			
		||||
      userPreferences = responses[1] as UserPreferencesResponseDto;
 | 
			
		||||
    } on ApiException catch (error, stackTrace) {
 | 
			
		||||
      if (error.code == 401) {
 | 
			
		||||
        _log.severe("Unauthorized access, token likely expired. Logging out.");
 | 
			
		||||
        return false;
 | 
			
		||||
      }
 | 
			
		||||
      _log.severe(
 | 
			
		||||
        "Error getting user information from the server [API EXCEPTION]",
 | 
			
		||||
        stackTrace,
 | 
			
		||||
      );
 | 
			
		||||
    } catch (error, stackTrace) {
 | 
			
		||||
      _log.severe(
 | 
			
		||||
        "Error getting user information from the server [CATCH ALL]",
 | 
			
		||||
        error,
 | 
			
		||||
        stackTrace,
 | 
			
		||||
      );
 | 
			
		||||
      debugPrint(
 | 
			
		||||
        "Error getting user information from the server [CATCH ALL] $error $stackTrace",
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // If the user information is successfully retrieved, update the store
 | 
			
		||||
    // Due to the flow of the code, this will always happen on first login
 | 
			
		||||
    if (userResponse != null) {
 | 
			
		||||
      Store.put(StoreKey.deviceId, deviceId);
 | 
			
		||||
      Store.put(StoreKey.deviceIdHash, fastHash(deviceId));
 | 
			
		||||
      Store.put(
 | 
			
		||||
        StoreKey.currentUser,
 | 
			
		||||
        User.fromUserDto(userResponse, userPreferences),
 | 
			
		||||
      );
 | 
			
		||||
      Store.put(StoreKey.serverUrl, serverUrl);
 | 
			
		||||
      Store.put(StoreKey.accessToken, accessToken);
 | 
			
		||||
 | 
			
		||||
      shouldChangePassword = userResponse.shouldChangePassword;
 | 
			
		||||
      user = User.fromUserDto(userResponse, userPreferences);
 | 
			
		||||
    } else {
 | 
			
		||||
      _log.severe("Unable to get user information from the server.");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // If the user is null, the login was not successful
 | 
			
		||||
    // and we don't have a local copy of the user from a prior successful login
 | 
			
		||||
    if (user == null) {
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    state = state.copyWith(
 | 
			
		||||
      isAuthenticated: true,
 | 
			
		||||
      userId: user.id,
 | 
			
		||||
      userEmail: user.email,
 | 
			
		||||
      name: user.name,
 | 
			
		||||
      profileImagePath: user.profileImagePath,
 | 
			
		||||
      isAdmin: user.isAdmin,
 | 
			
		||||
      shouldChangePassword: shouldChangePassword,
 | 
			
		||||
      deviceId: deviceId,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
final authenticationProvider =
 | 
			
		||||
    StateNotifierProvider<AuthenticationNotifier, AuthenticationState>((ref) {
 | 
			
		||||
  return AuthenticationNotifier(
 | 
			
		||||
    ref.watch(apiServiceProvider),
 | 
			
		||||
    ref.watch(dbProvider),
 | 
			
		||||
    ref,
 | 
			
		||||
  );
 | 
			
		||||
});
 | 
			
		||||
@ -22,8 +22,8 @@ import 'package:immich_mobile/repositories/backup.repository.dart';
 | 
			
		||||
import 'package:immich_mobile/repositories/file_media.repository.dart';
 | 
			
		||||
import 'package:immich_mobile/services/background.service.dart';
 | 
			
		||||
import 'package:immich_mobile/services/backup.service.dart';
 | 
			
		||||
import 'package:immich_mobile/models/authentication/authentication_state.model.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/authentication.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/models/auth/auth_state.model.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/auth.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/models/server_info/server_disk_info.model.dart';
 | 
			
		||||
import 'package:immich_mobile/entities/store.entity.dart';
 | 
			
		||||
@ -92,7 +92,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
 | 
			
		||||
  final log = Logger('BackupNotifier');
 | 
			
		||||
  final BackupService _backupService;
 | 
			
		||||
  final ServerInfoService _serverInfoService;
 | 
			
		||||
  final AuthenticationState _authState;
 | 
			
		||||
  final AuthState _authState;
 | 
			
		||||
  final BackgroundService _backgroundService;
 | 
			
		||||
  final GalleryPermissionNotifier _galleryPermissionNotifier;
 | 
			
		||||
  final Isar _db;
 | 
			
		||||
@ -765,7 +765,7 @@ final backupProvider =
 | 
			
		||||
  return BackupNotifier(
 | 
			
		||||
    ref.watch(backupServiceProvider),
 | 
			
		||||
    ref.watch(serverInfoServiceProvider),
 | 
			
		||||
    ref.watch(authenticationProvider),
 | 
			
		||||
    ref.watch(authProvider),
 | 
			
		||||
    ref.watch(backgroundServiceProvider),
 | 
			
		||||
    ref.watch(galleryPermissionNotifier.notifier),
 | 
			
		||||
    ref.watch(dbProvider),
 | 
			
		||||
 | 
			
		||||
@ -4,7 +4,7 @@ import 'package:collection/collection.dart';
 | 
			
		||||
import 'package:flutter/foundation.dart';
 | 
			
		||||
import 'package:flutter/widgets.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/authentication.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/auth.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/entities/asset.entity.dart';
 | 
			
		||||
import 'package:immich_mobile/models/server_info/server_version.model.dart';
 | 
			
		||||
import 'package:immich_mobile/entities/store.entity.dart';
 | 
			
		||||
@ -103,7 +103,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
 | 
			
		||||
  /// Connects websocket to server unless already connected
 | 
			
		||||
  void connect() {
 | 
			
		||||
    if (state.isConnected) return;
 | 
			
		||||
    final authenticationState = _ref.read(authenticationProvider);
 | 
			
		||||
    final authenticationState = _ref.read(authProvider);
 | 
			
		||||
 | 
			
		||||
    if (authenticationState.isAuthenticated) {
 | 
			
		||||
      try {
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										28
									
								
								mobile/lib/repositories/auth.repository.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								mobile/lib/repositories/auth.repository.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,28 @@
 | 
			
		||||
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/user.entity.dart';
 | 
			
		||||
import 'package:immich_mobile/interfaces/auth.interface.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/db.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/repositories/database.repository.dart';
 | 
			
		||||
 | 
			
		||||
final authRepositoryProvider = Provider<IAuthRepository>(
 | 
			
		||||
  (ref) => AuthRepository(ref.watch(dbProvider)),
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
class AuthRepository extends DatabaseRepository implements IAuthRepository {
 | 
			
		||||
  AuthRepository(super.db);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Future<void> clearLocalData() {
 | 
			
		||||
    return db.writeTxn(() async {
 | 
			
		||||
      await db.assets.clear();
 | 
			
		||||
      await db.exifInfos.clear();
 | 
			
		||||
      await db.albums.clear();
 | 
			
		||||
      await db.eTags.clear();
 | 
			
		||||
      await db.users.clear();
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										56
									
								
								mobile/lib/repositories/auth_api.repository.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								mobile/lib/repositories/auth_api.repository.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,56 @@
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/interfaces/auth_api.interface.dart';
 | 
			
		||||
import 'package:immich_mobile/models/auth/login_response.model.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/api.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/repositories/api.repository.dart';
 | 
			
		||||
import 'package:immich_mobile/services/api.service.dart';
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
 | 
			
		||||
final authApiRepositoryProvider =
 | 
			
		||||
    Provider((ref) => AuthApiRepository(ref.watch(apiServiceProvider)));
 | 
			
		||||
 | 
			
		||||
class AuthApiRepository extends ApiRepository implements IAuthApiRepository {
 | 
			
		||||
  final ApiService _apiService;
 | 
			
		||||
 | 
			
		||||
  AuthApiRepository(this._apiService);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Future<void> changePassword(String newPassword) async {
 | 
			
		||||
    await _apiService.usersApi.updateMyUser(
 | 
			
		||||
      UserUpdateMeDto(
 | 
			
		||||
        password: newPassword,
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Future<LoginResponse> login(String email, String password) async {
 | 
			
		||||
    final loginResponseDto = await checkNull(
 | 
			
		||||
      _apiService.authenticationApi.login(
 | 
			
		||||
        LoginCredentialDto(
 | 
			
		||||
          email: email,
 | 
			
		||||
          password: password,
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return _mapLoginReponse(loginResponseDto);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Future<void> logout() async {
 | 
			
		||||
    await _apiService.authenticationApi.logout().timeout(Duration(seconds: 7));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  _mapLoginReponse(LoginResponseDto dto) {
 | 
			
		||||
    return LoginResponse(
 | 
			
		||||
      accessToken: dto.accessToken,
 | 
			
		||||
      isAdmin: dto.isAdmin,
 | 
			
		||||
      name: dto.name,
 | 
			
		||||
      profileImagePath: dto.profileImagePath,
 | 
			
		||||
      shouldChangePassword: dto.shouldChangePassword,
 | 
			
		||||
      userEmail: dto.userEmail,
 | 
			
		||||
      userId: dto.userId,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -1,5 +1,4 @@
 | 
			
		||||
import 'dart:async';
 | 
			
		||||
 | 
			
		||||
import 'package:immich_mobile/interfaces/database.interface.dart';
 | 
			
		||||
import 'package:isar/isar.dart';
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -2,6 +2,7 @@ import 'dart:async';
 | 
			
		||||
import 'dart:convert';
 | 
			
		||||
import 'dart:io';
 | 
			
		||||
 | 
			
		||||
import 'package:device_info_plus/device_info_plus.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:immich_mobile/entities/store.entity.dart';
 | 
			
		||||
import 'package:immich_mobile/utils/url_helper.dart';
 | 
			
		||||
@ -69,7 +70,7 @@ class ApiService implements Authentication {
 | 
			
		||||
    final endpoint = await _resolveEndpoint(serverUrl);
 | 
			
		||||
    setEndpoint(endpoint);
 | 
			
		||||
 | 
			
		||||
    // Save in hivebox for next startup
 | 
			
		||||
    // Save in local database for next startup
 | 
			
		||||
    Store.put(StoreKey.serverEndpoint, endpoint);
 | 
			
		||||
    return endpoint;
 | 
			
		||||
  }
 | 
			
		||||
@ -148,11 +149,27 @@ class ApiService implements Authentication {
 | 
			
		||||
    return "";
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setAccessToken(String accessToken) {
 | 
			
		||||
  void setAccessToken(String accessToken) {
 | 
			
		||||
    _accessToken = accessToken;
 | 
			
		||||
    Store.put(StoreKey.accessToken, accessToken);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> setDeviceInfoHeader() async {
 | 
			
		||||
    DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
 | 
			
		||||
 | 
			
		||||
    if (Platform.isIOS) {
 | 
			
		||||
      final iosInfo = await deviceInfoPlugin.iosInfo;
 | 
			
		||||
      authenticationApi.apiClient
 | 
			
		||||
          .addDefaultHeader('deviceModel', iosInfo.utsname.machine);
 | 
			
		||||
      authenticationApi.apiClient.addDefaultHeader('deviceType', 'iOS');
 | 
			
		||||
    } else {
 | 
			
		||||
      final androidInfo = await deviceInfoPlugin.androidInfo;
 | 
			
		||||
      authenticationApi.apiClient
 | 
			
		||||
          .addDefaultHeader('deviceModel', androidInfo.model);
 | 
			
		||||
      authenticationApi.apiClient.addDefaultHeader('deviceType', 'Android');
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static Map<String, String> getRequestHeaders() {
 | 
			
		||||
    var accessToken = Store.get(StoreKey.accessToken, "");
 | 
			
		||||
    var customHeadersStr = Store.get(StoreKey.customHeaders, "");
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										96
									
								
								mobile/lib/services/auth.service.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								mobile/lib/services/auth.service.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,96 @@
 | 
			
		||||
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/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:logging/logging.dart';
 | 
			
		||||
 | 
			
		||||
final authServiceProvider = Provider(
 | 
			
		||||
  (ref) => AuthService(
 | 
			
		||||
    ref.watch(authApiRepositoryProvider),
 | 
			
		||||
    ref.watch(authRepositoryProvider),
 | 
			
		||||
    ref.watch(apiServiceProvider),
 | 
			
		||||
  ),
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
class AuthService {
 | 
			
		||||
  final IAuthApiRepository _authApiRepository;
 | 
			
		||||
  final IAuthRepository _authRepository;
 | 
			
		||||
  final ApiService _apiService;
 | 
			
		||||
 | 
			
		||||
  final _log = Logger("AuthService");
 | 
			
		||||
 | 
			
		||||
  AuthService(
 | 
			
		||||
    this._authApiRepository,
 | 
			
		||||
    this._authRepository,
 | 
			
		||||
    this._apiService,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  /// Validates the provided server URL by resolving and setting the endpoint.
 | 
			
		||||
  /// Also sets the device info header and stores the valid URL.
 | 
			
		||||
  ///
 | 
			
		||||
  /// [url] - The server URL to be validated.
 | 
			
		||||
  ///
 | 
			
		||||
  /// Returns the validated and resolved server URL as a [String].
 | 
			
		||||
  ///
 | 
			
		||||
  /// Throws an exception if the URL cannot be resolved or set.
 | 
			
		||||
  Future<String> validateServerUrl(String url) async {
 | 
			
		||||
    final validUrl = await _apiService.resolveAndSetEndpoint(url);
 | 
			
		||||
    await _apiService.setDeviceInfoHeader();
 | 
			
		||||
    Store.put(StoreKey.serverUrl, validUrl);
 | 
			
		||||
 | 
			
		||||
    return validUrl;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<LoginResponse> login(String email, String password) {
 | 
			
		||||
    return _authApiRepository.login(email, password);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Performs user logout operation by making a server request and clearing local data.
 | 
			
		||||
  ///
 | 
			
		||||
  /// This method attempts to log out the user through the authentication API repository.
 | 
			
		||||
  /// If the server request fails, the error is logged but local data is still cleared.
 | 
			
		||||
  /// The local data cleanup is guaranteed to execute regardless of the server request outcome.
 | 
			
		||||
  ///
 | 
			
		||||
  /// Throws any unhandled exceptions from the API request or local data clearing operations.
 | 
			
		||||
  Future<void> logout() async {
 | 
			
		||||
    try {
 | 
			
		||||
      await _authApiRepository.logout();
 | 
			
		||||
    } catch (error, stackTrace) {
 | 
			
		||||
      _log.severe("Error logging out", error, stackTrace);
 | 
			
		||||
    } finally {
 | 
			
		||||
      await clearLocalData();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Clears all local authentication-related data.
 | 
			
		||||
  ///
 | 
			
		||||
  /// This method performs a concurrent deletion of:
 | 
			
		||||
  /// - Authentication repository data
 | 
			
		||||
  /// - Current user information
 | 
			
		||||
  /// - Access token
 | 
			
		||||
  /// - Asset ETag
 | 
			
		||||
  ///
 | 
			
		||||
  /// All deletions are executed in parallel using [Future.wait].
 | 
			
		||||
  Future<void> clearLocalData() {
 | 
			
		||||
    return Future.wait([
 | 
			
		||||
      _authRepository.clearLocalData(),
 | 
			
		||||
      Store.delete(StoreKey.currentUser),
 | 
			
		||||
      Store.delete(StoreKey.accessToken),
 | 
			
		||||
      Store.delete(StoreKey.assetETag),
 | 
			
		||||
    ]);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> changePassword(String newPassword) {
 | 
			
		||||
    try {
 | 
			
		||||
      return _authApiRepository.changePassword(newPassword);
 | 
			
		||||
    } catch (error, stackTrace) {
 | 
			
		||||
      _log.severe("Error changing password", error, stackTrace);
 | 
			
		||||
      rethrow;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										24
									
								
								mobile/lib/services/device.service.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								mobile/lib/services/device.service.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,24 @@
 | 
			
		||||
import 'package:flutter_udid/flutter_udid.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/entities/store.entity.dart';
 | 
			
		||||
 | 
			
		||||
final deviceServiceProvider = Provider((ref) => DeviceService());
 | 
			
		||||
 | 
			
		||||
class DeviceService {
 | 
			
		||||
  DeviceService();
 | 
			
		||||
 | 
			
		||||
  createDeviceId() {
 | 
			
		||||
    return FlutterUdid.consistentUdid;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Returns the device ID from local storage or creates a new one if not found.
 | 
			
		||||
  ///
 | 
			
		||||
  /// This method first attempts to retrieve the device ID from the local store using
 | 
			
		||||
  /// [StoreKey.deviceId]. If no device ID is found (returns null), it generates a
 | 
			
		||||
  /// new device ID by calling [createDeviceId].
 | 
			
		||||
  ///
 | 
			
		||||
  /// Returns a [String] representing the device's unique identifier.
 | 
			
		||||
  String getDeviceId() {
 | 
			
		||||
    return Store.tryGet(StoreKey.deviceId) ?? createDeviceId();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -35,8 +35,9 @@ class UserService {
 | 
			
		||||
    this._syncService,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  Future<List<User>> getUsers({bool self = false}) =>
 | 
			
		||||
      _userRepository.getAll(self: self);
 | 
			
		||||
  Future<List<User>> getUsers({bool self = false}) {
 | 
			
		||||
    return _userRepository.getAll(self: self);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<({String profileImagePath})?> uploadProfileImage(XFile image) async {
 | 
			
		||||
    try {
 | 
			
		||||
 | 
			
		||||
@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
 | 
			
		||||
import 'package:immich_mobile/models/backup/backup_state.model.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/authentication.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/auth.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/routing/router.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/asset.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/user.provider.dart';
 | 
			
		||||
@ -128,7 +128,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
 | 
			
		||||
                onOk: () async {
 | 
			
		||||
                  isLoggingOut.value = true;
 | 
			
		||||
                  await ref
 | 
			
		||||
                      .read(authenticationProvider.notifier)
 | 
			
		||||
                      .read(authProvider.notifier)
 | 
			
		||||
                      .logout()
 | 
			
		||||
                      .whenComplete(() => isLoggingOut.value = false);
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -7,8 +7,7 @@ import 'package:immich_mobile/providers/upload_profile_image.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/entities/store.entity.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/user.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
 | 
			
		||||
import 'package:immich_mobile/models/authentication/authentication_state.model.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/authentication.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/auth.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
 | 
			
		||||
 | 
			
		||||
class AppBarProfileInfoBox extends HookConsumerWidget {
 | 
			
		||||
@ -18,7 +17,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget {
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    AuthenticationState authState = ref.watch(authenticationProvider);
 | 
			
		||||
    final authState = ref.watch(authProvider);
 | 
			
		||||
    final uploadProfileImageStatus =
 | 
			
		||||
        ref.watch(uploadProfileImageProvider).status;
 | 
			
		||||
    final user = Store.tryGet(StoreKey.currentUser);
 | 
			
		||||
@ -63,7 +62,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget {
 | 
			
		||||
        if (success) {
 | 
			
		||||
          final profileImagePath =
 | 
			
		||||
              ref.read(uploadProfileImageProvider).profileImagePath;
 | 
			
		||||
          ref.watch(authenticationProvider.notifier).updateUserProfileImagePath(
 | 
			
		||||
          ref.watch(authProvider.notifier).updateUserProfileImagePath(
 | 
			
		||||
                profileImagePath,
 | 
			
		||||
              );
 | 
			
		||||
          if (user != null) {
 | 
			
		||||
 | 
			
		||||
@ -7,7 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/authentication.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/auth.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/asset.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/websocket.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
 | 
			
		||||
@ -21,7 +21,7 @@ class ChangePasswordForm extends HookConsumerWidget {
 | 
			
		||||
        useTextEditingController.fromValue(TextEditingValue.empty);
 | 
			
		||||
    final confirmPasswordController =
 | 
			
		||||
        useTextEditingController.fromValue(TextEditingValue.empty);
 | 
			
		||||
    final authState = ref.watch(authenticationProvider);
 | 
			
		||||
    final authState = ref.watch(authProvider);
 | 
			
		||||
    final formKey = GlobalKey<FormState>();
 | 
			
		||||
 | 
			
		||||
    return Center(
 | 
			
		||||
@ -73,13 +73,11 @@ class ChangePasswordForm extends HookConsumerWidget {
 | 
			
		||||
                      onPressed: () async {
 | 
			
		||||
                        if (formKey.currentState!.validate()) {
 | 
			
		||||
                          var isSuccess = await ref
 | 
			
		||||
                              .read(authenticationProvider.notifier)
 | 
			
		||||
                              .read(authProvider.notifier)
 | 
			
		||||
                              .changePassword(passwordController.value.text);
 | 
			
		||||
 | 
			
		||||
                          if (isSuccess) {
 | 
			
		||||
                            await ref
 | 
			
		||||
                                .read(authenticationProvider.notifier)
 | 
			
		||||
                                .logout();
 | 
			
		||||
                            await ref.read(authProvider.notifier).logout();
 | 
			
		||||
 | 
			
		||||
                            ref
 | 
			
		||||
                                .read(manualUploadProvider.notifier)
 | 
			
		||||
 | 
			
		||||
@ -11,9 +11,7 @@ import 'package:immich_mobile/providers/oauth.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/routing/router.dart';
 | 
			
		||||
import 'package:immich_mobile/entities/store.entity.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/api.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/asset.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/authentication.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/auth.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/server_info.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/utils/provider_utils.dart';
 | 
			
		||||
@ -40,13 +38,12 @@ class LoginForm extends HookConsumerWidget {
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    final usernameController =
 | 
			
		||||
    final emailController =
 | 
			
		||||
        useTextEditingController.fromValue(TextEditingValue.empty);
 | 
			
		||||
    final passwordController =
 | 
			
		||||
        useTextEditingController.fromValue(TextEditingValue.empty);
 | 
			
		||||
    final serverEndpointController =
 | 
			
		||||
        useTextEditingController.fromValue(TextEditingValue.empty);
 | 
			
		||||
    final apiService = ref.watch(apiServiceProvider);
 | 
			
		||||
    final emailFocusNode = useFocusNode();
 | 
			
		||||
    final passwordFocusNode = useFocusNode();
 | 
			
		||||
    final serverEndpointFocusNode = useFocusNode();
 | 
			
		||||
@ -85,7 +82,7 @@ class LoginForm extends HookConsumerWidget {
 | 
			
		||||
 | 
			
		||||
    /// Fetch the server login credential and enables oAuth login if necessary
 | 
			
		||||
    /// Returns true if successful, false otherwise
 | 
			
		||||
    Future<bool> getServerLoginCredential() async {
 | 
			
		||||
    Future<void> getServerAuthSettings() async {
 | 
			
		||||
      final serverUrl = sanitizeUrl(serverEndpointController.text);
 | 
			
		||||
 | 
			
		||||
      // Guard empty URL
 | 
			
		||||
@ -95,13 +92,12 @@ class LoginForm extends HookConsumerWidget {
 | 
			
		||||
          msg: "login_form_server_empty".tr(),
 | 
			
		||||
          toastType: ToastType.error,
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        return false;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      try {
 | 
			
		||||
        isLoadingServer.value = true;
 | 
			
		||||
        final endpoint = await apiService.resolveAndSetEndpoint(serverUrl);
 | 
			
		||||
        final endpoint =
 | 
			
		||||
            await ref.read(authProvider.notifier).validateServerUrl(serverUrl);
 | 
			
		||||
 | 
			
		||||
        // Fetch and load server config and features
 | 
			
		||||
        await ref.read(serverInfoProvider.notifier).getServerInfo();
 | 
			
		||||
@ -127,7 +123,6 @@ class LoginForm extends HookConsumerWidget {
 | 
			
		||||
        isOauthEnable.value = false;
 | 
			
		||||
        isPasswordLoginEnable.value = true;
 | 
			
		||||
        isLoadingServer.value = false;
 | 
			
		||||
        return false;
 | 
			
		||||
      } on HandshakeException {
 | 
			
		||||
        ImmichToast.show(
 | 
			
		||||
          context: context,
 | 
			
		||||
@ -138,7 +133,6 @@ class LoginForm extends HookConsumerWidget {
 | 
			
		||||
        isOauthEnable.value = false;
 | 
			
		||||
        isPasswordLoginEnable.value = true;
 | 
			
		||||
        isLoadingServer.value = false;
 | 
			
		||||
        return false;
 | 
			
		||||
      } catch (e) {
 | 
			
		||||
        ImmichToast.show(
 | 
			
		||||
          context: context,
 | 
			
		||||
@ -149,11 +143,9 @@ class LoginForm extends HookConsumerWidget {
 | 
			
		||||
        isOauthEnable.value = false;
 | 
			
		||||
        isPasswordLoginEnable.value = true;
 | 
			
		||||
        isLoadingServer.value = false;
 | 
			
		||||
        return false;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      isLoadingServer.value = false;
 | 
			
		||||
      return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    useEffect(
 | 
			
		||||
@ -168,67 +160,50 @@ class LoginForm extends HookConsumerWidget {
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    populateTestLoginInfo() {
 | 
			
		||||
      usernameController.text = 'demo@immich.app';
 | 
			
		||||
      emailController.text = 'demo@immich.app';
 | 
			
		||||
      passwordController.text = 'demo';
 | 
			
		||||
      serverEndpointController.text = 'https://demo.immich.app';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    populateTestLoginInfo1() {
 | 
			
		||||
      usernameController.text = 'testuser@email.com';
 | 
			
		||||
      emailController.text = 'testuser@email.com';
 | 
			
		||||
      passwordController.text = 'password';
 | 
			
		||||
      serverEndpointController.text = 'http://10.1.15.216:2283/api';
 | 
			
		||||
      serverEndpointController.text = 'http://10.1.15.216:3000/api';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    login() async {
 | 
			
		||||
      TextInput.finishAutofillContext();
 | 
			
		||||
      // Start loading
 | 
			
		||||
      isLoading.value = true;
 | 
			
		||||
 | 
			
		||||
      // This will remove current cache asset state of previous user login.
 | 
			
		||||
      ref.read(assetProvider.notifier).clearAllAsset();
 | 
			
		||||
      isLoading.value = true;
 | 
			
		||||
 | 
			
		||||
      // Invalidate all api repository provider instance to take into account new access token
 | 
			
		||||
      invalidateAllApiRepositoryProviders(ref);
 | 
			
		||||
 | 
			
		||||
      try {
 | 
			
		||||
        final isAuthenticated =
 | 
			
		||||
            await ref.read(authenticationProvider.notifier).login(
 | 
			
		||||
                  usernameController.text,
 | 
			
		||||
        final result = await ref.read(authProvider.notifier).login(
 | 
			
		||||
              emailController.text,
 | 
			
		||||
              passwordController.text,
 | 
			
		||||
                  sanitizeUrl(serverEndpointController.text),
 | 
			
		||||
            );
 | 
			
		||||
        if (isAuthenticated) {
 | 
			
		||||
          // Resume backup (if enable) then navigate
 | 
			
		||||
          if (ref.read(authenticationProvider).shouldChangePassword &&
 | 
			
		||||
              !ref.read(authenticationProvider).isAdmin) {
 | 
			
		||||
 | 
			
		||||
        if (result.shouldChangePassword && !result.isAdmin) {
 | 
			
		||||
          context.pushRoute(const ChangePasswordRoute());
 | 
			
		||||
        } else {
 | 
			
		||||
            final hasPermission = await ref
 | 
			
		||||
                .read(galleryPermissionNotifier.notifier)
 | 
			
		||||
                .hasPermission;
 | 
			
		||||
            if (hasPermission) {
 | 
			
		||||
              // Don't resume the backup until we have gallery permission
 | 
			
		||||
              ref.read(backupProvider.notifier).resumeBackup();
 | 
			
		||||
            }
 | 
			
		||||
          context.replaceRoute(const TabControllerRoute());
 | 
			
		||||
        }
 | 
			
		||||
        } else {
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        ImmichToast.show(
 | 
			
		||||
          context: context,
 | 
			
		||||
          msg: "login_form_failed_login".tr(),
 | 
			
		||||
          toastType: ToastType.error,
 | 
			
		||||
          gravity: ToastGravity.TOP,
 | 
			
		||||
        );
 | 
			
		||||
        }
 | 
			
		||||
      } finally {
 | 
			
		||||
        // Make sure we stop loading
 | 
			
		||||
        isLoading.value = false;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    oAuthLogin() async {
 | 
			
		||||
      var oAuthService = ref.watch(oAuthServiceProvider);
 | 
			
		||||
      ref.watch(assetProvider.notifier).clearAllAsset();
 | 
			
		||||
      String? oAuthServerUrl;
 | 
			
		||||
 | 
			
		||||
      try {
 | 
			
		||||
@ -262,11 +237,8 @@ class LoginForm extends HookConsumerWidget {
 | 
			
		||||
            "Finished OAuth login with response: ${loginResponseDto.userEmail}",
 | 
			
		||||
          );
 | 
			
		||||
 | 
			
		||||
          final isSuccess = await ref
 | 
			
		||||
              .watch(authenticationProvider.notifier)
 | 
			
		||||
              .setSuccessLoginInfo(
 | 
			
		||||
          final isSuccess = await ref.watch(authProvider.notifier).saveAuthInfo(
 | 
			
		||||
                accessToken: loginResponseDto.accessToken,
 | 
			
		||||
                serverUrl: sanitizeUrl(serverEndpointController.text),
 | 
			
		||||
              );
 | 
			
		||||
 | 
			
		||||
          if (isSuccess) {
 | 
			
		||||
@ -309,7 +281,7 @@ class LoginForm extends HookConsumerWidget {
 | 
			
		||||
          ServerEndpointInput(
 | 
			
		||||
            controller: serverEndpointController,
 | 
			
		||||
            focusNode: serverEndpointFocusNode,
 | 
			
		||||
            onSubmit: getServerLoginCredential,
 | 
			
		||||
            onSubmit: getServerAuthSettings,
 | 
			
		||||
          ),
 | 
			
		||||
          const SizedBox(height: 18),
 | 
			
		||||
          Row(
 | 
			
		||||
@ -344,7 +316,7 @@ class LoginForm extends HookConsumerWidget {
 | 
			
		||||
                    ),
 | 
			
		||||
                  ),
 | 
			
		||||
                  onPressed:
 | 
			
		||||
                      isLoadingServer.value ? null : getServerLoginCredential,
 | 
			
		||||
                      isLoadingServer.value ? null : getServerAuthSettings,
 | 
			
		||||
                  icon: const Icon(Icons.arrow_forward_rounded),
 | 
			
		||||
                  label: const Text(
 | 
			
		||||
                    'login_form_next_button',
 | 
			
		||||
@ -402,7 +374,7 @@ class LoginForm extends HookConsumerWidget {
 | 
			
		||||
            if (isPasswordLoginEnable.value) ...[
 | 
			
		||||
              const SizedBox(height: 18),
 | 
			
		||||
              EmailInput(
 | 
			
		||||
                controller: usernameController,
 | 
			
		||||
                controller: emailController,
 | 
			
		||||
                focusNode: emailFocusNode,
 | 
			
		||||
                onSubmit: passwordFocusNode.requestFocus,
 | 
			
		||||
              ),
 | 
			
		||||
 | 
			
		||||
@ -3,6 +3,8 @@ import 'package:immich_mobile/interfaces/album_api.interface.dart';
 | 
			
		||||
import 'package:immich_mobile/interfaces/album_media.interface.dart';
 | 
			
		||||
import 'package:immich_mobile/interfaces/asset.interface.dart';
 | 
			
		||||
import 'package:immich_mobile/interfaces/asset_media.interface.dart';
 | 
			
		||||
import 'package:immich_mobile/interfaces/auth.interface.dart';
 | 
			
		||||
import 'package:immich_mobile/interfaces/auth_api.interface.dart';
 | 
			
		||||
import 'package:immich_mobile/interfaces/backup.interface.dart';
 | 
			
		||||
import 'package:immich_mobile/interfaces/etag.interface.dart';
 | 
			
		||||
import 'package:immich_mobile/interfaces/exif_info.interface.dart';
 | 
			
		||||
@ -29,3 +31,7 @@ class MockAssetMediaRepository extends Mock implements IAssetMediaRepository {}
 | 
			
		||||
class MockFileMediaRepository extends Mock implements IFileMediaRepository {}
 | 
			
		||||
 | 
			
		||||
class MockAlbumApiRepository extends Mock implements IAlbumApiRepository {}
 | 
			
		||||
 | 
			
		||||
class MockAuthApiRepository extends Mock implements IAuthApiRepository {}
 | 
			
		||||
 | 
			
		||||
class MockAuthRepository extends Mock implements IAuthRepository {}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										118
									
								
								mobile/test/services/auth.service_test.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										118
									
								
								mobile/test/services/auth.service_test.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,118 @@
 | 
			
		||||
import 'package:flutter/widgets.dart';
 | 
			
		||||
import 'package:flutter_test/flutter_test.dart';
 | 
			
		||||
import 'package:immich_mobile/entities/store.entity.dart';
 | 
			
		||||
import 'package:immich_mobile/services/auth.service.dart';
 | 
			
		||||
import 'package:mocktail/mocktail.dart';
 | 
			
		||||
import '../repository.mocks.dart';
 | 
			
		||||
import '../service.mocks.dart';
 | 
			
		||||
import '../test_utils.dart';
 | 
			
		||||
 | 
			
		||||
void main() {
 | 
			
		||||
  late AuthService sut;
 | 
			
		||||
  late MockAuthApiRepository authApiRepository;
 | 
			
		||||
  late MockAuthRepository authRepository;
 | 
			
		||||
  late MockApiService apiService;
 | 
			
		||||
 | 
			
		||||
  setUp(() async {
 | 
			
		||||
    authApiRepository = MockAuthApiRepository();
 | 
			
		||||
    authRepository = MockAuthRepository();
 | 
			
		||||
    apiService = MockApiService();
 | 
			
		||||
    sut = AuthService(authApiRepository, authRepository, apiService);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  group('validateServerUrl', () {
 | 
			
		||||
    setUpAll(() async {
 | 
			
		||||
      WidgetsFlutterBinding.ensureInitialized();
 | 
			
		||||
      final db = await TestUtils.initIsar();
 | 
			
		||||
      db.writeTxnSync(() => db.clearSync());
 | 
			
		||||
      Store.init(db);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    test('Should resolve HTTP endpoint', () async {
 | 
			
		||||
      const testUrl = 'http://ip:2283';
 | 
			
		||||
      const resolvedUrl = 'http://ip:2283/api';
 | 
			
		||||
 | 
			
		||||
      when(() => apiService.resolveAndSetEndpoint(testUrl))
 | 
			
		||||
          .thenAnswer((_) async => resolvedUrl);
 | 
			
		||||
      when(() => apiService.setDeviceInfoHeader()).thenAnswer((_) async => {});
 | 
			
		||||
 | 
			
		||||
      final result = await sut.validateServerUrl(testUrl);
 | 
			
		||||
 | 
			
		||||
      expect(result, resolvedUrl);
 | 
			
		||||
 | 
			
		||||
      verify(() => apiService.resolveAndSetEndpoint(testUrl)).called(1);
 | 
			
		||||
      verify(() => apiService.setDeviceInfoHeader()).called(1);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    test('Should resolve HTTPS endpoint', () async {
 | 
			
		||||
      const testUrl = 'https://immich.domain.com';
 | 
			
		||||
      const resolvedUrl = 'https://immich.domain.com/api';
 | 
			
		||||
 | 
			
		||||
      when(() => apiService.resolveAndSetEndpoint(testUrl))
 | 
			
		||||
          .thenAnswer((_) async => resolvedUrl);
 | 
			
		||||
      when(() => apiService.setDeviceInfoHeader()).thenAnswer((_) async => {});
 | 
			
		||||
 | 
			
		||||
      final result = await sut.validateServerUrl(testUrl);
 | 
			
		||||
 | 
			
		||||
      expect(result, resolvedUrl);
 | 
			
		||||
 | 
			
		||||
      verify(() => apiService.resolveAndSetEndpoint(testUrl)).called(1);
 | 
			
		||||
      verify(() => apiService.setDeviceInfoHeader()).called(1);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    test('Should throw error on invalid URL', () async {
 | 
			
		||||
      const testUrl = 'invalid-url';
 | 
			
		||||
 | 
			
		||||
      when(() => apiService.resolveAndSetEndpoint(testUrl))
 | 
			
		||||
          .thenThrow(Exception('Invalid URL'));
 | 
			
		||||
 | 
			
		||||
      expect(
 | 
			
		||||
        () async => await sut.validateServerUrl(testUrl),
 | 
			
		||||
        throwsA(isA<Exception>()),
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      verify(() => apiService.resolveAndSetEndpoint(testUrl)).called(1);
 | 
			
		||||
      verifyNever(() => apiService.setDeviceInfoHeader());
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    test('Should throw error on unreachable server', () async {
 | 
			
		||||
      const testUrl = 'https://unreachable.server';
 | 
			
		||||
 | 
			
		||||
      when(() => apiService.resolveAndSetEndpoint(testUrl))
 | 
			
		||||
          .thenThrow(Exception('Server is not reachable'));
 | 
			
		||||
 | 
			
		||||
      expect(
 | 
			
		||||
        () async => await sut.validateServerUrl(testUrl),
 | 
			
		||||
        throwsA(isA<Exception>()),
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      verify(() => apiService.resolveAndSetEndpoint(testUrl)).called(1);
 | 
			
		||||
      verifyNever(() => apiService.setDeviceInfoHeader());
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  group('logout', () {
 | 
			
		||||
    test('Should logout user', () async {
 | 
			
		||||
      when(() => authApiRepository.logout()).thenAnswer((_) async => {});
 | 
			
		||||
      when(() => authRepository.clearLocalData())
 | 
			
		||||
          .thenAnswer((_) => Future.value(null));
 | 
			
		||||
 | 
			
		||||
      await sut.logout();
 | 
			
		||||
 | 
			
		||||
      verify(() => authApiRepository.logout()).called(1);
 | 
			
		||||
      verify(() => authRepository.clearLocalData()).called(1);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    test('Should clear local data even on server error', () async {
 | 
			
		||||
      when(() => authApiRepository.logout())
 | 
			
		||||
          .thenThrow(Exception('Server error'));
 | 
			
		||||
      when(() => authRepository.clearLocalData())
 | 
			
		||||
          .thenAnswer((_) => Future.value(null));
 | 
			
		||||
 | 
			
		||||
      await sut.logout();
 | 
			
		||||
 | 
			
		||||
      verify(() => authApiRepository.logout()).called(1);
 | 
			
		||||
      verify(() => authRepository.clearLocalData()).called(1);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user