From 024177515de9164d4209b436a02c3beab58bd3af Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 27 Nov 2022 14:34:19 -0600 Subject: [PATCH] feat(mobile) Add in app logging to show app's log information (#1014) --- mobile/assets/i18n/en-US.json | 1 + mobile/fonts/Inconsolata-Regular.ttf | Bin 0 -> 97864 bytes mobile/lib/constants/hive_box.dart | 3 + mobile/lib/main.dart | 7 + .../background.service.dart | 1 - .../backup/providers/backup.provider.dart | 52 +++--- .../ui/profile_drawer/profile_drawer.dart | 29 +++- .../lib/modules/login/views/login_page.dart | 55 ++++++- mobile/lib/routing/router.dart | 31 ++-- mobile/lib/routing/router.gr.dart | 19 ++- .../models/immich_logger_message.model.dart | 34 ++++ .../models/immich_logger_message.model.g.dart | 53 ++++++ .../lib/shared/providers/asset.provider.dart | 20 +-- .../providers/release_info.provider.dart | 6 +- .../shared/providers/websocket.provider.dart | 47 +++--- .../services/immich_logger.service.dart | 87 ++++++++++ mobile/lib/shared/views/app_log_page.dart | 153 ++++++++++++++++++ mobile/pubspec.lock | 20 +-- mobile/pubspec.yaml | 5 +- .../src/api-v1/user/user.service.spec.ts | 2 +- 20 files changed, 539 insertions(+), 86 deletions(-) create mode 100644 mobile/fonts/Inconsolata-Regular.ttf create mode 100644 mobile/lib/shared/models/immich_logger_message.model.dart create mode 100644 mobile/lib/shared/models/immich_logger_message.model.g.dart create mode 100644 mobile/lib/shared/services/immich_logger.service.dart create mode 100644 mobile/lib/shared/views/app_log_page.dart diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index fd3dadcad..0adacb649 100644 --- a/mobile/assets/i18n/en-US.json +++ b/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", diff --git a/mobile/fonts/Inconsolata-Regular.ttf b/mobile/fonts/Inconsolata-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..0d879bf3a474dbd6989e73565cae9bfd752cef12 GIT binary patch literal 97864 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 literal 0 HcmV?d00001 diff --git a/mobile/lib/constants/hive_box.dart b/mobile/lib/constants/hive_box.dart index 9db175505..704be3586 100644 --- a/mobile/lib/constants/hive_box.dart +++ b/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 \ No newline at end of file diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index d1148df36..811dca7cf 100644 --- a/mobile/lib/main.dart +++ b/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(immichLoggerBox), Hive.openBox(userInfoBox), Hive.openBox(hiveLoginInfoBox), Hive.openBox(hiveGithubReleaseInfoBox), @@ -58,6 +62,9 @@ void main() async { } } + // Initialize Immich Logger Service + ImmichLogger().init(); + runApp( EasyLocalization( supportedLocales: locales, diff --git a/mobile/lib/modules/backup/background_service/background.service.dart b/mobile/lib/modules/backup/background_service/background.service.dart index 69000c3ba..8b4893491 100644 --- a/mobile/lib/modules/backup/background_service/background.service.dart +++ b/mobile/lib/modules/backup/background_service/background.service.dart @@ -349,7 +349,6 @@ class BackgroundService { Hive.openBox(duplicatedAssetsBox), Hive.openBox(hiveBackupInfoBox), ]); - ApiService apiService = ApiService(); apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey)); apiService.setAccessToken(Hive.box(userInfoBox).get(accessTokenKey)); diff --git a/mobile/lib/modules/backup/providers/backup.provider.dart b/mobile/lib/modules/backup/providers/backup.provider.dart index ddf58fea8..65f0eba62 100644 --- a/mobile/lib/modules/backup/providers/backup.provider.dart +++ b/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 { getBackupInfo(); } + final log = Logger('BackupNotifier'); final BackupService _backupService; final ServerInfoService _serverInfoService; final AuthenticationState _authState; @@ -218,13 +219,16 @@ class BackupNotifier extends StateNotifier { ); 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 { 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 { ); 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 { 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 { // 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 { _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 { 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 { 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 { await Hive.box(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(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(); } diff --git a/mobile/lib/modules/home/ui/profile_drawer/profile_drawer.dart b/mobile/lib/modules/home/ui/profile_drawer/profile_drawer.dart index d984a70af..04e7e648e 100644 --- a/mobile/lib/modules/home/ui/profile_drawer/profile_drawer.dart +++ b/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(), ], ), diff --git a/mobile/lib/modules/login/views/login_page.dart b/mobile/lib/modules/login/views/login_page.dart index 42c02a6ea..98778736e 100644 --- a/mobile/lib/modules/login/views/login_page.dart +++ b/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()); + }, + ), + ], + ), + ), + ), ); } } diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 3a00ae8c8..313a53e5f 100644 --- a/mobile/lib/routing/router.dart +++ b/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/views/library_page.dart'; -import 'package:immich_mobile/modules/asset_viewer/views/gallery_viewer.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/failed_backup_status_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/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/search/views/search_page.dart'; +import 'package:immich_mobile/modules/search/views/search_result_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 { diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index d28011491..bd266c00c 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -142,6 +142,14 @@ class _$AppRouter extends RootStackRouter { return MaterialPageX( routeData: routeData, child: const SettingsPage()); }, + AppLogRoute.name: (routeData) { + return CustomPage( + routeData: routeData, + child: const AppLogPage(), + transitionsBuilder: TransitionsBuilders.slideBottom, + opaque: true, + barrierDismissible: false); + }, HomeRoute.name: (routeData) { return MaterialPageX( 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 { static const String name = 'SettingsRoute'; } +/// generated route for +/// [AppLogPage] +class AppLogRoute extends PageRouteInfo { + const AppLogRoute() : super(AppLogRoute.name, path: '/app-log-page'); + + static const String name = 'AppLogRoute'; +} + /// generated route for /// [HomePage] class HomeRoute extends PageRouteInfo { diff --git a/mobile/lib/shared/models/immich_logger_message.model.dart b/mobile/lib/shared/models/immich_logger_message.model.dart new file mode 100644 index 000000000..ae22d9780 --- /dev/null +++ b/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)'; + } +} diff --git a/mobile/lib/shared/models/immich_logger_message.model.g.dart b/mobile/lib/shared/models/immich_logger_message.model.g.dart new file mode 100644 index 000000000..314d16507 --- /dev/null +++ b/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 { + @override + final int typeId = 3; + + @override + ImmichLoggerMessage read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + 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; +} diff --git a/mobile/lib/shared/providers/asset.provider.dart b/mobile/lib/shared/providers/asset.provider.dart index 39df1c58b..10c2325c9 100644 --- a/mobile/lib/shared/providers/asset.provider.dart +++ b/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> { 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> { 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> { final List currentLocal = state.slice(0, remoteBegin); List? newRemote = await remoteTask; List? 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 _combineLocalAndRemoteAssets({ @@ -155,8 +155,8 @@ class AssetNotifier extends StateNotifier> { 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 []; diff --git a/mobile/lib/shared/providers/release_info.provider.dart b/mobile/lib/shared/providers/release_info.provider.dart index fcdd398cc..d10a7a07a 100644 --- a/mobile/lib/shared/providers/release_info.provider.dart +++ b/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 { 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 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; diff --git a/mobile/lib/shared/providers/websocket.provider.dart b/mobile/lib/shared/providers/websocket.provider.dart index bc4876276..9b5e1e87c 100644 --- a/mobile/lib/shared/providers/websocket.provider.dart +++ b/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 { +class WebsocketNotifier extends StateNotifier { 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 { 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 { ); 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 { } 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 { } final websocketProvider = - StateNotifierProvider((ref) { + StateNotifierProvider((ref) { return WebsocketNotifier(ref); }); diff --git a/mobile/lib/shared/services/immich_logger.service.dart b/mobile/lib/shared/services/immich_logger.service.dart new file mode 100644 index 000000000..dac4d27a2 --- /dev/null +++ b/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 _box = Hive.box(immichLoggerBox); + + List 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 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, + ); + } +} diff --git a/mobile/lib/shared/views/app_log_page.dart b/mobile/lib/shared/views/app_log_page.dart new file mode 100644 index 000000000..1306d03eb --- /dev/null +++ b/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), + ); + }, + ), + ); + } +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index cbbf11d43..2870a2346 100644 --- a/mobile/pubspec.lock +++ b/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: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 0796cc9df..c479c27c7 100644 --- a/mobile/pubspec.yaml +++ b/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" diff --git a/server/apps/immich/src/api-v1/user/user.service.spec.ts b/server/apps/immich/src/api-v1/user/user.service.spec.ts index 9962752ca..8539e88f4 100644 --- a/server/apps/immich/src/api-v1/user/user.service.spec.ts +++ b/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';