Procházet zdrojové kódy

feat(mobile) Add in app logging to show app's log information (#1014)

Alex před 2 roky
rodič
revize
024177515d

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

@@ -120,6 +120,7 @@
   "profile_drawer_client_server_up_to_date": "Client and Server are up-to-date",
   "profile_drawer_settings": "Settings",
   "profile_drawer_sign_out": "Sign Out",
+  "profile_drawer_app_logs": "Logs",
   "search_bar_hint": "Search your photos",
   "search_page_no_objects": "No Objects Info Available",
   "search_page_no_places": "No Places Info Available",

binární
mobile/fonts/Inconsolata-Regular.ttf


+ 3 - 0
mobile/lib/constants/hive_box.dart

@@ -30,3 +30,6 @@ const String backupRequireCharging = "immichBackupRequireCharging"; // Key 3
 // Duplicate asset
 const String duplicatedAssetsBox = "immichDuplicatedAssetsBox"; // Box
 const String duplicatedAssetsKey = "immichDuplicatedAssetsKey"; // Key 1
+
+// In app logger
+const String immichLoggerBox = "immichInAppLogger"; // Box

+ 7 - 0
mobile/lib/main.dart

@@ -16,11 +16,13 @@ import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.d
 import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 import 'package:immich_mobile/routing/router.dart';
 import 'package:immich_mobile/routing/tab_navigation_observer.dart';
+import 'package:immich_mobile/shared/models/immich_logger_message.model.dart';
 import 'package:immich_mobile/shared/providers/app_state.provider.dart';
 import 'package:immich_mobile/shared/providers/asset.provider.dart';
 import 'package:immich_mobile/shared/providers/release_info.provider.dart';
 import 'package:immich_mobile/shared/providers/server_info.provider.dart';
 import 'package:immich_mobile/shared/providers/websocket.provider.dart';
+import 'package:immich_mobile/shared/services/immich_logger.service.dart';
 import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
 import 'package:immich_mobile/shared/views/version_announcement_overlay.dart';
 import 'package:immich_mobile/utils/immich_app_theme.dart';
@@ -31,8 +33,10 @@ void main() async {
   Hive.registerAdapter(HiveSavedLoginInfoAdapter());
   Hive.registerAdapter(HiveBackupAlbumsAdapter());
   Hive.registerAdapter(HiveDuplicatedAssetsAdapter());
+  Hive.registerAdapter(ImmichLoggerMessageAdapter());
 
   await Future.wait([
+    Hive.openBox<ImmichLoggerMessage>(immichLoggerBox),
     Hive.openBox(userInfoBox),
     Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox),
     Hive.openBox(hiveGithubReleaseInfoBox),
@@ -58,6 +62,9 @@ void main() async {
     }
   }
 
+  // Initialize Immich Logger Service
+  ImmichLogger().init();
+
   runApp(
     EasyLocalization(
       supportedLocales: locales,

+ 0 - 1
mobile/lib/modules/backup/background_service/background.service.dart

@@ -349,7 +349,6 @@ class BackgroundService {
       Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox),
       Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox),
     ]);
-
     ApiService apiService = ApiService();
     apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey));
     apiService.setAccessToken(Hive.box(userInfoBox).get(accessTokenKey));

+ 34 - 18
mobile/lib/modules/backup/providers/backup.provider.dart

@@ -1,7 +1,6 @@
 import 'dart:io';
 
 import 'package:cancellation_token_http/http.dart';
-import 'package:flutter/foundation.dart';
 import 'package:hive_flutter/hive_flutter.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/constants/hive_box.dart';
@@ -18,6 +17,7 @@ import 'package:immich_mobile/modules/login/models/authentication_state.model.da
 import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 import 'package:immich_mobile/shared/providers/app_state.provider.dart';
 import 'package:immich_mobile/shared/services/server_info.service.dart';
+import 'package:logging/logging.dart';
 import 'package:openapi/api.dart';
 import 'package:photo_manager/photo_manager.dart';
 
@@ -62,6 +62,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
     getBackupInfo();
   }
 
+  final log = Logger('BackupNotifier');
   final BackupService _backupService;
   final ServerInfoService _serverInfoService;
   final AuthenticationState _authState;
@@ -218,13 +219,16 @@ class BackupNotifier extends StateNotifier<BackUpState> {
     );
 
     if (backupAlbumInfo == null) {
-      debugPrint("[ERROR] getting Hive backup album infomation");
+      log.severe(
+        "backupAlbumInfo == null",
+        "Failed to get Hive backup album information",
+      );
       return;
     }
 
     // First time backup - set isAll album is the default one for backup.
     if (backupAlbumInfo.selectedAlbumIds.isEmpty) {
-      debugPrint("First time backup setup recent album as default");
+      log.info("First time backup; setup 'Recent(s)' album as default");
 
       // Get album that contains all assets
       var list = await PhotoManager.getAssetPathList(
@@ -286,8 +290,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
         selectedBackupAlbums: selectedAlbums,
         excludedBackupAlbums: excludedAlbums,
       );
-    } catch (e) {
-      debugPrint("[ERROR] Failed to generate album from id $e");
+    } catch (e, stackTrace) {
+      log.severe("Failed to generate album from id", e, stackTrace);
     }
   }
 
