fix: harden OriginOS recognition tracking

This commit is contained in:
2026-08-31 11:29:04 +08:00
parent 9ccbdfead4
commit 477f83c5b1
17 changed files with 980 additions and 108 deletions
@@ -4,8 +4,10 @@
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<application
android:label="@string/app_name"
@@ -50,18 +52,19 @@
android:taskAffinity=""
android:theme="@style/ProjectionConsentTheme"/>
<service
android:name=".OneShotProjectionService"
android:exported="false"
android:foregroundServiceType="mediaProjection"
android:process=":recognition"
android:stopWithTask="false"/>
<service
android:name=".OneShotProjectionService"
android:exported="false"
android:foregroundServiceType="mediaProjection"
android:process=":projection"
android:stopWithTask="false"/>
<service
android:name=".ScreenshotTileService"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/screenshot_tile_label"
android:process=":recognition"
android:stopWithTask="false"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
@@ -69,10 +72,11 @@
</intent-filter>
</service>
<service
<service
android:name=".PaymentNotificationListenerService"
android:exported="true"
android:label="@string/notification_listener_label"
android:process=":recognition"
android:stopWithTask="false"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
@@ -83,6 +87,7 @@
<service
android:name=".ScreenshotAccessibilityService"
android:exported="true"
android:process=":recognition"
android:stopWithTask="false"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
@@ -91,13 +96,36 @@
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_service_config"/>
</service>
<provider
android:name=".RecognitionBridgeProvider"
</service>
<service
android:name=".RecognitionKeepAliveService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:process=":recognition"
android:stopWithTask="false">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Keeps user-enabled payment accessibility and notification recognition active"/>
</service>
<receiver
android:name=".RecognitionKeepAliveReceiver"
android:enabled="true"
android:exported="false"
android:process=":recognition">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>
</intent-filter>
</receiver>
<provider
android:name=".RecognitionBridgeProvider"
android:authorities="${applicationId}.recognition.bridge"
android:exported="false"
android:grantUriPermissions="false"/>
android:grantUriPermissions="false"
android:process=":recognition"/>
<meta-data
android:name="flutterEmbedding"
@@ -1,7 +1,9 @@
package com.nx.miaoji
import android.Manifest
import android.app.StatusBarManager
import android.Manifest
import android.app.ActivityManager
import android.app.ApplicationExitInfo
import android.app.StatusBarManager
import android.app.UiModeManager
import android.content.BroadcastReceiver
import android.content.ComponentName
@@ -44,11 +46,13 @@ class MainActivity : FlutterActivity() {
const val ACTION_RECOGNITION_UNDO = "recognition_undo"
const val ACTION_RECOGNITION_EDIT = "recognition_edit"
const val ACTION_RECOGNITION_BATCH_REVIEW = "recognition_batch_review"
const val ACTION_OPEN_RECOGNITION_SETTINGS = "open_recognition_settings"
const val EXTRA_SCREENSHOT_PATH = "screenshotPath"
const val EXTRA_SCREENSHOT_ERROR = "screenshotError"
const val EXTRA_SCREENSHOT_SESSION_ID = "screenshotSessionId"
const val EXTRA_TRANSACTION_ID = "transactionId"
}
const val EXTRA_SCREENSHOT_SESSION_ID = "screenshotSessionId"
const val EXTRA_TRANSACTION_ID = "transactionId"
private const val TASK_CLEANER_WINDOW_MS = 15_000L
}
private val handler by lazy { Handler(mainLooper) }
private var channel: MethodChannel? = null
@@ -68,7 +72,12 @@ class MainActivity : FlutterActivity() {
private var pendingSpeechResult: MethodChannel.Result? = null
private var speechRecognizer: SpeechRecognizer? = null
private var streamingSpeech = false
private var streamingSpeech = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
updateRecentsProtection()
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
@@ -120,8 +129,8 @@ class MainActivity : FlutterActivity() {
"getRecognitionStatus" -> {
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<String>("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<ApplicationExitInfo> {
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(
@@ -8,6 +8,7 @@ class PaymentNotificationListenerService : NotificationListenerService() {
override fun onListenerConnected() {
super.onListenerConnected()
isConnected = true
RecognitionKeepAliveService.ensureRunning(this)
Log.i(TAG, "Notification recognition listener connected")
}
@@ -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? =
@@ -13,6 +13,9 @@ class RecognitionBridgeProvider : ContentProvider() {
private val captureResults = ConcurrentHashMap<String, CaptureResult>()
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"
@@ -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)
}
@@ -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)
}
}
@@ -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
}
}
@@ -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,
@@ -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(
+13 -1
View File
@@ -197,8 +197,16 @@ class _MiaoJiAppState extends State<MiaoJiApp> with WidgetsBindingObserver {
Future<void> _restoreRecognitionServices() async {
await RecognitionImportService.configureNativeContext();
await ScreenshotChannel.waitForAccessibilityConnection();
await ScreenshotChannel.ensureRecognitionKeepAlive();
await RecognitionImportService.importAutomatic();
unawaited(_runSafely(_importAfterAccessibilityReconnect));
}
Future<void> _importAfterAccessibilityReconnect() async {
final status = await ScreenshotChannel.waitForAccessibilityConnection();
if (status.accessibilityConnected) {
await RecognitionImportService.importAutomatic();
}
}
Future<void> _refreshRemoteState() async {
@@ -231,6 +239,10 @@ class _MiaoJiAppState extends State<MiaoJiApp> with WidgetsBindingObserver {
await Future<void>.delayed(const Duration(milliseconds: 180));
final context = _rootNavigatorKey.currentContext;
if (!mounted || context == null || !context.mounted) return;
if (action['action'] == 'open_recognition_settings') {
router.push('/screenshot-settings');
return;
}
await RecognitionImportService.handleAction(context, action);
}
@@ -27,12 +27,15 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
Timer? _diagnosticRefreshTimer;
Timer? _previewExpiryTimer;
bool _diagnosticRefreshInFlight = false;
bool _connectionCheckInFlight = false;
bool _keepAliveStarting = false;
bool _recentsProtectionNoticeChecked = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_check(waitForConnection: true);
_check();
_diagnosticRefreshTimer = Timer.periodic(
const Duration(seconds: 2),
(_) => _refreshRunningDiagnostic(),
@@ -60,18 +63,16 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
void _refreshRunningDiagnostic() {
if (!mounted ||
_diagnosticRefreshInFlight ||
_status?.latestDiagnostic?.result != 'started') {
!{'started', 'waiting'}.contains(_status?.latestDiagnostic?.result)) {
return;
}
_diagnosticRefreshInFlight = true;
_check().whenComplete(() => _diagnosticRefreshInFlight = false);
}
Future<void> _check({bool waitForConnection = false}) async {
Future<void> _check({bool monitorConnection = true}) async {
try {
var status = waitForConnection
? await ScreenshotChannel.waitForAccessibilityConnection()
: await ScreenshotChannel.recognitionStatus();
var status = await ScreenshotChannel.recognitionStatus();
final invalid = <String>[
if (status.accessibilityEvents &&
(!status.accessibilityAuthorized ||
@@ -99,6 +100,14 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
_error = null;
});
_schedulePreviewExpiry(status);
unawaited(_showRecentsProtectionNotice(status));
if (monitorConnection &&
status.accessibilityAuthorized &&
!status.accessibilityConnected &&
!status.taskCleanerRecoveryNeeded &&
(status.accessibilityEvents || status.aiScreenshot)) {
unawaited(_monitorAccessibilityConnection());
}
} catch (_) {
if (!mounted) return;
setState(() {
@@ -108,6 +117,49 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
}
}
Future<void> _showRecentsProtectionNotice(RecognitionStatus status) async {
if (_recentsProtectionNoticeChecked || !status.recentsProtectionActive) {
return;
}
_recentsProtectionNoticeChecked = true;
final prefs = await SharedPreferences.getInstance();
const key = 'originos_recents_protection_notice_v1';
if (prefs.getBool(key) == true || !mounted) return;
await prefs.setBool(key, true);
if (!mounted) return;
_showMessage('记之已从最近任务隐藏,可从桌面图标重新打开;关闭全部识别后恢复。');
}
Future<void> _monitorAccessibilityConnection({
bool ensureKeepAlive = false,
}) async {
if (_connectionCheckInFlight) return;
_connectionCheckInFlight = true;
if (mounted) setState(() {});
try {
if (ensureKeepAlive) {
_keepAliveStarting = true;
if (mounted) setState(() {});
await ScreenshotChannel.ensureRecognitionKeepAlive();
}
await ScreenshotChannel.waitForAccessibilityConnection();
final status = await ScreenshotChannel.recognitionStatus();
if (!mounted) return;
setState(() {
_status = status;
_error = null;
});
_schedulePreviewExpiry(status);
} catch (_) {
if (!mounted) return;
setState(() => _error = '连接状态复核失败,请重试');
} finally {
_connectionCheckInFlight = false;
_keepAliveStarting = false;
if (mounted) setState(() {});
}
}
void _schedulePreviewExpiry(RecognitionStatus status) {
_previewExpiryTimer?.cancel();
final expiresAt = status.ocrDiagnosticPreviewExpiresAt;
@@ -182,7 +234,7 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
Future<void> _resumeAuthorization() async {
if (_authorizing) return;
await _check(waitForConnection: true);
await _check();
final key = _pendingAuthorizationKey;
final status = _status;
if (key == null || status == null) return;
@@ -433,12 +485,31 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
'仅在微信和支付宝疑似支付流程中读取可见文字,并按需在内存中进行本地截图 OCR;不保存图片、完整控件树,也不监听按键。',
authorized: status!.accessibilityAuthorized,
connected: status.accessibilityConnected,
statusLabel: status.accessibilityConnectionLabel,
statusLabel:
_connectionCheckInFlight && !status.accessibilityConnected
? '正在连接'
: status.accessibilityConnectionLabel,
recoveryMessage: status.accessibilityNeedsRecovery
? status.taskCleanerRecoveryNeeded
? 'OriginOS 清理了识别进程,系统仍保留授权,但无障碍服务已被标记故障。'
: '系统仍显示已授权,但 OriginOS 未重新绑定服务。请前往系统设置,将记之无障碍服务关闭后重新开启。'
: (status.accessibilityEvents || status.aiScreenshot) &&
status.keepAliveNeedsRecovery
? _keepAliveStarting
? '正在启动后台保护…'
: status.keepAliveError ?? '后台保护未运行,划掉应用后识别可能中断。'
: null,
onOpenSettings: ScreenshotChannel.openAccessibilitySettings,
onRetry:
status.accessibilityAuthorized &&
!status.accessibilityConnected
? () => _check(waitForConnection: true)
settingsLabel: '前往无障碍设置',
onRetry: status.accessibilityNeedsRecovery
? () => _monitorAccessibilityConnection()
: null,
onStartKeepAlive:
(status.accessibilityEvents || status.aiScreenshot) &&
status.keepAliveNeedsRecovery &&
!_keepAliveStarting
? () =>
_monitorAccessibilityConnection(ensureKeepAlive: true)
: null,
child: Column(
children: [
@@ -546,7 +617,12 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
status.notificationEvents ||
status.aiScreenshot) ...[
const SizedBox(height: 10),
_BackgroundKeepAliveCard(status: status),
_BackgroundKeepAliveCard(
status: status,
starting: _keepAliveStarting,
onStart: () =>
_monitorAccessibilityConnection(ensureKeepAlive: true),
),
],
const SizedBox(height: 10),
_RecognitionCard(
@@ -844,8 +920,14 @@ class _EvidencePill extends StatelessWidget {
class _BackgroundKeepAliveCard extends StatelessWidget {
final RecognitionStatus status;
final bool starting;
final VoidCallback onStart;
const _BackgroundKeepAliveCard({required this.status});
const _BackgroundKeepAliveCard({
required this.status,
required this.starting,
required this.onStart,
});
@override
Widget build(BuildContext context) {
@@ -883,9 +965,18 @@ class _BackgroundKeepAliveCard extends StatelessWidget {
),
),
Text(
status.batteryOptimizationIgnored ? '后台限制较少' : '需要设置',
status.recentsProtectionActive
? '任务清理防护中'
: status.keepAliveRunning
? '保护运行中'
: status.keepAliveExpected
? '保护未运行'
: status.batteryOptimizationIgnored
? '后台限制较少'
: '需要设置',
style: TextStyle(
color: status.batteryOptimizationIgnored
color:
status.keepAliveRunning || status.recentsProtectionActive
? AppTheme.primaryDeep
: AppTheme.orange,
fontSize: 10,
@@ -896,15 +987,29 @@ class _BackgroundKeepAliveCard extends StatelessWidget {
),
const SizedBox(height: 10),
Text(
'无障碍和通知监听由 Android 系统持续绑定。请允许记之后台活动,'
'${isVivo ? '并在 OriginOS 中开启自启动,' : ''}'
'否则系统清理进程后可能暂时收不到支付事件。',
status.recentsProtectionActive
? 'OriginOS 最近任务保护已开启。记之不会显示在最近任务中,请从桌面图标重新打开;关闭全部识别后会自动恢复任务卡。'
: '无障碍和通知监听由 Android 系统持续绑定。请允许记之后台活动,'
'${isVivo ? '并在 OriginOS 中开启自启动,' : ''}'
'否则系统清理进程后可能暂时收不到支付事件。',
style: TextStyle(
color: context.jz.text2,
fontSize: 11.5,
height: 1.55,
),
),
if (status.keepAliveNeedsRecovery) ...[
const SizedBox(height: 10),
SizedBox(
width: double.infinity,
child: JzActionButton(
label: starting ? '正在启动…' : '启动后台保护',
secondary: true,
icon: const Icon(Icons.shield_outlined, size: 18),
onPressed: starting ? null : onStart,
),
),
],
const SizedBox(height: 12),
Row(
children: [
@@ -941,6 +1046,9 @@ class _RecognitionCard extends StatelessWidget {
final Widget child;
final VoidCallback? onOpenSettings;
final VoidCallback? onRetry;
final VoidCallback? onStartKeepAlive;
final String? recoveryMessage;
final String settingsLabel;
const _RecognitionCard({
required this.icon,
@@ -952,12 +1060,15 @@ class _RecognitionCard extends StatelessWidget {
this.statusLabel,
this.onOpenSettings,
this.onRetry,
this.onStartKeepAlive,
this.recoveryMessage,
this.settingsLabel = '前往系统设置',
});
@override
Widget build(BuildContext context) {
final palette = context.jz;
final color = connected ? AppTheme.primary : AppTheme.ai;
final color = connected ? AppTheme.primary : AppTheme.orange;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
@@ -976,7 +1087,7 @@ class _RecognitionCard extends StatelessWidget {
decoration: BoxDecoration(
color: connected
? palette.primaryBackground
: palette.aiBackground,
: palette.warningBackground,
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: color, size: 21),
@@ -996,7 +1107,7 @@ class _RecognitionCard extends StatelessWidget {
decoration: BoxDecoration(
color: connected
? palette.primaryBackground
: palette.aiBackground,
: palette.warningBackground,
borderRadius: BorderRadius.circular(20),
),
child: Text(
@@ -1020,9 +1131,35 @@ class _RecognitionCard extends StatelessWidget {
description,
style: TextStyle(color: palette.text2, fontSize: 12, height: 1.55),
),
if (recoveryMessage != null) ...[
const SizedBox(height: 9),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.info_outline_rounded,
color: AppTheme.orange,
size: 18,
),
const SizedBox(width: 7),
Expanded(
child: Text(
recoveryMessage!,
style: TextStyle(
color: palette.text2,
fontSize: 11.5,
height: 1.5,
),
),
),
],
),
],
const SizedBox(height: 6),
child,
if (onOpenSettings != null || onRetry != null) ...[
if (onOpenSettings != null ||
onRetry != null ||
onStartKeepAlive != null) ...[
const SizedBox(height: 6),
Wrap(
spacing: 6,
@@ -1034,11 +1171,17 @@ class _RecognitionCard extends StatelessWidget {
icon: const Icon(Icons.refresh_rounded, size: 18),
label: const Text('重新检测'),
),
if (onStartKeepAlive != null)
TextButton.icon(
onPressed: onStartKeepAlive,
icon: const Icon(Icons.shield_outlined, size: 18),
label: const Text('启动后台保护'),
),
if (onOpenSettings != null)
TextButton.icon(
onPressed: onOpenSettings,
icon: const Icon(Icons.settings_outlined, size: 18),
label: const Text('前往系统设置'),
label: Text(settingsLabel),
),
],
),
@@ -49,6 +49,7 @@ class RecognitionDiagnosticDisplay {
'matched' || 'auto_ready' => '已识别',
'confirm' => '待确认',
'started' => '处理中',
'waiting' => '等待结果页',
'failed' => '失败',
_ => '未触发入账',
};
@@ -56,6 +57,7 @@ class RecognitionDiagnosticDisplay {
static String _summaryLabel(String result, String reason) {
if (reason == 'duplicate_result_surface') return '已合并';
if (result == 'waiting') return '等待结果页';
if (_captureFailureReasons.contains(reason)) return '截图失败';
if (_ocrNoResultReasons.contains(reason)) return 'OCR 无结果';
if (_ruleRejectedReasons.contains(reason) || result == 'rejected') {
@@ -65,6 +67,7 @@ class RecognitionDiagnosticDisplay {
'matched' || 'auto_ready' => '已识别',
'confirm' => '待确认',
'started' => '处理中',
'waiting' => '等待结果页',
'failed' => '失败',
_ => '没事件',
};
@@ -75,8 +78,9 @@ class RecognitionDiagnosticDisplay {
'history_page' => '当前是账单或交易历史页',
'blocked_status' => '当前状态为失败、处理中或已取消',
'no_text' => '截图中没有识别到文字',
'no_success_status' => '没有找到明确或弱完成状态,可开启诊断预览查看脱敏结果',
'payment_input_page' => '当前仍是付款输入或确认页面,已拒绝入账',
'no_success_status' => '暂未找到完成状态,正在按计划复核结果',
'payment_input_page' => '当前仍是付款输入或确认页面,等待结果页,不会提前入账',
'result_page_timeout' => '90 秒内未等到明确结果页,本次流程已停止追踪',
'direction_unknown' => '识别到完成状态,但无法确认收支方向',
'missing_amount' => '成功状态已识别,但没有找到金额',
'expected_amount_missing' => '结果页没有金额,且付款前金额不唯一或未捕获',
@@ -167,8 +171,7 @@ class RecognitionDiagnosticDisplay {
static const _ruleRejectedReasons = {
'history_page',
'blocked_status',
'no_success_status',
'payment_input_page',
'result_page_timeout',
'direction_unknown',
'missing_amount',
'expected_amount_missing',
@@ -95,6 +95,14 @@ class RecognitionStatus {
final DateTime? accessibilityLastConnectedAt;
final DateTime? accessibilityLastDisconnectedAt;
final DateTime? recognitionProcessStartedAt;
final bool keepAliveExpected;
final bool keepAliveRunning;
final String? keepAliveError;
final bool recentsProtectionExpected;
final bool recentsProtectionActive;
final DateTime? lastRecognitionExitAt;
final String? lastRecognitionExitReason;
final bool taskCleanerRecoveryNeeded;
final bool notificationAuthorized;
final bool notificationConnected;
final bool postNotificationsGranted;
@@ -116,6 +124,14 @@ class RecognitionStatus {
this.accessibilityLastConnectedAt,
this.accessibilityLastDisconnectedAt,
this.recognitionProcessStartedAt,
this.keepAliveExpected = false,
this.keepAliveRunning = false,
this.keepAliveError,
this.recentsProtectionExpected = false,
this.recentsProtectionActive = false,
this.lastRecognitionExitAt,
this.lastRecognitionExitReason,
this.taskCleanerRecoveryNeeded = false,
required this.notificationAuthorized,
required this.notificationConnected,
required this.postNotificationsGranted,
@@ -158,6 +174,17 @@ class RecognitionStatus {
'accessibilityLastDisconnectedAt',
),
recognitionProcessStartedAt: epochDate('recognitionProcessStartedAt'),
keepAliveExpected: value['keepAliveExpected'] as bool? ?? false,
keepAliveRunning: value['keepAliveRunning'] as bool? ?? false,
keepAliveError: value['keepAliveError']?.toString(),
recentsProtectionExpected:
value['recentsProtectionExpected'] as bool? ?? false,
recentsProtectionActive:
value['recentsProtectionActive'] as bool? ?? false,
lastRecognitionExitAt: epochDate('lastRecognitionExitAt'),
lastRecognitionExitReason: value['lastRecognitionExitReason']?.toString(),
taskCleanerRecoveryNeeded:
value['taskCleanerRecoveryNeeded'] as bool? ?? false,
notificationAuthorized: value['notificationAuthorized'] as bool? ?? false,
notificationConnected: value['notificationConnected'] as bool? ?? false,
postNotificationsGranted:
@@ -189,6 +216,8 @@ class RecognitionStatus {
!accessibilityConnected &&
accessibilityConnectionState == AccessibilityConnectionState.disconnected;
bool get keepAliveNeedsRecovery => keepAliveExpected && !keepAliveRunning;
String get accessibilityConnectionLabel =>
switch (accessibilityConnectionState) {
AccessibilityConnectionState.unauthorized => '未授权',
@@ -320,6 +349,7 @@ class SpeechEvent {
/// Android native capabilities for screenshots, AI progress and speech.
class ScreenshotChannel {
static const _channel = MethodChannel('com.miaoji/screenshot');
static Future<RecognitionStatus>? _activeAccessibilityConnectionWait;
static void Function(String path)? _screenshotReady;
static void Function(String error)? _screenshotError;
static void Function(SpeechEvent event)? _speechEvent;
@@ -472,13 +502,32 @@ class ScreenshotChannel {
static Future<RecognitionStatus> waitForAccessibilityConnection({
Duration timeout = const Duration(seconds: 5),
Duration interval = const Duration(milliseconds: 500),
}) {
final active = _activeAccessibilityConnectionWait;
if (active != null) return active;
final operation = _waitForAccessibilityConnection(
timeout: timeout,
interval: interval,
);
_activeAccessibilityConnectionWait = operation;
return operation.whenComplete(() {
if (identical(_activeAccessibilityConnectionWait, operation)) {
_activeAccessibilityConnectionWait = null;
}
});
}
static Future<RecognitionStatus> _waitForAccessibilityConnection({
required Duration timeout,
required Duration interval,
}) async {
var status = await recognitionStatus();
final recognitionEnabled =
status.accessibilityEvents || status.aiScreenshot;
if (!recognitionEnabled ||
!status.accessibilityAuthorized ||
status.accessibilityConnected) {
status.accessibilityConnected ||
status.taskCleanerRecoveryNeeded) {
return status;
}
final deadline = DateTime.now().add(timeout);
@@ -487,6 +536,7 @@ class ScreenshotChannel {
status = await recognitionStatus();
if (!status.accessibilityAuthorized ||
status.accessibilityConnected ||
status.taskCleanerRecoveryNeeded ||
!(status.accessibilityEvents || status.aiScreenshot)) {
return status;
}
@@ -494,6 +544,15 @@ class ScreenshotChannel {
return status;
}
static Future<bool> ensureRecognitionKeepAlive() async {
try {
return await _channel.invokeMethod<bool>('ensureRecognitionKeepAlive') ??
false;
} on MissingPluginException {
return false;
}
}
static Future<bool> clearRecognitionDiagnostic() async {
try {
return await _channel.invokeMethod<bool>('clearRecognitionDiagnostic') ??
+17
View File
@@ -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);
+14 -2
View File
@@ -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(
+32 -4
View File
@@ -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(
'<provider\\s+android:name="\\.RecognitionBridgeProvider"[\\s\\S]*?/>',
).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(
'<service\\s+android:name="\\.OneShotProjectionService"[\\s\\S]*?/>',
).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'));
});
}