From 477f83c5b102d6bc4f742977e55866b604cf391c Mon Sep 17 00:00:00 2001 From: nanxun Date: Mon, 31 Aug 2026 11:29:04 +0800 Subject: [PATCH] fix: harden OriginOS recognition tracking --- .../android/app/src/main/AndroidManifest.xml | 56 +++-- .../main/kotlin/com/nx/miaoji/MainActivity.kt | 170 ++++++++++++++-- .../PaymentNotificationListenerService.kt | 1 + .../kotlin/com/nx/miaoji/PaymentParser.kt | 21 +- .../nx/miaoji/RecognitionBridgeProvider.kt | 48 +++-- .../nx/miaoji/RecognitionConnectionStore.kt | 70 +++++++ .../nx/miaoji/RecognitionKeepAliveReceiver.kt | 16 ++ .../nx/miaoji/RecognitionKeepAliveService.kt | 169 ++++++++++++++++ .../miaoji/ScreenshotAccessibilityService.kt | 155 ++++++++++++-- .../kotlin/com/nx/miaoji/PaymentParserTest.kt | 36 ++++ frontend/lib/app/app.dart | 14 +- .../settings/screenshot_settings_page.dart | 191 +++++++++++++++--- .../recognition_diagnostic_formatter.dart | 11 +- .../shared/services/screenshot_channel.dart | 61 +++++- frontend/test/api_models_test.dart | 17 ++ frontend/test/theme_and_time_test.dart | 16 +- frontend/test/visual_contract_test.dart | 36 +++- 17 files changed, 980 insertions(+), 108 deletions(-) create mode 100644 frontend/android/app/src/main/kotlin/com/nx/miaoji/RecognitionConnectionStore.kt create mode 100644 frontend/android/app/src/main/kotlin/com/nx/miaoji/RecognitionKeepAliveReceiver.kt create mode 100644 frontend/android/app/src/main/kotlin/com/nx/miaoji/RecognitionKeepAliveService.kt diff --git a/frontend/android/app/src/main/AndroidManifest.xml b/frontend/android/app/src/main/AndroidManifest.xml index b948273..17e4b25 100644 --- a/frontend/android/app/src/main/AndroidManifest.xml +++ b/frontend/android/app/src/main/AndroidManifest.xml @@ -4,8 +4,10 @@ - - + + + + - + @@ -69,10 +72,11 @@ - @@ -83,6 +87,7 @@ @@ -91,13 +96,36 @@ - - - + + + + + + + + + + + + + + android:grantUriPermissions="false" + android:process=":recognition"/> { result.success(recognitionStatus()) } - "setRecognitionToggle" -> { - val response = RecognitionBridge.call( + "setRecognitionToggle" -> { + val response = RecognitionBridge.call( this, RecognitionBridgeProvider.METHOD_SET_TOGGLE, extras = Bundle().apply { @@ -138,7 +147,7 @@ class MainActivity : FlutterActivity() { ) result.success(response?.getBoolean("success") == true) } - "configureRecognitionContext" -> { + "configureRecognitionContext" -> { val response = RecognitionBridge.call( this, RecognitionBridgeProvider.METHOD_SET_RUNTIME, @@ -181,7 +190,16 @@ class MainActivity : FlutterActivity() { putString("candidateId", call.argument("candidateId")) }, ) - result.success(response?.getBoolean("success") == true) + val changed = response?.getBoolean("success") == true + if (changed) { + updateRecentsProtection( + response.getBoolean("keepAliveExpected"), + ) + } + result.success(changed) + } + "ensureRecognitionKeepAlive" -> { + result.success(ensureRecognitionKeepAlive()) } "openAccessibilitySettings" -> { startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) @@ -223,17 +241,26 @@ class MainActivity : FlutterActivity() { super.onResume() updateInstallBridge?.onResume() vendorPushBridge?.onResume() - scheduleShortcutIfNeeded() - dispatchPendingScreenshot() - dispatchPendingRecognitionAction() + scheduleShortcutIfNeeded() + dispatchPendingScreenshot() + dispatchPendingRecognitionAction() + ensureRecognitionKeepAlive() + updateRecentsProtection() } private fun handleIncomingIntent(incoming: Intent?) { when (incoming?.getStringExtra(EXTRA_ACTION)) { - ACTION_SCREENSHOT_SHORTCUT -> { - shortcutQueued = true - scheduleShortcutIfNeeded() - } + ACTION_SCREENSHOT_SHORTCUT -> { + shortcutQueued = true + scheduleShortcutIfNeeded() + } + ACTION_OPEN_RECOGNITION_SETTINGS -> { + pendingRecognitionAction = mapOf( + "action" to ACTION_OPEN_RECOGNITION_SETTINGS, + ) + dispatchPendingRecognitionAction() + incoming.removeExtra(EXTRA_ACTION) + } ACTION_SCREENSHOT_RESULT -> { val path = incoming.getStringExtra(EXTRA_SCREENSHOT_PATH) val sessionId = @@ -794,20 +821,51 @@ class MainActivity : FlutterActivity() { val accessibilityAuthorized = isAccessibilityEnabledInSystem() val accessibilityConnected = response?.getBoolean("accessibilityConnected") == true val recognitionProcessStartedAt = response?.getLong("recognitionProcessStartedAt") ?: 0L + val keepAliveExpected = response?.getBoolean("keepAliveExpected") == true + val exits = recognitionExitHistory() + val recognitionExits = exits.filter { + it.processName == "$packageName:recognition" + } + val exit = (recognitionExits.ifEmpty { exits }).maxByOrNull { it.timestamp } + val lastConnectedAt = response?.getLong("accessibilityLastConnectedAt") ?: 0L + val taskRemovedAt = RecognitionConnectionStore.lastTaskRemovedAt(this) + val cleanerExit = exits + .filter { isTaskCleanerExit(it, taskRemovedAt) } + .maxByOrNull { it.timestamp } + val taskCleanerRecoveryNeeded = accessibilityAuthorized && + !accessibilityConnected && + cleanerExit != null && + cleanerExit.timestamp > lastConnectedAt + val recentsProtectionExpected = isOriginOsDevice() && keepAliveExpected + if (RecognitionConnectionStore.recentsProtectionActive(this) != + recentsProtectionExpected + ) { + updateRecentsProtection(keepAliveExpected) + } val accessibilityConnectionState = when { !accessibilityAuthorized -> "unauthorized" accessibilityConnected -> "connected" - recognitionProcessStartedAt > 0L && - System.currentTimeMillis() - recognitionProcessStartedAt < 5_000L -> "reconnecting" + taskCleanerRecoveryNeeded -> "disconnected" + keepAliveExpected && recognitionProcessStartedAt > 0L && + System.currentTimeMillis() - recognitionProcessStartedAt < 2_000L -> "reconnecting" else -> "disconnected" } return mapOf( "accessibilityAuthorized" to accessibilityAuthorized, "accessibilityConnected" to accessibilityConnected, "accessibilityConnectionState" to accessibilityConnectionState, - "accessibilityLastConnectedAt" to (response?.getLong("accessibilityLastConnectedAt") ?: 0L), + "accessibilityLastConnectedAt" to lastConnectedAt, "accessibilityLastDisconnectedAt" to (response?.getLong("accessibilityLastDisconnectedAt") ?: 0L), "recognitionProcessStartedAt" to recognitionProcessStartedAt, + "keepAliveExpected" to keepAliveExpected, + "keepAliveRunning" to (response?.getBoolean("keepAliveRunning") == true), + "keepAliveError" to response?.getString("keepAliveError"), + "recentsProtectionExpected" to recentsProtectionExpected, + "recentsProtectionActive" to RecognitionConnectionStore + .recentsProtectionActive(this), + "lastRecognitionExitAt" to (exit?.timestamp ?: 0L), + "lastRecognitionExitReason" to exit?.let(::recognitionExitReason), + "taskCleanerRecoveryNeeded" to taskCleanerRecoveryNeeded, "notificationAuthorized" to notificationAuthorized, "notificationConnected" to (response?.getBoolean("notificationConnected") == true), "postNotificationsGranted" to ( @@ -827,7 +885,77 @@ class MainActivity : FlutterActivity() { "latestStatus" to response?.getString("latestStatus"), "latestDiagnostic" to response?.getString("latestDiagnostic"), ) - } + } + + private fun ensureRecognitionKeepAlive(): Boolean { + val response = RecognitionBridge.call( + this, + RecognitionBridgeProvider.METHOD_ENSURE_KEEP_ALIVE, + ) + return response?.getBoolean("success") == true + } + + private fun updateRecentsProtection(expectedOverride: Boolean? = null) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return + val recognitionExpected = expectedOverride ?: ( + RecognitionBridge.call( + this, + RecognitionBridgeProvider.METHOD_STATUS, + )?.getBoolean("keepAliveExpected") == true + ) + val shouldProtect = isOriginOsDevice() && recognitionExpected + val activityManager = getSystemService(ActivityManager::class.java) + val currentTask = activityManager.appTasks.firstOrNull { + runCatching { it.taskInfo.taskId == taskId }.getOrDefault(false) + } ?: activityManager.appTasks.firstOrNull() + val applied = runCatching { + currentTask?.setExcludeFromRecents(shouldProtect) + currentTask != null + }.getOrDefault(false) + RecognitionConnectionStore.setRecentsProtectionActive( + this, + shouldProtect && applied, + ) + } + + private fun isOriginOsDevice(): Boolean { + val vendor = "${Build.MANUFACTURER} ${Build.BRAND}".lowercase() + return "vivo" in vendor || "iqoo" in vendor + } + + private fun recognitionExitHistory(): List { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return emptyList() + return runCatching { + getSystemService(ActivityManager::class.java) + .getHistoricalProcessExitReasons(packageName, 0, 16) + .filter { info -> + info.processName == packageName || + info.processName == "$packageName:recognition" + } + }.getOrDefault(emptyList()) + } + + private fun isTaskCleanerExit(info: ApplicationExitInfo, taskRemovedAt: Long): Boolean { + return info.description.orEmpty().contains("single-cleaner", ignoreCase = true) || + (info.reason == ApplicationExitInfo.REASON_LOW_MEMORY && + taskRemovedAt > 0L && + kotlin.math.abs(info.timestamp - taskRemovedAt) <= TASK_CLEANER_WINDOW_MS) + } + + private fun recognitionExitReason(info: ApplicationExitInfo): String { + val reason = when (info.reason) { + ApplicationExitInfo.REASON_LOW_MEMORY -> "low_memory" + ApplicationExitInfo.REASON_USER_REQUESTED -> "user_requested" + ApplicationExitInfo.REASON_CRASH -> "crash" + ApplicationExitInfo.REASON_CRASH_NATIVE -> "native_crash" + ApplicationExitInfo.REASON_ANR -> "anr" + ApplicationExitInfo.REASON_SIGNALED -> "signaled" + ApplicationExitInfo.REASON_OTHER -> "other" + else -> "reason_${info.reason}" + } + val description = info.description.orEmpty() + return if (description.isBlank()) reason else "$reason: $description" + } private fun acknowledgeRecognition(call: MethodCall, result: MethodChannel.Result) { val response = RecognitionBridge.call( diff --git a/frontend/android/app/src/main/kotlin/com/nx/miaoji/PaymentNotificationListenerService.kt b/frontend/android/app/src/main/kotlin/com/nx/miaoji/PaymentNotificationListenerService.kt index 30b84ae..b01725d 100644 --- a/frontend/android/app/src/main/kotlin/com/nx/miaoji/PaymentNotificationListenerService.kt +++ b/frontend/android/app/src/main/kotlin/com/nx/miaoji/PaymentNotificationListenerService.kt @@ -8,6 +8,7 @@ class PaymentNotificationListenerService : NotificationListenerService() { override fun onListenerConnected() { super.onListenerConnected() isConnected = true + RecognitionKeepAliveService.ensureRunning(this) Log.i(TAG, "Notification recognition listener connected") } diff --git a/frontend/android/app/src/main/kotlin/com/nx/miaoji/PaymentParser.kt b/frontend/android/app/src/main/kotlin/com/nx/miaoji/PaymentParser.kt index ddd8e30..f53f38f 100644 --- a/frontend/android/app/src/main/kotlin/com/nx/miaoji/PaymentParser.kt +++ b/frontend/android/app/src/main/kotlin/com/nx/miaoji/PaymentParser.kt @@ -125,7 +125,8 @@ object PaymentParser { ) private val paymentInputWords = listOf( "输入支付密码", "请输入支付密码", "确认转账", "确认支付", "确认付款", - "立即支付", "立即付款", "继续付款", + "立即支付", "立即付款", "继续付款", "转账全额", "添加转账说明", + "请输入转账金额", "输入转账金额", ) private val redPacketSendContextWords = listOf( "发红包", "塞钱进红包", "红包金额", "发送红包", "普通红包", "拼手气红包", @@ -494,8 +495,15 @@ object PaymentParser { fun isPaymentInputPage(value: String): Boolean { val normalized = normalize(value) + val transferInputComposite = "转账给" in normalized && ( + "转账全额" in normalized || + "添加转账说明" in normalized || + "请输入金额" in normalized || + Regex("""[¥¥]\s*0(?:\.0{1,2})?""").containsMatchIn(normalized) + ) return paymentInputWords.any(normalized::contains) || - redPacketSendActionWords.any(normalized::contains) + redPacketSendActionWords.any(normalized::contains) || + transferInputComposite } fun detectFlowKind(value: String): String? { @@ -565,12 +573,17 @@ object PaymentParser { val normalized = normalize(value) amountPatterns.firstNotNullOfOrNull { pattern -> pattern.find(normalized)?.groupValues?.getOrNull(1)?.toDoubleOrNull() - }?.let { return it } + }?.takeIf { it.isFinite() && it > 0.0 }?.let { return it } val match = STANDALONE_AMOUNT.matchEntire(normalized) ?: return null val number = match.groupValues[2] val hasCurrencyOrUnit = match.groupValues[1].isNotEmpty() || match.groupValues[3].isNotEmpty() - return if (!hasCurrencyOrUnit && !number.contains('.')) null else number.toDoubleOrNull() + val amount = if (!hasCurrencyOrUnit && !number.contains('.')) { + null + } else { + number.toDoubleOrNull() + } + return amount?.takeIf { it.isFinite() && it > 0.0 } } fun extractOrderId(value: String): String? = diff --git a/frontend/android/app/src/main/kotlin/com/nx/miaoji/RecognitionBridgeProvider.kt b/frontend/android/app/src/main/kotlin/com/nx/miaoji/RecognitionBridgeProvider.kt index 937c634..a5445dc 100644 --- a/frontend/android/app/src/main/kotlin/com/nx/miaoji/RecognitionBridgeProvider.kt +++ b/frontend/android/app/src/main/kotlin/com/nx/miaoji/RecognitionBridgeProvider.kt @@ -13,6 +13,9 @@ class RecognitionBridgeProvider : ContentProvider() { private val captureResults = ConcurrentHashMap() override fun onCreate(): Boolean { + context?.let { + RecognitionConnectionStore.markRecognitionProcessStarted(it, PROCESS_STARTED_AT) + } context?.let(RecognitionCoordinator::get) return true } @@ -22,23 +25,40 @@ class RecognitionBridgeProvider : ContentProvider() { return when (method) { METHOD_STATUS -> Bundle().apply { putBoolean("accessibilityConnected", ScreenshotAccessibilityService.isConnected) - putLong("accessibilityLastConnectedAt", ScreenshotAccessibilityService.lastConnectedAt) - putLong("accessibilityLastDisconnectedAt", ScreenshotAccessibilityService.lastDisconnectedAt) + putLong( + "accessibilityLastConnectedAt", + RecognitionConnectionStore.lastConnectedAt(appContext), + ) + putLong( + "accessibilityLastDisconnectedAt", + RecognitionConnectionStore.lastDisconnectedAt(appContext), + ) putLong("recognitionProcessStartedAt", PROCESS_STARTED_AT) putBoolean("notificationConnected", PaymentNotificationListenerService.isConnected) + putBoolean( + "keepAliveExpected", + RecognitionKeepAliveService.isExpected(appContext), + ) + putBoolean("keepAliveRunning", RecognitionKeepAliveService.isRunning) + putString("keepAliveError", RecognitionKeepAliveService.lastStartError) putString("settings", RecognitionSettings.statusJson(appContext)) - putString("latestStatus", RecognitionCoordinator.get(appContext).latestStatus()) + putString("latestStatus", RecognitionCoordinator.get(appContext).latestStatus()) putString("latestDiagnostic", RecognitionDiagnostics.latest(appContext)) } - METHOD_SET_TOGGLE -> Bundle().apply { - putBoolean( - "success", - RecognitionSettings.setToggle( - appContext, - extras?.getString("key").orEmpty(), - extras?.getBoolean("enabled") ?: false, - ), + METHOD_SET_TOGGLE -> { + val changed = RecognitionSettings.setToggle( + appContext, + extras?.getString("key").orEmpty(), + extras?.getBoolean("enabled") ?: false, ) + if (changed) RecognitionKeepAliveService.reconcile(appContext) + Bundle().apply { + putBoolean("success", changed) + putBoolean( + "keepAliveExpected", + RecognitionKeepAliveService.isExpected(appContext), + ) + } } METHOD_CLEAR_DIAGNOSTIC -> Bundle().apply { RecognitionDiagnostics.clear(appContext) @@ -54,6 +74,9 @@ class RecognitionBridgeProvider : ContentProvider() { ) putBoolean("success", true) } + METHOD_ENSURE_KEEP_ALIVE -> Bundle().apply { + putBoolean("success", RecognitionKeepAliveService.reconcile(appContext)) + } METHOD_REQUEST_SCREENSHOT -> requestScreenshot(extras) METHOD_SCREENSHOT_RESULT -> takeScreenshotResult(arg) METHOD_DRAIN -> Bundle().apply { @@ -149,7 +172,8 @@ class RecognitionBridgeProvider : ContentProvider() { private val PROCESS_STARTED_AT = System.currentTimeMillis() const val METHOD_STATUS = "status" const val METHOD_SET_TOGGLE = "setToggle" - const val METHOD_SET_RUNTIME = "setRuntime" + const val METHOD_SET_RUNTIME = "setRuntime" + const val METHOD_ENSURE_KEEP_ALIVE = "ensureKeepAlive" const val METHOD_CLEAR_DIAGNOSTIC = "clearDiagnostic" const val METHOD_REQUEST_SCREENSHOT = "requestScreenshot" const val METHOD_SCREENSHOT_RESULT = "screenshotResult" diff --git a/frontend/android/app/src/main/kotlin/com/nx/miaoji/RecognitionConnectionStore.kt b/frontend/android/app/src/main/kotlin/com/nx/miaoji/RecognitionConnectionStore.kt new file mode 100644 index 0000000..d3bd1a8 --- /dev/null +++ b/frontend/android/app/src/main/kotlin/com/nx/miaoji/RecognitionConnectionStore.kt @@ -0,0 +1,70 @@ +package com.nx.miaoji + +import android.content.Context + +object RecognitionConnectionStore { + private const val PREFS = "recognition_connection" + private const val KEY_LAST_CONNECTED_AT = "accessibility_last_connected_at" + private const val KEY_LAST_DISCONNECTED_AT = "accessibility_last_disconnected_at" + private const val KEY_LAST_TASK_REMOVED_AT = "recognition_last_task_removed_at" + private const val KEY_PROCESS_STARTED_AT = "recognition_process_started_at" + private const val KEY_RECENTS_PROTECTION_ACTIVE = "recents_protection_active" + + fun markConnected(context: Context, at: Long = System.currentTimeMillis()) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit() + .putLong(KEY_LAST_CONNECTED_AT, at) + .apply() + } + + fun markDisconnected(context: Context, at: Long = System.currentTimeMillis()) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit() + .putLong(KEY_LAST_DISCONNECTED_AT, at) + .apply() + } + + fun lastConnectedAt(context: Context): Long = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getLong(KEY_LAST_CONNECTED_AT, 0L) + + fun lastDisconnectedAt(context: Context): Long = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getLong(KEY_LAST_DISCONNECTED_AT, 0L) + + fun markTaskRemoved(context: Context, at: Long = System.currentTimeMillis()) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit() + .putLong(KEY_LAST_TASK_REMOVED_AT, at) + .apply() + } + + fun lastTaskRemovedAt(context: Context): Long = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getLong(KEY_LAST_TASK_REMOVED_AT, 0L) + + fun markRecognitionProcessStarted( + context: Context, + at: Long = System.currentTimeMillis(), + ) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit() + .putLong(KEY_PROCESS_STARTED_AT, at) + .apply() + } + + fun recognitionProcessStartedAt(context: Context): Long = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getLong(KEY_PROCESS_STARTED_AT, 0L) + + fun setRecentsProtectionActive(context: Context, active: Boolean) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit() + .putBoolean(KEY_RECENTS_PROTECTION_ACTIVE, active) + .apply() + } + + fun recentsProtectionActive(context: Context): Boolean = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getBoolean(KEY_RECENTS_PROTECTION_ACTIVE, false) +} diff --git a/frontend/android/app/src/main/kotlin/com/nx/miaoji/RecognitionKeepAliveReceiver.kt b/frontend/android/app/src/main/kotlin/com/nx/miaoji/RecognitionKeepAliveReceiver.kt new file mode 100644 index 0000000..e80cf7d --- /dev/null +++ b/frontend/android/app/src/main/kotlin/com/nx/miaoji/RecognitionKeepAliveReceiver.kt @@ -0,0 +1,16 @@ +package com.nx.miaoji + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + +class RecognitionKeepAliveReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent?) { + if (intent?.action !in setOf( + Intent.ACTION_BOOT_COMPLETED, + Intent.ACTION_MY_PACKAGE_REPLACED, + ) + ) return + RecognitionKeepAliveService.ensureRunning(context) + } +} diff --git a/frontend/android/app/src/main/kotlin/com/nx/miaoji/RecognitionKeepAliveService.kt b/frontend/android/app/src/main/kotlin/com/nx/miaoji/RecognitionKeepAliveService.kt new file mode 100644 index 0000000..04d02ff --- /dev/null +++ b/frontend/android/app/src/main/kotlin/com/nx/miaoji/RecognitionKeepAliveService.kt @@ -0,0 +1,169 @@ +package com.nx.miaoji + +import android.Manifest +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import androidx.core.content.ContextCompat + +class RecognitionKeepAliveService : Service() { + override fun onCreate() { + super.onCreate() + isRunning = true + lastStartError = null + createNotificationChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (!isExpected(this)) { + stopSelf() + return START_NOT_STICKY + } + promoteToForeground() + return START_STICKY + } + + override fun onDestroy() { + isRunning = false + super.onDestroy() + } + + override fun onTaskRemoved(rootIntent: Intent?) { + RecognitionConnectionStore.markTaskRemoved(this) + super.onTaskRemoved(rootIntent) + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun promoteToForeground() { + val notification = buildNotification() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground( + NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE, + ) + } else { + startForeground(NOTIFICATION_ID, notification) + } + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = getSystemService(NotificationManager::class.java) + if (manager.getNotificationChannel(CHANNEL_ID) != null) return + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + "智能识别后台保护", + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = "保持用户主动开启的微信、支付宝智能识别在后台运行" + setSound(null, null) + enableVibration(false) + setShowBadge(false) + }, + ) + } + + private fun buildNotification(): Notification { + val openIntent = Intent(this, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + putExtra(MainActivity.EXTRA_ACTION, MainActivity.ACTION_OPEN_RECOGNITION_SETTINGS) + } + val pendingIntent = PendingIntent.getActivity( + this, + NOTIFICATION_ID, + openIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + @Suppress("DEPRECATION") + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(this, CHANNEL_ID) + } else { + Notification.Builder(this) + } + return builder + .setSmallIcon(android.R.drawable.ic_menu_view) + .setContentTitle("智能识别运行中") + .setContentText("正在等待微信、支付宝支付结果") + .setContentIntent(pendingIntent) + .setCategory(Notification.CATEGORY_SERVICE) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setShowWhen(false) + .build() + } + + companion object { + private const val CHANNEL_ID = "recognition_keep_alive" + private const val NOTIFICATION_ID = 2201 + + @Volatile + var isRunning = false + private set + + @Volatile + var lastStartError: String? = null + private set + + fun isExpected(context: Context): Boolean { + val settings = RecognitionSettings.snapshot(context) + return settings.accessibilityEvents || + settings.notificationEvents || + settings.aiScreenshot + } + + fun ensureRunning(context: Context): Boolean { + val appContext = context.applicationContext + if (!isExpected(appContext)) { + stop(appContext) + return true + } + if (!canShowNotification(appContext)) { + lastStartError = "请允许通知权限后重新启动后台保护" + return false + } + return try { + ContextCompat.startForegroundService( + appContext, + Intent(appContext, RecognitionKeepAliveService::class.java), + ) + lastStartError = null + true + } catch (error: RuntimeException) { + lastStartError = error.message ?: "系统限制了后台保护启动" + false + } + } + + fun reconcile(context: Context): Boolean = + if (isExpected(context)) ensureRunning(context) else { + stop(context) + true + } + + fun stop(context: Context) { + context.applicationContext.stopService( + Intent(context.applicationContext, RecognitionKeepAliveService::class.java), + ) + isRunning = false + lastStartError = null + } + + private fun canShowNotification(context: Context): Boolean = + Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + } +} diff --git a/frontend/android/app/src/main/kotlin/com/nx/miaoji/ScreenshotAccessibilityService.kt b/frontend/android/app/src/main/kotlin/com/nx/miaoji/ScreenshotAccessibilityService.kt index fc12713..eb60804 100644 --- a/frontend/android/app/src/main/kotlin/com/nx/miaoji/ScreenshotAccessibilityService.kt +++ b/frontend/android/app/src/main/kotlin/com/nx/miaoji/ScreenshotAccessibilityService.kt @@ -30,6 +30,7 @@ class ScreenshotAccessibilityService : AccessibilityService() { private var lastVisualCaptureAt = 0L private var paymentFlow: PaymentFlow? = null private var pendingVisualCapture: Runnable? = null + private var pendingVisualCaptureAt = 0L private var ocrInProgress = false private var visualOperationId: String? = null private var visualTimeout: Runnable? = null @@ -58,6 +59,7 @@ class ScreenshotAccessibilityService : AccessibilityService() { var resultSurfaceExited: Boolean = false, var retryCount: Int = 0, var probeCount: Int = 0, + var trackingTimedOut: Boolean = false, ) private data class CaptureRequest( @@ -79,6 +81,8 @@ class ScreenshotAccessibilityService : AccessibilityService() { activeInstance = this isConnected = true lastConnectedAt = System.currentTimeMillis() + RecognitionConnectionStore.markConnected(this, lastConnectedAt) + RecognitionKeepAliveService.ensureRunning(this) Log.i(TAG, "Accessibility recognition service connected") } @@ -365,9 +369,11 @@ class ScreenshotAccessibilityService : AccessibilityService() { if (activeInstance === this) activeInstance = null isConnected = false lastDisconnectedAt = System.currentTimeMillis() + RecognitionConnectionStore.markDisconnected(this, lastDisconnectedAt) pendingRetry = null retryTimeout = null pendingVisualCapture = null + pendingVisualCaptureAt = 0L visualTimeout?.let(handler::removeCallbacks) visualTimeout = null batchCaptureTimeout?.let(handler::removeCallbacks) @@ -492,9 +498,13 @@ class ScreenshotAccessibilityService : AccessibilityService() { private fun expirePaymentFlow(now: Long) { val flow = paymentFlow ?: return if (now - flow.startedAt > PAYMENT_FLOW_TTL_MS) { + if (flow.committedAt != null && !flow.completed && !flow.trackingTimedOut) { + recordResultPageTimeout(flow, 0) + } paymentFlow = null pendingVisualCapture?.let(handler::removeCallbacks) pendingVisualCapture = null + pendingVisualCaptureAt = 0L } } @@ -514,6 +524,9 @@ class ScreenshotAccessibilityService : AccessibilityService() { flow.completed = true flow.completedAt = System.currentTimeMillis() flow.resultFingerprint = signal.resultFingerprint + pendingVisualCapture?.let(handler::removeCallbacks) + pendingVisualCapture = null + pendingVisualCaptureAt = 0L val coordinator = RecognitionCoordinator.get(this) val settings = RecognitionSettings.snapshot(this) val batchEnabled = settings.aiScreenshot && settings.aiAllowed && settings.hasAccount @@ -564,22 +577,58 @@ class ScreenshotAccessibilityService : AccessibilityService() { ) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || flow.completed || - flow.probeCount >= MAX_VISUAL_PROBES || - ocrInProgress + flow.trackingTimedOut || + flow.probeCount >= MAX_VISUAL_PROBES ) return val now = System.currentTimeMillis() + if (now - flow.startedAt >= PAYMENT_FLOW_TTL_MS) { + recordResultPageTimeout(flow, nodeCount) + return + } val throttleWait = ( VISUAL_CAPTURE_THROTTLE_MS - (now - lastVisualCaptureAt) ).coerceAtLeast(0L) + val targetAt = now + maxOf(delayMs, throttleWait) + if (pendingVisualCapture != null && + pendingVisualCaptureAt > 0L && + pendingVisualCaptureAt <= targetAt + ) return pendingVisualCapture?.let(handler::removeCallbacks) pendingVisualCapture = Runnable { pendingVisualCapture = null + pendingVisualCaptureAt = 0L val current = paymentFlow - if (current?.id != flow.id || current.completed || - lastPackageName != flow.packageName - ) return@Runnable + if (current?.id != flow.id || current.completed || current.trackingTimedOut) { + return@Runnable + } + val currentTime = System.currentTimeMillis() + if (currentTime - current.startedAt >= PAYMENT_FLOW_TTL_MS) { + recordResultPageTimeout(current, nodeCount) + return@Runnable + } + if (lastPackageName != current.packageName) { + scheduleVisualRecognition( + current, + reason = "waiting_for_payment_app", + nodeCount = nodeCount, + delayMs = PAYMENT_APP_RECHECK_MS, + ) + return@Runnable + } + if (captureInProgress || ocrInProgress) { + scheduleVisualRecognition( + current, + reason = "recognition_busy", + nodeCount = nodeCount, + delayMs = BUSY_RECHECK_MS, + ) + return@Runnable + } captureForLocalOcr(current, reason, nodeCount) - }.also { handler.postDelayed(it, maxOf(delayMs, throttleWait)) } + }.also { + pendingVisualCaptureAt = targetAt + handler.postDelayed(it, (targetAt - now).coerceAtLeast(0L)) + } } private fun captureForLocalOcr(flow: PaymentFlow, reason: String, nodeCount: Int) { @@ -699,10 +748,7 @@ class ScreenshotAccessibilityService : AccessibilityService() { val recognitionSettings = RecognitionSettings.snapshot( this@ScreenshotAccessibilityService, ) - val visualTransitionObserved = flow.resultTransitionObserved || - flow.committedAt?.let { - capturedAt - it >= VISUAL_STABILITY_DELAY_MS - } == true + val visualTransitionObserved = flow.resultTransitionObserved runCatching { LocalPaymentOcr.analyze( context = this@ScreenshotAccessibilityService, @@ -815,15 +861,30 @@ class ScreenshotAccessibilityService : AccessibilityService() { nodeCount: Int, ) { try { + val retryable = outcome.signal == null && isRetryableOcrOutcome(outcome.reason) + val shouldWait = retryable && shouldScheduleResultProbe( + probeCount = flow.probeCount, + completed = flow.completed, + expired = System.currentTimeMillis() - flow.startedAt >= PAYMENT_FLOW_TTL_MS, + ) + val diagnosticReason = if (retryable && !shouldWait) { + "result_page_timeout" + } else { + outcome.reason + } RecognitionDiagnostics.record( this, flow.packageName, stage = "ocr", - result = if (outcome.signal != null) "matched" else "rejected", + result = when { + outcome.signal != null -> "matched" + shouldWait -> "waiting" + else -> "rejected" + }, nodeCount = nodeCount, ocrMs = outcome.latencyMs, amountCandidates = outcome.amountCandidateCount, - reason = outcome.reason, + reason = diagnosticReason, statusStrength = outcome.statusStrength, expectedAmountMatched = outcome.expectedAmountMatched, resultTransitionObserved = outcome.resultTransitionObserved, @@ -854,17 +915,17 @@ class ScreenshotAccessibilityService : AccessibilityService() { ) return } - val retryable = isRetryableOcrOutcome(outcome.reason) - if (retryable && flow.retryCount < MAX_VISUAL_RETRIES) { + if (shouldWait) { flow.retryCount += 1 scheduleVisualRecognition( flow, - reason = "ocr_retry", + reason = "result_page_follow_up", nodeCount = nodeCount, - delayMs = VISUAL_RETRY_DELAY_MS, + delayMs = resultProbeDelayAfterCapture(flow.probeCount), ) return } + if (retryable) flow.trackingTimedOut = true if (outcome.sawSuccess) maybeUseAiFallback(flow, bitmap) } finally { bitmap.recycle() @@ -967,17 +1028,51 @@ class ScreenshotAccessibilityService : AccessibilityService() { nodeCount = nodeCount, reason = reason, ) - if (reason == "interval_short" && flow.retryCount < MAX_VISUAL_RETRIES) { + val retryable = reason in RESULT_TRACKING_CAPTURE_RETRY_REASONS + if (retryable && shouldScheduleResultProbe( + probeCount = flow.probeCount, + completed = flow.completed, + expired = System.currentTimeMillis() - flow.startedAt >= PAYMENT_FLOW_TTL_MS, + ) + ) { flow.retryCount += 1 + RecognitionDiagnostics.record( + this, + flow.packageName, + stage = "capture", + result = "waiting", + nodeCount = nodeCount, + reason = reason, + ) scheduleVisualRecognition( flow, reason = "capture_retry", nodeCount = nodeCount, - delayMs = VISUAL_RETRY_DELAY_MS, + delayMs = resultProbeDelayAfterCapture(flow.probeCount), ) + } else if (retryable) { + recordResultPageTimeout(flow, nodeCount) } } + private fun recordResultPageTimeout(flow: PaymentFlow, nodeCount: Int) { + if (flow.completed || flow.trackingTimedOut) return + flow.trackingTimedOut = true + pendingVisualCapture?.let(handler::removeCallbacks) + pendingVisualCapture = null + pendingVisualCaptureAt = 0L + RecognitionDiagnostics.record( + this, + flow.packageName, + stage = "ocr", + result = "rejected", + nodeCount = nodeCount, + reason = "result_page_timeout", + recognitionKind = flow.kind, + resultTransitionObserved = flow.resultTransitionObserved, + ) + } + private fun shouldFallbackToDisplay(errorCode: Int): Boolean = errorCode == ERROR_TAKE_SCREENSHOT_INTERNAL_ERROR || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && @@ -1216,14 +1311,14 @@ class ScreenshotAccessibilityService : AccessibilityService() { private const val MIN_RESULT_TRANSITION_DELAY_MS = 250L private const val AMBIGUOUS_REPEAT_GAP_MS = 3_000L private const val VISUAL_STABILITY_DELAY_MS = 700L - private const val VISUAL_RETRY_DELAY_MS = 850L private const val VISUAL_CAPTURE_THROTTLE_MS = 2_500L + private const val PAYMENT_APP_RECHECK_MS = 1_000L + private const val BUSY_RECHECK_MS = 500L private const val CAPTURE_CALLBACK_TIMEOUT_MS = 6_000L private const val OCR_CALLBACK_TIMEOUT_MS = 12_000L private const val STALE_BITMAP_RELEASE_DELAY_MS = 60_000L private const val WINDOW_CAPTURE_FALLBACK_DELAY_MS = 450L - private const val MAX_VISUAL_RETRIES = 1 - private const val MAX_VISUAL_PROBES = 4 + private const val MAX_VISUAL_PROBES = 5 private const val MAX_TREE_NODES = 160 private const val MAX_CHILDREN_PER_NODE = 40 private const val MAX_TEXT_CHARS = 8_000 @@ -1240,6 +1335,13 @@ class ScreenshotAccessibilityService : AccessibilityService() { AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED, AccessibilityEvent.TYPE_VIEW_CLICKED, ) + private val RESULT_PROBE_DELAYS_MS = longArrayOf(2_500L, 5_000L, 10_000L, 20_000L) + private val RESULT_TRACKING_CAPTURE_RETRY_REASONS = setOf( + "secure_window", + "invalid_window", + "interval_short", + "accessibility_unavailable", + ) @Volatile private var activeInstance: ScreenshotAccessibilityService? = null @@ -1255,6 +1357,17 @@ class ScreenshotAccessibilityService : AccessibilityService() { "payment_input_page", ) + internal fun shouldScheduleResultProbe( + probeCount: Int, + completed: Boolean, + expired: Boolean, + ): Boolean = !completed && !expired && probeCount < MAX_VISUAL_PROBES + + internal fun resultProbeDelayAfterCapture(probeCount: Int): Long = + RESULT_PROBE_DELAYS_MS[ + (probeCount - 1).coerceIn(0, RESULT_PROBE_DELAYS_MS.lastIndex) + ] + internal fun completedResultStartReason( hasObservedResult: Boolean, resultFingerprintChanged: Boolean, diff --git a/frontend/android/app/src/test/kotlin/com/nx/miaoji/PaymentParserTest.kt b/frontend/android/app/src/test/kotlin/com/nx/miaoji/PaymentParserTest.kt index 1f0f127..1c2e630 100644 --- a/frontend/android/app/src/test/kotlin/com/nx/miaoji/PaymentParserTest.kt +++ b/frontend/android/app/src/test/kotlin/com/nx/miaoji/PaymentParserTest.kt @@ -117,10 +117,46 @@ class PaymentParserTest { @Test fun paymentInputAndAmbiguousAmountsAreRejected() { assertTrue(PaymentParser.isPaymentInputPage("请输入支付密码\n确认转账")) + assertTrue( + PaymentParser.isPaymentInputPage( + "转账给:张三\n转账全额\n¥0.0\n添加转账说明\n转账", + ), + ) + assertTrue(PaymentParser.amountCandidateCents("转账全额\n¥0.0").isEmpty()) + assertNull(PaymentParser.parseAmountCandidate("¥0.0")) assertEquals(2_000L, PaymentParser.uniqueAmountCents("付款金额 ¥20.00\n¥20.00")) assertNull(PaymentParser.uniqueAmountCents("¥20.00\n优惠 ¥2.00")) } + @Test + fun resultPageTrackingUsesBoundedProgressiveProbes() { + assertEquals(2_500L, ScreenshotAccessibilityService.resultProbeDelayAfterCapture(1)) + assertEquals(5_000L, ScreenshotAccessibilityService.resultProbeDelayAfterCapture(2)) + assertEquals(10_000L, ScreenshotAccessibilityService.resultProbeDelayAfterCapture(3)) + assertEquals(20_000L, ScreenshotAccessibilityService.resultProbeDelayAfterCapture(4)) + assertTrue( + ScreenshotAccessibilityService.shouldScheduleResultProbe( + probeCount = 4, + completed = false, + expired = false, + ), + ) + assertFalse( + ScreenshotAccessibilityService.shouldScheduleResultProbe( + probeCount = 5, + completed = false, + expired = false, + ), + ) + assertFalse( + ScreenshotAccessibilityService.shouldScheduleResultProbe( + probeCount = 1, + completed = false, + expired = true, + ), + ) + } + @Test fun diagnosticPreviewMasksSensitiveValuesAndLimitsLines() { val preview = OcrDiagnosticRedactor.redact( diff --git a/frontend/lib/app/app.dart b/frontend/lib/app/app.dart index 9a9a90c..59d3648 100644 --- a/frontend/lib/app/app.dart +++ b/frontend/lib/app/app.dart @@ -197,8 +197,16 @@ class _MiaoJiAppState extends State with WidgetsBindingObserver { Future _restoreRecognitionServices() async { await RecognitionImportService.configureNativeContext(); - await ScreenshotChannel.waitForAccessibilityConnection(); + await ScreenshotChannel.ensureRecognitionKeepAlive(); await RecognitionImportService.importAutomatic(); + unawaited(_runSafely(_importAfterAccessibilityReconnect)); + } + + Future _importAfterAccessibilityReconnect() async { + final status = await ScreenshotChannel.waitForAccessibilityConnection(); + if (status.accessibilityConnected) { + await RecognitionImportService.importAutomatic(); + } } Future _refreshRemoteState() async { @@ -231,6 +239,10 @@ class _MiaoJiAppState extends State with WidgetsBindingObserver { await Future.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); } diff --git a/frontend/lib/features/settings/screenshot_settings_page.dart b/frontend/lib/features/settings/screenshot_settings_page.dart index db254ce..85c3085 100644 --- a/frontend/lib/features/settings/screenshot_settings_page.dart +++ b/frontend/lib/features/settings/screenshot_settings_page.dart @@ -27,12 +27,15 @@ class _ScreenshotSettingsPageState extends State 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 void _refreshRunningDiagnostic() { if (!mounted || _diagnosticRefreshInFlight || - _status?.latestDiagnostic?.result != 'started') { + !{'started', 'waiting'}.contains(_status?.latestDiagnostic?.result)) { return; } _diagnosticRefreshInFlight = true; _check().whenComplete(() => _diagnosticRefreshInFlight = false); } - Future _check({bool waitForConnection = false}) async { + Future _check({bool monitorConnection = true}) async { try { - var status = waitForConnection - ? await ScreenshotChannel.waitForAccessibilityConnection() - : await ScreenshotChannel.recognitionStatus(); + var status = await ScreenshotChannel.recognitionStatus(); final invalid = [ if (status.accessibilityEvents && (!status.accessibilityAuthorized || @@ -99,6 +100,14 @@ class _ScreenshotSettingsPageState extends State _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 } } + Future _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 _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 Future _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 '仅在微信和支付宝疑似支付流程中读取可见文字,并按需在内存中进行本地截图 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 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), ), ], ), diff --git a/frontend/lib/shared/services/recognition_diagnostic_formatter.dart b/frontend/lib/shared/services/recognition_diagnostic_formatter.dart index f00a6bc..99d2ec7 100644 --- a/frontend/lib/shared/services/recognition_diagnostic_formatter.dart +++ b/frontend/lib/shared/services/recognition_diagnostic_formatter.dart @@ -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', diff --git a/frontend/lib/shared/services/screenshot_channel.dart b/frontend/lib/shared/services/screenshot_channel.dart index ddfeaa6..e3a11ea 100644 --- a/frontend/lib/shared/services/screenshot_channel.dart +++ b/frontend/lib/shared/services/screenshot_channel.dart @@ -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? _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 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 _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 ensureRecognitionKeepAlive() async { + try { + return await _channel.invokeMethod('ensureRecognitionKeepAlive') ?? + false; + } on MissingPluginException { + return false; + } + } + static Future clearRecognitionDiagnostic() async { try { return await _channel.invokeMethod('clearRecognitionDiagnostic') ?? diff --git a/frontend/test/api_models_test.dart b/frontend/test/api_models_test.dart index 5de18e2..fd2e076 100644 --- a/frontend/test/api_models_test.dart +++ b/frontend/test/api_models_test.dart @@ -183,6 +183,14 @@ void main() { 'accessibilityConnected': false, 'accessibilityConnectionState': 'reconnecting', 'accessibilityLastConnectedAt': 1724472000000, + 'keepAliveExpected': true, + 'keepAliveRunning': false, + 'keepAliveError': '系统限制了后台保护启动', + 'recentsProtectionExpected': true, + 'recentsProtectionActive': true, + 'lastRecognitionExitAt': 1788077518000, + 'lastRecognitionExitReason': 'low_memory: single-cleaner', + 'taskCleanerRecoveryNeeded': true, 'settings': jsonEncode({'accessibilityEvents': true}), }); final connected = RecognitionStatus.fromMap({ @@ -204,6 +212,15 @@ void main() { ); expect(reconnecting.accessibilityConnectionLabel, '正在重连'); expect(reconnecting.accessibilityLastConnectedAt, isNotNull); + expect(reconnecting.keepAliveExpected, isTrue); + expect(reconnecting.keepAliveRunning, isFalse); + expect(reconnecting.keepAliveNeedsRecovery, isTrue); + expect(reconnecting.keepAliveError, '系统限制了后台保护启动'); + expect(reconnecting.recentsProtectionExpected, isTrue); + expect(reconnecting.recentsProtectionActive, isTrue); + expect(reconnecting.lastRecognitionExitAt, isNotNull); + expect(reconnecting.lastRecognitionExitReason, contains('single-cleaner')); + expect(reconnecting.taskCleanerRecoveryNeeded, isTrue); expect(connected.accessibilityConnectionLabel, '已连接'); expect(disconnected.accessibilityConnectionLabel, '服务未连接'); expect(disconnected.accessibilityNeedsRecovery, isTrue); diff --git a/frontend/test/theme_and_time_test.dart b/frontend/test/theme_and_time_test.dart index 04192b1..36ec151 100644 --- a/frontend/test/theme_and_time_test.dart +++ b/frontend/test/theme_and_time_test.dart @@ -153,9 +153,21 @@ void main() { ); expect( RecognitionDiagnosticDisplay.from( - diagnostic('rejected', 'payment_input_page'), + diagnostic('waiting', 'payment_input_page'), ).summaryLabel, - '规则拒绝', + '等待结果页', + ); + expect( + RecognitionDiagnosticDisplay.from( + diagnostic('waiting', 'payment_input_page'), + ).reasonLabel, + '当前仍是付款输入或确认页面,等待结果页,不会提前入账', + ); + expect( + RecognitionDiagnosticDisplay.from( + diagnostic('rejected', 'result_page_timeout'), + ).reasonLabel, + '90 秒内未等到明确结果页,本次流程已停止追踪', ); expect( RecognitionDiagnosticDisplay.from( diff --git a/frontend/test/visual_contract_test.dart b/frontend/test/visual_contract_test.dart index 4482fcc..a410591 100644 --- a/frontend/test/visual_contract_test.dart +++ b/frontend/test/visual_contract_test.dart @@ -280,7 +280,7 @@ void main() { expect(source, contains('_BackgroundKeepAliveCard')); }); - test('长期识别组件与系统绑定使用默认进程并支持重连状态', () { + test('长期识别组件使用独立保活进程并支持非阻塞重连', () { final manifest = File( 'android/app/src/main/AndroidManifest.xml', ).readAsStringSync(); @@ -290,6 +290,12 @@ void main() { final activity = File( 'android/app/src/main/kotlin/com/nx/miaoji/MainActivity.kt', ).readAsStringSync(); + final settingsPage = File( + 'lib/features/settings/screenshot_settings_page.dart', + ).readAsStringSync(); + final keepAlive = File( + 'android/app/src/main/kotlin/com/nx/miaoji/RecognitionKeepAliveService.kt', + ).readAsStringSync(); for (final component in [ 'ScreenshotTileService', @@ -301,18 +307,40 @@ void main() { ).firstMatch(manifest)?.group(0); expect(declaration, isNotNull); expect(declaration, contains('android:stopWithTask="false"')); - expect(declaration, isNot(contains('android:process='))); + expect(declaration, contains('android:process=":recognition"')); } final provider = RegExp( '', ).firstMatch(manifest)?.group(0); expect(provider, isNotNull); - expect(provider, isNot(contains('android:process='))); + expect(provider, contains('android:process=":recognition"')); expect(manifest, contains('android:name=".OneShotProjectionService"')); - expect(manifest, contains('android:process=":recognition"')); + final projection = RegExp( + '', + ).firstMatch(manifest)?.group(0); + expect(projection, isNotNull); + expect(projection, contains('android:process=":projection"')); + expect(manifest, contains('android:name=".RecognitionKeepAliveService"')); + expect(manifest, contains('android:foregroundServiceType="specialUse"')); + expect(manifest, contains('FOREGROUND_SERVICE_SPECIAL_USE')); + expect(manifest, contains('PROPERTY_SPECIAL_USE_FGS_SUBTYPE')); + expect(keepAlive, contains('START_STICKY')); + expect(keepAlive, contains('智能识别运行中')); expect(channel, contains('waitForAccessibilityConnection')); + expect(channel, contains('_activeAccessibilityConnectionWait')); + expect(channel, contains('ensureRecognitionKeepAlive')); expect(channel, contains('AccessibilityConnectionState.reconnecting')); expect(activity, contains('"reconnecting"')); expect(activity, contains('"disconnected"')); + expect(activity, contains('setExcludeFromRecents')); + expect(activity, contains('getHistoricalProcessExitReasons')); + expect(activity, contains('single-cleaner')); + expect(settingsPage, contains('_check();')); + expect(settingsPage, isNot(contains('_check(waitForConnection: true)'))); + expect(settingsPage, contains('系统仍显示已授权,但 OriginOS 未重新绑定服务')); + expect(settingsPage, contains('启动后台保护')); + expect(settingsPage, contains('OriginOS 清理了识别进程')); + expect(settingsPage, contains('记之已从最近任务隐藏')); + expect(channel, contains('taskCleanerRecoveryNeeded')); }); }