@@ -338,7 +342,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
     );
 
     if (allUniqueAssets.isEmpty) {
-      debugPrint("No Asset On Device");
+      log.info("Not found albums or assets on the device to backup");
       state = state.copyWith(
         backupProgress: BackUpProgressEnum.idle,
         allAssetsInDatabase: allAssetsInDatabase,
@@ -412,7 +416,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
       await PhotoManager.clearFileCache();
 
       if (state.allUniqueAssets.isEmpty) {
-        debugPrint("No Asset On Device - Abort Backup Process");
+        log.info("No Asset On Device - Abort Backup Process");
         state = state.copyWith(backupProgress: BackUpProgressEnum.idle);
         return;
       }
@@ -530,7 +534,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
 
     // User has been logged out return
     if (accessKey == null || !_authState.isAuthenticated) {
-      debugPrint("[resumeBackup] not authenticated - abort");
+      log.info("[_resumeBackup] not authenticated - abort");
       return;
     }
 
@@ -539,17 +543,17 @@ class BackupNotifier extends StateNotifier<BackUpState> {
         _authState.deviceInfo.isAutoBackup) {
       // check if backup is alreayd in process - then return
       if (state.backupProgress == BackUpProgressEnum.inProgress) {
-        debugPrint("[resumeBackup] Backup is already in progress - abort");
+        log.info("[_resumeBackup] Backup is already in progress - abort");
         return;
       }
 
       if (state.backupProgress == BackUpProgressEnum.inBackground) {
-        debugPrint("[resumeBackup] Background backup is running - abort");
+        log.info("[_resumeBackup] Background backup is running - abort");
         return;
       }
 
       // Run backup
-      debugPrint("[resumeBackup] Start back up");
+      log.info("[_resumeBackup] Start back up");
       await startBackupProcess();
     }
 
@@ -565,7 +569,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
       state = state.copyWith(backupProgress: BackUpProgressEnum.inBackground);
       final bool hasLock = await _backgroundService.acquireLock();
       if (!hasLock) {
-        debugPrint("WARNING [resumeBackup] failed to acquireLock");
+        log.warning("WARNING [resumeBackup] failed to acquireLock");
         return;
       }
       await Future.wait([
@@ -612,7 +616,11 @@ class BackupNotifier extends StateNotifier<BackUpState> {
         AvailableAlbum a = albums.firstWhere((e) => e.id == ids[i]);
         result.add(a.copyWith(lastBackup: times[i]));
       } on StateError {
-        debugPrint("[_updateAlbumBackupTime] failed to find album in state");
+        log.severe(
+          "[_updateAlbumBackupTime] failed to find album in state",
+          "State Error",
+          StackTrace.current,
+        );
       }
     }
     return result;
@@ -631,21 +639,29 @@ class BackupNotifier extends StateNotifier<BackUpState> {
           await Hive.box<HiveBackupAlbums>(hiveBackupInfoBox).close();
         }
       } catch (error) {
-        debugPrint("[_notifyBackgroundServiceCanRun] failed to close box");
+        log.info("[_notifyBackgroundServiceCanRun] failed to close box");
       }
       try {
         if (Hive.isBoxOpen(duplicatedAssetsBox)) {
           await Hive.box<HiveDuplicatedAssets>(duplicatedAssetsBox).close();
         }
-      } catch (error) {
-        debugPrint("[_notifyBackgroundServiceCanRun] failed to close box");
+      } catch (error, stackTrace) {
+        log.severe(
+          "[_notifyBackgroundServiceCanRun] failed to close box",
+          error,
+          stackTrace,
+        );
       }
       try {
         if (Hive.isBoxOpen(backgroundBackupInfoBox)) {
           await Hive.box(backgroundBackupInfoBox).close();
         }
-      } catch (error) {
-        debugPrint("[_notifyBackgroundServiceCanRun] failed to close box");
+      } catch (error, stackTrace) {
+        log.severe(
+          "[_notifyBackgroundServiceCanRun] failed to close box",
+          error,
+          stackTrace,
+        );
       }
       _backgroundService.releaseLock();
     }

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

@@ -2,12 +2,12 @@ import 'package:auto_route/auto_route.dart';
 import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
 import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer_header.dart';
 import 'package:immich_mobile/modules/home/ui/profile_drawer/server_info_box.dart';
+import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 import 'package:immich_mobile/routing/router.dart';
 import 'package:immich_mobile/shared/providers/asset.provider.dart';
-import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
-import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
 import 'package:immich_mobile/shared/providers/websocket.provider.dart';
 
 class ProfileDrawer extends HookConsumerWidget {
@@ -70,6 +70,30 @@ class ProfileDrawer extends HookConsumerWidget {
       );
     }
 
+    buildAppLogButton() {
+      return ListTile(
+        horizontalTitleGap: 0,
+        leading: SizedBox(
+          height: double.infinity,
+          child: Icon(
+            Icons.assignment_outlined,
+            color: Theme.of(context).textTheme.labelMedium?.color,
+            size: 20,
+          ),
+        ),
+        title: Text(
+          "profile_drawer_app_logs",
+          style: Theme.of(context)
+              .textTheme
+              .labelLarge
+              ?.copyWith(fontWeight: FontWeight.bold),
+        ).tr(),
+        onTap: () {
+          AutoRouter.of(context).push(const AppLogRoute());
+        },
+      );
+    }
+
     return Drawer(
       child: Column(
         mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -80,6 +104,7 @@ class ProfileDrawer extends HookConsumerWidget {
             children: [
               const ProfileDrawerHeader(),
               buildSettingButton(),
+              buildAppLogButton(),
               buildSignoutButton(),
             ],
           ),

+ 53 - 2
mobile/lib/modules/login/views/login_page.dart

@@ -1,14 +1,65 @@
+import 'package:auto_route/auto_route.dart';
 import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/login/ui/login_form.dart';
+import 'package:immich_mobile/routing/router.dart';
+import 'package:package_info_plus/package_info_plus.dart';
 
 class LoginPage extends HookConsumerWidget {
   const LoginPage({Key? key}) : super(key: key);
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
-    return const Scaffold(
-      body: LoginForm(),
+    final appVersion = useState('0.0.0');
+
+    getAppInfo() async {
+      PackageInfo packageInfo = await PackageInfo.fromPlatform();
+      appVersion.value = packageInfo.version;
+    }
+
+    useEffect(
+      () {
+        getAppInfo();
+        return null;
+      },
+    );
+
+    return Scaffold(
+      body: const LoginForm(),
+      bottomNavigationBar: Padding(
+        padding: const EdgeInsets.only(bottom: 16.0),
+        child: SizedBox(
+          height: 50,
+          child: Row(
+            mainAxisAlignment: MainAxisAlignment.center,
+            children: [
+              Text(
+                'v${appVersion.value}',
+                style: const TextStyle(
+                  color: Colors.grey,
+                  fontWeight: FontWeight.bold,
+                  fontFamily: "Inconsolata",
+                ),
+              ),
+              const Text(' '),
+              GestureDetector(
+                child: Text(
+                  'Logs',
+                  style: TextStyle(
+                    color: Theme.of(context).primaryColor,
+                    fontWeight: FontWeight.bold,
+                    fontFamily: "Inconsolata",
+                  ),
+                ),
+                onTap: () {
+                  AutoRouter.of(context).push(const AppLogRoute());
+                },
+              ),
+            ],
+          ),
+        ),
+      ),
     );
   }
 }

+ 16 - 11
mobile/lib/routing/router.dart

@@ -1,33 +1,34 @@
 import 'package:auto_route/auto_route.dart';
 import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
+import 'package:immich_mobile/modules/album/views/album_viewer_page.dart';
+import 'package:immich_mobile/modules/album/views/asset_selection_page.dart';
+import 'package:immich_mobile/modules/album/views/create_album_page.dart';
 import 'package:immich_mobile/modules/album/views/library_page.dart';
+import 'package:immich_mobile/modules/album/views/select_additional_user_for_sharing_page.dart';
+import 'package:immich_mobile/modules/album/views/select_user_for_sharing_page.dart';
+import 'package:immich_mobile/modules/album/views/sharing_page.dart';
 import 'package:immich_mobile/modules/asset_viewer/views/gallery_viewer.dart';
+import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
+import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
 import 'package:immich_mobile/modules/backup/views/album_preview_page.dart';
 import 'package:immich_mobile/modules/backup/views/backup_album_selection_page.dart';
+import 'package:immich_mobile/modules/backup/views/backup_controller_page.dart';
 import 'package:immich_mobile/modules/backup/views/failed_backup_status_page.dart';
+import 'package:immich_mobile/modules/home/views/home_page.dart';
 import 'package:immich_mobile/modules/login/views/change_password_page.dart';
 import 'package:immich_mobile/modules/login/views/login_page.dart';
-import 'package:immich_mobile/modules/home/views/home_page.dart';
 import 'package:immich_mobile/modules/search/views/search_page.dart';
 import 'package:immich_mobile/modules/search/views/search_result_page.dart';
-import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
-import 'package:immich_mobile/modules/album/views/album_viewer_page.dart';
-import 'package:immich_mobile/modules/album/views/asset_selection_page.dart';
-import 'package:immich_mobile/modules/album/views/create_album_page.dart';
-import 'package:immich_mobile/modules/album/views/select_additional_user_for_sharing_page.dart';
-import 'package:immich_mobile/modules/album/views/select_user_for_sharing_page.dart';
-import 'package:immich_mobile/modules/album/views/sharing_page.dart';
 import 'package:immich_mobile/modules/settings/views/settings_page.dart';
 import 'package:immich_mobile/routing/auth_guard.dart';
-import 'package:immich_mobile/modules/backup/views/backup_controller_page.dart';
-import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/providers/api.provider.dart';
 import 'package:immich_mobile/shared/services/api.service.dart';
+import 'package:immich_mobile/shared/views/app_log_page.dart';
 import 'package:immich_mobile/shared/views/splash_screen.dart';
 import 'package:immich_mobile/shared/views/tab_controller_page.dart';
-import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
 import 'package:openapi/api.dart';
 import 'package:photo_manager/photo_manager.dart';
 
@@ -80,6 +81,10 @@ part 'router.gr.dart';
       transitionsBuilder: TransitionsBuilders.slideBottom,
     ),
     AutoRoute(page: SettingsPage, guards: [AuthGuard]),
+    CustomRoute(
+      page: AppLogPage,
+      transitionsBuilder: TransitionsBuilders.slideBottom,
+    ),
   ],
 )
 class AppRouter extends _$AppRouter {

+ 18 - 1
mobile/lib/routing/router.gr.dart

@@ -142,6 +142,14 @@ class _$AppRouter extends RootStackRouter {
       return MaterialPageX<dynamic>(
           routeData: routeData, child: const SettingsPage());
     },
+    AppLogRoute.name: (routeData) {
+      return CustomPage<dynamic>(
+          routeData: routeData,
+          child: const AppLogPage(),
+          transitionsBuilder: TransitionsBuilders.slideBottom,
+          opaque: true,
+          barrierDismissible: false);
+    },
     HomeRoute.name: (routeData) {
       return MaterialPageX<dynamic>(
           routeData: routeData, child: const HomePage());
@@ -218,7 +226,8 @@ class _$AppRouter extends RootStackRouter {
         RouteConfig(FailedBackupStatusRoute.name,
             path: '/failed-backup-status-page', guards: [authGuard]),
         RouteConfig(SettingsRoute.name,
-            path: '/settings-page', guards: [authGuard])
+            path: '/settings-page', guards: [authGuard]),
+        RouteConfig(AppLogRoute.name, path: '/app-log-page')
       ];
 }
 
