此版本包含: 1. 新增位置消息发送与展示功能 2. 完善多语言本地化文案 3. 新增安卓端管理空间Activity与图标背景 4. 优化摇一摇开关逻辑与深度链接配置 5. 新增信息流平台过滤与A/B测试后台功能 6. 更新签名配置与构建脚本 7. 修复若干已知问题与代码优化
298 lines
8.8 KiB
Dart
298 lines
8.8 KiB
Dart
/// ============================================================
|
||
/// 闲言APP — Catcher2 配置服务
|
||
/// 创建时间: 2026-05-21
|
||
/// 更新时间: 2026-06-06
|
||
/// 作用: 统一管理 Catcher2 异常捕获开关与动态配置更新
|
||
/// 上次更新: 错误弹窗中文化改英文、复制显示字数上限提升至2000
|
||
/// ============================================================
|
||
|
||
import 'package:catcher_2/catcher_2.dart';
|
||
import 'package:catcher_2/model/platform_type.dart';
|
||
import 'package:flutter/cupertino.dart';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter/material.dart' show SelectableText;
|
||
import 'package:flutter/services.dart';
|
||
|
||
import '../router/app_router.dart' show rootNavigatorKey;
|
||
import '../storage/kv_storage.dart';
|
||
import '../utils/logger.dart';
|
||
import 'crash_log_service.dart';
|
||
|
||
class Catcher2ConfigService {
|
||
Catcher2ConfigService._();
|
||
static final Catcher2ConfigService instance = Catcher2ConfigService._();
|
||
|
||
Catcher2? _catcher2;
|
||
bool _initialized = false;
|
||
|
||
bool get isInitialized => _initialized;
|
||
|
||
bool get isEnabled {
|
||
return KvStorage.getBool('general_catcher_enabled') ?? true;
|
||
}
|
||
|
||
/// 初始化 Catcher2(使用 rootWidget 而非 runAppFunction,避免 Zone mismatch)
|
||
void init({required Widget rootWidget}) {
|
||
final enabled = isEnabled;
|
||
|
||
final debugConfig = enabled
|
||
? Catcher2Options(
|
||
CopyableDialogReportMode(),
|
||
[_ConsoleLogHandler()],
|
||
localizationOptions: [
|
||
LocalizationOptions.buildDefaultChineseOptions(),
|
||
],
|
||
)
|
||
: Catcher2Options(SilentReportMode(), []);
|
||
|
||
final releaseConfig = enabled
|
||
? Catcher2Options(SilentReportMode(), [_ConsoleLogHandler()])
|
||
: Catcher2Options(SilentReportMode(), []);
|
||
|
||
// 使用 rootWidget 而非 runAppFunction,Catcher2 会在内部调用 runApp
|
||
// 但不会创建新的 Zone,避免 Zone mismatch 警告
|
||
_catcher2 = Catcher2(
|
||
rootWidget: rootWidget,
|
||
debugConfig: debugConfig,
|
||
releaseConfig: releaseConfig,
|
||
profileConfig: Catcher2Options(SilentReportMode(), []),
|
||
navigatorKey: rootNavigatorKey,
|
||
);
|
||
|
||
_initialized = true;
|
||
Log.i(
|
||
'Catcher2ConfigService: 初始化完成 (enabled=$enabled, mode=${kDebugMode ? "debug" : "release"})',
|
||
);
|
||
}
|
||
|
||
void updateFromSettings(bool enabled) {
|
||
if (_catcher2 == null) {
|
||
Log.w('Catcher2ConfigService: Catcher2 未初始化,跳过更新');
|
||
return;
|
||
}
|
||
|
||
final debugConfig = enabled
|
||
? Catcher2Options(
|
||
CopyableDialogReportMode(),
|
||
[_ConsoleLogHandler()],
|
||
localizationOptions: [
|
||
LocalizationOptions.buildDefaultChineseOptions(),
|
||
],
|
||
)
|
||
: Catcher2Options(SilentReportMode(), []);
|
||
|
||
final releaseConfig = enabled
|
||
? Catcher2Options(SilentReportMode(), [_ConsoleLogHandler()])
|
||
: Catcher2Options(SilentReportMode(), []);
|
||
|
||
_catcher2!.updateConfig(
|
||
debugConfig: debugConfig,
|
||
releaseConfig: releaseConfig,
|
||
);
|
||
|
||
Log.i('Catcher2ConfigService: 配置已更新 (enabled=$enabled)');
|
||
}
|
||
}
|
||
|
||
class CopyableDialogReportMode extends ReportMode {
|
||
@override
|
||
void requestAction(Report report, BuildContext? context) {
|
||
_showDialog(report, context);
|
||
}
|
||
|
||
Future<void> _showDialog(Report report, BuildContext? context) async {
|
||
// 布局溢出错误不弹窗
|
||
final errorStr = report.error.toString();
|
||
if (errorStr.contains('overflowed') || errorStr.contains('RenderFlex')) {
|
||
onActionConfirmed(report); // 自动确认,不弹窗
|
||
return;
|
||
}
|
||
|
||
await Future<void>.delayed(Duration.zero);
|
||
if (context != null && context.mounted) {
|
||
showCupertinoDialog<void>(
|
||
context: context,
|
||
builder: (ctx) => _CopyableErrorDialog(
|
||
report: report,
|
||
onAccept: () {
|
||
onActionConfirmed(report);
|
||
Navigator.of(ctx).pop();
|
||
},
|
||
onReject: () {
|
||
onActionRejected(report);
|
||
Navigator.of(ctx).pop();
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
@override
|
||
bool isContextRequired() => true;
|
||
|
||
@override
|
||
List<PlatformType> getSupportedPlatforms() => PlatformType.values.toList();
|
||
}
|
||
|
||
class _CopyableErrorDialog extends StatelessWidget {
|
||
const _CopyableErrorDialog({
|
||
required this.report,
|
||
required this.onAccept,
|
||
required this.onReject,
|
||
});
|
||
|
||
final Report report;
|
||
final VoidCallback onAccept;
|
||
final VoidCallback onReject;
|
||
|
||
String get _errorId {
|
||
final ts = report.dateTime.millisecondsSinceEpoch
|
||
.toRadixString(36)
|
||
.toUpperCase();
|
||
return 'ERR-$ts';
|
||
}
|
||
|
||
String get _errorText {
|
||
final buffer = StringBuffer();
|
||
buffer.writeln('[$_errorId] ${report.dateTime.toIso8601String()}');
|
||
buffer.writeln();
|
||
buffer.writeln('Error: ${report.error}');
|
||
buffer.writeln();
|
||
buffer.writeln('Stack Trace:');
|
||
buffer.writeln(report.stackTrace);
|
||
return buffer.toString();
|
||
}
|
||
|
||
void _copyToClipboard(BuildContext context) {
|
||
// 限制剪贴板内容不超过400字,避免超长文本占用剪贴板
|
||
final text = _errorText;
|
||
final clipboardText = text.length > 400
|
||
? '${text.substring(0, 400)}...'
|
||
: text;
|
||
Clipboard.setData(ClipboardData(text: clipboardText));
|
||
HapticFeedback.lightImpact();
|
||
showCupertinoDialog<void>(
|
||
context: context,
|
||
barrierDismissible: true,
|
||
builder: (ctx) => CupertinoAlertDialog(
|
||
content: const Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 12),
|
||
child: Text('已复制到剪贴板', style: TextStyle(fontSize: 14)),
|
||
),
|
||
actions: [
|
||
CupertinoDialogAction(
|
||
onPressed: () => Navigator.of(ctx).pop(),
|
||
child: const Text('OK'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final errorStr = report.error.toString();
|
||
final displayError = errorStr.length > 2000
|
||
? '${errorStr.substring(0, 2000)}...'
|
||
: errorStr;
|
||
|
||
return CupertinoAlertDialog(
|
||
title: Column(
|
||
children: [
|
||
const Text('App Error'),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
_errorId,
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
color: CupertinoColors.secondaryLabel.resolveFrom(context),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
content: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const SizedBox(height: 8),
|
||
Container(
|
||
constraints: const BoxConstraints(maxHeight: 200),
|
||
child: SingleChildScrollView(
|
||
child: SelectableText(
|
||
displayError,
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
color: CupertinoColors.systemRed.resolveFrom(context),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'Time: ${report.dateTime.toIso8601String()}',
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
color: CupertinoColors.secondaryLabel.resolveFrom(context),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
actions: [
|
||
CupertinoDialogAction(
|
||
onPressed: () => _copyToClipboard(context),
|
||
child: const Text('Copy Details'),
|
||
),
|
||
CupertinoDialogAction(
|
||
isDestructiveAction: true,
|
||
onPressed: onReject,
|
||
child: const Text('Dismiss'),
|
||
),
|
||
CupertinoDialogAction(
|
||
isDefaultAction: true,
|
||
onPressed: onAccept,
|
||
child: const Text('Confirm'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ConsoleLogHandler extends ReportHandler {
|
||
DateTime? _lastLog;
|
||
static const _cooldown = Duration(seconds: 3);
|
||
|
||
@override
|
||
Future<bool> handle(Report report, BuildContext? context) async {
|
||
final now = DateTime.now();
|
||
final ts = report.dateTime.millisecondsSinceEpoch
|
||
.toRadixString(36)
|
||
.toUpperCase();
|
||
final errorId = 'ERR-$ts';
|
||
|
||
if (_lastLog == null || now.difference(_lastLog!) > _cooldown) {
|
||
_lastLog = now;
|
||
Log.e('[$errorId] ${report.error}');
|
||
if (kDebugMode) {
|
||
debugPrint('──────────────────────────────────────');
|
||
debugPrint('[$errorId] Catcher2 异常详情:');
|
||
debugPrint('Error: ${report.error}');
|
||
debugPrint('Stack: ${report.stackTrace}');
|
||
debugPrint('──────────────────────────────────────');
|
||
}
|
||
}
|
||
|
||
CrashLogService.instance.addLog(
|
||
error: report.error.toString(),
|
||
stackTrace: report.stackTrace.toString(),
|
||
);
|
||
|
||
return true;
|
||
}
|
||
|
||
@override
|
||
List<PlatformType> getSupportedPlatforms() => PlatformType.values.toList();
|
||
|
||
@override
|
||
bool isContextRequired() => false;
|
||
}
|