diff --git a/mobile/lib/modules/login/providers/authentication.provider.dart b/mobile/lib/modules/login/providers/authentication.provider.dart index 96dded5cf0..f2657e8f94 100644 --- a/mobile/lib/modules/login/providers/authentication.provider.dart +++ b/mobile/lib/modules/login/providers/authentication.provider.dart @@ -37,6 +37,7 @@ class AuthenticationNotifier extends StateNotifier { final ApiService _apiService; final Isar _db; + final _log = Logger("AuthenticationNotifier"); Future login( String email, @@ -145,38 +146,66 @@ class AuthenticationNotifier extends StateNotifier { Future setSuccessLoginInfo({ required String accessToken, required String serverUrl, + bool offlineLogin = false, }) async { _apiService.setAccessToken(accessToken); - UserResponseDto? userResponseDto; - try { - userResponseDto = await _apiService.userApi.getMyUserInfo(); - } on ApiException catch (e) { - if (e.innerException is SocketException) { - state = state.copyWith(isAuthenticated: true); + + // 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; + + bool retResult = false; + User? offlineUser = Store.tryGet(StoreKey.currentUser); + + // If the user is offline and there is a user saved on the device, + // if not try an online login + if (offlineLogin && offlineUser != null) { + user = offlineUser; + retResult = false; + } else { + UserResponseDto? userResponseDto; + try { + userResponseDto = await _apiService.userApi.getMyUserInfo(); + } on ApiException catch (e) { + if (e.innerException is SocketException) { + state = state.copyWith(isAuthenticated: true); + } + } + + if (userResponseDto != null) { + Store.put(StoreKey.deviceId, deviceId); + Store.put(StoreKey.deviceIdHash, fastHash(deviceId)); + Store.put(StoreKey.currentUser, User.fromDto(userResponseDto)); + Store.put(StoreKey.serverUrl, serverUrl); + Store.put(StoreKey.accessToken, accessToken); + + shouldChangePassword = userResponseDto.shouldChangePassword; + user = User.fromDto(userResponseDto); + + retResult = true; + } + else { + _log.severe("Unable to get user information from the server."); + return false; } } - if (userResponseDto != null) { - final deviceId = await FlutterUdid.consistentUdid; - Store.put(StoreKey.deviceId, deviceId); - Store.put(StoreKey.deviceIdHash, fastHash(deviceId)); - Store.put(StoreKey.currentUser, User.fromDto(userResponseDto)); - Store.put(StoreKey.serverUrl, serverUrl); - Store.put(StoreKey.accessToken, accessToken); + state = state.copyWith( + isAuthenticated: true, + userId: user.id, + userEmail: user.email, + firstName: user.firstName, + lastName: user.lastName, + profileImagePath: user.profileImagePath, + isAdmin: user.isAdmin, + shouldChangePassword: shouldChangePassword, + deviceId: deviceId, + ); - state = state.copyWith( - isAuthenticated: true, - userId: userResponseDto.id, - userEmail: userResponseDto.email, - firstName: userResponseDto.firstName, - lastName: userResponseDto.lastName, - profileImagePath: userResponseDto.profileImagePath, - isAdmin: userResponseDto.isAdmin, - shouldChangePassword: userResponseDto.shouldChangePassword, - deviceId: deviceId, - ); - } - return true; + return retResult; } } diff --git a/mobile/lib/routing/auth_guard.dart b/mobile/lib/routing/auth_guard.dart index 3fd7be7c62..8022f18e34 100644 --- a/mobile/lib/routing/auth_guard.dart +++ b/mobile/lib/routing/auth_guard.dart @@ -4,29 +4,40 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/foundation.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/services/api.service.dart'; +import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; class AuthGuard extends AutoRouteGuard { final ApiService _apiService; + final _log = Logger("AuthGuard"); AuthGuard(this._apiService); @override void onNavigation(NavigationResolver resolver, StackRouter router) async { + + resolver.next(true); + try { var res = await _apiService.authenticationApi.validateAccessToken(); - if (res != null && res.authStatus) { - resolver.next(true); - } else { + if (res == null || res.authStatus != true) { + // If the access token is invalid, take user back to login + _log.fine("User token is invalid. Redirecting to login"); router.replaceAll([const LoginRoute()]); } } on ApiException catch (e) { if (e.code == HttpStatus.badRequest && e.innerException is SocketException) { // offline? - resolver.next(true); + _log.fine( + "Unable to validate user token. User may be offline and offline browsing is allowed.", + ); } else { debugPrint("Error [onNavigation] ${e.toString()}"); router.replaceAll([const LoginRoute()]); + return; } + } catch (e) { + debugPrint("Error [onNavigation] ${e.toString()}"); + router.replaceAll([const LoginRoute()]); return; } } diff --git a/mobile/lib/shared/models/user.dart b/mobile/lib/shared/models/user.dart index 267503c304..362adebc0b 100644 --- a/mobile/lib/shared/models/user.dart +++ b/mobile/lib/shared/models/user.dart @@ -16,6 +16,7 @@ class User { required this.isAdmin, this.isPartnerSharedBy = false, this.isPartnerSharedWith = false, + this.profileImagePath = '', }); Id get isarId => fastHash(id); @@ -28,6 +29,7 @@ class User { lastName = dto.lastName, isPartnerSharedBy = false, isPartnerSharedWith = false, + profileImagePath = dto.profileImagePath, isAdmin = dto.isAdmin; @Index(unique: true, replace: false, type: IndexType.hash) @@ -39,6 +41,7 @@ class User { bool isPartnerSharedBy; bool isPartnerSharedWith; bool isAdmin; + String profileImagePath; @Backlink(to: 'owner') final IsarLinks albums = IsarLinks(); @Backlink(to: 'sharedUsers') @@ -54,6 +57,7 @@ class User { lastName == other.lastName && isPartnerSharedBy == other.isPartnerSharedBy && isPartnerSharedWith == other.isPartnerSharedWith && + profileImagePath == other.profileImagePath && isAdmin == other.isAdmin; } @@ -67,5 +71,6 @@ class User { lastName.hashCode ^ isPartnerSharedBy.hashCode ^ isPartnerSharedWith.hashCode ^ + profileImagePath.hashCode ^ isAdmin.hashCode; } diff --git a/mobile/lib/shared/services/api.service.dart b/mobile/lib/shared/services/api.service.dart index a3984b1200..3f6c309827 100644 --- a/mobile/lib/shared/services/api.service.dart +++ b/mobile/lib/shared/services/api.service.dart @@ -1,4 +1,6 @@ +import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:immich_mobile/shared/models/store.dart'; @@ -62,6 +64,10 @@ class ApiService { Future _resolveEndpoint(String serverUrl) async { final url = sanitizeUrl(serverUrl); + if (!await _isEndpointAvailable(serverUrl)) { + throw ApiException(503, "Server is not reachable"); + } + // Check for /.well-known/immich final wellKnownEndpoint = await _getWellKnownEndpoint(url); if (wellKnownEndpoint.isNotEmpty) return wellKnownEndpoint; @@ -70,6 +76,29 @@ class ApiService { return url; } + Future _isEndpointAvailable(String serverUrl) async { + final Client client = Client(); + + if (!serverUrl.endsWith('/api')) { + serverUrl += '/api'; + } + + // Throw Socket or Timeout exceptions, + // we do not care if the endpoints hits an HTTP error + try { + await client + .get( + Uri.parse(serverUrl), + ) + .timeout(const Duration(seconds: 5)); + } on TimeoutException catch (_) { + return false; + } on SocketException catch (_) { + return false; + } + return true; + } + Future _getWellKnownEndpoint(String baseUrl) async { final Client client = Client(); diff --git a/mobile/lib/shared/views/splash_screen.dart b/mobile/lib/shared/views/splash_screen.dart index b03fd781c8..bd419bc020 100644 --- a/mobile/lib/shared/views/splash_screen.dart +++ b/mobile/lib/shared/views/splash_screen.dart @@ -8,6 +8,8 @@ import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.pr import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart'; +import 'package:logging/logging.dart'; +import 'package:openapi/api.dart'; class SplashScreenPage extends HookConsumerWidget { const SplashScreenPage({Key? key}) : super(key: key); @@ -17,24 +19,41 @@ class SplashScreenPage extends HookConsumerWidget { final apiService = ref.watch(apiServiceProvider); final serverUrl = Store.tryGet(StoreKey.serverUrl); final accessToken = Store.tryGet(StoreKey.accessToken); + final log = Logger("SplashScreenPage"); void performLoggingIn() async { bool isSuccess = false; + bool deviceIsOffline = false; if (accessToken != null && serverUrl != null) { try { // Resolve API server endpoint from user provided serverUrl await apiService.resolveAndSetEndpoint(serverUrl); - } catch (e) { + } on ApiException catch (e) { // okay, try to continue anyway if offline + if (e.code == 503) { + deviceIsOffline = true; + log.fine("Device seems to be offline upon launch"); + } else { + log.severe(e); + } + } catch (e) { + log.severe(e); } isSuccess = await ref.read(authenticationProvider.notifier).setSuccessLoginInfo( accessToken: accessToken, serverUrl: serverUrl, + offlineLogin: deviceIsOffline, ); } - if (isSuccess) { + + // If the device is offline and there is a currentUser stored locallly + // Proceed into the app + if (deviceIsOffline && Store.tryGet(StoreKey.currentUser) != null) { + AutoRouter.of(context).replace(const TabControllerRoute()); + } else if (isSuccess) { + // If device was able to login through the internet successfully final hasPermission = await ref.read(galleryPermissionNotifier.notifier).hasPermission; if (hasPermission) { @@ -43,6 +62,7 @@ class SplashScreenPage extends HookConsumerWidget { } AutoRouter.of(context).replace(const TabControllerRoute()); } else { + // User was unable to login through either offline or online methods AutoRouter.of(context).replace(const LoginRoute()); } }