fix: harden OriginOS recognition tracking

This commit is contained in:
2026-08-31 11:29:04 +08:00
parent 9ccbdfead4
commit 477f83c5b1
17 changed files with 980 additions and 108 deletions
+13 -1
View File
@@ -197,8 +197,16 @@ class _MiaoJiAppState extends State<MiaoJiApp> with WidgetsBindingObserver {
Future<void> _restoreRecognitionServices() async {
await RecognitionImportService.configureNativeContext();
await ScreenshotChannel.waitForAccessibilityConnection();
await ScreenshotChannel.ensureRecognitionKeepAlive();
await RecognitionImportService.importAutomatic();
unawaited(_runSafely(_importAfterAccessibilityReconnect));
}
Future<void> _importAfterAccessibilityReconnect() async {
final status = await ScreenshotChannel.waitForAccessibilityConnection();
if (status.accessibilityConnected) {
await RecognitionImportService.importAutomatic();
}
}
Future<void> _refreshRemoteState() async {
@@ -231,6 +239,10 @@ class _MiaoJiAppState extends State<MiaoJiApp> with WidgetsBindingObserver {
await Future<void>.delayed(const Duration(milliseconds: 180));
final context = _rootNavigatorKey.currentContext;
if (!mounted || context == null || !context.mounted) return;
if (action['action'] == 'open_recognition_settings') {
router.push('/screenshot-settings');
return;
}
await RecognitionImportService.handleAction(context, action);
}
@@ -27,12 +27,15 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
Timer? _diagnosticRefreshTimer;
Timer? _previewExpiryTimer;
bool _diagnosticRefreshInFlight = false;
bool _connectionCheckInFlight = false;
bool _keepAliveStarting = false;
bool _recentsProtectionNoticeChecked = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_check(waitForConnection: true);
_check();
_diagnosticRefreshTimer = Timer.periodic(
const Duration(seconds: 2),
(_) => _refreshRunningDiagnostic(),
@@ -60,18 +63,16 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
void _refreshRunningDiagnostic() {
if (!mounted ||
_diagnosticRefreshInFlight ||
_status?.latestDiagnostic?.result != 'started') {
!{'started', 'waiting'}.contains(_status?.latestDiagnostic?.result)) {
return;
}
_diagnosticRefreshInFlight = true;
_check().whenComplete(() => _diagnosticRefreshInFlight = false);
}
Future<void> _check({bool waitForConnection = false}) async {
Future<void> _check({bool monitorConnection = true}) async {
try {
var status = waitForConnection
? await ScreenshotChannel.waitForAccessibilityConnection()
: await ScreenshotChannel.recognitionStatus();
var status = await ScreenshotChannel.recognitionStatus();
final invalid = <String>[
if (status.accessibilityEvents &&
(!status.accessibilityAuthorized ||
@@ -99,6 +100,14 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
_error = null;
});
_schedulePreviewExpiry(status);
unawaited(_showRecentsProtectionNotice(status));
if (monitorConnection &&
status.accessibilityAuthorized &&
!status.accessibilityConnected &&
!status.taskCleanerRecoveryNeeded &&
(status.accessibilityEvents || status.aiScreenshot)) {
unawaited(_monitorAccessibilityConnection());
}
} catch (_) {
if (!mounted) return;
setState(() {
@@ -108,6 +117,49 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
}
}
Future<void> _showRecentsProtectionNotice(RecognitionStatus status) async {
if (_recentsProtectionNoticeChecked || !status.recentsProtectionActive) {
return;
}
_recentsProtectionNoticeChecked = true;
final prefs = await SharedPreferences.getInstance();
const key = 'originos_recents_protection_notice_v1';
if (prefs.getBool(key) == true || !mounted) return;
await prefs.setBool(key, true);
if (!mounted) return;
_showMessage('记之已从最近任务隐藏,可从桌面图标重新打开;关闭全部识别后恢复。');
}
Future<void> _monitorAccessibilityConnection({
bool ensureKeepAlive = false,
}) async {
if (_connectionCheckInFlight) return;
_connectionCheckInFlight = true;
if (mounted) setState(() {});
try {
if (ensureKeepAlive) {
_keepAliveStarting = true;
if (mounted) setState(() {});
await ScreenshotChannel.ensureRecognitionKeepAlive();
}
await ScreenshotChannel.waitForAccessibilityConnection();
final status = await ScreenshotChannel.recognitionStatus();
if (!mounted) return;
setState(() {
_status = status;
_error = null;
});
_schedulePreviewExpiry(status);
} catch (_) {
if (!mounted) return;
setState(() => _error = '连接状态复核失败,请重试');
} finally {
_connectionCheckInFlight = false;
_keepAliveStarting = false;
if (mounted) setState(() {});
}
}
void _schedulePreviewExpiry(RecognitionStatus status) {
_previewExpiryTimer?.cancel();
final expiresAt = status.ocrDiagnosticPreviewExpiresAt;
@@ -182,7 +234,7 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
Future<void> _resumeAuthorization() async {
if (_authorizing) return;
await _check(waitForConnection: true);
await _check();
final key = _pendingAuthorizationKey;
final status = _status;
if (key == null || status == null) return;
@@ -433,12 +485,31 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
'仅在微信和支付宝疑似支付流程中读取可见文字,并按需在内存中进行本地截图 OCR;不保存图片、完整控件树,也不监听按键。',
authorized: status!.accessibilityAuthorized,
connected: status.accessibilityConnected,
statusLabel: status.accessibilityConnectionLabel,
statusLabel:
_connectionCheckInFlight && !status.accessibilityConnected
? '正在连接'
: status.accessibilityConnectionLabel,
recoveryMessage: status.accessibilityNeedsRecovery
? status.taskCleanerRecoveryNeeded
? 'OriginOS 清理了识别进程,系统仍保留授权,但无障碍服务已被标记故障。'
: '系统仍显示已授权,但 OriginOS 未重新绑定服务。请前往系统设置,将记之无障碍服务关闭后重新开启。'
: (status.accessibilityEvents || status.aiScreenshot) &&
status.keepAliveNeedsRecovery
? _keepAliveStarting
? '正在启动后台保护…'
: status.keepAliveError ?? '后台保护未运行,划掉应用后识别可能中断。'
: null,
onOpenSettings: ScreenshotChannel.openAccessibilitySettings,
onRetry:
status.accessibilityAuthorized &&
!status.accessibilityConnected
? () => _check(waitForConnection: true)
settingsLabel: '前往无障碍设置',
onRetry: status.accessibilityNeedsRecovery
? () => _monitorAccessibilityConnection()
: null,
onStartKeepAlive:
(status.accessibilityEvents || status.aiScreenshot) &&
status.keepAliveNeedsRecovery &&
!_keepAliveStarting
? () =>
_monitorAccessibilityConnection(ensureKeepAlive: true)
: null,
child: Column(
children: [
@@ -546,7 +617,12 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
status.notificationEvents ||
status.aiScreenshot) ...[
const SizedBox(height: 10),
_BackgroundKeepAliveCard(status: status),
_BackgroundKeepAliveCard(
status: status,
starting: _keepAliveStarting,
onStart: () =>
_monitorAccessibilityConnection(ensureKeepAlive: true),
),
],
const SizedBox(height: 10),
_RecognitionCard(
@@ -844,8 +920,14 @@ class _EvidencePill extends StatelessWidget {
class _BackgroundKeepAliveCard extends StatelessWidget {
final RecognitionStatus status;
final bool starting;
final VoidCallback onStart;
const _BackgroundKeepAliveCard({required this.status});
const _BackgroundKeepAliveCard({
required this.status,
required this.starting,
required this.onStart,
});
@override
Widget build(BuildContext context) {
@@ -883,9 +965,18 @@ class _BackgroundKeepAliveCard extends StatelessWidget {
),
),
Text(
status.batteryOptimizationIgnored ? '后台限制较少' : '需要设置',
status.recentsProtectionActive
? '任务清理防护中'
: status.keepAliveRunning
? '保护运行中'
: status.keepAliveExpected
? '保护未运行'
: status.batteryOptimizationIgnored
? '后台限制较少'
: '需要设置',
style: TextStyle(
color: status.batteryOptimizationIgnored
color:
status.keepAliveRunning || status.recentsProtectionActive
? AppTheme.primaryDeep
: AppTheme.orange,
fontSize: 10,
@@ -896,15 +987,29 @@ class _BackgroundKeepAliveCard extends StatelessWidget {
),
const SizedBox(height: 10),
Text(
'无障碍和通知监听由 Android 系统持续绑定。请允许记之后台活动,'
'${isVivo ? '并在 OriginOS 中开启自启动,' : ''}'
'否则系统清理进程后可能暂时收不到支付事件。',
status.recentsProtectionActive
? 'OriginOS 最近任务保护已开启。记之不会显示在最近任务中,请从桌面图标重新打开;关闭全部识别后会自动恢复任务卡。'
: '无障碍和通知监听由 Android 系统持续绑定。请允许记之后台活动,'
'${isVivo ? '并在 OriginOS 中开启自启动,' : ''}'
'否则系统清理进程后可能暂时收不到支付事件。',
style: TextStyle(
color: context.jz.text2,
fontSize: 11.5,
height: 1.55,
),
),
if (status.keepAliveNeedsRecovery) ...[
const SizedBox(height: 10),
SizedBox(
width: double.infinity,
child: JzActionButton(
label: starting ? '正在启动…' : '启动后台保护',
secondary: true,
icon: const Icon(Icons.shield_outlined, size: 18),
onPressed: starting ? null : onStart,
),
),
],
const SizedBox(height: 12),
Row(
children: [
@@ -941,6 +1046,9 @@ class _RecognitionCard extends StatelessWidget {
final Widget child;
final VoidCallback? onOpenSettings;
final VoidCallback? onRetry;
final VoidCallback? onStartKeepAlive;
final String? recoveryMessage;
final String settingsLabel;
const _RecognitionCard({
required this.icon,
@@ -952,12 +1060,15 @@ class _RecognitionCard extends StatelessWidget {
this.statusLabel,
this.onOpenSettings,
this.onRetry,
this.onStartKeepAlive,
this.recoveryMessage,
this.settingsLabel = '前往系统设置',
});
@override
Widget build(BuildContext context) {
final palette = context.jz;
final color = connected ? AppTheme.primary : AppTheme.ai;
final color = connected ? AppTheme.primary : AppTheme.orange;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
@@ -976,7 +1087,7 @@ class _RecognitionCard extends StatelessWidget {
decoration: BoxDecoration(
color: connected
? palette.primaryBackground
: palette.aiBackground,
: palette.warningBackground,
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: color, size: 21),
@@ -996,7 +1107,7 @@ class _RecognitionCard extends StatelessWidget {
decoration: BoxDecoration(
color: connected
? palette.primaryBackground
: palette.aiBackground,
: palette.warningBackground,
borderRadius: BorderRadius.circular(20),
),
child: Text(
@@ -1020,9 +1131,35 @@ class _RecognitionCard extends StatelessWidget {
description,
style: TextStyle(color: palette.text2, fontSize: 12, height: 1.55),
),
if (recoveryMessage != null) ...[
const SizedBox(height: 9),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.info_outline_rounded,
color: AppTheme.orange,
size: 18,
),
const SizedBox(width: 7),
Expanded(
child: Text(
recoveryMessage!,
style: TextStyle(
color: palette.text2,
fontSize: 11.5,
height: 1.5,
),
),
),
],
),
],
const SizedBox(height: 6),
child,
if (onOpenSettings != null || onRetry != null) ...[
if (onOpenSettings != null ||
onRetry != null ||
onStartKeepAlive != null) ...[
const SizedBox(height: 6),
Wrap(
spacing: 6,
@@ -1034,11 +1171,17 @@ class _RecognitionCard extends StatelessWidget {
icon: const Icon(Icons.refresh_rounded, size: 18),
label: const Text('重新检测'),
),
if (onStartKeepAlive != null)
TextButton.icon(
onPressed: onStartKeepAlive,
icon: const Icon(Icons.shield_outlined, size: 18),
label: const Text('启动后台保护'),
),
if (onOpenSettings != null)
TextButton.icon(
onPressed: onOpenSettings,
icon: const Icon(Icons.settings_outlined, size: 18),
label: const Text('前往系统设置'),
label: Text(settingsLabel),
),
],
),
@@ -49,6 +49,7 @@ class RecognitionDiagnosticDisplay {
'matched' || 'auto_ready' => '已识别',
'confirm' => '待确认',
'started' => '处理中',
'waiting' => '等待结果页',
'failed' => '失败',
_ => '未触发入账',
};
@@ -56,6 +57,7 @@ class RecognitionDiagnosticDisplay {
static String _summaryLabel(String result, String reason) {
if (reason == 'duplicate_result_surface') return '已合并';
if (result == 'waiting') return '等待结果页';
if (_captureFailureReasons.contains(reason)) return '截图失败';
if (_ocrNoResultReasons.contains(reason)) return 'OCR 无结果';
if (_ruleRejectedReasons.contains(reason) || result == 'rejected') {
@@ -65,6 +67,7 @@ class RecognitionDiagnosticDisplay {
'matched' || 'auto_ready' => '已识别',
'confirm' => '待确认',
'started' => '处理中',
'waiting' => '等待结果页',
'failed' => '失败',
_ => '没事件',
};
@@ -75,8 +78,9 @@ class RecognitionDiagnosticDisplay {
'history_page' => '当前是账单或交易历史页',
'blocked_status' => '当前状态为失败、处理中或已取消',
'no_text' => '截图中没有识别到文字',
'no_success_status' => '没有找到明确或弱完成状态,可开启诊断预览查看脱敏结果',
'payment_input_page' => '当前仍是付款输入或确认页面,已拒绝入账',
'no_success_status' => '暂未找到完成状态,正在按计划复核结果',
'payment_input_page' => '当前仍是付款输入或确认页面,等待结果页,不会提前入账',
'result_page_timeout' => '90 秒内未等到明确结果页,本次流程已停止追踪',
'direction_unknown' => '识别到完成状态,但无法确认收支方向',
'missing_amount' => '成功状态已识别,但没有找到金额',
'expected_amount_missing' => '结果页没有金额,且付款前金额不唯一或未捕获',
@@ -167,8 +171,7 @@ class RecognitionDiagnosticDisplay {
static const _ruleRejectedReasons = {
'history_page',
'blocked_status',
'no_success_status',
'payment_input_page',
'result_page_timeout',
'direction_unknown',
'missing_amount',
'expected_amount_missing',
@@ -95,6 +95,14 @@ class RecognitionStatus {
final DateTime? accessibilityLastConnectedAt;
final DateTime? accessibilityLastDisconnectedAt;
final DateTime? recognitionProcessStartedAt;
final bool keepAliveExpected;
final bool keepAliveRunning;
final String? keepAliveError;
final bool recentsProtectionExpected;
final bool recentsProtectionActive;
final DateTime? lastRecognitionExitAt;
final String? lastRecognitionExitReason;
final bool taskCleanerRecoveryNeeded;
final bool notificationAuthorized;
final bool notificationConnected;
final bool postNotificationsGranted;
@@ -116,6 +124,14 @@ class RecognitionStatus {
this.accessibilityLastConnectedAt,
this.accessibilityLastDisconnectedAt,
this.recognitionProcessStartedAt,
this.keepAliveExpected = false,
this.keepAliveRunning = false,
this.keepAliveError,
this.recentsProtectionExpected = false,
this.recentsProtectionActive = false,
this.lastRecognitionExitAt,
this.lastRecognitionExitReason,
this.taskCleanerRecoveryNeeded = false,
required this.notificationAuthorized,
required this.notificationConnected,
required this.postNotificationsGranted,
@@ -158,6 +174,17 @@ class RecognitionStatus {
'accessibilityLastDisconnectedAt',
),
recognitionProcessStartedAt: epochDate('recognitionProcessStartedAt'),
keepAliveExpected: value['keepAliveExpected'] as bool? ?? false,
keepAliveRunning: value['keepAliveRunning'] as bool? ?? false,
keepAliveError: value['keepAliveError']?.toString(),
recentsProtectionExpected:
value['recentsProtectionExpected'] as bool? ?? false,
recentsProtectionActive:
value['recentsProtectionActive'] as bool? ?? false,
lastRecognitionExitAt: epochDate('lastRecognitionExitAt'),
lastRecognitionExitReason: value['lastRecognitionExitReason']?.toString(),
taskCleanerRecoveryNeeded:
value['taskCleanerRecoveryNeeded'] as bool? ?? false,
notificationAuthorized: value['notificationAuthorized'] as bool? ?? false,
notificationConnected: value['notificationConnected'] as bool? ?? false,
postNotificationsGranted:
@@ -189,6 +216,8 @@ class RecognitionStatus {
!accessibilityConnected &&
accessibilityConnectionState == AccessibilityConnectionState.disconnected;
bool get keepAliveNeedsRecovery => keepAliveExpected && !keepAliveRunning;
String get accessibilityConnectionLabel =>
switch (accessibilityConnectionState) {
AccessibilityConnectionState.unauthorized => '未授权',
@@ -320,6 +349,7 @@ class SpeechEvent {
/// Android native capabilities for screenshots, AI progress and speech.
class ScreenshotChannel {
static const _channel = MethodChannel('com.miaoji/screenshot');
static Future<RecognitionStatus>? _activeAccessibilityConnectionWait;
static void Function(String path)? _screenshotReady;
static void Function(String error)? _screenshotError;
static void Function(SpeechEvent event)? _speechEvent;
@@ -472,13 +502,32 @@ class ScreenshotChannel {
static Future<RecognitionStatus> waitForAccessibilityConnection({
Duration timeout = const Duration(seconds: 5),
Duration interval = const Duration(milliseconds: 500),
}) {
final active = _activeAccessibilityConnectionWait;
if (active != null) return active;
final operation = _waitForAccessibilityConnection(
timeout: timeout,
interval: interval,
);
_activeAccessibilityConnectionWait = operation;
return operation.whenComplete(() {
if (identical(_activeAccessibilityConnectionWait, operation)) {
_activeAccessibilityConnectionWait = null;
}
});
}
static Future<RecognitionStatus> _waitForAccessibilityConnection({
required Duration timeout,
required Duration interval,
}) async {
var status = await recognitionStatus();
final recognitionEnabled =
status.accessibilityEvents || status.aiScreenshot;
if (!recognitionEnabled ||
!status.accessibilityAuthorized ||
status.accessibilityConnected) {
status.accessibilityConnected ||
status.taskCleanerRecoveryNeeded) {
return status;
}
final deadline = DateTime.now().add(timeout);
@@ -487,6 +536,7 @@ class ScreenshotChannel {
status = await recognitionStatus();
if (!status.accessibilityAuthorized ||
status.accessibilityConnected ||
status.taskCleanerRecoveryNeeded ||
!(status.accessibilityEvents || status.aiScreenshot)) {
return status;
}
@@ -494,6 +544,15 @@ class ScreenshotChannel {
return status;
}
static Future<bool> ensureRecognitionKeepAlive() async {
try {
return await _channel.invokeMethod<bool>('ensureRecognitionKeepAlive') ??
false;
} on MissingPluginException {
return false;
}
}
static Future<bool> clearRecognitionDiagnostic() async {
try {
return await _channel.invokeMethod<bool>('clearRecognitionDiagnostic') ??