From 48d5f039f98e790364f83e7bea7964876e3dbb28 Mon Sep 17 00:00:00 2001 From: Dr-Blank <64108942+Dr-Blank@users.noreply.github.com> Date: Thu, 3 Oct 2024 02:19:57 -0400 Subject: [PATCH] feat: add ability to get logs file from ui --- lib/features/logging/core/logger.dart | 36 +++ .../logging/providers/logs_provider.dart | 72 +++++ .../logging/providers/logs_provider.g.dart | 25 ++ lib/features/logging/view/logs_page.dart | 266 ++++++++++++++++++ lib/features/you/view/you_page.dart | 9 +- lib/main.dart | 11 +- lib/router/constants.dart | 6 + lib/router/router.dart | 9 + pubspec.lock | 44 ++- pubspec.yaml | 4 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 12 files changed, 474 insertions(+), 12 deletions(-) create mode 100644 lib/features/logging/core/logger.dart create mode 100644 lib/features/logging/providers/logs_provider.dart create mode 100644 lib/features/logging/providers/logs_provider.g.dart create mode 100644 lib/features/logging/view/logs_page.dart diff --git a/lib/features/logging/core/logger.dart b/lib/features/logging/core/logger.dart new file mode 100644 index 0000000..52eb099 --- /dev/null +++ b/lib/features/logging/core/logger.dart @@ -0,0 +1,36 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:logging/logging.dart'; +import 'package:logging_appenders/logging_appenders.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:vaani/shared/extensions/duration_format.dart'; + +Future getLoggingFilePath() async { + final Directory directory = await getApplicationDocumentsDirectory(); + return '${directory.path}/vaani.log'; +} + +Future initLogging() async { + final formatter = const DefaultLogRecordFormatter(); + if (kReleaseMode) { + Logger.root.level = Level.INFO; // is also the default + // Write to a file + RotatingFileAppender( + baseFilePath: await getLoggingFilePath(), + formatter: formatter, + ).attachToLogger(Logger.root); + } else { + Logger.root.level = Level.FINE; // Capture all logs + RotatingFileAppender( + baseFilePath: await getLoggingFilePath(), + formatter: formatter, + ).attachToLogger(Logger.root); + Logger.root.onRecord.listen((record) { + // Print log records to the console + debugPrint( + '${record.loggerName}: ${record.level.name}: ${record.time.time}: ${record.message}', + ); + }); + } +} diff --git a/lib/features/logging/providers/logs_provider.dart b/lib/features/logging/providers/logs_provider.dart new file mode 100644 index 0000000..0181acc --- /dev/null +++ b/lib/features/logging/providers/logs_provider.dart @@ -0,0 +1,72 @@ +import 'dart:io'; + +import 'package:archive/archive_io.dart'; +import 'package:logging/logging.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:vaani/features/logging/core/logger.dart'; + +part 'logs_provider.g.dart'; + +@riverpod +class Logs extends _$Logs { + @override + Future> build() async { + final path = await getLoggingFilePath(); + final file = File(path); + if (!file.existsSync()) { + return []; + } + final lines = await file.readAsLines(); + return lines.map(parseLogLine).toList(); + } + + Future clear() async { + final path = await getLoggingFilePath(); + final file = File(path); + await file.writeAsString(''); + state = AsyncData([]); + } + + Future getZipFilePath() async { + var encoder = ZipFileEncoder(); + encoder.create(await generateZipFilePath()); + encoder.addFile(File(await getLoggingFilePath())); + encoder.close(); + return encoder.zipPath; + } +} + +Future generateZipFilePath() async { + Directory appDocDirectory = await getTemporaryDirectory(); + return '${appDocDirectory.path}/vaani_logs.zip'; +} + +Level parseLevel(String level) { + return Level.LEVELS + .firstWhere((l) => l.name == level, orElse: () => Level.ALL); +} + +LogRecord parseLogLine(String line) { + // 2024-10-03 00:48:58.012400 INFO GoRouter - getting location for name: "logs" + + final RegExp logLineRegExp = RegExp( + r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{6}) (\w+) (\w+) - (.+)', + ); + + final match = logLineRegExp.firstMatch(line); + if (match == null) { + // return as is + return LogRecord(Level.ALL, line, 'Unknown'); + } + + final timeString = match.group(1)!; + final levelString = match.group(2)!; + final loggerName = match.group(3)!; + final message = match.group(4)!; + + final time = DateTime.parse(timeString); + final level = parseLevel(levelString); + + return LogRecord(level, message, loggerName, time); +} diff --git a/lib/features/logging/providers/logs_provider.g.dart b/lib/features/logging/providers/logs_provider.g.dart new file mode 100644 index 0000000..094893e --- /dev/null +++ b/lib/features/logging/providers/logs_provider.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'logs_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$logsHash() => r'901376741d17ddbb889d1b7b96bc2882289720a0'; + +/// See also [Logs]. +@ProviderFor(Logs) +final logsProvider = + AutoDisposeAsyncNotifierProvider>.internal( + Logs.new, + name: r'logsProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$logsHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$Logs = AutoDisposeAsyncNotifier>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/features/logging/view/logs_page.dart b/lib/features/logging/view/logs_page.dart new file mode 100644 index 0000000..063cdc3 --- /dev/null +++ b/lib/features/logging/view/logs_page.dart @@ -0,0 +1,266 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:logging/logging.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:vaani/features/logging/providers/logs_provider.dart'; +import 'package:vaani/main.dart'; + +class LogsPage extends HookConsumerWidget { + const LogsPage({super.key}); + @override + Widget build(BuildContext context, WidgetRef ref) { + final logs = ref.watch(logsProvider); + final theme = Theme.of(context); + final scrollController = useScrollController(); + return Scaffold( + appBar: AppBar( + title: const Text('Logs'), + actions: [ + IconButton( + tooltip: 'Clear logs', + icon: const Icon(Icons.delete_forever), + onPressed: () async { + // ask for confirmation + final shouldClear = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Clear logs?'), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(false); + }, + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(true); + }, + child: const Text('Clear'), + ), + ], + ); + }, + ); + if (shouldClear == true) { + ref.read(logsProvider.notifier).clear(); + } + }, + ), + IconButton( + tooltip: 'Share logs', + icon: const Icon(Icons.share), + onPressed: () async { + appLogger.info('Preparing logs for sharing'); + final zipLogFilePath = + await ref.read(logsProvider.notifier).getZipFilePath(); + + // submit logs + final result = await Share.shareXFiles([XFile(zipLogFilePath)]); + + switch (result.status) { + case ShareResultStatus.success: + appLogger.info('Share success'); + break; + case ShareResultStatus.dismissed: + appLogger.info('Share dismissed'); + break; + case ShareResultStatus.unavailable: + appLogger.severe('Share unavailable'); + break; + } + }, + ), + IconButton( + tooltip: 'Download logs', + icon: const Icon(Icons.download), + onPressed: () async { + appLogger.info('Preparing logs for download'); + final zipLogFilePath = + await ref.read(logsProvider.notifier).getZipFilePath(); + + // save to folder + String? outputFile = await FilePicker.platform.saveFile( + dialogTitle: 'Please select an output file:', + fileName: 'vaani_logs.zip', + ); + if (outputFile != null) { + try { + final file = File(outputFile); + final zipFile = File(zipLogFilePath); + await zipFile.copy(file.path); + } catch (e) { + appLogger.severe('Error saving file: $e'); + } + } else { + appLogger.info('Download cancelled'); + } + }, + ), + IconButton( + tooltip: 'Refresh logs', + icon: const Icon(Icons.refresh), + onPressed: () { + ref.invalidate(logsProvider); + }, + ), + IconButton( + tooltip: 'Scroll to top', + icon: const Icon(Icons.arrow_upward), + onPressed: () { + scrollController.animateTo( + 0, + duration: const Duration(milliseconds: 500), + curve: Curves.easeInOut, + ); + }, + ), + ], + ), + // a column with listview.builder and a scrollable list of logs + body: Column( + children: [ + // a filter for log levels, loggers, and search + // TODO: implement filters and search + + Expanded( + child: logs.when( + data: (logRecords) { + return Scrollbar( + controller: scrollController, + child: ListView.builder( + controller: scrollController, + itemCount: logRecords.length, + itemBuilder: (context, index) { + final logRecord = logRecords[index]; + return LogRecordTile(logRecord: logRecord, theme: theme); + }, + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stackTrace) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Error loading logs'), + ElevatedButton( + onPressed: () { + ref.invalidate(logsProvider); + }, + child: const Text('Retry'), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} + +class LogRecordTile extends StatelessWidget { + final LogRecord logRecord; + final ThemeData theme; + const LogRecordTile({ + required this.logRecord, + required this.theme, + super.key, + }); + @override + Widget build(BuildContext context) { + return ListTile( + leading: CircleAvatar( + backgroundColor: getLogLevelColor(logRecord.level, theme), + child: Icon( + getLogLevelIcon(logRecord.level), + color: getLogLevelTextColor(logRecord.level, theme), + ), + ), + title: Text(logRecord.loggerName), + subtitle: Text(logRecord.message), + onTap: () { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + icon: Icon(getLogLevelIcon(logRecord.level)), + title: Text(logRecord.loggerName), + content: Text.rich( + TextSpan( + children: [ + TextSpan( + text: logRecord.time.toIso8601String(), + style: const TextStyle(fontStyle: FontStyle.italic), + ), + const TextSpan(text: '\n\n'), + TextSpan( + text: logRecord.message, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Close'), + ), + ], + ); + }, + ); + }, + ); + } +} + +IconData getLogLevelIcon(Level level) { + switch (level) { + case Level.INFO: + return (Icons.info); + case Level.WARNING: + return (Icons.warning); + case Level.SEVERE: + case Level.SHOUT: + return (Icons.error); + default: + return (Icons.bug_report); + } +} + +Color? getLogLevelColor(Level level, ThemeData theme) { + switch (level) { + case Level.INFO: + return theme.colorScheme.surfaceContainerLow; + case Level.WARNING: + return theme.colorScheme.surfaceBright; + case Level.SEVERE: + case Level.SHOUT: + return theme.colorScheme.errorContainer; + default: + return theme.colorScheme.primaryContainer; + } +} + +Color? getLogLevelTextColor(Level level, ThemeData theme) { + switch (level) { + case Level.INFO: + return theme.colorScheme.onSurface; + case Level.WARNING: + return theme.colorScheme.onSurface; + case Level.SEVERE: + case Level.SHOUT: + return theme.colorScheme.onErrorContainer; + default: + return theme.colorScheme.onPrimaryContainer; + } +} diff --git a/lib/features/you/view/you_page.dart b/lib/features/you/view/you_page.dart index a171a70..57f6a5b 100644 --- a/lib/features/you/view/you_page.dart +++ b/lib/features/you/view/you_page.dart @@ -41,14 +41,21 @@ class _YouPage extends HookConsumerWidget { // title: const Text('You'), backgroundColor: Colors.transparent, actions: [ + IconButton( + tooltip: 'Logs', + icon: const Icon(Icons.bug_report), + onPressed: () { + context.pushNamed(Routes.logs.name); + }, + ), // IconButton( // icon: const Icon(Icons.edit), // onPressed: () { // // Handle edit profile // }, // ), - // settings button IconButton( + tooltip: 'Settings', icon: const Icon(Icons.settings), onPressed: () { context.pushNamed(Routes.settings.name); diff --git a/lib/main.dart b/lib/main.dart index 8eef350..7f54ec1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,6 +4,7 @@ import 'package:logging/logging.dart'; import 'package:vaani/api/server_provider.dart'; import 'package:vaani/db/storage.dart'; import 'package:vaani/features/downloads/providers/download_manager.dart'; +import 'package:vaani/features/logging/core/logger.dart'; import 'package:vaani/features/playback_reporting/providers/playback_reporter_provider.dart'; import 'package:vaani/features/player/core/init.dart'; import 'package:vaani/features/player/providers/audiobook_player.dart'; @@ -12,7 +13,6 @@ import 'package:vaani/features/sleep_timer/providers/sleep_timer_provider.dart'; import 'package:vaani/router/router.dart'; import 'package:vaani/settings/api_settings_provider.dart'; import 'package:vaani/settings/app_settings_provider.dart'; -import 'package:vaani/shared/extensions/duration_format.dart'; import 'package:vaani/theme/theme.dart'; final appLogger = Logger('vaani'); @@ -20,13 +20,7 @@ final appLogger = Logger('vaani'); void main() async { WidgetsFlutterBinding.ensureInitialized(); // Configure the root Logger - Logger.root.level = Level.FINE; // Capture all logs - Logger.root.onRecord.listen((record) { - // Print log records to the console - debugPrint( - '${record.loggerName}: ${record.level.name}: ${record.time.time}: ${record.message}', - ); - }); + await initLogging(); // initialize the storage await initStorage(); @@ -34,7 +28,6 @@ void main() async { // initialize audio player await configurePlayer(); - // run the app runApp( const ProviderScope( diff --git a/lib/router/constants.dart b/lib/router/constants.dart index 4d8e7c1..9d01c29 100644 --- a/lib/router/constants.dart +++ b/lib/router/constants.dart @@ -90,6 +90,12 @@ class Routes { name: 'openIDCallback', parentRoute: onboarding, ); + + // logs page + static const logs = _SimpleRoute( + pathName: 'logs', + name: 'logs', + ); } // a class to store path diff --git a/lib/router/router.dart b/lib/router/router.dart index 8910fc3..28f9cca 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -5,6 +5,7 @@ import 'package:vaani/features/explore/view/explore_page.dart'; import 'package:vaani/features/explore/view/search_result_page.dart'; import 'package:vaani/features/item_viewer/view/library_item_page.dart'; import 'package:vaani/features/library_browser/view/library_browser_page.dart'; +import 'package:vaani/features/logging/view/logs_page.dart'; import 'package:vaani/features/onboarding/view/callback_page.dart'; import 'package:vaani/features/onboarding/view/onboarding_single_page.dart'; import 'package:vaani/features/you/view/server_manager.dart'; @@ -215,6 +216,14 @@ class MyAppRouter { ), ], ), + + // loggers page + GoRoute( + path: Routes.logs.localPath, + name: Routes.logs.name, + // builder: (context, state) => const LogsPage(), + pageBuilder: defaultPageBuilder(const LogsPage()), + ), ], ); diff --git a/pubspec.lock b/pubspec.lock index e675e19..b6de7a9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -47,7 +47,7 @@ packages: source: hosted version: "2.0.10" archive: - dependency: transitive + dependency: "direct main" description: name: archive sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d @@ -366,6 +366,22 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + dio: + dependency: transitive + description: + name: dio + sha256: "5598aa796bbf4699afd5c67c0f5f6e2ed542afc956884b9cd58c306966efc260" + url: "https://pub.dev" + source: hosted + version: "5.7.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "33259a9276d6cea88774a0000cfae0d861003497755969c92faa223108620dc8" + url: "https://pub.dev" + source: hosted + version: "2.0.0" duration_picker: dependency: "direct main" description: @@ -407,7 +423,7 @@ packages: source: hosted version: "7.0.0" file_picker: - dependency: transitive + dependency: "direct main" description: name: file_picker sha256: "167bb619cdddaa10ef2907609feb8a79c16dfa479d3afaf960f8e223f754bf12" @@ -782,6 +798,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + logging_appenders: + dependency: "direct main" + description: + name: logging_appenders + sha256: e329e7472f99416d0edaaf6451fe6c02dec91d34535bd252e284a0b94ab23d79 + url: "https://pub.dev" + source: hosted + version: "1.3.1" lottie: dependency: "direct main" description: @@ -1095,6 +1119,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: "468c43f285207c84bcabf5737f33b914ceb8eb38398b91e5e3ad1698d1b72a52" + url: "https://pub.dev" + source: hosted + version: "10.0.2" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "6ababf341050edff57da8b6990f11f4e99eaba837865e2e6defe16d039619db5" + url: "https://pub.dev" + source: hosted + version: "5.0.0" shelf: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index fc1bfdb..8a632f4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -32,6 +32,7 @@ isar_version: &isar_version ^4.0.0-dev.13 # define the version to be used dependencies: animated_list_plus: ^0.5.2 animated_theme_switcher: ^2.0.10 + archive: ^3.6.1 audio_service: ^0.18.15 audio_session: ^0.1.19 audio_video_progress_bar: ^2.0.2 @@ -44,6 +45,7 @@ dependencies: device_info_plus: ^10.1.0 duration_picker: ^1.2.0 easy_stepper: ^0.8.4 + file_picker: ^8.1.2 flutter: sdk: flutter flutter_animate: ^4.5.0 @@ -69,6 +71,7 @@ dependencies: just_audio_media_kit: ^2.0.4 list_wheel_scroll_view_nls: ^0.0.3 logging: ^1.2.0 + logging_appenders: ^1.3.1 lottie: ^3.1.0 material_symbols_icons: ^4.2785.1 media_kit_libs_linux: any @@ -84,6 +87,7 @@ dependencies: riverpod_annotation: ^2.3.5 scroll_loop_auto_scroll: ^0.0.5 sensors_plus: ^6.0.1 + share_plus: ^10.0.2 shelfsdk: path: ./shelfsdk shimmer: ^3.0.0 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 1e47bc9..8d09818 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -15,6 +16,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("IsarFlutterLibsPlugin")); MediaKitLibsWindowsAudioPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("MediaKitLibsWindowsAudioPluginCApi")); + SharePlusWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index c41e9ee..51689fc 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST isar_flutter_libs media_kit_libs_windows_audio + share_plus url_launcher_windows )