refa: logfile (#1764)

* refa: logfile

* opt: log page

* opt: raf log file

* remove old log

* update

Signed-off-by: bggRGjQaUbCoE <githubaccount56556@proton.me>

---------

Co-authored-by: bggRGjQaUbCoE <githubaccount56556@proton.me>
This commit is contained in:
My-Responsitories
2025-12-06 22:33:00 +08:00
committed by GitHub
parent 255e39b709
commit 0d273f6909
6 changed files with 539 additions and 156 deletions

View File

@@ -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());

View File

@@ -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<LogsPage> createState() => _LogsPageState();
}
typedef _LogInfo = ({Object? date, String body});
class _LogsPageState extends State<LogsPage> {
late File logsPath;
late String fileContent;
List<_LogInfo> logsContent = [];
DateTime? latestLog;
List<Report> logsContent = [];
Report? latestLog;
late bool enableLog = Pref.enableLog;
@override
@@ -37,7 +36,8 @@ class _LogsPageState extends State<LogsPage> {
@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<LogsPage> {
}
Future<void> 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<LogsPage> {
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) => <PopupMenuEntry<String>>[
if (kDebugMode)
const PopupMenuItem<String>(
value: 'assert',
child: Text('引发错误'),
),
PopupMenuItem<String>(
value: 'log',
child: Text('${enableLog ? '关闭' : '开启'}日志'),
@@ -165,78 +158,370 @@ class _LogsPageState extends State<LogsPage> {
],
),
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<String, dynamic> 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<Widget> 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<String, dynamic> deviceParameters;
final Map<String, dynamic> applicationParameters;
final Map<String, dynamic> customParameters;
final FlutterErrorDetails? errorDetails;
final PlatformType platformType;
bool isExpanded = false;
factory Report.fromJson(Map<String, dynamic> 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<String, dynamic>? deviceParameters,
Map<String, dynamic>? applicationParameters,
Map<String, dynamic>? 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<String, dynamic> 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)}';
}
}

View File

@@ -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());
}

View File

@@ -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<void> 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<File> 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<bool> 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;

View File

@@ -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<RandomAccessFile> _future;
JsonFileHandler._({
this.enableDeviceParameters = true,
this.enableApplicationParameters = true,
this.enableStackTrace = true,
this.enableCustomParameters = true,
this.printLogs = false,
this.handleWhenRejected = false,
});
static Future<JsonFileHandler?> 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<RandomAccessFile> add(
Future<RandomAccessFile> Function(RandomAccessFile) onValue,
) {
return _future = _future.then(onValue);
}
@override
Future<bool> handle(Report report, BuildContext? context) async {
try {
await _processReport(report);
return true;
} catch (exc, stackTrace) {
_printLog('Exception occurred: $exc stack: $stackTrace');
return false;
}
}
Future<void> _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<PlatformType> getSupportedPlatforms() => const [
PlatformType.android,
PlatformType.iOS,
PlatformType.linux,
PlatformType.macOS,
PlatformType.windows,
];
@override
bool shouldHandleWhenRejected() => handleWhenRejected;
}

View File

@@ -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);
}
}