Forráskód Böngészése

feat(mobile) - Add better offline support (#3279)

* WIP: Adding init support for offline-loading

* WIP: found bug and fixed with offline browing adv setting

* WIP: big some bugs with first login

* WIP: static analysis fixes

* PR: Removed setting for offline browing

* PR: static analysis - remove imports

* PR: Refactored user login state

* PR: changed logger log level as it happens a lot

* PR: change log var to _log

* PR: addressing comments

* WIP: bug fixes

* WIP: static analysis on the logger variable
Dhrumil Shah 2 éve
szülő
commit
fe9ef1a3ea

+ 56 - 27
mobile/lib/modules/login/providers/authentication.provider.dart

@@ -37,6 +37,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
 
   final ApiService _apiService;
   final Isar _db;
+  final _log = Logger("AuthenticationNotifier");
 
   Future<bool> login(
     String email,
@@ -145,38 +146,66 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
   Future<bool> 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) {
-      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: userResponseDto.id,
-        userEmail: userResponseDto.email,
-        firstName: userResponseDto.firstName,
-        lastName: userResponseDto.lastName,
-        profileImagePath: userResponseDto.profileImagePath,
-        isAdmin: userResponseDto.isAdmin,
-        shouldChangePassword: userResponseDto.shouldChangePassword,
-        deviceId: deviceId,
-      );
+      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;
+      }
     }
-    return true;
+
+    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,
+    );
+
+    return retResult;
   }
 }
 

+ 15 - 4
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;
     }
   }

+ 5 - 0
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<Album> albums = IsarLinks<Album>();
   @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;
 }

+ 29 - 0
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<String> _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<bool> _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<String> _getWellKnownEndpoint(String baseUrl) async {
     final Client client = Client();
 

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