fix: harden OriginOS recognition tracking
This commit is contained in:
@@ -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(
|
||||
|
||||
+1
@@ -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
|
||||
}
|
||||
}
|
||||
+134
-21
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user