diff --git a/lib/main.dart b/lib/main.dart index 02bd4ce7b..4d06e4e90 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,12 +10,12 @@ import 'package:PiliPlus/plugin/pl_player/controller.dart'; import 'package:PiliPlus/router/app_pages.dart'; import 'package:PiliPlus/services/account_service.dart'; import 'package:PiliPlus/services/download/download_service.dart'; -import 'package:PiliPlus/services/logger.dart'; import 'package:PiliPlus/services/service_locator.dart'; import 'package:PiliPlus/utils/app_scheme.dart'; import 'package:PiliPlus/utils/cache_manager.dart'; import 'package:PiliPlus/utils/calc_window_position.dart'; import 'package:PiliPlus/utils/date_utils.dart'; +import 'package:PiliPlus/utils/json_file_handler.dart'; import 'package:PiliPlus/utils/page_utils.dart'; import 'package:PiliPlus/utils/path_utils.dart'; import 'package:PiliPlus/utils/request_utils.dart'; @@ -175,15 +175,14 @@ void main() async { // 异常捕获 logo记录 final customParameters = { 'BuildConfig': - '''\n -Build Time: ${DateFormatUtils.format(BuildConfig.buildTime, format: DateFormatUtils.longFormatDs)} -Commit Hash: ${BuildConfig.commitHash}''', + '\nBuild Time: ${DateFormatUtils.format(BuildConfig.buildTime, format: DateFormatUtils.longFormatDs)}\n' + 'Commit Hash: ${BuildConfig.commitHash}', }; - final fileHandler = FileHandler(await LoggerUtils.getLogsPath()); + final fileHandler = await JsonFileHandler.init(); final Catcher2Options debugConfig = Catcher2Options( SilentReportMode(), [ - fileHandler, + ?fileHandler, ConsoleHandler( enableDeviceParameters: false, enableApplicationParameters: false, @@ -196,7 +195,7 @@ Commit Hash: ${BuildConfig.commitHash}''', final Catcher2Options releaseConfig = Catcher2Options( SilentReportMode(), [ - fileHandler, + ?fileHandler, ConsoleHandler(enableCustomParameters: true), ], customParameters: customParameters, @@ -205,7 +204,7 @@ Commit Hash: ${BuildConfig.commitHash}''', Catcher2( debugConfig: debugConfig, releaseConfig: releaseConfig, - runAppFunction: () => runApp(const MyApp()), + rootWidget: const MyApp(), ); } else { runApp(const MyApp()); diff --git a/lib/pages/setting/pages/logs.dart b/lib/pages/setting/pages/logs.dart index 4a75c2d9c..0cddb20f7 100644 --- a/lib/pages/setting/pages/logs.dart +++ b/lib/pages/setting/pages/logs.dart @@ -1,14 +1,17 @@ -import 'dart:io'; +import 'dart:async'; +import 'dart:convert'; import 'package:PiliPlus/common/constants.dart'; import 'package:PiliPlus/common/widgets/loading_widget/loading_widget.dart'; import 'package:PiliPlus/services/logger.dart'; +import 'package:PiliPlus/utils/date_utils.dart'; import 'package:PiliPlus/utils/page_utils.dart'; import 'package:PiliPlus/utils/storage.dart'; import 'package:PiliPlus/utils/storage_key.dart'; import 'package:PiliPlus/utils/storage_pref.dart'; import 'package:PiliPlus/utils/utils.dart'; -import 'package:flutter/foundation.dart' show kDebugMode; +import 'package:catcher_2/model/platform_type.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; @@ -19,13 +22,9 @@ class LogsPage extends StatefulWidget { State createState() => _LogsPageState(); } -typedef _LogInfo = ({Object? date, String body}); - class _LogsPageState extends State { - late File logsPath; - late String fileContent; - List<_LogInfo> logsContent = []; - DateTime? latestLog; + List logsContent = []; + Report? latestLog; late bool enableLog = Pref.enableLog; @override @@ -37,7 +36,8 @@ class _LogsPageState extends State { @override void dispose() { if (latestLog != null) { - if (DateTime.now().difference(latestLog!) >= const Duration(days: 14)) { + final time = latestLog!.dateTime; + if (DateTime.now().difference(time) >= const Duration(days: 14)) { LoggerUtils.clearLogs(); } } @@ -45,56 +45,35 @@ class _LogsPageState extends State { } Future getLog() async { - logsPath = await LoggerUtils.getLogsPath(); - fileContent = await logsPath.readAsString(); - logsContent = parseLogs(fileContent); - setState(() {}); - } - - List<_LogInfo> parseLogs(String fileContent) { - const String splitToken = - '======================================================================'; - List contentList = fileContent.split(splitToken).map((item) { - return item - .replaceAll( - '============================== CATCHER 2 LOG ==============================', - '${Constants.appName}错误日志\n********************', - ) - .replaceAll('DEVICE INFO', '设备信息') - .replaceAll('APP INFO', '应用信息') - .replaceAll('ERROR', '错误信息') - .replaceAll('STACK TRACE', '错误堆栈'); - }).toList(); - List<_LogInfo> result = []; - for (String i in contentList) { - Object? date; - String body = i - .split("\n") - .map((l) { - if (l.startsWith("Crash occurred on")) { - try { - date = DateTime.parse( - l.split("Crash occurred on")[1].trim(), - ); - } catch (e) { - if (kDebugMode) debugPrint(e.toString()); - date = l.toString(); - } - return ""; - } - return l; - }) - .where((l) => l.replaceAll("\n", "").trim().isNotEmpty) - .join("\n"); - if (date != null || body != '') { - result.add((date: date, body: body)); + final logsPath = await LoggerUtils.getLogsPath(); + logsContent = (await logsPath.readAsLines()).reversed.map((i) { + try { + final log = Report.fromJson(jsonDecode(i)); + latestLog ??= log.copyWith(); + return log; + } catch (e, s) { + return Report( + 'Parse log failed: $e\n\n\n$i', + s, + DateTime.now(), + const {}, + const {}, + const {}, + null, + PlatformType.unknown, + ); } + }).toList(); + if (mounted) { + setState(() {}); } - return result.reversed.toList(); } void copyLogs() { - Utils.copyText('```\n$fileContent\n```', needToast: false); + Utils.copyText( + '```\n${logsContent.join('\n\n')}```', + needToast: false, + ); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('复制成功')), @@ -140,9 +119,23 @@ class _LogsPageState extends State { clearLogsHandle(); break; default: + if (kDebugMode) { + Timer.periodic(const Duration(milliseconds: 3500), (timer) { + Utils.reportError('Manual'); + if (timer.tick > 3) { + timer.cancel(); + if (mounted) getLog(); + } + }); + } } }, itemBuilder: (BuildContext context) => >[ + if (kDebugMode) + const PopupMenuItem( + value: 'assert', + child: Text('引发错误'), + ), PopupMenuItem( value: 'log', child: Text('${enableLog ? '关闭' : '开启'}日志'), @@ -165,78 +158,370 @@ class _LogsPageState extends State { ], ), body: logsContent.isNotEmpty - ? ListView.separated( + ? Padding( padding: EdgeInsets.only( - left: padding.left, - right: padding.right, - bottom: padding.bottom + 100, + left: padding.left + 12, + right: padding.right + 12, ), - itemCount: logsContent.length, - itemBuilder: (context, index) { - final log = logsContent[index]; - if (log.date case DateTime date) { - latestLog ??= date; - } - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - spacing: 5, - children: [ - Row( - spacing: 10, - children: [ - Text( - log.date.toString(), - style: TextStyle( - fontSize: Theme.of( - context, - ).textTheme.titleMedium!.fontSize, - ), - ), - TextButton.icon( - style: TextButton.styleFrom( - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: VisualDensity.compact, - ), - onPressed: () { - Utils.copyText( - '```\n${log.body}\n```', - needToast: false, - ); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - '已将 ${log.date} 复制至剪贴板', - ), - ), - ); - } - }, - icon: const Icon(Icons.copy_outlined, size: 16), - label: const Text('复制'), - ), - ], + child: CustomScrollView( + slivers: [ + if (latestLog != null) + SliverToBoxAdapter( + child: Padding( + padding: const .only(bottom: 12), + child: InfoCard(report: latestLog!), ), - Card( - child: Padding( - padding: const EdgeInsets.all(12.0), - child: SelectableText(log.body), - ), - ), - ], + ), + SliverPadding( + padding: EdgeInsets.only(bottom: padding.bottom + 100), + sliver: SliverList.separated( + itemCount: logsContent.length, + itemBuilder: (context, index) => + ReportCard(report: logsContent[index]), + separatorBuilder: (_, _) => const SizedBox(height: 12), + ), ), - ); - }, - separatorBuilder: (context, index) => const Divider( - indent: 12, - endIndent: 12, - height: 24, + ], ), ) : scrollErrorWidget(), ); } } + +class InfoCard extends StatelessWidget { + final Report report; + + const InfoCard({super.key, required this.report}); + + Widget _buildMapSection( + Color color, + String title, + Map map, + ) { + if (map.isEmpty) { + return const SizedBox.shrink(); + } + + return Column( + spacing: 4, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + Text( + title, + style: TextStyle( + fontWeight: FontWeight.bold, + color: color, + fontSize: 16, + ), + ), + ...map.entries.map( + (entry) => Text.rich( + TextSpan( + children: [ + TextSpan( + text: '• ${entry.key}: ', + style: const TextStyle(fontWeight: FontWeight.w500), + ), + TextSpan( + text: entry.value.toString(), + ), + ], + ), + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + final colorScheme = ColorScheme.of(context); + return _card([ + Row( + spacing: 8, + children: [ + Icon( + Icons.info_outline, + size: 22, + color: colorScheme.primary, + ), + const Expanded( + child: Text( + '相关信息', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + style: IconButton.styleFrom( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + icon: Icon( + report.isExpanded ? Icons.expand_less : Icons.expand_more, + ), + onPressed: () { + report.isExpanded = !report.isExpanded; + (context as Element).markNeedsBuild(); + }, + ), + ], + ), + if (report.isExpanded) ...[ + _buildMapSection( + colorScheme.primary, + '设备信息', + report.deviceParameters, + ), + _buildMapSection( + colorScheme.primary, + '应用信息', + report.applicationParameters, + ), + _buildMapSection( + colorScheme.primary, + '编译信息', + report.customParameters, + ), + ], + ]); + } +} + +class ReportCard extends StatelessWidget { + final Report report; + + const ReportCard({super.key, required this.report}); + + @override + Widget build(BuildContext context) { + final colorScheme = ColorScheme.of(context); + late final stackTrace = report.stackTrace.toString().trim(); + final dateTime = DateFormatUtils.longFormatDs.format(report.dateTime); + return _card([ + Row( + children: [ + Icon(Icons.error_outline, color: colorScheme.error, size: 22), + const SizedBox(width: 8), + Expanded( + child: Text( + report.error.toString(), + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + TextButton.icon( + style: TextButton.styleFrom( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + foregroundColor: colorScheme.secondary, + ), + onPressed: () { + Utils.copyText('```\n$report```', needToast: false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('已将 $dateTime 复制至剪贴板')), + ); + }, + icon: const Icon( + Icons.copy_outlined, + size: 16, + ), + label: const Text('复制'), + ), + IconButton( + style: IconButton.styleFrom( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + icon: Icon( + report.isExpanded ? Icons.expand_less : Icons.expand_more, + ), + onPressed: () { + report.isExpanded = !report.isExpanded; + (context as Element).markNeedsBuild(); + }, + ), + ], + ), + const SizedBox(height: 8), + Row( + spacing: 4, + children: [ + Icon( + Icons.access_time, + size: 16, + color: colorScheme.outline, + ), + Text( + dateTime, + style: TextStyle( + height: 1.2, + color: colorScheme.outline, + ), + ), + ], + ), + if (report.isExpanded) ...[ + const SizedBox(height: 16), + Text( + '错误详情', + style: TextStyle( + fontWeight: FontWeight.bold, + color: colorScheme.error, + fontSize: 16, + ), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: const BorderRadius.all(Radius.circular(8)), + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.5), + ), + ), + child: SelectableText( + report.error.toString(), + style: TextStyle( + fontFamily: 'Monospace', + color: colorScheme.onSurfaceVariant, + ), + ), + ), + // stackTrace may be null or String("null") or blank + if (stackTrace.isNotEmpty && stackTrace != 'null') ...[ + const SizedBox(height: 16), + Text( + '堆栈跟踪', + style: TextStyle( + fontWeight: FontWeight.bold, + color: colorScheme.error, + fontSize: 16, + ), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: const BorderRadius.all(Radius.circular(8)), + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.5), + ), + ), + child: SelectableText( + stackTrace, + style: TextStyle( + fontFamily: 'Monospace', + fontSize: 13, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ], + ]); + } +} + +Widget _card(List contents) { + return Card( + child: Padding( + padding: const .all(12), + child: Column( + crossAxisAlignment: .stretch, + children: contents, + ), + ), + ); +} + +class Report { + Report( + this.error, + this.stackTrace, + this.dateTime, + this.deviceParameters, + this.applicationParameters, + this.customParameters, + this.errorDetails, + this.platformType, + ); + + final dynamic error; + + final dynamic stackTrace; + + final DateTime dateTime; + + final Map deviceParameters; + + final Map applicationParameters; + + final Map customParameters; + + final FlutterErrorDetails? errorDetails; + + final PlatformType platformType; + + bool isExpanded = false; + + factory Report.fromJson(Map json) => Report( + json['error'], + json['stackTrace'], + DateTime.tryParse(json['dateTime'] ?? '') ?? DateTime(1970), + json['deviceParameters'] ?? const {}, + json['applicationParameters'] ?? const {}, + json['customParameters'] ?? const {}, + null, + PlatformType.values.byName(json['platformType']), + ); + + Report copyWith({ + dynamic error, + dynamic stackTrace, + DateTime? dateTime, + Map? deviceParameters, + Map? applicationParameters, + Map? customParameters, + FlutterErrorDetails? errorDetails, + PlatformType? platformType, + }) { + return Report( + error ?? this.error, + stackTrace ?? this.stackTrace, + dateTime ?? this.dateTime, + deviceParameters ?? this.deviceParameters, + applicationParameters ?? this.applicationParameters, + customParameters ?? this.customParameters, + errorDetails ?? this.errorDetails, + platformType ?? this.platformType, + ); + } + + String _params2String(Map params) { + return params.entries + .map((entry) => '${entry.key}: ${entry.value}\n') + .join(); + } + + @override + String toString() { + return '------- DEVICE INFO -------\n${_params2String(deviceParameters)}' + '------- APP INFO -------\n${_params2String(applicationParameters)}' + '------- ERROR -------\n$error\n' + '------- STACK TRACE -------\n${stackTrace.toString().trim()}\n' + '------- CUSTOM INFO -------\n${_params2String(customParameters)}'; + } +} diff --git a/lib/plugin/pl_player/controller.dart b/lib/plugin/pl_player/controller.dart index 45a33307c..fb970c42b 100644 --- a/lib/plugin/pl_player/controller.dart +++ b/lib/plugin/pl_player/controller.dart @@ -1084,7 +1084,7 @@ class PlPlayerController { if (kDebugMode) videoPlayerController!.stream.log.listen(((PlayerLog log) { if (log.level == 'error' || log.level == 'fatal') { - Utils.reportError(log.text, null, log.prefix); + Utils.reportError('${log.prefix}: ${log.text}', null); } else { debugPrint(log.toString()); } diff --git a/lib/services/logger.dart b/lib/services/logger.dart index 0d5c66de6..60bcd48ab 100644 --- a/lib/services/logger.dart +++ b/lib/services/logger.dart @@ -1,5 +1,9 @@ import 'dart:io'; +import 'package:PiliPlus/utils/extension.dart'; +import 'package:PiliPlus/utils/json_file_handler.dart'; +import 'package:PiliPlus/utils/storage_pref.dart'; +import 'package:catcher_2/catcher_2.dart'; import 'package:logger/logger.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; @@ -10,44 +14,48 @@ class PiliLogger extends Logger { PiliLogger() : super(); @override - Future log( + void log( Level level, dynamic message, { Object? error, StackTrace? stackTrace, DateTime? time, - }) async { + }) { if (level == Level.error || level == Level.fatal) { - // 添加至文件末尾 - File logFile = await LoggerUtils.getLogsPath(); - logFile.writeAsString( - "**${DateTime.now()}** \n $message \n $stackTrace", - mode: FileMode.writeOnlyAppend, - ); + Catcher2.reportCheckedError(error, stackTrace); } - super.log(level, "$message", error: error, stackTrace: stackTrace); + super.log(level, message, error: error, stackTrace: stackTrace, time: time); } } -class LoggerUtils { +abstract final class LoggerUtils { static File? _logFile; static Future getLogsPath() async { if (_logFile != null) return _logFile!; String dir = (await getApplicationDocumentsDirectory()).path; - final String filename = p.join(dir, ".pili_logs"); + final String filename = p.join(dir, '.pili_logs.json'); final File file = File(filename); if (!file.existsSync()) { await file.create(recursive: true); + // TODO: remove after next two versions + final oldFile = File(p.join(dir, '.pili_logs')); + if (oldFile.existsSync()) oldFile.tryDel(); } return _logFile = file; } static Future clearLogs() async { - final file = await getLogsPath(); try { - await file.writeAsBytes(const [], flush: true); + if (Pref.enableLog) { + await JsonFileHandler.add( + (raf) => raf.setPosition(0).then((raf) => raf.truncate(0)), + ); + } else { + final file = await getLogsPath(); + await file.writeAsBytes(const [], flush: true); + } } catch (e) { // if (kDebugMode) debugPrint('Error clearing file: $e'); return false; diff --git a/lib/utils/json_file_handler.dart b/lib/utils/json_file_handler.dart new file mode 100644 index 000000000..5330eeb90 --- /dev/null +++ b/lib/utils/json_file_handler.dart @@ -0,0 +1,103 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:PiliPlus/services/logger.dart' show LoggerUtils; +import 'package:catcher_2/model/platform_type.dart'; +import 'package:catcher_2/model/report.dart'; +import 'package:catcher_2/model/report_handler.dart'; +import 'package:flutter/material.dart'; + +class JsonFileHandler extends ReportHandler { + final bool enableDeviceParameters; + final bool enableApplicationParameters; + final bool enableStackTrace; + final bool enableCustomParameters; + final bool printLogs; + final bool handleWhenRejected; + + static late Future _future; + + JsonFileHandler._({ + this.enableDeviceParameters = true, + this.enableApplicationParameters = true, + this.enableStackTrace = true, + this.enableCustomParameters = true, + this.printLogs = false, + this.handleWhenRejected = false, + }); + + static Future init({ + bool enableDeviceParameters = true, + bool enableApplicationParameters = true, + bool enableStackTrace = true, + bool enableCustomParameters = true, + bool printLogs = false, + bool handleWhenRejected = false, + }) async { + try { + final raf = await (await LoggerUtils.getLogsPath()).open( + mode: FileMode.writeOnlyAppend, + ); + await raf.writeFrom(const []); + await raf.flush(); + _future = Future.syncValue(raf); + return JsonFileHandler._( + enableDeviceParameters: enableDeviceParameters, + enableApplicationParameters: enableApplicationParameters, + enableStackTrace: enableStackTrace, + enableCustomParameters: enableCustomParameters, + printLogs: printLogs, + handleWhenRejected: handleWhenRejected, + ); + } catch (e, s) { + debugPrintStack(stackTrace: s, label: e.toString()); + return null; + } + } + + static Future add( + Future Function(RandomAccessFile) onValue, + ) { + return _future = _future.then(onValue); + } + + @override + Future handle(Report report, BuildContext? context) async { + try { + await _processReport(report); + return true; + } catch (exc, stackTrace) { + _printLog('Exception occurred: $exc stack: $stackTrace'); + return false; + } + } + + Future _processReport(Report report) { + _printLog('Writing report to file'); + final json = report.toJson( + enableDeviceParameters: enableDeviceParameters, + enableApplicationParameters: enableApplicationParameters, + enableStackTrace: enableStackTrace, + enableCustomParameters: enableCustomParameters, + ); + return add((raf) => raf.writeString('${jsonEncode(json)}\n')); + } + + void _printLog(String log) { + if (printLogs) { + logger.info(log); + } + } + + @override + List getSupportedPlatforms() => const [ + PlatformType.android, + PlatformType.iOS, + PlatformType.linux, + PlatformType.macOS, + PlatformType.windows, + ]; + + @override + bool shouldHandleWhenRejected() => handleWhenRejected; +} diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index 4b9b84c63..3a841d5ad 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -4,10 +4,10 @@ import 'dart:io'; import 'dart:math' show Random; import 'package:PiliPlus/common/constants.dart'; +import 'package:catcher_2/catcher_2.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:file_picker/file_picker.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; @@ -163,19 +163,7 @@ abstract class Utils { /// containing the `catch` block with /// `@pragma('vm:notify-debugger-on-exception')` to allow an attached debugger /// to treat the exception as unhandled. - static void reportError( - Object exception, [ - StackTrace? stack, - String? library = Constants.appName, - bool silent = false, - ]) { - FlutterError.reportError( - FlutterErrorDetails( - exception: exception, - stack: stack, - library: library, - silent: silent, - ), - ); + static void reportError(Object exception, [StackTrace? stack]) { + Catcher2.reportCheckedError(exception, stack); } }