Browse Source

feature(mobile): allow app to be used offline (#1932)

* feature(mobile): allow app to be used offline

* translatable server/network error message

* adjust profile drawer error message

* call getAllAsset after cold app starts

* fix analyzer error

* update asset state if length differs

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
Fynn Petersen-Frey 2 years ago
parent
commit
04955a4123

+ 1 - 0
mobile/assets/i18n/en-US.json

@@ -101,6 +101,7 @@
   "common_change_password": "Change Password",
   "common_create_new_album": "Create new album",
   "common_shared": "Shared",
+  "common_server_error": "Please check your network connection, make sure the server is reachable and app/server versions are compatible.",
   "control_bottom_app_bar_add_to_album": "Add to album",
   "control_bottom_app_bar_album_info": "{} items",
   "control_bottom_app_bar_album_info_shared": "{} items · Shared",

+ 2 - 0
mobile/lib/modules/album/ui/album_thumbnail_listtile.dart

@@ -52,6 +52,8 @@ class AlbumThumbnailListTile extends StatelessWidget {
         ),
         httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
         cacheKey: getAlbumThumbNailCacheKey(album, type: ThumbnailFormat.JPEG),
+        errorWidget: (context, url, error) =>
+            const Icon(Icons.image_not_supported_outlined),
       );
     }
 

+ 49 - 21
mobile/lib/modules/asset_viewer/views/gallery_viewer.dart

@@ -18,6 +18,7 @@ import 'package:immich_mobile/shared/services/asset.service.dart';
 import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
 import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
 import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
+import 'package:immich_mobile/shared/ui/immich_image.dart';
 import 'package:immich_mobile/shared/ui/photo_view/photo_view_gallery.dart';
 import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart';
 import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart';
@@ -38,8 +39,7 @@ class GalleryViewerPage extends HookConsumerWidget {
     super.key,
     required this.assetList,
     required this.asset,
-  }) : controller =
-      PageController(initialPage: assetList.indexOf(asset));
+  }) : controller = PageController(initialPage: assetList.indexOf(asset));
 
   Asset? assetDetail;
 
