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