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(