diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index fd3dadcadd..0adacb6494 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 0000000000..0d879bf3a4 Binary files /dev/null and b/mobile/fonts/Inconsolata-Regular.ttf differ diff --git a/mobile/lib/constants/hive_box.dart b/mobile/lib/constants/hive_box.dart index 9db1755053..704be3586e 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 d1148df360..811dca7cff 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 69000c3ba5..8b48934917 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 ddf58fea84..65f0eba624 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 d984a70af6..04e7e648e8 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 42c02a6eac..98778736e3 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 3a00ae8c81..313a53e5fb 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 d280114915..bd266c00cd 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 0000000000..ae22d97809 --- /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 0000000000..314d165070 Binary files /dev/null and b/mobile/lib/shared/models/immich_logger_message.model.g.dart differ diff --git a/mobile/lib/shared/providers/asset.provider.dart b/mobile/lib/shared/providers/asset.provider.dart index 39df1c58b4..10c2325c97 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 fcdd398cc0..d10a7a07a0 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 bc48762768..9b5e1e87c9 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 0000000000..dac4d27a27 --- /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 0000000000..1306d03eb5 --- /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 cbbf11d432..2870a23466 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 0796cc9df8..c479c27c7e 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 9962752cae..8539e88f46 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';