@@ -560,6 +569,14 @@ class SettingsRoute extends PageRouteInfo<void> {
   static const String name = 'SettingsRoute';
 }
 
+/// generated route for
+/// [AppLogPage]
+class AppLogRoute extends PageRouteInfo<void> {
+  const AppLogRoute() : super(AppLogRoute.name, path: '/app-log-page');
+
+  static const String name = 'AppLogRoute';
+}
+
 /// generated route for
 /// [HomePage]
 class HomeRoute extends PageRouteInfo<void> {

+ 34 - 0
mobile/lib/shared/models/immich_logger_message.model.dart

@@ -0,0 +1,34 @@
+import 'package:hive/hive.dart';
+
+part 'immich_logger_message.model.g.dart';
+
+@HiveType(typeId: 3)
+class ImmichLoggerMessage {
+  @HiveField(0)
+  String message;
+
+  @HiveField(1, defaultValue: "INFO")
+  String level;
+
+  @HiveField(2)
+  DateTime createdAt;
+
+  @HiveField(3)
+  String? context1;
+
+  @HiveField(4)
+  String? context2;
+
+  ImmichLoggerMessage({
+    required this.message,
+    required this.level,
+    required this.createdAt,
+    required this.context1,
+    required this.context2,
+  });
+
+  @override
+  String toString() {
+    return 'InAppLoggerMessage(message: $message, level: $level, createdAt: $createdAt)';
+  }
+}

+ 53 - 0
mobile/lib/shared/models/immich_logger_message.model.g.dart

@@ -0,0 +1,53 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'immich_logger_message.model.dart';
+
+// **************************************************************************
+// TypeAdapterGenerator
+// **************************************************************************
+
+class ImmichLoggerMessageAdapter extends TypeAdapter<ImmichLoggerMessage> {
+  @override
+  final int typeId = 3;
+
+  @override
+  ImmichLoggerMessage read(BinaryReader reader) {
+    final numOfFields = reader.readByte();
+    final fields = <int, dynamic>{
+      for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
+    };
+    return ImmichLoggerMessage(
+      message: fields[0] as String,
+      level: fields[1] == null ? 'INFO' : fields[1] as String,
+      createdAt: fields[2] as DateTime,
+      context1: fields[3] as String?,
+      context2: fields[4] as String?,
+    );
+  }
+
+  @override
+  void write(BinaryWriter writer, ImmichLoggerMessage obj) {
+    writer
+      ..writeByte(5)
+      ..writeByte(0)
+      ..write(obj.message)
+      ..writeByte(1)
+      ..write(obj.level)
+      ..writeByte(2)
+      ..write(obj.createdAt)
+      ..writeByte(3)
+      ..write(obj.context1)
+      ..writeByte(4)
+      ..write(obj.context2);
+  }
+
+  @override
+  int get hashCode => typeId.hashCode;
+
+  @override
+  bool operator ==(Object other) =>
+      identical(this, other) ||
+      other is ImmichLoggerMessageAdapter &&
+          runtimeType == other.runtimeType &&
+          typeId == other.typeId;
+}

+ 10 - 10
mobile/lib/shared/providers/asset.provider.dart

@@ -1,6 +1,5 @@
 import 'dart:collection';
 
-import 'package:flutter/foundation.dart';
 import 'package:hive/hive.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/constants/hive_box.dart';
@@ -10,13 +9,14 @@ import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/services/device_info.service.dart';
 import 'package:collection/collection.dart';
 import 'package:intl/intl.dart';
+import 'package:logging/logging.dart';
 import 'package:openapi/api.dart';
 import 'package:photo_manager/photo_manager.dart';
 
 class AssetNotifier extends StateNotifier<List<Asset>> {
   final AssetService _assetService;
   final AssetCacheService _assetCacheService;
-
+  final log = Logger('AssetNotifier');
   final DeviceInfoService _deviceInfoService = DeviceInfoService();
   bool _getAllAssetInProgress = false;
   bool _deleteInProgress = false;
@@ -41,7 +41,7 @@ class AssetNotifier extends StateNotifier<List<Asset>> {
       final remoteTask = _assetService.getRemoteAssets();
       if (isCacheValid && state.isEmpty) {
         state = await _assetCacheService.get();
-        debugPrint(
+        log.info(
           "Reading assets from cache: ${stopwatch.elapsedMilliseconds}ms",
         );
         stopwatch.reset();
@@ -52,25 +52,25 @@ class AssetNotifier extends StateNotifier<List<Asset>> {
       final List<Asset> currentLocal = state.slice(0, remoteBegin);
       List<Asset>? newRemote = await remoteTask;
       List<Asset>? newLocal = await localTask;
-      debugPrint("Load assets: ${stopwatch.elapsedMilliseconds}ms");
+      log.info("Load assets: ${stopwatch.elapsedMilliseconds}ms");
       stopwatch.reset();
       if (newRemote == null &&
           (newLocal == null || currentLocal.equals(newLocal))) {
-        debugPrint("state is already up-to-date");
+        log.info("state is already up-to-date");
         return;
       }
       newRemote ??= state.slice(remoteBegin);
       newLocal ??= [];
       state = _combineLocalAndRemoteAssets(local: newLocal, remote: newRemote);
-      debugPrint("Combining assets: ${stopwatch.elapsedMilliseconds}ms");
+      log.info("Combining assets: ${stopwatch.elapsedMilliseconds}ms");
     } finally {
       _getAllAssetInProgress = false;
     }
-    debugPrint("[getAllAsset] setting new asset state");
+    log.info("setting new asset state");
 
     stopwatch.reset();
     _cacheState();
-    debugPrint("Store assets in cache: ${stopwatch.elapsedMilliseconds}ms");
+    log.info("Store assets in cache: ${stopwatch.elapsedMilliseconds}ms");
   }
 
   List<Asset> _combineLocalAndRemoteAssets({
@@ -155,8 +155,8 @@ class AssetNotifier extends StateNotifier<List<Asset>> {
     if (local.isNotEmpty) {
       try {
         return await PhotoManager.editor.deleteWithIds(local);
-      } catch (e) {
-        debugPrint("Delete asset from device failed: $e");
+      } catch (e, stack) {
+        log.severe("Failed to delete asset from device", e, stack);
       }
     }
     return [];

+ 2 - 4
mobile/lib/shared/providers/release_info.provider.dart

@@ -6,10 +6,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:http/http.dart';
 import 'package:immich_mobile/constants/hive_box.dart';
 import 'package:immich_mobile/shared/views/version_announcement_overlay.dart';
+import 'package:logging/logging.dart';
 
 class ReleaseInfoNotifier extends StateNotifier<String> {
   ReleaseInfoNotifier() : super("");
-
+  final log = Logger('ReleaseInfoNotifier');
   void checkGithubReleaseInfo() async {
     final Client client = Client();
     var box = Hive.box(hiveGithubReleaseInfoBox);
@@ -28,9 +29,6 @@ class ReleaseInfoNotifier extends StateNotifier<String> {
         String latestTagVersion = data["tag_name"];
         state = latestTagVersion;
 
-        debugPrint("Local release version $localReleaseVersion");
-        debugPrint("Remote release veresion $latestTagVersion");
-
         if (localReleaseVersion == null && latestTagVersion.isNotEmpty) {
           VersionAnnouncementOverlayController.appLoader.show();
           return;

+ 24 - 23
mobile/lib/shared/providers/websocket.provider.dart

@@ -6,23 +6,24 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/constants/hive_box.dart';
 import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 import 'package:immich_mobile/shared/providers/asset.provider.dart';
+import 'package:logging/logging.dart';
 import 'package:openapi/api.dart';
 import 'package:socket_io_client/socket_io_client.dart';
 
-class WebscoketState {
+class WebsocketState {
   final Socket? socket;
   final bool isConnected;
 
-  WebscoketState({
+  WebsocketState({
     this.socket,
     required this.isConnected,
   });
 
-  WebscoketState copyWith({
+  WebsocketState copyWith({
     Socket? socket,
     bool? isConnected,
   }) {
-    return WebscoketState(
+    return WebsocketState(
       socket: socket ?? this.socket,
       isConnected: isConnected ?? this.isConnected,
     );
@@ -30,13 +31,13 @@ class WebscoketState {
 
   @override
   String toString() =>
-      'WebscoketState(socket: $socket, isConnected: $isConnected)';
+      'WebsocketState(socket: $socket, isConnected: $isConnected)';
 
   @override
   bool operator ==(Object other) {
     if (identical(this, other)) return true;
 
-    return other is WebscoketState &&
+    return other is WebsocketState &&
         other.socket == socket &&
         other.isConnected == isConnected;
   }
@@ -45,12 +46,11 @@ class WebscoketState {
   int get hashCode => socket.hashCode ^ isConnected.hashCode;
 }
 
-class WebsocketNotifier extends StateNotifier<WebscoketState> {
+class WebsocketNotifier extends StateNotifier<WebsocketState> {
   WebsocketNotifier(this.ref)
-      : super(WebscoketState(socket: null, isConnected: false)) {
-    debugPrint("Init websocket instance");
-  }
+      : super(WebsocketState(socket: null, isConnected: false));
 
+  final log = Logger('WebsocketNotifier');
   final Ref ref;
 
   connect() {
@@ -60,8 +60,8 @@ class WebsocketNotifier extends StateNotifier<WebscoketState> {
       var accessToken = Hive.box(userInfoBox).get(accessTokenKey);
       var endpoint = Hive.box(userInfoBox).get(serverEndpointKey);
       try {
-        debugPrint("[WEBSOCKET] Attempting to connect to ws");
-        // Configure socket transports must be sepecified
+        log.info("Attempting to connect to websocket");
+        // Configure socket transports must be specified
         Socket socket = io(
           endpoint.toString().replaceAll('/api', ''),
           OptionBuilder()
@@ -76,18 +76,18 @@ class WebsocketNotifier extends StateNotifier<WebscoketState> {
         );
 
         socket.onConnect((_) {
-          debugPrint("[WEBSOCKET] Established Websocket Connection");
-          state = WebscoketState(isConnected: true, socket: socket);
+          log.info("Established Websocket Connection");
+          state = WebsocketState(isConnected: true, socket: socket);
         });
 
         socket.onDisconnect((_) {
-          debugPrint("[WEBSOCKET] Disconnect to Websocket Connection");
-          state = WebscoketState(isConnected: false, socket: null);
+          log.info("Disconnect to Websocket Connection");
+          state = WebsocketState(isConnected: false, socket: null);
         });
 
         socket.on('error', (errorMessage) {
-          debugPrint("Webcoket Error - $errorMessage");
-          state = WebscoketState(isConnected: false, socket: null);
+          log.severe("Websocket Error - $errorMessage");
+          state = WebsocketState(isConnected: false, socket: null);
         });
 
         socket.on('on_upload_success', (data) {
@@ -105,21 +105,22 @@ class WebsocketNotifier extends StateNotifier<WebscoketState> {
   }
 
   disconnect() {
-    debugPrint("[WEBSOCKET] Attempting to disconnect");
+    log.info("Attempting to disconnect from websocket");
+
     var socket = state.socket?.disconnect();
 
     if (socket?.disconnected == true) {
-      state = WebscoketState(isConnected: false, socket: null);
+      state = WebsocketState(isConnected: false, socket: null);
     }
   }
 
   stopListenToEvent(String eventName) {
-    debugPrint("[Websocket] Stop listening to event $eventName");
+    log.info("Stop listening to event $eventName");
     state.socket?.off(eventName);
   }
 
   listenUploadEvent() {
-    debugPrint("[Websocket] Start listening to event on_upload_success");
+    log.info("Start listening to event on_upload_success");
     state.socket?.on('on_upload_success', (data) {
       var jsonString = jsonDecode(data.toString());
       AssetResponseDto? newAsset = AssetResponseDto.fromJson(jsonString);
@@ -132,6 +133,6 @@ class WebsocketNotifier extends StateNotifier<WebscoketState> {
 }
 
 final websocketProvider =
-    StateNotifierProvider<WebsocketNotifier, WebscoketState>((ref) {
+    StateNotifierProvider<WebsocketNotifier, WebsocketState>((ref) {
   return WebsocketNotifier(ref);
 });

+ 87 - 0
mobile/lib/shared/services/immich_logger.service.dart

@@ -0,0 +1,87 @@
+import 'dart:io';
+
+import 'package:flutter/widgets.dart';
+import 'package:hive/hive.dart';
+import 'package:immich_mobile/constants/hive_box.dart';
+import 'package:immich_mobile/shared/models/immich_logger_message.model.dart';
+import 'package:logging/logging.dart';
+import 'package:path_provider/path_provider.dart';
+import 'package:share_plus/share_plus.dart';
+
+/// [ImmichLogger] is a custom logger that is built on top of the [logging] package.
+/// The logs are written to a Hive box and onto console, using `debugPrint` method.
+///
+/// The logs are deleted when exceeding the `maxLogEntries` (default 200) property
+/// in the class.
+///
+/// Logs can be shared by calling the `shareLogs` method, which will open a share dialog
+/// and generate a csv file.
+class ImmichLogger {
+  final maxLogEntries = 200;
+  final Box<ImmichLoggerMessage> _box = Hive.box(immichLoggerBox);
+
+  List<ImmichLoggerMessage> get messages =>
+      _box.values.toList().reversed.toList();
+
+  ImmichLogger() {
+    _removeOverflowMessages();
+  }
+
+  init() {
+    Logger.root.level = Level.INFO;
+    Logger.root.onRecord.listen(_writeLogToHiveBox);
+  }
+
+  _removeOverflowMessages() {
+    if (_box.length > maxLogEntries) {
+      var numberOfEntryToBeDeleted = _box.length - maxLogEntries;
+      for (var i = 0; i < numberOfEntryToBeDeleted; i++) {
+        _box.deleteAt(0);
+      }
+    }
+  }
+
+  _writeLogToHiveBox(LogRecord record) {
+    final Box<ImmichLoggerMessage> box = Hive.box(immichLoggerBox);
+    var formattedMessage = record.message;
+
+    debugPrint('[${record.level.name}] [${record.time}] ${record.message}');
+    box.add(
+      ImmichLoggerMessage(
+        message: formattedMessage,
+        level: record.level.name,
+        createdAt: record.time,
+        context1: record.loggerName,
+        context2: record.stackTrace
+            ?.toString(), // Something more useful here? (e.g. stacktrace - I cannot get it to format nicely though)
+      ),
+    );
+  }
+
+  void clearLogs() {
+    _box.clear();
+  }
+
+  shareLogs() async {
+    var tempDir = await getTemporaryDirectory();
+    var filePath = '${tempDir.path}/${DateTime.now().toIso8601String()}.csv';
+    var logFile = await File(filePath).create();
+    // Write header
+    logFile.writeAsStringSync("created_at,context_1,context_2,message,type\n");
+
+    // Write messages
+    for (var message in messages) {
+      logFile.writeAsStringSync(
+        "${message.createdAt},${message.context1 ?? ""},${message.context2 ?? ""},${message.message},${message.level.toString()}\n",
+        mode: FileMode.append,
+      );
+    }
+
+    // Share file
+    Share.shareFiles(
+      [filePath],
+      subject: "Immich logs ${DateTime.now().toIso8601String()}",
+      sharePositionOrigin: Rect.zero,
+    );
+  }
+}

+ 153 - 0
mobile/lib/shared/views/app_log_page.dart

@@ -0,0 +1,153 @@
+import 'package:auto_route/auto_route.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/shared/services/immich_logger.service.dart';
+import 'package:intl/intl.dart';
+
+class AppLogPage extends HookConsumerWidget {
+  const AppLogPage({
+    Key? key,
+  }) : super(key: key);
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final immichLogger = ImmichLogger();
+    final logMessages = useState(immichLogger.messages);
+
+    Widget buildLeadingIcon(String level) {
+      switch (level) {
+        case "INFO":
+          return Container(
+            width: 10,
+            height: 10,
+            decoration: BoxDecoration(
+              color: Theme.of(context).primaryColor,
+              borderRadius: BorderRadius.circular(5),
+            ),
+          );
+        case "SEVERE":
+          return Container(
+            width: 10,
+            height: 10,
+            decoration: BoxDecoration(
+              color: Colors.redAccent,
+              borderRadius: BorderRadius.circular(5),
+            ),
+          );
+        case "WARNING":
+          return Container(
+            width: 10,
+            height: 10,
+            decoration: BoxDecoration(
+              color: Colors.orangeAccent,
+              borderRadius: BorderRadius.circular(5),
+            ),
+          );
+        default:
+          return Container(
+            width: 10,
+            height: 10,
+            decoration: BoxDecoration(
+              color: Theme.of(context).primaryColor,
+              borderRadius: BorderRadius.circular(5),
+            ),
+          );
+      }
+    }
+
+    getTileColor(String level) {
+      switch (level) {
+        case "INFO":
+          return Colors.transparent;
+        case "SEVERE":
+          return Colors.redAccent.withOpacity(0.075);
+        case "WARNING":
+          return Colors.orangeAccent.withOpacity(0.075);
+        default:
+          return Theme.of(context).primaryColor.withOpacity(0.1);
+      }
+    }
+
+    return Scaffold(
+      appBar: AppBar(
+        title: Text(
+          "Logs - ${logMessages.value.length}",
+          style: const TextStyle(
+            fontWeight: FontWeight.bold,
+            fontSize: 16.0,
+          ),
+        ),
+        scrolledUnderElevation: 1,
+        elevation: 2,
+        actions: [
+          IconButton(
+            icon: Icon(
+              Icons.delete_outline_rounded,
+              color: Theme.of(context).primaryColor,
+              semanticLabel: "Clear logs",
+              size: 20.0,
+            ),
+            onPressed: () {
+              immichLogger.clearLogs();
+              logMessages.value = [];
+            },
+          ),
+          IconButton(
+            icon: Icon(
+              Icons.share_rounded,
+              color: Theme.of(context).primaryColor,
+              semanticLabel: "Share logs",
+              size: 20.0,
+            ),
+            onPressed: () {
+              immichLogger.shareLogs();
+            },
+          ),
+        ],
+        leading: IconButton(
+          onPressed: () {
+            AutoRouter.of(context).pop();
+          },
+          icon: const Icon(
+            Icons.arrow_back_ios_new_rounded,
+            size: 20.0,
+          ),
+        ),
+        centerTitle: true,
+      ),
+      body: ListView.separated(
+        separatorBuilder: (context, index) {
+          return Divider(
+            height: 0,
+            color: Theme.of(context).brightness == Brightness.dark
+                ? Colors.white70
+                : Colors.grey[500],
+          );
+        },
+        itemCount: logMessages.value.length,
+        itemBuilder: (context, index) {
+          var logMessage = logMessages.value[index];
+          return ListTile(
+            visualDensity: VisualDensity.compact,
+            dense: true,
+            tileColor: getTileColor(logMessage.level),
+            minLeadingWidth: 10,
+            title: Text(
+              logMessage.message,
+              style: const TextStyle(fontSize: 14.0, fontFamily: "Inconsolata"),
+            ),
+            subtitle: Text(
+              "[${logMessage.context1}] Logged on ${DateFormat("HH:mm:ss.SSS").format(logMessage.createdAt)}",
+              style: TextStyle(
+                fontSize: 12.0,
+                color: Colors.grey[600],
+              ),
+            ),
+            leading: buildLeadingIcon(logMessage.level),
+          );
+        },
+      ),
+    );
+  }
+}

+ 10 - 10
mobile/pubspec.lock

@@ -266,7 +266,7 @@ packages:
       name: ffi
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "1.2.1"
+    version: "2.0.1"
   file:
     dependency: transitive
     description:
@@ -554,12 +554,12 @@ packages:
     source: hosted
     version: "1.0.1"
   logging:
-    dependency: transitive
+    dependency: "direct main"
     description:
       name: logging
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "1.0.2"
+    version: "1.1.0"
   matcher:
     dependency: transitive
     description:
@@ -629,7 +629,7 @@ packages:
       name: package_info_plus
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "1.4.2"
+    version: "1.4.3+1"
   package_info_plus_linux:
     dependency: transitive
     description:
@@ -664,7 +664,7 @@ packages:
       name: package_info_plus_windows
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "1.0.5"
+    version: "2.1.0"
   path:
     dependency: "direct main"
     description:
@@ -699,7 +699,7 @@ packages:
       name: path_provider_linux
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "2.1.6"
+    version: "2.1.7"
   path_provider_macos:
     dependency: transitive
     description:
@@ -720,7 +720,7 @@ packages:
       name: path_provider_windows
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "2.0.6"
+    version: "2.1.3"
   pedantic:
     dependency: transitive
     description:
@@ -998,14 +998,14 @@ packages:
       name: sqflite
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "2.0.2+1"
+    version: "2.2.0+3"
   sqflite_common:
     dependency: transitive
     description:
       name: sqflite_common
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "2.2.1+1"
+    version: "2.4.0+2"
   stack_trace:
     dependency: transitive
     description:
@@ -1257,7 +1257,7 @@ packages:
       name: win32
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "2.5.2"
+    version: "2.7.0"
   wkt_parser:
     dependency: transitive
     description:

+ 4 - 1
mobile/pubspec.yaml

@@ -47,6 +47,7 @@ dependencies:
 
   # easy to remove packages:
   image_picker: ^0.8.5+3 # only used to select user profile image from system gallery -> we can simply select an image from within immich?
+  logging: ^1.1.0
 
 dev_dependencies:
   flutter_test:
@@ -71,7 +72,9 @@ flutter:
     - family: SnowburstOne
       fonts:
         - asset: fonts/SnowburstOne.ttf
-
+    - family: Inconsolata
+      fonts:
+        - asset: fonts/Inconsolata-Regular.ttf
 flutter_icons:
   image_path_android: "assets/immich-logo-no-outline.png"
   image_path_ios: "assets/immich-logo-no-outline.png"

+ 1 - 1
server/apps/immich/src/api-v1/user/user.service.spec.ts

@@ -1,5 +1,5 @@
 import { UserEntity } from '@app/database/entities/user.entity';
-import { BadRequestException, NotFoundException, UnauthorizedException } from '@nestjs/common';
+import { BadRequestException, NotFoundException } from '@nestjs/common';
 import { newUserRepositoryMock } from '../../../test/test-utils';
 import { AuthUserDto } from '../../decorators/auth-user.decorator';
 import { IUserRepository } from './user-repository';