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.VIBRATE"/>
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
<uses-permission android:name="android.permission.POST_PROMOTED_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"/>
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION"/>
|
<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
|
<application
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
@@ -50,18 +52,19 @@
|
|||||||
android:taskAffinity=""
|
android:taskAffinity=""
|
||||||
android:theme="@style/ProjectionConsentTheme"/>
|
android:theme="@style/ProjectionConsentTheme"/>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".OneShotProjectionService"
|
android:name=".OneShotProjectionService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="mediaProjection"
|
android:foregroundServiceType="mediaProjection"
|
||||||
android:process=":recognition"
|
android:process=":projection"
|
||||||
android:stopWithTask="false"/>
|
android:stopWithTask="false"/>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".ScreenshotTileService"
|
android:name=".ScreenshotTileService"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/screenshot_tile_label"
|
android:label="@string/screenshot_tile_label"
|
||||||
|
android:process=":recognition"
|
||||||
android:stopWithTask="false"
|
android:stopWithTask="false"
|
||||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
@@ -69,10 +72,11 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".PaymentNotificationListenerService"
|
android:name=".PaymentNotificationListenerService"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:label="@string/notification_listener_label"
|
android:label="@string/notification_listener_label"
|
||||||
|
android:process=":recognition"
|
||||||
android:stopWithTask="false"
|
android:stopWithTask="false"
|
||||||
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
|
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
@@ -83,6 +87,7 @@
|
|||||||
<service
|
<service
|
||||||
android:name=".ScreenshotAccessibilityService"
|
android:name=".ScreenshotAccessibilityService"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
android:process=":recognition"
|
||||||
android:stopWithTask="false"
|
android:stopWithTask="false"
|
||||||
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
|
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
@@ -91,13 +96,36 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.accessibilityservice"
|
android:name="android.accessibilityservice"
|
||||||
android:resource="@xml/accessibility_service_config"/>
|
android:resource="@xml/accessibility_service_config"/>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
<provider
|
<service
|
||||||
android:name=".RecognitionBridgeProvider"
|
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:authorities="${applicationId}.recognition.bridge"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:grantUriPermissions="false"/>
|
android:grantUriPermissions="false"
|
||||||
|
android:process=":recognition"/>
|
||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.nx.miaoji
|
package com.nx.miaoji
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.app.StatusBarManager
|
import android.app.ActivityManager
|
||||||
|
import android.app.ApplicationExitInfo
|
||||||
|
import android.app.StatusBarManager
|
||||||
import android.app.UiModeManager
|
import android.app.UiModeManager
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
@@ -44,11 +46,13 @@ class MainActivity : FlutterActivity() {
|
|||||||
const val ACTION_RECOGNITION_UNDO = "recognition_undo"
|
const val ACTION_RECOGNITION_UNDO = "recognition_undo"
|
||||||
const val ACTION_RECOGNITION_EDIT = "recognition_edit"
|
const val ACTION_RECOGNITION_EDIT = "recognition_edit"
|
||||||
const val ACTION_RECOGNITION_BATCH_REVIEW = "recognition_batch_review"
|
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_PATH = "screenshotPath"
|
||||||
const val EXTRA_SCREENSHOT_ERROR = "screenshotError"
|
const val EXTRA_SCREENSHOT_ERROR = "screenshotError"
|
||||||
const val EXTRA_SCREENSHOT_SESSION_ID = "screenshotSessionId"
|
const val EXTRA_SCREENSHOT_SESSION_ID = "screenshotSessionId"
|
||||||
const val EXTRA_TRANSACTION_ID = "transactionId"
|
const val EXTRA_TRANSACTION_ID = "transactionId"
|
||||||
}
|
private const val TASK_CLEANER_WINDOW_MS = 15_000L
|
||||||
|
}
|
||||||
|
|
||||||
private val handler by lazy { Handler(mainLooper) }
|
private val handler by lazy { Handler(mainLooper) }
|
||||||
private var channel: MethodChannel? = null
|
private var channel: MethodChannel? = null
|
||||||
@@ -68,7 +72,12 @@ class MainActivity : FlutterActivity() {
|
|||||||
|
|
||||||
private var pendingSpeechResult: MethodChannel.Result? = null
|
private var pendingSpeechResult: MethodChannel.Result? = null
|
||||||
private var speechRecognizer: SpeechRecognizer? = 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) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
@@ -120,8 +129,8 @@ class MainActivity : FlutterActivity() {
|
|||||||
"getRecognitionStatus" -> {
|
"getRecognitionStatus" -> {
|
||||||
result.success(recognitionStatus())
|
result.success(recognitionStatus())
|
||||||
}
|
}
|
||||||
"setRecognitionToggle" -> {
|
"setRecognitionToggle" -> {
|
||||||
val response = RecognitionBridge.call(
|
val response = RecognitionBridge.call(
|
||||||
this,
|
this,
|
||||||
RecognitionBridgeProvider.METHOD_SET_TOGGLE,
|
RecognitionBridgeProvider.METHOD_SET_TOGGLE,
|
||||||
extras = Bundle().apply {
|
extras = Bundle().apply {
|
||||||
@@ -138,7 +147,7 @@ class MainActivity : FlutterActivity() {
|
|||||||
)
|
)
|
||||||
result.success(response?.getBoolean("success") == true)
|
result.success(response?.getBoolean("success") == true)
|
||||||
}
|
}
|
||||||
"configureRecognitionContext" -> {
|
"configureRecognitionContext" -> {
|
||||||
val response = RecognitionBridge.call(
|
val response = RecognitionBridge.call(
|
||||||
this,
|
this,
|
||||||
RecognitionBridgeProvider.METHOD_SET_RUNTIME,
|
RecognitionBridgeProvider.METHOD_SET_RUNTIME,
|
||||||
@@ -181,7 +190,16 @@ class MainActivity : FlutterActivity() {
|
|||||||
putString("candidateId", call.argument<String>("candidateId"))
|
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" -> {
|
"openAccessibilitySettings" -> {
|
||||||
startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
|
startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
|
||||||
@@ -223,17 +241,26 @@ class MainActivity : FlutterActivity() {
|
|||||||
super.onResume()
|
super.onResume()
|
||||||
updateInstallBridge?.onResume()
|
updateInstallBridge?.onResume()
|
||||||
vendorPushBridge?.onResume()
|
vendorPushBridge?.onResume()
|
||||||
scheduleShortcutIfNeeded()
|
scheduleShortcutIfNeeded()
|
||||||
dispatchPendingScreenshot()
|
dispatchPendingScreenshot()
|
||||||
dispatchPendingRecognitionAction()
|
dispatchPendingRecognitionAction()
|
||||||
|
ensureRecognitionKeepAlive()
|
||||||
|
updateRecentsProtection()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleIncomingIntent(incoming: Intent?) {
|
private fun handleIncomingIntent(incoming: Intent?) {
|
||||||
when (incoming?.getStringExtra(EXTRA_ACTION)) {
|
when (incoming?.getStringExtra(EXTRA_ACTION)) {
|
||||||
ACTION_SCREENSHOT_SHORTCUT -> {
|
ACTION_SCREENSHOT_SHORTCUT -> {
|
||||||
shortcutQueued = true
|
shortcutQueued = true
|
||||||
scheduleShortcutIfNeeded()
|
scheduleShortcutIfNeeded()
|
||||||
}
|
}
|
||||||
|
ACTION_OPEN_RECOGNITION_SETTINGS -> {
|
||||||
|
pendingRecognitionAction = mapOf(
|
||||||
|
"action" to ACTION_OPEN_RECOGNITION_SETTINGS,
|
||||||
|
)
|
||||||
|
dispatchPendingRecognitionAction()
|
||||||
|
incoming.removeExtra(EXTRA_ACTION)
|
||||||
|
}
|
||||||
ACTION_SCREENSHOT_RESULT -> {
|
ACTION_SCREENSHOT_RESULT -> {
|
||||||
val path = incoming.getStringExtra(EXTRA_SCREENSHOT_PATH)
|
val path = incoming.getStringExtra(EXTRA_SCREENSHOT_PATH)
|
||||||
val sessionId =
|
val sessionId =
|
||||||
@@ -794,20 +821,51 @@ class MainActivity : FlutterActivity() {
|
|||||||
val accessibilityAuthorized = isAccessibilityEnabledInSystem()
|
val accessibilityAuthorized = isAccessibilityEnabledInSystem()
|
||||||
val accessibilityConnected = response?.getBoolean("accessibilityConnected") == true
|
val accessibilityConnected = response?.getBoolean("accessibilityConnected") == true
|
||||||
val recognitionProcessStartedAt = response?.getLong("recognitionProcessStartedAt") ?: 0L
|
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 {
|
val accessibilityConnectionState = when {
|
||||||
!accessibilityAuthorized -> "unauthorized"
|
!accessibilityAuthorized -> "unauthorized"
|
||||||
accessibilityConnected -> "connected"
|
accessibilityConnected -> "connected"
|
||||||
recognitionProcessStartedAt > 0L &&
|
taskCleanerRecoveryNeeded -> "disconnected"
|
||||||
System.currentTimeMillis() - recognitionProcessStartedAt < 5_000L -> "reconnecting"
|
keepAliveExpected && recognitionProcessStartedAt > 0L &&
|
||||||
|
System.currentTimeMillis() - recognitionProcessStartedAt < 2_000L -> "reconnecting"
|
||||||
else -> "disconnected"
|
else -> "disconnected"
|
||||||
}
|
}
|
||||||
return mapOf(
|
return mapOf(
|
||||||
"accessibilityAuthorized" to accessibilityAuthorized,
|
"accessibilityAuthorized" to accessibilityAuthorized,
|
||||||
"accessibilityConnected" to accessibilityConnected,
|
"accessibilityConnected" to accessibilityConnected,
|
||||||
"accessibilityConnectionState" to accessibilityConnectionState,
|
"accessibilityConnectionState" to accessibilityConnectionState,
|
||||||
"accessibilityLastConnectedAt" to (response?.getLong("accessibilityLastConnectedAt") ?: 0L),
|
"accessibilityLastConnectedAt" to lastConnectedAt,
|
||||||
"accessibilityLastDisconnectedAt" to (response?.getLong("accessibilityLastDisconnectedAt") ?: 0L),
|
"accessibilityLastDisconnectedAt" to (response?.getLong("accessibilityLastDisconnectedAt") ?: 0L),
|
||||||
"recognitionProcessStartedAt" to recognitionProcessStartedAt,
|
"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,
|
"notificationAuthorized" to notificationAuthorized,
|
||||||
"notificationConnected" to (response?.getBoolean("notificationConnected") == true),
|
"notificationConnected" to (response?.getBoolean("notificationConnected") == true),
|
||||||
"postNotificationsGranted" to (
|
"postNotificationsGranted" to (
|
||||||
@@ -827,7 +885,77 @@ class MainActivity : FlutterActivity() {
|
|||||||
"latestStatus" to response?.getString("latestStatus"),
|
"latestStatus" to response?.getString("latestStatus"),
|
||||||
"latestDiagnostic" to response?.getString("latestDiagnostic"),
|
"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) {
|
private fun acknowledgeRecognition(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val response = RecognitionBridge.call(
|
val response = RecognitionBridge.call(
|
||||||
|
|||||||
+1
@@ -8,6 +8,7 @@ class PaymentNotificationListenerService : NotificationListenerService() {
|
|||||||
override fun onListenerConnected() {
|
override fun onListenerConnected() {
|
||||||
super.onListenerConnected()
|
super.onListenerConnected()
|
||||||
isConnected = true
|
isConnected = true
|
||||||
|
RecognitionKeepAliveService.ensureRunning(this)
|
||||||
Log.i(TAG, "Notification recognition listener connected")
|
Log.i(TAG, "Notification recognition listener connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -125,7 +125,8 @@ object PaymentParser {
|
|||||||
)
|
)
|
||||||
private val paymentInputWords = listOf(
|
private val paymentInputWords = listOf(
|
||||||
"输入支付密码", "请输入支付密码", "确认转账", "确认支付", "确认付款",
|
"输入支付密码", "请输入支付密码", "确认转账", "确认支付", "确认付款",
|
||||||
"立即支付", "立即付款", "继续付款",
|
"立即支付", "立即付款", "继续付款", "转账全额", "添加转账说明",
|
||||||
|
"请输入转账金额", "输入转账金额",
|
||||||
)
|
)
|
||||||
private val redPacketSendContextWords = listOf(
|
private val redPacketSendContextWords = listOf(
|
||||||
"发红包", "塞钱进红包", "红包金额", "发送红包", "普通红包", "拼手气红包",
|
"发红包", "塞钱进红包", "红包金额", "发送红包", "普通红包", "拼手气红包",
|
||||||
@@ -494,8 +495,15 @@ object PaymentParser {
|
|||||||
|
|
||||||
fun isPaymentInputPage(value: String): Boolean {
|
fun isPaymentInputPage(value: String): Boolean {
|
||||||
val normalized = normalize(value)
|
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) ||
|
return paymentInputWords.any(normalized::contains) ||
|
||||||
redPacketSendActionWords.any(normalized::contains)
|
redPacketSendActionWords.any(normalized::contains) ||
|
||||||
|
transferInputComposite
|
||||||
}
|
}
|
||||||
|
|
||||||
fun detectFlowKind(value: String): String? {
|
fun detectFlowKind(value: String): String? {
|
||||||
@@ -565,12 +573,17 @@ object PaymentParser {
|
|||||||
val normalized = normalize(value)
|
val normalized = normalize(value)
|
||||||
amountPatterns.firstNotNullOfOrNull { pattern ->
|
amountPatterns.firstNotNullOfOrNull { pattern ->
|
||||||
pattern.find(normalized)?.groupValues?.getOrNull(1)?.toDoubleOrNull()
|
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 match = STANDALONE_AMOUNT.matchEntire(normalized) ?: return null
|
||||||
val number = match.groupValues[2]
|
val number = match.groupValues[2]
|
||||||
val hasCurrencyOrUnit = match.groupValues[1].isNotEmpty() ||
|
val hasCurrencyOrUnit = match.groupValues[1].isNotEmpty() ||
|
||||||
match.groupValues[3].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? =
|
fun extractOrderId(value: String): String? =
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ class RecognitionBridgeProvider : ContentProvider() {
|
|||||||
private val captureResults = ConcurrentHashMap<String, CaptureResult>()
|
private val captureResults = ConcurrentHashMap<String, CaptureResult>()
|
||||||
|
|
||||||
override fun onCreate(): Boolean {
|
override fun onCreate(): Boolean {
|
||||||
|
context?.let {
|
||||||
|
RecognitionConnectionStore.markRecognitionProcessStarted(it, PROCESS_STARTED_AT)
|
||||||
|
}
|
||||||
context?.let(RecognitionCoordinator::get)
|
context?.let(RecognitionCoordinator::get)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -22,23 +25,40 @@ class RecognitionBridgeProvider : ContentProvider() {
|
|||||||
return when (method) {
|
return when (method) {
|
||||||
METHOD_STATUS -> Bundle().apply {
|
METHOD_STATUS -> Bundle().apply {
|
||||||
putBoolean("accessibilityConnected", ScreenshotAccessibilityService.isConnected)
|
putBoolean("accessibilityConnected", ScreenshotAccessibilityService.isConnected)
|
||||||
putLong("accessibilityLastConnectedAt", ScreenshotAccessibilityService.lastConnectedAt)
|
putLong(
|
||||||
putLong("accessibilityLastDisconnectedAt", ScreenshotAccessibilityService.lastDisconnectedAt)
|
"accessibilityLastConnectedAt",
|
||||||
|
RecognitionConnectionStore.lastConnectedAt(appContext),
|
||||||
|
)
|
||||||
|
putLong(
|
||||||
|
"accessibilityLastDisconnectedAt",
|
||||||
|
RecognitionConnectionStore.lastDisconnectedAt(appContext),
|
||||||
|
)
|
||||||
putLong("recognitionProcessStartedAt", PROCESS_STARTED_AT)
|
putLong("recognitionProcessStartedAt", PROCESS_STARTED_AT)
|
||||||
putBoolean("notificationConnected", PaymentNotificationListenerService.isConnected)
|
putBoolean("notificationConnected", PaymentNotificationListenerService.isConnected)
|
||||||
|
putBoolean(
|
||||||
|
"keepAliveExpected",
|
||||||
|
RecognitionKeepAliveService.isExpected(appContext),
|
||||||
|
)
|
||||||
|
putBoolean("keepAliveRunning", RecognitionKeepAliveService.isRunning)
|
||||||
|
putString("keepAliveError", RecognitionKeepAliveService.lastStartError)
|
||||||
putString("settings", RecognitionSettings.statusJson(appContext))
|
putString("settings", RecognitionSettings.statusJson(appContext))
|
||||||
putString("latestStatus", RecognitionCoordinator.get(appContext).latestStatus())
|
putString("latestStatus", RecognitionCoordinator.get(appContext).latestStatus())
|
||||||
putString("latestDiagnostic", RecognitionDiagnostics.latest(appContext))
|
putString("latestDiagnostic", RecognitionDiagnostics.latest(appContext))
|
||||||
}
|
}
|
||||||
METHOD_SET_TOGGLE -> Bundle().apply {
|
METHOD_SET_TOGGLE -> {
|
||||||
putBoolean(
|
val changed = RecognitionSettings.setToggle(
|
||||||
"success",
|
appContext,
|
||||||
RecognitionSettings.setToggle(
|
extras?.getString("key").orEmpty(),
|
||||||
appContext,
|
extras?.getBoolean("enabled") ?: false,
|
||||||
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 {
|
METHOD_CLEAR_DIAGNOSTIC -> Bundle().apply {
|
||||||
RecognitionDiagnostics.clear(appContext)
|
RecognitionDiagnostics.clear(appContext)
|
||||||
@@ -54,6 +74,9 @@ class RecognitionBridgeProvider : ContentProvider() {
|
|||||||
)
|
)
|
||||||
putBoolean("success", true)
|
putBoolean("success", true)
|
||||||
}
|
}
|
||||||
|
METHOD_ENSURE_KEEP_ALIVE -> Bundle().apply {
|
||||||
|
putBoolean("success", RecognitionKeepAliveService.reconcile(appContext))
|
||||||
|
}
|
||||||
METHOD_REQUEST_SCREENSHOT -> requestScreenshot(extras)
|
METHOD_REQUEST_SCREENSHOT -> requestScreenshot(extras)
|
||||||
METHOD_SCREENSHOT_RESULT -> takeScreenshotResult(arg)
|
METHOD_SCREENSHOT_RESULT -> takeScreenshotResult(arg)
|
||||||
METHOD_DRAIN -> Bundle().apply {
|
METHOD_DRAIN -> Bundle().apply {
|
||||||
@@ -149,7 +172,8 @@ class RecognitionBridgeProvider : ContentProvider() {
|
|||||||
private val PROCESS_STARTED_AT = System.currentTimeMillis()
|
private val PROCESS_STARTED_AT = System.currentTimeMillis()
|
||||||
const val METHOD_STATUS = "status"
|
const val METHOD_STATUS = "status"
|
||||||
const val METHOD_SET_TOGGLE = "setToggle"
|
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_CLEAR_DIAGNOSTIC = "clearDiagnostic"
|
||||||
const val METHOD_REQUEST_SCREENSHOT = "requestScreenshot"
|
const val METHOD_REQUEST_SCREENSHOT = "requestScreenshot"
|
||||||
const val METHOD_SCREENSHOT_RESULT = "screenshotResult"
|
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 lastVisualCaptureAt = 0L
|
||||||
private var paymentFlow: PaymentFlow? = null
|
private var paymentFlow: PaymentFlow? = null
|
||||||
private var pendingVisualCapture: Runnable? = null
|
private var pendingVisualCapture: Runnable? = null
|
||||||
|
private var pendingVisualCaptureAt = 0L
|
||||||
private var ocrInProgress = false
|
private var ocrInProgress = false
|
||||||
private var visualOperationId: String? = null
|
private var visualOperationId: String? = null
|
||||||
private var visualTimeout: Runnable? = null
|
private var visualTimeout: Runnable? = null
|
||||||
@@ -58,6 +59,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
var resultSurfaceExited: Boolean = false,
|
var resultSurfaceExited: Boolean = false,
|
||||||
var retryCount: Int = 0,
|
var retryCount: Int = 0,
|
||||||
var probeCount: Int = 0,
|
var probeCount: Int = 0,
|
||||||
|
var trackingTimedOut: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class CaptureRequest(
|
private data class CaptureRequest(
|
||||||
@@ -79,6 +81,8 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
activeInstance = this
|
activeInstance = this
|
||||||
isConnected = true
|
isConnected = true
|
||||||
lastConnectedAt = System.currentTimeMillis()
|
lastConnectedAt = System.currentTimeMillis()
|
||||||
|
RecognitionConnectionStore.markConnected(this, lastConnectedAt)
|
||||||
|
RecognitionKeepAliveService.ensureRunning(this)
|
||||||
Log.i(TAG, "Accessibility recognition service connected")
|
Log.i(TAG, "Accessibility recognition service connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,9 +369,11 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
if (activeInstance === this) activeInstance = null
|
if (activeInstance === this) activeInstance = null
|
||||||
isConnected = false
|
isConnected = false
|
||||||
lastDisconnectedAt = System.currentTimeMillis()
|
lastDisconnectedAt = System.currentTimeMillis()
|
||||||
|
RecognitionConnectionStore.markDisconnected(this, lastDisconnectedAt)
|
||||||
pendingRetry = null
|
pendingRetry = null
|
||||||
retryTimeout = null
|
retryTimeout = null
|
||||||
pendingVisualCapture = null
|
pendingVisualCapture = null
|
||||||
|
pendingVisualCaptureAt = 0L
|
||||||
visualTimeout?.let(handler::removeCallbacks)
|
visualTimeout?.let(handler::removeCallbacks)
|
||||||
visualTimeout = null
|
visualTimeout = null
|
||||||
batchCaptureTimeout?.let(handler::removeCallbacks)
|
batchCaptureTimeout?.let(handler::removeCallbacks)
|
||||||
@@ -492,9 +498,13 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
private fun expirePaymentFlow(now: Long) {
|
private fun expirePaymentFlow(now: Long) {
|
||||||
val flow = paymentFlow ?: return
|
val flow = paymentFlow ?: return
|
||||||
if (now - flow.startedAt > PAYMENT_FLOW_TTL_MS) {
|
if (now - flow.startedAt > PAYMENT_FLOW_TTL_MS) {
|
||||||
|
if (flow.committedAt != null && !flow.completed && !flow.trackingTimedOut) {
|
||||||
|
recordResultPageTimeout(flow, 0)
|
||||||
|
}
|
||||||
paymentFlow = null
|
paymentFlow = null
|
||||||
pendingVisualCapture?.let(handler::removeCallbacks)
|
pendingVisualCapture?.let(handler::removeCallbacks)
|
||||||
pendingVisualCapture = null
|
pendingVisualCapture = null
|
||||||
|
pendingVisualCaptureAt = 0L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,6 +524,9 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
flow.completed = true
|
flow.completed = true
|
||||||
flow.completedAt = System.currentTimeMillis()
|
flow.completedAt = System.currentTimeMillis()
|
||||||
flow.resultFingerprint = signal.resultFingerprint
|
flow.resultFingerprint = signal.resultFingerprint
|
||||||
|
pendingVisualCapture?.let(handler::removeCallbacks)
|
||||||
|
pendingVisualCapture = null
|
||||||
|
pendingVisualCaptureAt = 0L
|
||||||
val coordinator = RecognitionCoordinator.get(this)
|
val coordinator = RecognitionCoordinator.get(this)
|
||||||
val settings = RecognitionSettings.snapshot(this)
|
val settings = RecognitionSettings.snapshot(this)
|
||||||
val batchEnabled = settings.aiScreenshot && settings.aiAllowed && settings.hasAccount
|
val batchEnabled = settings.aiScreenshot && settings.aiAllowed && settings.hasAccount
|
||||||
@@ -564,22 +577,58 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
) {
|
) {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R ||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R ||
|
||||||
flow.completed ||
|
flow.completed ||
|
||||||
flow.probeCount >= MAX_VISUAL_PROBES ||
|
flow.trackingTimedOut ||
|
||||||
ocrInProgress
|
flow.probeCount >= MAX_VISUAL_PROBES
|
||||||
) return
|
) return
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
|
if (now - flow.startedAt >= PAYMENT_FLOW_TTL_MS) {
|
||||||
|
recordResultPageTimeout(flow, nodeCount)
|
||||||
|
return
|
||||||
|
}
|
||||||
val throttleWait = (
|
val throttleWait = (
|
||||||
VISUAL_CAPTURE_THROTTLE_MS - (now - lastVisualCaptureAt)
|
VISUAL_CAPTURE_THROTTLE_MS - (now - lastVisualCaptureAt)
|
||||||
).coerceAtLeast(0L)
|
).coerceAtLeast(0L)
|
||||||
|
val targetAt = now + maxOf(delayMs, throttleWait)
|
||||||
|
if (pendingVisualCapture != null &&
|
||||||
|
pendingVisualCaptureAt > 0L &&
|
||||||
|
pendingVisualCaptureAt <= targetAt
|
||||||
|
) return
|
||||||
pendingVisualCapture?.let(handler::removeCallbacks)
|
pendingVisualCapture?.let(handler::removeCallbacks)
|
||||||
pendingVisualCapture = Runnable {
|
pendingVisualCapture = Runnable {
|
||||||
pendingVisualCapture = null
|
pendingVisualCapture = null
|
||||||
|
pendingVisualCaptureAt = 0L
|
||||||
val current = paymentFlow
|
val current = paymentFlow
|
||||||
if (current?.id != flow.id || current.completed ||
|
if (current?.id != flow.id || current.completed || current.trackingTimedOut) {
|
||||||
lastPackageName != flow.packageName
|
return@Runnable
|
||||||
) 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)
|
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) {
|
private fun captureForLocalOcr(flow: PaymentFlow, reason: String, nodeCount: Int) {
|
||||||
@@ -699,10 +748,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
val recognitionSettings = RecognitionSettings.snapshot(
|
val recognitionSettings = RecognitionSettings.snapshot(
|
||||||
this@ScreenshotAccessibilityService,
|
this@ScreenshotAccessibilityService,
|
||||||
)
|
)
|
||||||
val visualTransitionObserved = flow.resultTransitionObserved ||
|
val visualTransitionObserved = flow.resultTransitionObserved
|
||||||
flow.committedAt?.let {
|
|
||||||
capturedAt - it >= VISUAL_STABILITY_DELAY_MS
|
|
||||||
} == true
|
|
||||||
runCatching {
|
runCatching {
|
||||||
LocalPaymentOcr.analyze(
|
LocalPaymentOcr.analyze(
|
||||||
context = this@ScreenshotAccessibilityService,
|
context = this@ScreenshotAccessibilityService,
|
||||||
@@ -815,15 +861,30 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
nodeCount: Int,
|
nodeCount: Int,
|
||||||
) {
|
) {
|
||||||
try {
|
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(
|
RecognitionDiagnostics.record(
|
||||||
this,
|
this,
|
||||||
flow.packageName,
|
flow.packageName,
|
||||||
stage = "ocr",
|
stage = "ocr",
|
||||||
result = if (outcome.signal != null) "matched" else "rejected",
|
result = when {
|
||||||
|
outcome.signal != null -> "matched"
|
||||||
|
shouldWait -> "waiting"
|
||||||
|
else -> "rejected"
|
||||||
|
},
|
||||||
nodeCount = nodeCount,
|
nodeCount = nodeCount,
|
||||||
ocrMs = outcome.latencyMs,
|
ocrMs = outcome.latencyMs,
|
||||||
amountCandidates = outcome.amountCandidateCount,
|
amountCandidates = outcome.amountCandidateCount,
|
||||||
reason = outcome.reason,
|
reason = diagnosticReason,
|
||||||
statusStrength = outcome.statusStrength,
|
statusStrength = outcome.statusStrength,
|
||||||
expectedAmountMatched = outcome.expectedAmountMatched,
|
expectedAmountMatched = outcome.expectedAmountMatched,
|
||||||
resultTransitionObserved = outcome.resultTransitionObserved,
|
resultTransitionObserved = outcome.resultTransitionObserved,
|
||||||
@@ -854,17 +915,17 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val retryable = isRetryableOcrOutcome(outcome.reason)
|
if (shouldWait) {
|
||||||
if (retryable && flow.retryCount < MAX_VISUAL_RETRIES) {
|
|
||||||
flow.retryCount += 1
|
flow.retryCount += 1
|
||||||
scheduleVisualRecognition(
|
scheduleVisualRecognition(
|
||||||
flow,
|
flow,
|
||||||
reason = "ocr_retry",
|
reason = "result_page_follow_up",
|
||||||
nodeCount = nodeCount,
|
nodeCount = nodeCount,
|
||||||
delayMs = VISUAL_RETRY_DELAY_MS,
|
delayMs = resultProbeDelayAfterCapture(flow.probeCount),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (retryable) flow.trackingTimedOut = true
|
||||||
if (outcome.sawSuccess) maybeUseAiFallback(flow, bitmap)
|
if (outcome.sawSuccess) maybeUseAiFallback(flow, bitmap)
|
||||||
} finally {
|
} finally {
|
||||||
bitmap.recycle()
|
bitmap.recycle()
|
||||||
@@ -967,17 +1028,51 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
nodeCount = nodeCount,
|
nodeCount = nodeCount,
|
||||||
reason = reason,
|
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
|
flow.retryCount += 1
|
||||||
|
RecognitionDiagnostics.record(
|
||||||
|
this,
|
||||||
|
flow.packageName,
|
||||||
|
stage = "capture",
|
||||||
|
result = "waiting",
|
||||||
|
nodeCount = nodeCount,
|
||||||
|
reason = reason,
|
||||||
|
)
|
||||||
scheduleVisualRecognition(
|
scheduleVisualRecognition(
|
||||||
flow,
|
flow,
|
||||||
reason = "capture_retry",
|
reason = "capture_retry",
|
||||||
nodeCount = nodeCount,
|
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 =
|
private fun shouldFallbackToDisplay(errorCode: Int): Boolean =
|
||||||
errorCode == ERROR_TAKE_SCREENSHOT_INTERNAL_ERROR ||
|
errorCode == ERROR_TAKE_SCREENSHOT_INTERNAL_ERROR ||
|
||||||
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE &&
|
(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 MIN_RESULT_TRANSITION_DELAY_MS = 250L
|
||||||
private const val AMBIGUOUS_REPEAT_GAP_MS = 3_000L
|
private const val AMBIGUOUS_REPEAT_GAP_MS = 3_000L
|
||||||
private const val VISUAL_STABILITY_DELAY_MS = 700L
|
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 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 CAPTURE_CALLBACK_TIMEOUT_MS = 6_000L
|
||||||
private const val OCR_CALLBACK_TIMEOUT_MS = 12_000L
|
private const val OCR_CALLBACK_TIMEOUT_MS = 12_000L
|
||||||
private const val STALE_BITMAP_RELEASE_DELAY_MS = 60_000L
|
private const val STALE_BITMAP_RELEASE_DELAY_MS = 60_000L
|
||||||
private const val WINDOW_CAPTURE_FALLBACK_DELAY_MS = 450L
|
private const val WINDOW_CAPTURE_FALLBACK_DELAY_MS = 450L
|
||||||
private const val MAX_VISUAL_RETRIES = 1
|
private const val MAX_VISUAL_PROBES = 5
|
||||||
private const val MAX_VISUAL_PROBES = 4
|
|
||||||
private const val MAX_TREE_NODES = 160
|
private const val MAX_TREE_NODES = 160
|
||||||
private const val MAX_CHILDREN_PER_NODE = 40
|
private const val MAX_CHILDREN_PER_NODE = 40
|
||||||
private const val MAX_TEXT_CHARS = 8_000
|
private const val MAX_TEXT_CHARS = 8_000
|
||||||
@@ -1240,6 +1335,13 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED,
|
AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED,
|
||||||
AccessibilityEvent.TYPE_VIEW_CLICKED,
|
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
|
@Volatile
|
||||||
private var activeInstance: ScreenshotAccessibilityService? = null
|
private var activeInstance: ScreenshotAccessibilityService? = null
|
||||||
@@ -1255,6 +1357,17 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
"payment_input_page",
|
"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(
|
internal fun completedResultStartReason(
|
||||||
hasObservedResult: Boolean,
|
hasObservedResult: Boolean,
|
||||||
resultFingerprintChanged: Boolean,
|
resultFingerprintChanged: Boolean,
|
||||||
|
|||||||
@@ -117,10 +117,46 @@ class PaymentParserTest {
|
|||||||
@Test
|
@Test
|
||||||
fun paymentInputAndAmbiguousAmountsAreRejected() {
|
fun paymentInputAndAmbiguousAmountsAreRejected() {
|
||||||
assertTrue(PaymentParser.isPaymentInputPage("请输入支付密码\n确认转账"))
|
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"))
|
assertEquals(2_000L, PaymentParser.uniqueAmountCents("付款金额 ¥20.00\n¥20.00"))
|
||||||
assertNull(PaymentParser.uniqueAmountCents("¥20.00\n优惠 ¥2.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
|
@Test
|
||||||
fun diagnosticPreviewMasksSensitiveValuesAndLimitsLines() {
|
fun diagnosticPreviewMasksSensitiveValuesAndLimitsLines() {
|
||||||
val preview = OcrDiagnosticRedactor.redact(
|
val preview = OcrDiagnosticRedactor.redact(
|
||||||
|
|||||||
@@ -197,8 +197,16 @@ class _MiaoJiAppState extends State<MiaoJiApp> with WidgetsBindingObserver {
|
|||||||
|
|
||||||
Future<void> _restoreRecognitionServices() async {
|
Future<void> _restoreRecognitionServices() async {
|
||||||
await RecognitionImportService.configureNativeContext();
|
await RecognitionImportService.configureNativeContext();
|
||||||
await ScreenshotChannel.waitForAccessibilityConnection();
|
await ScreenshotChannel.ensureRecognitionKeepAlive();
|
||||||
await RecognitionImportService.importAutomatic();
|
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 {
|
Future<void> _refreshRemoteState() async {
|
||||||
@@ -231,6 +239,10 @@ class _MiaoJiAppState extends State<MiaoJiApp> with WidgetsBindingObserver {
|
|||||||
await Future<void>.delayed(const Duration(milliseconds: 180));
|
await Future<void>.delayed(const Duration(milliseconds: 180));
|
||||||
final context = _rootNavigatorKey.currentContext;
|
final context = _rootNavigatorKey.currentContext;
|
||||||
if (!mounted || context == null || !context.mounted) return;
|
if (!mounted || context == null || !context.mounted) return;
|
||||||
|
if (action['action'] == 'open_recognition_settings') {
|
||||||
|
router.push('/screenshot-settings');
|
||||||
|
return;
|
||||||
|
}
|
||||||
await RecognitionImportService.handleAction(context, action);
|
await RecognitionImportService.handleAction(context, action);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,12 +27,15 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
|
|||||||
Timer? _diagnosticRefreshTimer;
|
Timer? _diagnosticRefreshTimer;
|
||||||
Timer? _previewExpiryTimer;
|
Timer? _previewExpiryTimer;
|
||||||
bool _diagnosticRefreshInFlight = false;
|
bool _diagnosticRefreshInFlight = false;
|
||||||
|
bool _connectionCheckInFlight = false;
|
||||||
|
bool _keepAliveStarting = false;
|
||||||
|
bool _recentsProtectionNoticeChecked = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_check(waitForConnection: true);
|
_check();
|
||||||
_diagnosticRefreshTimer = Timer.periodic(
|
_diagnosticRefreshTimer = Timer.periodic(
|
||||||
const Duration(seconds: 2),
|
const Duration(seconds: 2),
|
||||||
(_) => _refreshRunningDiagnostic(),
|
(_) => _refreshRunningDiagnostic(),
|
||||||
@@ -60,18 +63,16 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
|
|||||||
void _refreshRunningDiagnostic() {
|
void _refreshRunningDiagnostic() {
|
||||||
if (!mounted ||
|
if (!mounted ||
|
||||||
_diagnosticRefreshInFlight ||
|
_diagnosticRefreshInFlight ||
|
||||||
_status?.latestDiagnostic?.result != 'started') {
|
!{'started', 'waiting'}.contains(_status?.latestDiagnostic?.result)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_diagnosticRefreshInFlight = true;
|
_diagnosticRefreshInFlight = true;
|
||||||
_check().whenComplete(() => _diagnosticRefreshInFlight = false);
|
_check().whenComplete(() => _diagnosticRefreshInFlight = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _check({bool waitForConnection = false}) async {
|
Future<void> _check({bool monitorConnection = true}) async {
|
||||||
try {
|
try {
|
||||||
var status = waitForConnection
|
var status = await ScreenshotChannel.recognitionStatus();
|
||||||
? await ScreenshotChannel.waitForAccessibilityConnection()
|
|
||||||
: await ScreenshotChannel.recognitionStatus();
|
|
||||||
final invalid = <String>[
|
final invalid = <String>[
|
||||||
if (status.accessibilityEvents &&
|
if (status.accessibilityEvents &&
|
||||||
(!status.accessibilityAuthorized ||
|
(!status.accessibilityAuthorized ||
|
||||||
@@ -99,6 +100,14 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
|
|||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
_schedulePreviewExpiry(status);
|
_schedulePreviewExpiry(status);
|
||||||
|
unawaited(_showRecentsProtectionNotice(status));
|
||||||
|
if (monitorConnection &&
|
||||||
|
status.accessibilityAuthorized &&
|
||||||
|
!status.accessibilityConnected &&
|
||||||
|
!status.taskCleanerRecoveryNeeded &&
|
||||||
|
(status.accessibilityEvents || status.aiScreenshot)) {
|
||||||
|
unawaited(_monitorAccessibilityConnection());
|
||||||
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
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) {
|
void _schedulePreviewExpiry(RecognitionStatus status) {
|
||||||
_previewExpiryTimer?.cancel();
|
_previewExpiryTimer?.cancel();
|
||||||
final expiresAt = status.ocrDiagnosticPreviewExpiresAt;
|
final expiresAt = status.ocrDiagnosticPreviewExpiresAt;
|
||||||
@@ -182,7 +234,7 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
|
|||||||
|
|
||||||
Future<void> _resumeAuthorization() async {
|
Future<void> _resumeAuthorization() async {
|
||||||
if (_authorizing) return;
|
if (_authorizing) return;
|
||||||
await _check(waitForConnection: true);
|
await _check();
|
||||||
final key = _pendingAuthorizationKey;
|
final key = _pendingAuthorizationKey;
|
||||||
final status = _status;
|
final status = _status;
|
||||||
if (key == null || status == null) return;
|
if (key == null || status == null) return;
|
||||||
@@ -433,12 +485,31 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
|
|||||||
'仅在微信和支付宝疑似支付流程中读取可见文字,并按需在内存中进行本地截图 OCR;不保存图片、完整控件树,也不监听按键。',
|
'仅在微信和支付宝疑似支付流程中读取可见文字,并按需在内存中进行本地截图 OCR;不保存图片、完整控件树,也不监听按键。',
|
||||||
authorized: status!.accessibilityAuthorized,
|
authorized: status!.accessibilityAuthorized,
|
||||||
connected: status.accessibilityConnected,
|
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,
|
onOpenSettings: ScreenshotChannel.openAccessibilitySettings,
|
||||||
onRetry:
|
settingsLabel: '前往无障碍设置',
|
||||||
status.accessibilityAuthorized &&
|
onRetry: status.accessibilityNeedsRecovery
|
||||||
!status.accessibilityConnected
|
? () => _monitorAccessibilityConnection()
|
||||||
? () => _check(waitForConnection: true)
|
: null,
|
||||||
|
onStartKeepAlive:
|
||||||
|
(status.accessibilityEvents || status.aiScreenshot) &&
|
||||||
|
status.keepAliveNeedsRecovery &&
|
||||||
|
!_keepAliveStarting
|
||||||
|
? () =>
|
||||||
|
_monitorAccessibilityConnection(ensureKeepAlive: true)
|
||||||
: null,
|
: null,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -546,7 +617,12 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
|
|||||||
status.notificationEvents ||
|
status.notificationEvents ||
|
||||||
status.aiScreenshot) ...[
|
status.aiScreenshot) ...[
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
_BackgroundKeepAliveCard(status: status),
|
_BackgroundKeepAliveCard(
|
||||||
|
status: status,
|
||||||
|
starting: _keepAliveStarting,
|
||||||
|
onStart: () =>
|
||||||
|
_monitorAccessibilityConnection(ensureKeepAlive: true),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
_RecognitionCard(
|
_RecognitionCard(
|
||||||
@@ -844,8 +920,14 @@ class _EvidencePill extends StatelessWidget {
|
|||||||
|
|
||||||
class _BackgroundKeepAliveCard extends StatelessWidget {
|
class _BackgroundKeepAliveCard extends StatelessWidget {
|
||||||
final RecognitionStatus status;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -883,9 +965,18 @@ class _BackgroundKeepAliveCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
status.batteryOptimizationIgnored ? '后台限制较少' : '需要设置',
|
status.recentsProtectionActive
|
||||||
|
? '任务清理防护中'
|
||||||
|
: status.keepAliveRunning
|
||||||
|
? '保护运行中'
|
||||||
|
: status.keepAliveExpected
|
||||||
|
? '保护未运行'
|
||||||
|
: status.batteryOptimizationIgnored
|
||||||
|
? '后台限制较少'
|
||||||
|
: '需要设置',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: status.batteryOptimizationIgnored
|
color:
|
||||||
|
status.keepAliveRunning || status.recentsProtectionActive
|
||||||
? AppTheme.primaryDeep
|
? AppTheme.primaryDeep
|
||||||
: AppTheme.orange,
|
: AppTheme.orange,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
@@ -896,15 +987,29 @@ class _BackgroundKeepAliveCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Text(
|
Text(
|
||||||
'无障碍和通知监听由 Android 系统持续绑定。请允许记之后台活动,'
|
status.recentsProtectionActive
|
||||||
'${isVivo ? '并在 OriginOS 中开启自启动,' : ''}'
|
? 'OriginOS 最近任务保护已开启。记之不会显示在最近任务中,请从桌面图标重新打开;关闭全部识别后会自动恢复任务卡。'
|
||||||
'否则系统清理进程后可能暂时收不到支付事件。',
|
: '无障碍和通知监听由 Android 系统持续绑定。请允许记之后台活动,'
|
||||||
|
'${isVivo ? '并在 OriginOS 中开启自启动,' : ''}'
|
||||||
|
'否则系统清理进程后可能暂时收不到支付事件。',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: context.jz.text2,
|
color: context.jz.text2,
|
||||||
fontSize: 11.5,
|
fontSize: 11.5,
|
||||||
height: 1.55,
|
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),
|
const SizedBox(height: 12),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -941,6 +1046,9 @@ class _RecognitionCard extends StatelessWidget {
|
|||||||
final Widget child;
|
final Widget child;
|
||||||
final VoidCallback? onOpenSettings;
|
final VoidCallback? onOpenSettings;
|
||||||
final VoidCallback? onRetry;
|
final VoidCallback? onRetry;
|
||||||
|
final VoidCallback? onStartKeepAlive;
|
||||||
|
final String? recoveryMessage;
|
||||||
|
final String settingsLabel;
|
||||||
|
|
||||||
const _RecognitionCard({
|
const _RecognitionCard({
|
||||||
required this.icon,
|
required this.icon,
|
||||||
@@ -952,12 +1060,15 @@ class _RecognitionCard extends StatelessWidget {
|
|||||||
this.statusLabel,
|
this.statusLabel,
|
||||||
this.onOpenSettings,
|
this.onOpenSettings,
|
||||||
this.onRetry,
|
this.onRetry,
|
||||||
|
this.onStartKeepAlive,
|
||||||
|
this.recoveryMessage,
|
||||||
|
this.settingsLabel = '前往系统设置',
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final palette = context.jz;
|
final palette = context.jz;
|
||||||
final color = connected ? AppTheme.primary : AppTheme.ai;
|
final color = connected ? AppTheme.primary : AppTheme.orange;
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -976,7 +1087,7 @@ class _RecognitionCard extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: connected
|
color: connected
|
||||||
? palette.primaryBackground
|
? palette.primaryBackground
|
||||||
: palette.aiBackground,
|
: palette.warningBackground,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Icon(icon, color: color, size: 21),
|
child: Icon(icon, color: color, size: 21),
|
||||||
@@ -996,7 +1107,7 @@ class _RecognitionCard extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: connected
|
color: connected
|
||||||
? palette.primaryBackground
|
? palette.primaryBackground
|
||||||
: palette.aiBackground,
|
: palette.warningBackground,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -1020,9 +1131,35 @@ class _RecognitionCard extends StatelessWidget {
|
|||||||
description,
|
description,
|
||||||
style: TextStyle(color: palette.text2, fontSize: 12, height: 1.55),
|
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),
|
const SizedBox(height: 6),
|
||||||
child,
|
child,
|
||||||
if (onOpenSettings != null || onRetry != null) ...[
|
if (onOpenSettings != null ||
|
||||||
|
onRetry != null ||
|
||||||
|
onStartKeepAlive != null) ...[
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 6,
|
spacing: 6,
|
||||||
@@ -1034,11 +1171,17 @@ class _RecognitionCard extends StatelessWidget {
|
|||||||
icon: const Icon(Icons.refresh_rounded, size: 18),
|
icon: const Icon(Icons.refresh_rounded, size: 18),
|
||||||
label: const Text('重新检测'),
|
label: const Text('重新检测'),
|
||||||
),
|
),
|
||||||
|
if (onStartKeepAlive != null)
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: onStartKeepAlive,
|
||||||
|
icon: const Icon(Icons.shield_outlined, size: 18),
|
||||||
|
label: const Text('启动后台保护'),
|
||||||
|
),
|
||||||
if (onOpenSettings != null)
|
if (onOpenSettings != null)
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: onOpenSettings,
|
onPressed: onOpenSettings,
|
||||||
icon: const Icon(Icons.settings_outlined, size: 18),
|
icon: const Icon(Icons.settings_outlined, size: 18),
|
||||||
label: const Text('前往系统设置'),
|
label: Text(settingsLabel),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ class RecognitionDiagnosticDisplay {
|
|||||||
'matched' || 'auto_ready' => '已识别',
|
'matched' || 'auto_ready' => '已识别',
|
||||||
'confirm' => '待确认',
|
'confirm' => '待确认',
|
||||||
'started' => '处理中',
|
'started' => '处理中',
|
||||||
|
'waiting' => '等待结果页',
|
||||||
'failed' => '失败',
|
'failed' => '失败',
|
||||||
_ => '未触发入账',
|
_ => '未触发入账',
|
||||||
};
|
};
|
||||||
@@ -56,6 +57,7 @@ class RecognitionDiagnosticDisplay {
|
|||||||
|
|
||||||
static String _summaryLabel(String result, String reason) {
|
static String _summaryLabel(String result, String reason) {
|
||||||
if (reason == 'duplicate_result_surface') return '已合并';
|
if (reason == 'duplicate_result_surface') return '已合并';
|
||||||
|
if (result == 'waiting') return '等待结果页';
|
||||||
if (_captureFailureReasons.contains(reason)) return '截图失败';
|
if (_captureFailureReasons.contains(reason)) return '截图失败';
|
||||||
if (_ocrNoResultReasons.contains(reason)) return 'OCR 无结果';
|
if (_ocrNoResultReasons.contains(reason)) return 'OCR 无结果';
|
||||||
if (_ruleRejectedReasons.contains(reason) || result == 'rejected') {
|
if (_ruleRejectedReasons.contains(reason) || result == 'rejected') {
|
||||||
@@ -65,6 +67,7 @@ class RecognitionDiagnosticDisplay {
|
|||||||
'matched' || 'auto_ready' => '已识别',
|
'matched' || 'auto_ready' => '已识别',
|
||||||
'confirm' => '待确认',
|
'confirm' => '待确认',
|
||||||
'started' => '处理中',
|
'started' => '处理中',
|
||||||
|
'waiting' => '等待结果页',
|
||||||
'failed' => '失败',
|
'failed' => '失败',
|
||||||
_ => '没事件',
|
_ => '没事件',
|
||||||
};
|
};
|
||||||
@@ -75,8 +78,9 @@ class RecognitionDiagnosticDisplay {
|
|||||||
'history_page' => '当前是账单或交易历史页',
|
'history_page' => '当前是账单或交易历史页',
|
||||||
'blocked_status' => '当前状态为失败、处理中或已取消',
|
'blocked_status' => '当前状态为失败、处理中或已取消',
|
||||||
'no_text' => '截图中没有识别到文字',
|
'no_text' => '截图中没有识别到文字',
|
||||||
'no_success_status' => '没有找到明确或弱完成状态,可开启诊断预览查看脱敏结果',
|
'no_success_status' => '暂未找到完成状态,正在按计划复核结果页',
|
||||||
'payment_input_page' => '当前仍是付款输入或确认页面,已拒绝入账',
|
'payment_input_page' => '当前仍是付款输入或确认页面,等待结果页,不会提前入账',
|
||||||
|
'result_page_timeout' => '90 秒内未等到明确结果页,本次流程已停止追踪',
|
||||||
'direction_unknown' => '识别到完成状态,但无法确认收支方向',
|
'direction_unknown' => '识别到完成状态,但无法确认收支方向',
|
||||||
'missing_amount' => '成功状态已识别,但没有找到金额',
|
'missing_amount' => '成功状态已识别,但没有找到金额',
|
||||||
'expected_amount_missing' => '结果页没有金额,且付款前金额不唯一或未捕获',
|
'expected_amount_missing' => '结果页没有金额,且付款前金额不唯一或未捕获',
|
||||||
@@ -167,8 +171,7 @@ class RecognitionDiagnosticDisplay {
|
|||||||
static const _ruleRejectedReasons = {
|
static const _ruleRejectedReasons = {
|
||||||
'history_page',
|
'history_page',
|
||||||
'blocked_status',
|
'blocked_status',
|
||||||
'no_success_status',
|
'result_page_timeout',
|
||||||
'payment_input_page',
|
|
||||||
'direction_unknown',
|
'direction_unknown',
|
||||||
'missing_amount',
|
'missing_amount',
|
||||||
'expected_amount_missing',
|
'expected_amount_missing',
|
||||||
|
|||||||
@@ -95,6 +95,14 @@ class RecognitionStatus {
|
|||||||
final DateTime? accessibilityLastConnectedAt;
|
final DateTime? accessibilityLastConnectedAt;
|
||||||
final DateTime? accessibilityLastDisconnectedAt;
|
final DateTime? accessibilityLastDisconnectedAt;
|
||||||
final DateTime? recognitionProcessStartedAt;
|
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 notificationAuthorized;
|
||||||
final bool notificationConnected;
|
final bool notificationConnected;
|
||||||
final bool postNotificationsGranted;
|
final bool postNotificationsGranted;
|
||||||
@@ -116,6 +124,14 @@ class RecognitionStatus {
|
|||||||
this.accessibilityLastConnectedAt,
|
this.accessibilityLastConnectedAt,
|
||||||
this.accessibilityLastDisconnectedAt,
|
this.accessibilityLastDisconnectedAt,
|
||||||
this.recognitionProcessStartedAt,
|
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.notificationAuthorized,
|
||||||
required this.notificationConnected,
|
required this.notificationConnected,
|
||||||
required this.postNotificationsGranted,
|
required this.postNotificationsGranted,
|
||||||
@@ -158,6 +174,17 @@ class RecognitionStatus {
|
|||||||
'accessibilityLastDisconnectedAt',
|
'accessibilityLastDisconnectedAt',
|
||||||
),
|
),
|
||||||
recognitionProcessStartedAt: epochDate('recognitionProcessStartedAt'),
|
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,
|
notificationAuthorized: value['notificationAuthorized'] as bool? ?? false,
|
||||||
notificationConnected: value['notificationConnected'] as bool? ?? false,
|
notificationConnected: value['notificationConnected'] as bool? ?? false,
|
||||||
postNotificationsGranted:
|
postNotificationsGranted:
|
||||||
@@ -189,6 +216,8 @@ class RecognitionStatus {
|
|||||||
!accessibilityConnected &&
|
!accessibilityConnected &&
|
||||||
accessibilityConnectionState == AccessibilityConnectionState.disconnected;
|
accessibilityConnectionState == AccessibilityConnectionState.disconnected;
|
||||||
|
|
||||||
|
bool get keepAliveNeedsRecovery => keepAliveExpected && !keepAliveRunning;
|
||||||
|
|
||||||
String get accessibilityConnectionLabel =>
|
String get accessibilityConnectionLabel =>
|
||||||
switch (accessibilityConnectionState) {
|
switch (accessibilityConnectionState) {
|
||||||
AccessibilityConnectionState.unauthorized => '未授权',
|
AccessibilityConnectionState.unauthorized => '未授权',
|
||||||
@@ -320,6 +349,7 @@ class SpeechEvent {
|
|||||||
/// Android native capabilities for screenshots, AI progress and speech.
|
/// Android native capabilities for screenshots, AI progress and speech.
|
||||||
class ScreenshotChannel {
|
class ScreenshotChannel {
|
||||||
static const _channel = MethodChannel('com.miaoji/screenshot');
|
static const _channel = MethodChannel('com.miaoji/screenshot');
|
||||||
|
static Future<RecognitionStatus>? _activeAccessibilityConnectionWait;
|
||||||
static void Function(String path)? _screenshotReady;
|
static void Function(String path)? _screenshotReady;
|
||||||
static void Function(String error)? _screenshotError;
|
static void Function(String error)? _screenshotError;
|
||||||
static void Function(SpeechEvent event)? _speechEvent;
|
static void Function(SpeechEvent event)? _speechEvent;
|
||||||
@@ -472,13 +502,32 @@ class ScreenshotChannel {
|
|||||||
static Future<RecognitionStatus> waitForAccessibilityConnection({
|
static Future<RecognitionStatus> waitForAccessibilityConnection({
|
||||||
Duration timeout = const Duration(seconds: 5),
|
Duration timeout = const Duration(seconds: 5),
|
||||||
Duration interval = const Duration(milliseconds: 500),
|
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 {
|
}) async {
|
||||||
var status = await recognitionStatus();
|
var status = await recognitionStatus();
|
||||||
final recognitionEnabled =
|
final recognitionEnabled =
|
||||||
status.accessibilityEvents || status.aiScreenshot;
|
status.accessibilityEvents || status.aiScreenshot;
|
||||||
if (!recognitionEnabled ||
|
if (!recognitionEnabled ||
|
||||||
!status.accessibilityAuthorized ||
|
!status.accessibilityAuthorized ||
|
||||||
status.accessibilityConnected) {
|
status.accessibilityConnected ||
|
||||||
|
status.taskCleanerRecoveryNeeded) {
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
final deadline = DateTime.now().add(timeout);
|
final deadline = DateTime.now().add(timeout);
|
||||||
@@ -487,6 +536,7 @@ class ScreenshotChannel {
|
|||||||
status = await recognitionStatus();
|
status = await recognitionStatus();
|
||||||
if (!status.accessibilityAuthorized ||
|
if (!status.accessibilityAuthorized ||
|
||||||
status.accessibilityConnected ||
|
status.accessibilityConnected ||
|
||||||
|
status.taskCleanerRecoveryNeeded ||
|
||||||
!(status.accessibilityEvents || status.aiScreenshot)) {
|
!(status.accessibilityEvents || status.aiScreenshot)) {
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
@@ -494,6 +544,15 @@ class ScreenshotChannel {
|
|||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<bool> ensureRecognitionKeepAlive() async {
|
||||||
|
try {
|
||||||
|
return await _channel.invokeMethod<bool>('ensureRecognitionKeepAlive') ??
|
||||||
|
false;
|
||||||
|
} on MissingPluginException {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static Future<bool> clearRecognitionDiagnostic() async {
|
static Future<bool> clearRecognitionDiagnostic() async {
|
||||||
try {
|
try {
|
||||||
return await _channel.invokeMethod<bool>('clearRecognitionDiagnostic') ??
|
return await _channel.invokeMethod<bool>('clearRecognitionDiagnostic') ??
|
||||||
|
|||||||
@@ -183,6 +183,14 @@ void main() {
|
|||||||
'accessibilityConnected': false,
|
'accessibilityConnected': false,
|
||||||
'accessibilityConnectionState': 'reconnecting',
|
'accessibilityConnectionState': 'reconnecting',
|
||||||
'accessibilityLastConnectedAt': 1724472000000,
|
'accessibilityLastConnectedAt': 1724472000000,
|
||||||
|
'keepAliveExpected': true,
|
||||||
|
'keepAliveRunning': false,
|
||||||
|
'keepAliveError': '系统限制了后台保护启动',
|
||||||
|
'recentsProtectionExpected': true,
|
||||||
|
'recentsProtectionActive': true,
|
||||||
|
'lastRecognitionExitAt': 1788077518000,
|
||||||
|
'lastRecognitionExitReason': 'low_memory: single-cleaner',
|
||||||
|
'taskCleanerRecoveryNeeded': true,
|
||||||
'settings': jsonEncode({'accessibilityEvents': true}),
|
'settings': jsonEncode({'accessibilityEvents': true}),
|
||||||
});
|
});
|
||||||
final connected = RecognitionStatus.fromMap({
|
final connected = RecognitionStatus.fromMap({
|
||||||
@@ -204,6 +212,15 @@ void main() {
|
|||||||
);
|
);
|
||||||
expect(reconnecting.accessibilityConnectionLabel, '正在重连');
|
expect(reconnecting.accessibilityConnectionLabel, '正在重连');
|
||||||
expect(reconnecting.accessibilityLastConnectedAt, isNotNull);
|
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(connected.accessibilityConnectionLabel, '已连接');
|
||||||
expect(disconnected.accessibilityConnectionLabel, '服务未连接');
|
expect(disconnected.accessibilityConnectionLabel, '服务未连接');
|
||||||
expect(disconnected.accessibilityNeedsRecovery, isTrue);
|
expect(disconnected.accessibilityNeedsRecovery, isTrue);
|
||||||
|
|||||||
@@ -153,9 +153,21 @@ void main() {
|
|||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
RecognitionDiagnosticDisplay.from(
|
RecognitionDiagnosticDisplay.from(
|
||||||
diagnostic('rejected', 'payment_input_page'),
|
diagnostic('waiting', 'payment_input_page'),
|
||||||
).summaryLabel,
|
).summaryLabel,
|
||||||
'规则拒绝',
|
'等待结果页',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
RecognitionDiagnosticDisplay.from(
|
||||||
|
diagnostic('waiting', 'payment_input_page'),
|
||||||
|
).reasonLabel,
|
||||||
|
'当前仍是付款输入或确认页面,等待结果页,不会提前入账',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
RecognitionDiagnosticDisplay.from(
|
||||||
|
diagnostic('rejected', 'result_page_timeout'),
|
||||||
|
).reasonLabel,
|
||||||
|
'90 秒内未等到明确结果页,本次流程已停止追踪',
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
RecognitionDiagnosticDisplay.from(
|
RecognitionDiagnosticDisplay.from(
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ void main() {
|
|||||||
expect(source, contains('_BackgroundKeepAliveCard'));
|
expect(source, contains('_BackgroundKeepAliveCard'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('长期识别组件与系统绑定使用默认进程并支持重连状态', () {
|
test('长期识别组件使用独立保活进程并支持非阻塞重连', () {
|
||||||
final manifest = File(
|
final manifest = File(
|
||||||
'android/app/src/main/AndroidManifest.xml',
|
'android/app/src/main/AndroidManifest.xml',
|
||||||
).readAsStringSync();
|
).readAsStringSync();
|
||||||
@@ -290,6 +290,12 @@ void main() {
|
|||||||
final activity = File(
|
final activity = File(
|
||||||
'android/app/src/main/kotlin/com/nx/miaoji/MainActivity.kt',
|
'android/app/src/main/kotlin/com/nx/miaoji/MainActivity.kt',
|
||||||
).readAsStringSync();
|
).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 [
|
for (final component in [
|
||||||
'ScreenshotTileService',
|
'ScreenshotTileService',
|
||||||
@@ -301,18 +307,40 @@ void main() {
|
|||||||
).firstMatch(manifest)?.group(0);
|
).firstMatch(manifest)?.group(0);
|
||||||
expect(declaration, isNotNull);
|
expect(declaration, isNotNull);
|
||||||
expect(declaration, contains('android:stopWithTask="false"'));
|
expect(declaration, contains('android:stopWithTask="false"'));
|
||||||
expect(declaration, isNot(contains('android:process=')));
|
expect(declaration, contains('android:process=":recognition"'));
|
||||||
}
|
}
|
||||||
final provider = RegExp(
|
final provider = RegExp(
|
||||||
'<provider\\s+android:name="\\.RecognitionBridgeProvider"[\\s\\S]*?/>',
|
'<provider\\s+android:name="\\.RecognitionBridgeProvider"[\\s\\S]*?/>',
|
||||||
).firstMatch(manifest)?.group(0);
|
).firstMatch(manifest)?.group(0);
|
||||||
expect(provider, isNotNull);
|
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: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('waitForAccessibilityConnection'));
|
||||||
|
expect(channel, contains('_activeAccessibilityConnectionWait'));
|
||||||
|
expect(channel, contains('ensureRecognitionKeepAlive'));
|
||||||
expect(channel, contains('AccessibilityConnectionState.reconnecting'));
|
expect(channel, contains('AccessibilityConnectionState.reconnecting'));
|
||||||
expect(activity, contains('"reconnecting"'));
|
expect(activity, contains('"reconnecting"'));
|
||||||
expect(activity, contains('"disconnected"'));
|
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'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user