@@ -139,12 +139,16 @@ class GalleryViewerPage extends HookConsumerWidget {
     }
 
     void precacheNextImage(int index) {
-      if (index < assetList.length && index > 0) {
+      if (index < assetList.length && index >= 0) {
         final asset = assetList[index];
+
         if (asset.isLocal) {
           // Preload the local asset
           precacheImage(localImageProvider(asset), context);
         } else {
+          onError(Object exception, StackTrace? stackTrace) {
+            // swallow error silently
+          }
           // Probably load WEBP either way
           precacheImage(
             remoteThumbnailImageProvider(
@@ -152,6 +156,7 @@ class GalleryViewerPage extends HookConsumerWidget {
               api.ThumbnailFormat.WEBP,
             ),
             context,
+            onError: onError,
           );
           if (isLoadPreview.value) {
             // Precache the JPEG thumbnail
@@ -161,6 +166,7 @@ class GalleryViewerPage extends HookConsumerWidget {
                 api.ThumbnailFormat.JPEG,
               ),
               context,
+              onError: onError,
             );
           }
           if (isLoadOriginal.value) {
@@ -168,6 +174,7 @@ class GalleryViewerPage extends HookConsumerWidget {
             precacheImage(
               originalImageProvider(asset),
               context,
+              onError: onError,
             );
           }
         }
@@ -350,27 +357,37 @@ class GalleryViewerPage extends HookConsumerWidget {
                             type: api.ThumbnailFormat.WEBP,
                           ),
                           httpHeaders: {'Authorization': authToken},
-                          progressIndicatorBuilder: (_, __, ___) => const Center(
+                          progressIndicatorBuilder: (_, __, ___) =>
+                              const Center(
                             child: ImmichLoadingIndicator(),
                           ),
                           fadeInDuration: const Duration(milliseconds: 0),
                           fit: BoxFit.contain,
+                          errorWidget: (context, url, error) =>
+                              const Icon(Icons.image_not_supported_outlined),
                         );
 
-                        return CachedNetworkImage(
-                          imageUrl: getThumbnailUrl(
-                            asset,
-                            type: api.ThumbnailFormat.JPEG,
-                          ),
-                          cacheKey: getThumbnailCacheKey(
-                            asset,
-                            type: api.ThumbnailFormat.JPEG,
-                          ),
-                          httpHeaders: {'Authorization': authToken},
-                          fit: BoxFit.contain,
-                          fadeInDuration: const Duration(milliseconds: 0),
-                          placeholder: (_, __) => webPThumbnail,
-                        );
+                        if (isLoadOriginal.value) {
+                          // loading the preview in the loadingBuilder only
+                          // makes sense if the original is loaded in the builder
+                          return CachedNetworkImage(
+                            imageUrl: getThumbnailUrl(
+                              asset,
+                              type: api.ThumbnailFormat.JPEG,
+                            ),
+                            cacheKey: getThumbnailCacheKey(
+                              asset,
+                              type: api.ThumbnailFormat.JPEG,
+                            ),
+                            httpHeaders: {'Authorization': authToken},
+                            fit: BoxFit.contain,
+                            fadeInDuration: const Duration(milliseconds: 0),
+                            placeholder: (_, __) => webPThumbnail,
+                            errorWidget: (_, __, ___) => webPThumbnail,
+                          );
+                        } else {
+                          return webPThumbnail;
+                        }
                       } else {
                         return Image(
                           image: localThumbnailImageProvider(asset),
@@ -389,17 +406,23 @@ class GalleryViewerPage extends HookConsumerWidget {
                   } else {
                     if (isLoadOriginal.value) {
                       provider = originalImageProvider(assetList[index]);
-                    } else {
+                    } else if (isLoadPreview.value) {
                       provider = remoteThumbnailImageProvider(
                         assetList[index],
                         api.ThumbnailFormat.JPEG,
                       );
+                    } else {
+                      provider = remoteThumbnailImageProvider(
+                        assetList[index],
+                        api.ThumbnailFormat.WEBP,
+                      );
                     }
                   }
                   return PhotoViewGalleryPageOptions(
                     onDragStart: (_, details, __) =>
                         localPosition = details.localPosition,
-                    onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
+                    onDragUpdate: (_, details, __) =>
+                        handleSwipeUpDown(details),
                     onTapDown: (_, __, ___) =>
                         showAppBar.value = !showAppBar.value,
                     imageProvider: provider,
@@ -409,12 +432,17 @@ class GalleryViewerPage extends HookConsumerWidget {
                     filterQuality: FilterQuality.high,
                     tightMode: true,
                     minScale: PhotoViewComputedScale.contained,
+                    errorBuilder: (context, error, stackTrace) => ImmichImage(
+                      assetList[indexOfAsset.value],
+                      fit: BoxFit.contain,
+                    ),
                   );
                 } else {
                   return PhotoViewGalleryPageOptions.customChild(
                     onDragStart: (_, details, __) =>
                         localPosition = details.localPosition,
-                    onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
+                    onDragUpdate: (_, details, __) =>
+                        handleSwipeUpDown(details),
                     heroAttributes: PhotoViewHeroAttributes(
                       tag: assetList[index].id,
                     ),

+ 2 - 0
mobile/lib/modules/home/ui/home_page_app_bar.dart

@@ -66,6 +66,8 @@ class HomePageAppBar extends ConsumerWidget with PreferredSizeWidget {
                 image:
                     '$endpoint/user/profile-image/${authState.userId}?d=${dummy++}',
                 fadeInDuration: const Duration(milliseconds: 200),
+                imageErrorBuilder: (context, error, stackTrace) =>
+                    Image.memory(kTransparentImage),
               ),
             ),
           ),

+ 2 - 0
mobile/lib/modules/home/ui/profile_drawer/profile_drawer_header.dart

@@ -40,6 +40,8 @@ class ProfileDrawerHeader extends HookConsumerWidget {
             image:
                 '$endpoint/user/profile-image/${authState.userId}?d=${dummy++}',
             fadeInDuration: const Duration(milliseconds: 200),
+            imageErrorBuilder: (context, error, stackTrace) =>
+                Image.memory(kTransparentImage),
           ),
         ),
       );

+ 3 - 1
mobile/lib/modules/home/ui/profile_drawer/server_info_box.dart

@@ -106,7 +106,9 @@ class ServerInfoBox extends HookConsumerWidget {
                     ),
                   ),
                   Text(
-                    "${serverInfoState.serverVersion.major}.${serverInfoState.serverVersion.minor}.${serverInfoState.serverVersion.patch_}",
+                    serverInfoState.serverVersion.major > 0
+                        ? "${serverInfoState.serverVersion.major}.${serverInfoState.serverVersion.minor}.${serverInfoState.serverVersion.patch_}"
+                        : "?",
                     style: TextStyle(
                       fontSize: 11,
                       color: Colors.grey[500],

+ 11 - 2
mobile/lib/modules/login/providers/authentication.provider.dart

@@ -1,3 +1,5 @@
+import 'dart:io';
+
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:hive/hive.dart';
@@ -145,7 +147,14 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
     required String serverUrl,
   }) async {
     _apiService.setAccessToken(accessToken);
-    var userResponseDto = await _apiService.userApi.getMyUserInfo();
+    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) {
       var userInfoHiveBox = await Hive.openBox(userInfoBox);
@@ -200,7 +209,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
       state = state.copyWith(deviceInfo: deviceInfo);
     } catch (e) {
       debugPrint("ERROR Register Device Info: $e");
-      return false;
+      return e is ApiException && e.innerException is SocketException;
     }
 
     return true;

+ 2 - 0
mobile/lib/modules/search/ui/thumbnail_with_info.dart

@@ -53,6 +53,8 @@ class ThumbnailWithInfo extends StatelessWidget {
                           httpHeaders: {
                             "Authorization": "Bearer ${box.get(accessTokenKey)}"
                           },
+                          errorWidget: (context, url, error) =>
+                              const Icon(Icons.image_not_supported_outlined),
                         ),
                       )
                     : Center(

+ 4 - 1
mobile/lib/modules/search/views/search_result_page.dart

@@ -119,7 +119,10 @@ class SearchResultPage extends HookConsumerWidget {
           settings.getSetting(AppSettingsEnum.storageIndicator);
 
       if (searchResultPageState.isError) {
-        return const Text("Error");
+        return Padding(
+          padding: const EdgeInsets.all(12),
+          child: const Text("common_server_error").tr(),
+        );
       }
 
       if (searchResultPageState.isLoading) {

+ 12 - 10
mobile/lib/routing/auth_guard.dart

@@ -1,9 +1,10 @@
+import 'dart:io';
+
 import 'package:auto_route/auto_route.dart';
 import 'package:flutter/foundation.dart';
-import 'package:hive/hive.dart';
-import 'package:immich_mobile/constants/hive_box.dart';
 import 'package:immich_mobile/routing/router.dart';
 import 'package:immich_mobile/shared/services/api.service.dart';
+import 'package:openapi/api.dart';
 
 class AuthGuard extends AutoRouteGuard {
   final ApiService _apiService;
@@ -11,11 +12,6 @@ class AuthGuard extends AutoRouteGuard {
   @override
   void onNavigation(NavigationResolver resolver, StackRouter router) async {
     try {
-      // temporary fix for race condition that the _apiService
-      // get called before accessToken is set
-      var userInfoHiveBox = await Hive.openBox(userInfoBox);
-      var accessToken = userInfoHiveBox.get(accessTokenKey);
-      _apiService.setAccessToken(accessToken);
       var res = await _apiService.authenticationApi.validateAccessToken();
 
       if (res != null && res.authStatus) {
@@ -23,9 +19,15 @@ class AuthGuard extends AutoRouteGuard {
       } else {
         router.replaceAll([const LoginRoute()]);
       }
-    } catch (e) {
-      debugPrint("Error [onNavigation] ${e.toString()}");
-      router.replaceAll([const LoginRoute()]);
+    } on ApiException catch (e) {
+      if (e.code == HttpStatus.badRequest &&
+          e.innerException is SocketException) {
+        // offline?
+        resolver.next(true);
+      } else {
+        debugPrint("Error [onNavigation] ${e.toString()}");
+        router.replaceAll([const LoginRoute()]);
+      }
       return;
     }
   }

+ 4 - 1
mobile/lib/shared/providers/asset.provider.dart

@@ -104,7 +104,10 @@ class AssetNotifier extends StateNotifier<AssetsState> {
       final bool newLocal = await _albumService.refreshDeviceAlbums();
       log.info("Load assets: ${stopwatch.elapsedMilliseconds}ms");
       stopwatch.reset();
-      if (!newRemote && !newLocal) {
+      if (!newRemote &&
+          !newLocal &&
+          state.allAssets.length ==
+              await _db.assets.filter().ownerIdEqualTo(me.isarId).count()) {
         log.info("state is already up-to-date");
         return;
       }

+ 2 - 2
mobile/lib/shared/providers/server_info.provider.dart

@@ -1,3 +1,4 @@
+import 'package:easy_localization/easy_localization.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 
 import 'package:immich_mobile/shared/models/server_info_state.model.dart';
@@ -28,8 +29,7 @@ class ServerInfoNotifier extends StateNotifier<ServerInfoState> {
     if (serverVersion == null) {
       state = state.copyWith(
         isVersionMismatch: true,
-        versionMismatchErrorMessage:
-            "Server is out of date. Some functionalities might not working correctly. Download and rebuild server",
+        versionMismatchErrorMessage: "common_server_error".tr(),
       );
       return;
     }

+ 8 - 6
mobile/lib/shared/ui/immich_image.dart

@@ -11,15 +11,17 @@ import 'package:photo_manager/photo_manager.dart';
 class ImmichImage extends StatelessWidget {
   const ImmichImage(
     this.asset, {
-    required this.width,
-    required this.height,
+    this.width,
+    this.height,
+    this.fit = BoxFit.cover,
     this.useGrayBoxPlaceholder = false,
     super.key,
   });
   final Asset? asset;
   final bool useGrayBoxPlaceholder;
-  final double width;
-  final double height;
+  final double? width;
+  final double? height;
+  final BoxFit fit;
 
   @override
   Widget build(BuildContext context) {
@@ -47,7 +49,7 @@ class ImmichImage extends StatelessWidget {
         ),
         width: width,
         height: height,
-        fit: BoxFit.cover,
+        fit: fit,
         frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
           if (wasSynchronouslyLoaded || frame != null) {
             return child;
@@ -93,7 +95,7 @@ class ImmichImage extends StatelessWidget {
       // keeping memCacheWidth, memCacheHeight, maxWidthDiskCache and
       // maxHeightDiskCache = null allows to simply store the webp thumbnail
       // from the server and use it for all rendered thumbnail sizes
-      fit: BoxFit.cover,
+      fit: fit,
       fadeInDuration: const Duration(milliseconds: 250),
       progressIndicatorBuilder: (context, url, downloadProgress) {
         if (useGrayBoxPlaceholder) {

+ 20 - 21
mobile/lib/shared/views/splash_screen.dart

@@ -21,31 +21,30 @@ class SplashScreenPage extends HookConsumerWidget {
         Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).get(savedLoginInfoKey);
 
     void performLoggingIn() async {
-      try {
-        if (loginInfo != null) {
+      bool isSuccess = false;
+      if (loginInfo != null) {
+        try {
           // Resolve API server endpoint from user provided serverUrl
           await apiService.resolveAndSetEndpoint(loginInfo.serverUrl);
+        } catch (e) {
+          // okay, try to continue anyway if offline
+        }
 
-          var isSuccess = await ref
-              .read(authenticationProvider.notifier)
-              .setSuccessLoginInfo(
-                accessToken: loginInfo.accessToken,
-                serverUrl: loginInfo.serverUrl,
-              );
-          if (isSuccess) {
-            final hasPermission = await ref
-                .read(galleryPermissionNotifier.notifier)
-                .hasPermission;
-            if (hasPermission) {
-              // Resume backup (if enable) then navigate
-              ref.watch(backupProvider.notifier).resumeBackup();
-            }
-            AutoRouter.of(context).replace(const TabControllerRoute());
-          } else {
-            AutoRouter.of(context).replace(const LoginRoute());
-          }
+        isSuccess =
+            await ref.read(authenticationProvider.notifier).setSuccessLoginInfo(
+                  accessToken: loginInfo.accessToken,
+                  serverUrl: loginInfo.serverUrl,
+                );
+      }
+      if (isSuccess) {
+        final hasPermission =
+            await ref.read(galleryPermissionNotifier.notifier).hasPermission;
+        if (hasPermission) {
+          // Resume backup (if enable) then navigate
+          ref.watch(backupProvider.notifier).resumeBackup();
         }
-      } catch (_) {
+        AutoRouter.of(context).replace(const TabControllerRoute());
+      } else {
         AutoRouter.of(context).replace(const LoginRoute());
       }
     }