fix: harden OriginOS recognition tracking
This commit is contained in:
@@ -6,6 +6,8 @@
|
||||
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
|
||||
<application
|
||||
android:label="@string/app_name"
|
||||
@@ -54,7 +56,7 @@
|
||||
android:name=".OneShotProjectionService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaProjection"
|
||||
android:process=":recognition"
|
||||
android:process=":projection"
|
||||
android:stopWithTask="false"/>
|
||||
|
||||
<service
|
||||
@@ -62,6 +64,7 @@
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/screenshot_tile_label"
|
||||
android:process=":recognition"
|
||||
android:stopWithTask="false"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||
<intent-filter>
|
||||
@@ -73,6 +76,7 @@
|
||||
android:name=".PaymentNotificationListenerService"
|
||||
android:exported="true"
|
||||
android:label="@string/notification_listener_label"
|
||||
android:process=":recognition"
|
||||
android:stopWithTask="false"
|
||||
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
|
||||
<intent-filter>
|
||||
@@ -83,6 +87,7 @@
|
||||
<service
|
||||
android:name=".ScreenshotAccessibilityService"
|
||||
android:exported="true"
|
||||
android:process=":recognition"
|
||||
android:stopWithTask="false"
|
||||
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
|
||||
<intent-filter>
|
||||
@@ -93,11 +98,34 @@
|
||||
android:resource="@xml/accessibility_service_config"/>
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".RecognitionKeepAliveService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:process=":recognition"
|
||||
android:stopWithTask="false">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Keeps user-enabled payment accessibility and notification recognition active"/>
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name=".RecognitionKeepAliveReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:process=":recognition">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED"/>
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<provider
|
||||
android:name=".RecognitionBridgeProvider"
|
||||
android:authorities="${applicationId}.recognition.bridge"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="false"/>
|
||||
android:grantUriPermissions="false"
|
||||
android:process=":recognition"/>
|
||||
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.nx.miaoji
|
||||
|
||||
import android.Manifest
|
||||
import android.app.ActivityManager
|
||||
import android.app.ApplicationExitInfo
|
||||
import android.app.StatusBarManager
|
||||
import android.app.UiModeManager
|
||||
import android.content.BroadcastReceiver
|
||||
@@ -44,10 +46,12 @@ class MainActivity : FlutterActivity() {
|
||||
const val ACTION_RECOGNITION_UNDO = "recognition_undo"
|
||||
const val ACTION_RECOGNITION_EDIT = "recognition_edit"
|
||||
const val ACTION_RECOGNITION_BATCH_REVIEW = "recognition_batch_review"
|
||||
const val ACTION_OPEN_RECOGNITION_SETTINGS = "open_recognition_settings"
|
||||
const val EXTRA_SCREENSHOT_PATH = "screenshotPath"
|
||||
const val EXTRA_SCREENSHOT_ERROR = "screenshotError"
|
||||
const val EXTRA_SCREENSHOT_SESSION_ID = "screenshotSessionId"
|
||||
const val EXTRA_TRANSACTION_ID = "transactionId"
|
||||
private const val TASK_CLEANER_WINDOW_MS = 15_000L
|
||||
}
|
||||
|
||||
private val handler by lazy { Handler(mainLooper) }
|
||||
@@ -70,6 +74,11 @@ class MainActivity : FlutterActivity() {
|
||||
private var speechRecognizer: SpeechRecognizer? = null
|
||||
private var streamingSpeech = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
updateRecentsProtection()
|
||||
}
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
updateInstallBridge = UpdateInstallBridge(this).also { it.register(flutterEngine) }
|
||||
@@ -181,7 +190,16 @@ class MainActivity : FlutterActivity() {
|
||||
putString("candidateId", call.argument<String>("candidateId"))
|
||||
},
|
||||
)
|
||||
result.success(response?.getBoolean("success") == true)
|
||||
val changed = response?.getBoolean("success") == true
|
||||
if (changed) {
|
||||
updateRecentsProtection(
|
||||
response.getBoolean("keepAliveExpected"),
|
||||
)
|
||||
}
|
||||
result.success(changed)
|
||||
}
|
||||
"ensureRecognitionKeepAlive" -> {
|
||||
result.success(ensureRecognitionKeepAlive())
|
||||
}
|
||||
"openAccessibilitySettings" -> {
|
||||
startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
|
||||
@@ -226,6 +244,8 @@ class MainActivity : FlutterActivity() {
|
||||
scheduleShortcutIfNeeded()
|
||||
dispatchPendingScreenshot()
|
||||
dispatchPendingRecognitionAction()
|
||||
ensureRecognitionKeepAlive()
|
||||
updateRecentsProtection()
|
||||
}
|
||||
|
||||
private fun handleIncomingIntent(incoming: Intent?) {
|
||||
@@ -234,6 +254,13 @@ class MainActivity : FlutterActivity() {
|
||||
shortcutQueued = true
|
||||
scheduleShortcutIfNeeded()
|
||||
}
|
||||
ACTION_OPEN_RECOGNITION_SETTINGS -> {
|
||||
pendingRecognitionAction = mapOf(
|
||||
"action" to ACTION_OPEN_RECOGNITION_SETTINGS,
|
||||
)
|
||||
dispatchPendingRecognitionAction()
|
||||
incoming.removeExtra(EXTRA_ACTION)
|
||||
}
|
||||
ACTION_SCREENSHOT_RESULT -> {
|
||||
val path = incoming.getStringExtra(EXTRA_SCREENSHOT_PATH)
|
||||
val sessionId =
|
||||
@@ -794,20 +821,51 @@ class MainActivity : FlutterActivity() {
|
||||
val accessibilityAuthorized = isAccessibilityEnabledInSystem()
|
||||
val accessibilityConnected = response?.getBoolean("accessibilityConnected") == true
|
||||
val recognitionProcessStartedAt = response?.getLong("recognitionProcessStartedAt") ?: 0L
|
||||
val keepAliveExpected = response?.getBoolean("keepAliveExpected") == true
|
||||
val exits = recognitionExitHistory()
|
||||
val recognitionExits = exits.filter {
|
||||
it.processName == "$packageName:recognition"
|
||||
}
|
||||
val exit = (recognitionExits.ifEmpty { exits }).maxByOrNull { it.timestamp }
|
||||
val lastConnectedAt = response?.getLong("accessibilityLastConnectedAt") ?: 0L
|
||||
val taskRemovedAt = RecognitionConnectionStore.lastTaskRemovedAt(this)
|
||||
val cleanerExit = exits
|
||||
.filter { isTaskCleanerExit(it, taskRemovedAt) }
|
||||
.maxByOrNull { it.timestamp }
|
||||
val taskCleanerRecoveryNeeded = accessibilityAuthorized &&
|
||||
!accessibilityConnected &&
|
||||
cleanerExit != null &&
|
||||
cleanerExit.timestamp > lastConnectedAt
|
||||
val recentsProtectionExpected = isOriginOsDevice() && keepAliveExpected
|
||||
if (RecognitionConnectionStore.recentsProtectionActive(this) !=
|
||||
recentsProtectionExpected
|
||||
) {
|
||||
updateRecentsProtection(keepAliveExpected)
|
||||
}
|
||||
val accessibilityConnectionState = when {
|
||||
!accessibilityAuthorized -> "unauthorized"
|
||||
accessibilityConnected -> "connected"
|
||||
recognitionProcessStartedAt > 0L &&
|
||||
System.currentTimeMillis() - recognitionProcessStartedAt < 5_000L -> "reconnecting"
|
||||
taskCleanerRecoveryNeeded -> "disconnected"
|
||||
keepAliveExpected && recognitionProcessStartedAt > 0L &&
|
||||
System.currentTimeMillis() - recognitionProcessStartedAt < 2_000L -> "reconnecting"
|
||||
else -> "disconnected"
|
||||
}
|
||||
return mapOf(
|
||||
"accessibilityAuthorized" to accessibilityAuthorized,
|
||||
"accessibilityConnected" to accessibilityConnected,
|
||||
"accessibilityConnectionState" to accessibilityConnectionState,
|
||||
"accessibilityLastConnectedAt" to (response?.getLong("accessibilityLastConnectedAt") ?: 0L),
|
||||
"accessibilityLastConnectedAt" to lastConnectedAt,
|
||||
"accessibilityLastDisconnectedAt" to (response?.getLong("accessibilityLastDisconnectedAt") ?: 0L),
|
||||
"recognitionProcessStartedAt" to recognitionProcessStartedAt,
|
||||
"keepAliveExpected" to keepAliveExpected,
|
||||
"keepAliveRunning" to (response?.getBoolean("keepAliveRunning") == true),
|
||||
"keepAliveError" to response?.getString("keepAliveError"),
|
||||
"recentsProtectionExpected" to recentsProtectionExpected,
|
||||
"recentsProtectionActive" to RecognitionConnectionStore
|
||||
.recentsProtectionActive(this),
|
||||
"lastRecognitionExitAt" to (exit?.timestamp ?: 0L),
|
||||
"lastRecognitionExitReason" to exit?.let(::recognitionExitReason),
|
||||
"taskCleanerRecoveryNeeded" to taskCleanerRecoveryNeeded,
|
||||
"notificationAuthorized" to notificationAuthorized,
|
||||
"notificationConnected" to (response?.getBoolean("notificationConnected") == true),
|
||||
"postNotificationsGranted" to (
|
||||
@@ -829,6 +887,76 @@ class MainActivity : FlutterActivity() {
|
||||
)
|
||||
}
|
||||
|
||||
private fun ensureRecognitionKeepAlive(): Boolean {
|
||||
val response = RecognitionBridge.call(
|
||||
this,
|
||||
RecognitionBridgeProvider.METHOD_ENSURE_KEEP_ALIVE,
|
||||
)
|
||||
return response?.getBoolean("success") == true
|
||||
}
|
||||
|
||||
private fun updateRecentsProtection(expectedOverride: Boolean? = null) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return
|
||||
val recognitionExpected = expectedOverride ?: (
|
||||
RecognitionBridge.call(
|
||||
this,
|
||||
RecognitionBridgeProvider.METHOD_STATUS,
|
||||
)?.getBoolean("keepAliveExpected") == true
|
||||
)
|
||||
val shouldProtect = isOriginOsDevice() && recognitionExpected
|
||||
val activityManager = getSystemService(ActivityManager::class.java)
|
||||
val currentTask = activityManager.appTasks.firstOrNull {
|
||||
runCatching { it.taskInfo.taskId == taskId }.getOrDefault(false)
|
||||
} ?: activityManager.appTasks.firstOrNull()
|
||||
val applied = runCatching {
|
||||
currentTask?.setExcludeFromRecents(shouldProtect)
|
||||
currentTask != null
|
||||
}.getOrDefault(false)
|
||||
RecognitionConnectionStore.setRecentsProtectionActive(
|
||||
this,
|
||||
shouldProtect && applied,
|
||||
)
|
||||
}
|
||||
|
||||
private fun isOriginOsDevice(): Boolean {
|
||||
val vendor = "${Build.MANUFACTURER} ${Build.BRAND}".lowercase()
|
||||
return "vivo" in vendor || "iqoo" in vendor
|
||||
}
|
||||
|
||||
private fun recognitionExitHistory(): List<ApplicationExitInfo> {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return emptyList()
|
||||
return runCatching {
|
||||
getSystemService(ActivityManager::class.java)
|
||||
.getHistoricalProcessExitReasons(packageName, 0, 16)
|
||||
.filter { info ->
|
||||
info.processName == packageName ||
|
||||
info.processName == "$packageName:recognition"
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun isTaskCleanerExit(info: ApplicationExitInfo, taskRemovedAt: Long): Boolean {
|
||||
return info.description.orEmpty().contains("single-cleaner", ignoreCase = true) ||
|
||||
(info.reason == ApplicationExitInfo.REASON_LOW_MEMORY &&
|
||||
taskRemovedAt > 0L &&
|
||||
kotlin.math.abs(info.timestamp - taskRemovedAt) <= TASK_CLEANER_WINDOW_MS)
|
||||
}
|
||||
|
||||
private fun recognitionExitReason(info: ApplicationExitInfo): String {
|
||||
val reason = when (info.reason) {
|
||||
ApplicationExitInfo.REASON_LOW_MEMORY -> "low_memory"
|
||||
ApplicationExitInfo.REASON_USER_REQUESTED -> "user_requested"
|
||||
ApplicationExitInfo.REASON_CRASH -> "crash"
|
||||
ApplicationExitInfo.REASON_CRASH_NATIVE -> "native_crash"
|
||||
ApplicationExitInfo.REASON_ANR -> "anr"
|
||||
ApplicationExitInfo.REASON_SIGNALED -> "signaled"
|
||||
ApplicationExitInfo.REASON_OTHER -> "other"
|
||||
else -> "reason_${info.reason}"
|
||||
}
|
||||
val description = info.description.orEmpty()
|
||||
return if (description.isBlank()) reason else "$reason: $description"
|
||||
}
|
||||
|
||||
private fun acknowledgeRecognition(call: MethodCall, result: MethodChannel.Result) {
|
||||
val response = RecognitionBridge.call(
|
||||
this,
|
||||
|
||||
+1
@@ -8,6 +8,7 @@ class PaymentNotificationListenerService : NotificationListenerService() {
|
||||
override fun onListenerConnected() {
|
||||
super.onListenerConnected()
|
||||
isConnected = true
|
||||
RecognitionKeepAliveService.ensureRunning(this)
|
||||
Log.i(TAG, "Notification recognition listener connected")
|
||||
}
|
||||
|
||||
|
||||
@@ -125,7 +125,8 @@ object PaymentParser {
|
||||
)
|
||||
private val paymentInputWords = listOf(
|
||||
"输入支付密码", "请输入支付密码", "确认转账", "确认支付", "确认付款",
|
||||
"立即支付", "立即付款", "继续付款",
|
||||
"立即支付", "立即付款", "继续付款", "转账全额", "添加转账说明",
|
||||
"请输入转账金额", "输入转账金额",
|
||||
)
|
||||
private val redPacketSendContextWords = listOf(
|
||||
"发红包", "塞钱进红包", "红包金额", "发送红包", "普通红包", "拼手气红包",
|
||||
@@ -494,8 +495,15 @@ object PaymentParser {
|
||||
|
||||
fun isPaymentInputPage(value: String): Boolean {
|
||||
val normalized = normalize(value)
|
||||
val transferInputComposite = "转账给" in normalized && (
|
||||
"转账全额" in normalized ||
|
||||
"添加转账说明" in normalized ||
|
||||
"请输入金额" in normalized ||
|
||||
Regex("""[¥¥]\s*0(?:\.0{1,2})?""").containsMatchIn(normalized)
|
||||
)
|
||||
return paymentInputWords.any(normalized::contains) ||
|
||||
redPacketSendActionWords.any(normalized::contains)
|
||||
redPacketSendActionWords.any(normalized::contains) ||
|
||||
transferInputComposite
|
||||
}
|
||||
|
||||
fun detectFlowKind(value: String): String? {
|
||||
@@ -565,12 +573,17 @@ object PaymentParser {
|
||||
val normalized = normalize(value)
|
||||
amountPatterns.firstNotNullOfOrNull { pattern ->
|
||||
pattern.find(normalized)?.groupValues?.getOrNull(1)?.toDoubleOrNull()
|
||||
}?.let { return it }
|
||||
}?.takeIf { it.isFinite() && it > 0.0 }?.let { return it }
|
||||
val match = STANDALONE_AMOUNT.matchEntire(normalized) ?: return null
|
||||
val number = match.groupValues[2]
|
||||
val hasCurrencyOrUnit = match.groupValues[1].isNotEmpty() ||
|
||||
match.groupValues[3].isNotEmpty()
|
||||
return if (!hasCurrencyOrUnit && !number.contains('.')) null else number.toDoubleOrNull()
|
||||
val amount = if (!hasCurrencyOrUnit && !number.contains('.')) {
|
||||
null
|
||||
} else {
|
||||
number.toDoubleOrNull()
|
||||
}
|
||||
return amount?.takeIf { it.isFinite() && it > 0.0 }
|
||||
}
|
||||
|
||||
fun extractOrderId(value: String): String? =
|
||||
|
||||
@@ -13,6 +13,9 @@ class RecognitionBridgeProvider : ContentProvider() {
|
||||
private val captureResults = ConcurrentHashMap<String, CaptureResult>()
|
||||
|
||||
override fun onCreate(): Boolean {
|
||||
context?.let {
|
||||
RecognitionConnectionStore.markRecognitionProcessStarted(it, PROCESS_STARTED_AT)
|
||||
}
|
||||
context?.let(RecognitionCoordinator::get)
|
||||
return true
|
||||
}
|
||||
@@ -22,23 +25,40 @@ class RecognitionBridgeProvider : ContentProvider() {
|
||||
return when (method) {
|
||||
METHOD_STATUS -> Bundle().apply {
|
||||
putBoolean("accessibilityConnected", ScreenshotAccessibilityService.isConnected)
|
||||
putLong("accessibilityLastConnectedAt", ScreenshotAccessibilityService.lastConnectedAt)
|
||||
putLong("accessibilityLastDisconnectedAt", ScreenshotAccessibilityService.lastDisconnectedAt)
|
||||
putLong(
|
||||
"accessibilityLastConnectedAt",
|
||||
RecognitionConnectionStore.lastConnectedAt(appContext),
|
||||
)
|
||||
putLong(
|
||||
"accessibilityLastDisconnectedAt",
|
||||
RecognitionConnectionStore.lastDisconnectedAt(appContext),
|
||||
)
|
||||
putLong("recognitionProcessStartedAt", PROCESS_STARTED_AT)
|
||||
putBoolean("notificationConnected", PaymentNotificationListenerService.isConnected)
|
||||
putBoolean(
|
||||
"keepAliveExpected",
|
||||
RecognitionKeepAliveService.isExpected(appContext),
|
||||
)
|
||||
putBoolean("keepAliveRunning", RecognitionKeepAliveService.isRunning)
|
||||
putString("keepAliveError", RecognitionKeepAliveService.lastStartError)
|
||||
putString("settings", RecognitionSettings.statusJson(appContext))
|
||||
putString("latestStatus", RecognitionCoordinator.get(appContext).latestStatus())
|
||||
putString("latestDiagnostic", RecognitionDiagnostics.latest(appContext))
|
||||
}
|
||||
METHOD_SET_TOGGLE -> Bundle().apply {
|
||||
putBoolean(
|
||||
"success",
|
||||
RecognitionSettings.setToggle(
|
||||
METHOD_SET_TOGGLE -> {
|
||||
val changed = RecognitionSettings.setToggle(
|
||||
appContext,
|
||||
extras?.getString("key").orEmpty(),
|
||||
extras?.getBoolean("enabled") ?: false,
|
||||
),
|
||||
)
|
||||
if (changed) RecognitionKeepAliveService.reconcile(appContext)
|
||||
Bundle().apply {
|
||||
putBoolean("success", changed)
|
||||
putBoolean(
|
||||
"keepAliveExpected",
|
||||
RecognitionKeepAliveService.isExpected(appContext),
|
||||
)
|
||||
}
|
||||
}
|
||||
METHOD_CLEAR_DIAGNOSTIC -> Bundle().apply {
|
||||
RecognitionDiagnostics.clear(appContext)
|
||||
@@ -54,6 +74,9 @@ class RecognitionBridgeProvider : ContentProvider() {
|
||||
)
|
||||
putBoolean("success", true)
|
||||
}
|
||||
METHOD_ENSURE_KEEP_ALIVE -> Bundle().apply {
|
||||
putBoolean("success", RecognitionKeepAliveService.reconcile(appContext))
|
||||
}
|
||||
METHOD_REQUEST_SCREENSHOT -> requestScreenshot(extras)
|
||||
METHOD_SCREENSHOT_RESULT -> takeScreenshotResult(arg)
|
||||
METHOD_DRAIN -> Bundle().apply {
|
||||
@@ -150,6 +173,7 @@ class RecognitionBridgeProvider : ContentProvider() {
|
||||
const val METHOD_STATUS = "status"
|
||||
const val METHOD_SET_TOGGLE = "setToggle"
|
||||
const val METHOD_SET_RUNTIME = "setRuntime"
|
||||
const val METHOD_ENSURE_KEEP_ALIVE = "ensureKeepAlive"
|
||||
const val METHOD_CLEAR_DIAGNOSTIC = "clearDiagnostic"
|
||||
const val METHOD_REQUEST_SCREENSHOT = "requestScreenshot"
|
||||
const val METHOD_SCREENSHOT_RESULT = "screenshotResult"
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.nx.miaoji
|
||||
|
||||
import android.content.Context
|
||||
|
||||
object RecognitionConnectionStore {
|
||||
private const val PREFS = "recognition_connection"
|
||||
private const val KEY_LAST_CONNECTED_AT = "accessibility_last_connected_at"
|
||||
private const val KEY_LAST_DISCONNECTED_AT = "accessibility_last_disconnected_at"
|
||||
private const val KEY_LAST_TASK_REMOVED_AT = "recognition_last_task_removed_at"
|
||||
private const val KEY_PROCESS_STARTED_AT = "recognition_process_started_at"
|
||||
private const val KEY_RECENTS_PROTECTION_ACTIVE = "recents_protection_active"
|
||||
|
||||
fun markConnected(context: Context, at: Long = System.currentTimeMillis()) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putLong(KEY_LAST_CONNECTED_AT, at)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun markDisconnected(context: Context, at: Long = System.currentTimeMillis()) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putLong(KEY_LAST_DISCONNECTED_AT, at)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun lastConnectedAt(context: Context): Long =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getLong(KEY_LAST_CONNECTED_AT, 0L)
|
||||
|
||||
fun lastDisconnectedAt(context: Context): Long =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getLong(KEY_LAST_DISCONNECTED_AT, 0L)
|
||||
|
||||
fun markTaskRemoved(context: Context, at: Long = System.currentTimeMillis()) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putLong(KEY_LAST_TASK_REMOVED_AT, at)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun lastTaskRemovedAt(context: Context): Long =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getLong(KEY_LAST_TASK_REMOVED_AT, 0L)
|
||||
|
||||
fun markRecognitionProcessStarted(
|
||||
context: Context,
|
||||
at: Long = System.currentTimeMillis(),
|
||||
) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putLong(KEY_PROCESS_STARTED_AT, at)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun recognitionProcessStartedAt(context: Context): Long =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getLong(KEY_PROCESS_STARTED_AT, 0L)
|
||||
|
||||
fun setRecentsProtectionActive(context: Context, active: Boolean) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putBoolean(KEY_RECENTS_PROTECTION_ACTIVE, active)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun recentsProtectionActive(context: Context): Boolean =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getBoolean(KEY_RECENTS_PROTECTION_ACTIVE, false)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.nx.miaoji
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
class RecognitionKeepAliveReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
if (intent?.action !in setOf(
|
||||
Intent.ACTION_BOOT_COMPLETED,
|
||||
Intent.ACTION_MY_PACKAGE_REPLACED,
|
||||
)
|
||||
) return
|
||||
RecognitionKeepAliveService.ensureRunning(context)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package com.nx.miaoji
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
class RecognitionKeepAliveService : Service() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
isRunning = true
|
||||
lastStartError = null
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (!isExpected(this)) {
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
promoteToForeground()
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
isRunning = false
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
RecognitionConnectionStore.markTaskRemoved(this)
|
||||
super.onTaskRemoved(rootIntent)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
private fun promoteToForeground() {
|
||||
val notification = buildNotification()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(
|
||||
NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE,
|
||||
)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"智能识别后台保护",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = "保持用户主动开启的微信、支付宝智能识别在后台运行"
|
||||
setSound(null, null)
|
||||
enableVibration(false)
|
||||
setShowBadge(false)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildNotification(): Notification {
|
||||
val openIntent = Intent(this, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
putExtra(MainActivity.EXTRA_ACTION, MainActivity.ACTION_OPEN_RECOGNITION_SETTINGS)
|
||||
}
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
NOTIFICATION_ID,
|
||||
openIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
@Suppress("DEPRECATION")
|
||||
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
Notification.Builder(this, CHANNEL_ID)
|
||||
} else {
|
||||
Notification.Builder(this)
|
||||
}
|
||||
return builder
|
||||
.setSmallIcon(android.R.drawable.ic_menu_view)
|
||||
.setContentTitle("智能识别运行中")
|
||||
.setContentText("正在等待微信、支付宝支付结果")
|
||||
.setContentIntent(pendingIntent)
|
||||
.setCategory(Notification.CATEGORY_SERVICE)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setShowWhen(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "recognition_keep_alive"
|
||||
private const val NOTIFICATION_ID = 2201
|
||||
|
||||
@Volatile
|
||||
var isRunning = false
|
||||
private set
|
||||
|
||||
@Volatile
|
||||
var lastStartError: String? = null
|
||||
private set
|
||||
|
||||
fun isExpected(context: Context): Boolean {
|
||||
val settings = RecognitionSettings.snapshot(context)
|
||||
return settings.accessibilityEvents ||
|
||||
settings.notificationEvents ||
|
||||
settings.aiScreenshot
|
||||
}
|
||||
|
||||
fun ensureRunning(context: Context): Boolean {
|
||||
val appContext = context.applicationContext
|
||||
if (!isExpected(appContext)) {
|
||||
stop(appContext)
|
||||
return true
|
||||
}
|
||||
if (!canShowNotification(appContext)) {
|
||||
lastStartError = "请允许通知权限后重新启动后台保护"
|
||||
return false
|
||||
}
|
||||
return try {
|
||||
ContextCompat.startForegroundService(
|
||||
appContext,
|
||||
Intent(appContext, RecognitionKeepAliveService::class.java),
|
||||
)
|
||||
lastStartError = null
|
||||
true
|
||||
} catch (error: RuntimeException) {
|
||||
lastStartError = error.message ?: "系统限制了后台保护启动"
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun reconcile(context: Context): Boolean =
|
||||
if (isExpected(context)) ensureRunning(context) else {
|
||||
stop(context)
|
||||
true
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
context.applicationContext.stopService(
|
||||
Intent(context.applicationContext, RecognitionKeepAliveService::class.java),
|
||||
)
|
||||
isRunning = false
|
||||
lastStartError = null
|
||||
}
|
||||
|
||||
private fun canShowNotification(context: Context): Boolean =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
+134
-21
@@ -30,6 +30,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
private var lastVisualCaptureAt = 0L
|
||||
private var paymentFlow: PaymentFlow? = null
|
||||
private var pendingVisualCapture: Runnable? = null
|
||||
private var pendingVisualCaptureAt = 0L
|
||||
private var ocrInProgress = false
|
||||
private var visualOperationId: String? = null
|
||||
private var visualTimeout: Runnable? = null
|
||||
@@ -58,6 +59,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
var resultSurfaceExited: Boolean = false,
|
||||
var retryCount: Int = 0,
|
||||
var probeCount: Int = 0,
|
||||
var trackingTimedOut: Boolean = false,
|
||||
)
|
||||
|
||||
private data class CaptureRequest(
|
||||
@@ -79,6 +81,8 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
activeInstance = this
|
||||
isConnected = true
|
||||
lastConnectedAt = System.currentTimeMillis()
|
||||
RecognitionConnectionStore.markConnected(this, lastConnectedAt)
|
||||
RecognitionKeepAliveService.ensureRunning(this)
|
||||
Log.i(TAG, "Accessibility recognition service connected")
|
||||
}
|
||||
|
||||
@@ -365,9 +369,11 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
if (activeInstance === this) activeInstance = null
|
||||
isConnected = false
|
||||
lastDisconnectedAt = System.currentTimeMillis()
|
||||
RecognitionConnectionStore.markDisconnected(this, lastDisconnectedAt)
|
||||
pendingRetry = null
|
||||
retryTimeout = null
|
||||
pendingVisualCapture = null
|
||||
pendingVisualCaptureAt = 0L
|
||||
visualTimeout?.let(handler::removeCallbacks)
|
||||
visualTimeout = null
|
||||
batchCaptureTimeout?.let(handler::removeCallbacks)
|
||||
@@ -492,9 +498,13 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
private fun expirePaymentFlow(now: Long) {
|
||||
val flow = paymentFlow ?: return
|
||||
if (now - flow.startedAt > PAYMENT_FLOW_TTL_MS) {
|
||||
if (flow.committedAt != null && !flow.completed && !flow.trackingTimedOut) {
|
||||
recordResultPageTimeout(flow, 0)
|
||||
}
|
||||
paymentFlow = null
|
||||
pendingVisualCapture?.let(handler::removeCallbacks)
|
||||
pendingVisualCapture = null
|
||||
pendingVisualCaptureAt = 0L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,6 +524,9 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
flow.completed = true
|
||||
flow.completedAt = System.currentTimeMillis()
|
||||
flow.resultFingerprint = signal.resultFingerprint
|
||||
pendingVisualCapture?.let(handler::removeCallbacks)
|
||||
pendingVisualCapture = null
|
||||
pendingVisualCaptureAt = 0L
|
||||
val coordinator = RecognitionCoordinator.get(this)
|
||||
val settings = RecognitionSettings.snapshot(this)
|
||||
val batchEnabled = settings.aiScreenshot && settings.aiAllowed && settings.hasAccount
|
||||
@@ -564,22 +577,58 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R ||
|
||||
flow.completed ||
|
||||
flow.probeCount >= MAX_VISUAL_PROBES ||
|
||||
ocrInProgress
|
||||
flow.trackingTimedOut ||
|
||||
flow.probeCount >= MAX_VISUAL_PROBES
|
||||
) return
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - flow.startedAt >= PAYMENT_FLOW_TTL_MS) {
|
||||
recordResultPageTimeout(flow, nodeCount)
|
||||
return
|
||||
}
|
||||
val throttleWait = (
|
||||
VISUAL_CAPTURE_THROTTLE_MS - (now - lastVisualCaptureAt)
|
||||
).coerceAtLeast(0L)
|
||||
val targetAt = now + maxOf(delayMs, throttleWait)
|
||||
if (pendingVisualCapture != null &&
|
||||
pendingVisualCaptureAt > 0L &&
|
||||
pendingVisualCaptureAt <= targetAt
|
||||
) return
|
||||
pendingVisualCapture?.let(handler::removeCallbacks)
|
||||
pendingVisualCapture = Runnable {
|
||||
pendingVisualCapture = null
|
||||
pendingVisualCaptureAt = 0L
|
||||
val current = paymentFlow
|
||||
if (current?.id != flow.id || current.completed ||
|
||||
lastPackageName != flow.packageName
|
||||
) return@Runnable
|
||||
if (current?.id != flow.id || current.completed || current.trackingTimedOut) {
|
||||
return@Runnable
|
||||
}
|
||||
val currentTime = System.currentTimeMillis()
|
||||
if (currentTime - current.startedAt >= PAYMENT_FLOW_TTL_MS) {
|
||||
recordResultPageTimeout(current, nodeCount)
|
||||
return@Runnable
|
||||
}
|
||||
if (lastPackageName != current.packageName) {
|
||||
scheduleVisualRecognition(
|
||||
current,
|
||||
reason = "waiting_for_payment_app",
|
||||
nodeCount = nodeCount,
|
||||
delayMs = PAYMENT_APP_RECHECK_MS,
|
||||
)
|
||||
return@Runnable
|
||||
}
|
||||
if (captureInProgress || ocrInProgress) {
|
||||
scheduleVisualRecognition(
|
||||
current,
|
||||
reason = "recognition_busy",
|
||||
nodeCount = nodeCount,
|
||||
delayMs = BUSY_RECHECK_MS,
|
||||
)
|
||||
return@Runnable
|
||||
}
|
||||
captureForLocalOcr(current, reason, nodeCount)
|
||||
}.also { handler.postDelayed(it, maxOf(delayMs, throttleWait)) }
|
||||
}.also {
|
||||
pendingVisualCaptureAt = targetAt
|
||||
handler.postDelayed(it, (targetAt - now).coerceAtLeast(0L))
|
||||
}
|
||||
}
|
||||
|
||||
private fun captureForLocalOcr(flow: PaymentFlow, reason: String, nodeCount: Int) {
|
||||
@@ -699,10 +748,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
val recognitionSettings = RecognitionSettings.snapshot(
|
||||
this@ScreenshotAccessibilityService,
|
||||
)
|
||||
val visualTransitionObserved = flow.resultTransitionObserved ||
|
||||
flow.committedAt?.let {
|
||||
capturedAt - it >= VISUAL_STABILITY_DELAY_MS
|
||||
} == true
|
||||
val visualTransitionObserved = flow.resultTransitionObserved
|
||||
runCatching {
|
||||
LocalPaymentOcr.analyze(
|
||||
context = this@ScreenshotAccessibilityService,
|
||||
@@ -815,15 +861,30 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
nodeCount: Int,
|
||||
) {
|
||||
try {
|
||||
val retryable = outcome.signal == null && isRetryableOcrOutcome(outcome.reason)
|
||||
val shouldWait = retryable && shouldScheduleResultProbe(
|
||||
probeCount = flow.probeCount,
|
||||
completed = flow.completed,
|
||||
expired = System.currentTimeMillis() - flow.startedAt >= PAYMENT_FLOW_TTL_MS,
|
||||
)
|
||||
val diagnosticReason = if (retryable && !shouldWait) {
|
||||
"result_page_timeout"
|
||||
} else {
|
||||
outcome.reason
|
||||
}
|
||||
RecognitionDiagnostics.record(
|
||||
this,
|
||||
flow.packageName,
|
||||
stage = "ocr",
|
||||
result = if (outcome.signal != null) "matched" else "rejected",
|
||||
result = when {
|
||||
outcome.signal != null -> "matched"
|
||||
shouldWait -> "waiting"
|
||||
else -> "rejected"
|
||||
},
|
||||
nodeCount = nodeCount,
|
||||
ocrMs = outcome.latencyMs,
|
||||
amountCandidates = outcome.amountCandidateCount,
|
||||
reason = outcome.reason,
|
||||
reason = diagnosticReason,
|
||||
statusStrength = outcome.statusStrength,
|
||||
expectedAmountMatched = outcome.expectedAmountMatched,
|
||||
resultTransitionObserved = outcome.resultTransitionObserved,
|
||||
@@ -854,17 +915,17 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
)
|
||||
return
|
||||
}
|
||||
val retryable = isRetryableOcrOutcome(outcome.reason)
|
||||
if (retryable && flow.retryCount < MAX_VISUAL_RETRIES) {
|
||||
if (shouldWait) {
|
||||
flow.retryCount += 1
|
||||
scheduleVisualRecognition(
|
||||
flow,
|
||||
reason = "ocr_retry",
|
||||
reason = "result_page_follow_up",
|
||||
nodeCount = nodeCount,
|
||||
delayMs = VISUAL_RETRY_DELAY_MS,
|
||||
delayMs = resultProbeDelayAfterCapture(flow.probeCount),
|
||||
)
|
||||
return
|
||||
}
|
||||
if (retryable) flow.trackingTimedOut = true
|
||||
if (outcome.sawSuccess) maybeUseAiFallback(flow, bitmap)
|
||||
} finally {
|
||||
bitmap.recycle()
|
||||
@@ -967,17 +1028,51 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
nodeCount = nodeCount,
|
||||
reason = reason,
|
||||
)
|
||||
if (reason == "interval_short" && flow.retryCount < MAX_VISUAL_RETRIES) {
|
||||
val retryable = reason in RESULT_TRACKING_CAPTURE_RETRY_REASONS
|
||||
if (retryable && shouldScheduleResultProbe(
|
||||
probeCount = flow.probeCount,
|
||||
completed = flow.completed,
|
||||
expired = System.currentTimeMillis() - flow.startedAt >= PAYMENT_FLOW_TTL_MS,
|
||||
)
|
||||
) {
|
||||
flow.retryCount += 1
|
||||
RecognitionDiagnostics.record(
|
||||
this,
|
||||
flow.packageName,
|
||||
stage = "capture",
|
||||
result = "waiting",
|
||||
nodeCount = nodeCount,
|
||||
reason = reason,
|
||||
)
|
||||
scheduleVisualRecognition(
|
||||
flow,
|
||||
reason = "capture_retry",
|
||||
nodeCount = nodeCount,
|
||||
delayMs = VISUAL_RETRY_DELAY_MS,
|
||||
delayMs = resultProbeDelayAfterCapture(flow.probeCount),
|
||||
)
|
||||
} else if (retryable) {
|
||||
recordResultPageTimeout(flow, nodeCount)
|
||||
}
|
||||
}
|
||||
|
||||
private fun recordResultPageTimeout(flow: PaymentFlow, nodeCount: Int) {
|
||||
if (flow.completed || flow.trackingTimedOut) return
|
||||
flow.trackingTimedOut = true
|
||||
pendingVisualCapture?.let(handler::removeCallbacks)
|
||||
pendingVisualCapture = null
|
||||
pendingVisualCaptureAt = 0L
|
||||
RecognitionDiagnostics.record(
|
||||
this,
|
||||
flow.packageName,
|
||||
stage = "ocr",
|
||||
result = "rejected",
|
||||
nodeCount = nodeCount,
|
||||
reason = "result_page_timeout",
|
||||
recognitionKind = flow.kind,
|
||||
resultTransitionObserved = flow.resultTransitionObserved,
|
||||
)
|
||||
}
|
||||
|
||||
private fun shouldFallbackToDisplay(errorCode: Int): Boolean =
|
||||
errorCode == ERROR_TAKE_SCREENSHOT_INTERNAL_ERROR ||
|
||||
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE &&
|
||||
@@ -1216,14 +1311,14 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
private const val MIN_RESULT_TRANSITION_DELAY_MS = 250L
|
||||
private const val AMBIGUOUS_REPEAT_GAP_MS = 3_000L
|
||||
private const val VISUAL_STABILITY_DELAY_MS = 700L
|
||||
private const val VISUAL_RETRY_DELAY_MS = 850L
|
||||
private const val VISUAL_CAPTURE_THROTTLE_MS = 2_500L
|
||||
private const val PAYMENT_APP_RECHECK_MS = 1_000L
|
||||
private const val BUSY_RECHECK_MS = 500L
|
||||
private const val CAPTURE_CALLBACK_TIMEOUT_MS = 6_000L
|
||||
private const val OCR_CALLBACK_TIMEOUT_MS = 12_000L
|
||||
private const val STALE_BITMAP_RELEASE_DELAY_MS = 60_000L
|
||||
private const val WINDOW_CAPTURE_FALLBACK_DELAY_MS = 450L
|
||||
private const val MAX_VISUAL_RETRIES = 1
|
||||
private const val MAX_VISUAL_PROBES = 4
|
||||
private const val MAX_VISUAL_PROBES = 5
|
||||
private const val MAX_TREE_NODES = 160
|
||||
private const val MAX_CHILDREN_PER_NODE = 40
|
||||
private const val MAX_TEXT_CHARS = 8_000
|
||||
@@ -1240,6 +1335,13 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED,
|
||||
AccessibilityEvent.TYPE_VIEW_CLICKED,
|
||||
)
|
||||
private val RESULT_PROBE_DELAYS_MS = longArrayOf(2_500L, 5_000L, 10_000L, 20_000L)
|
||||
private val RESULT_TRACKING_CAPTURE_RETRY_REASONS = setOf(
|
||||
"secure_window",
|
||||
"invalid_window",
|
||||
"interval_short",
|
||||
"accessibility_unavailable",
|
||||
)
|
||||
|
||||
@Volatile
|
||||
private var activeInstance: ScreenshotAccessibilityService? = null
|
||||
@@ -1255,6 +1357,17 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
"payment_input_page",
|
||||
)
|
||||
|
||||
internal fun shouldScheduleResultProbe(
|
||||
probeCount: Int,
|
||||
completed: Boolean,
|
||||
expired: Boolean,
|
||||
): Boolean = !completed && !expired && probeCount < MAX_VISUAL_PROBES
|
||||
|
||||
internal fun resultProbeDelayAfterCapture(probeCount: Int): Long =
|
||||
RESULT_PROBE_DELAYS_MS[
|
||||
(probeCount - 1).coerceIn(0, RESULT_PROBE_DELAYS_MS.lastIndex)
|
||||
]
|
||||
|
||||
internal fun completedResultStartReason(
|
||||
hasObservedResult: Boolean,
|
||||
resultFingerprintChanged: Boolean,
|
||||
|
||||
@@ -117,10 +117,46 @@ class PaymentParserTest {
|
||||
@Test
|
||||
fun paymentInputAndAmbiguousAmountsAreRejected() {
|
||||
assertTrue(PaymentParser.isPaymentInputPage("请输入支付密码\n确认转账"))
|
||||
assertTrue(
|
||||
PaymentParser.isPaymentInputPage(
|
||||
"转账给:张三\n转账全额\n¥0.0\n添加转账说明\n转账",
|
||||
),
|
||||
)
|
||||
assertTrue(PaymentParser.amountCandidateCents("转账全额\n¥0.0").isEmpty())
|
||||
assertNull(PaymentParser.parseAmountCandidate("¥0.0"))
|
||||
assertEquals(2_000L, PaymentParser.uniqueAmountCents("付款金额 ¥20.00\n¥20.00"))
|
||||
assertNull(PaymentParser.uniqueAmountCents("¥20.00\n优惠 ¥2.00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resultPageTrackingUsesBoundedProgressiveProbes() {
|
||||
assertEquals(2_500L, ScreenshotAccessibilityService.resultProbeDelayAfterCapture(1))
|
||||
assertEquals(5_000L, ScreenshotAccessibilityService.resultProbeDelayAfterCapture(2))
|
||||
assertEquals(10_000L, ScreenshotAccessibilityService.resultProbeDelayAfterCapture(3))
|
||||
assertEquals(20_000L, ScreenshotAccessibilityService.resultProbeDelayAfterCapture(4))
|
||||
assertTrue(
|
||||
ScreenshotAccessibilityService.shouldScheduleResultProbe(
|
||||
probeCount = 4,
|
||||
completed = false,
|
||||
expired = false,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
ScreenshotAccessibilityService.shouldScheduleResultProbe(
|
||||
probeCount = 5,
|
||||
completed = false,
|
||||
expired = false,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
ScreenshotAccessibilityService.shouldScheduleResultProbe(
|
||||
probeCount = 1,
|
||||
completed = false,
|
||||
expired = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun diagnosticPreviewMasksSensitiveValuesAndLimitsLines() {
|
||||
val preview = OcrDiagnosticRedactor.redact(
|
||||
|
||||
@@ -197,8 +197,16 @@ class _MiaoJiAppState extends State<MiaoJiApp> with WidgetsBindingObserver {
|
||||
|
||||
Future<void> _restoreRecognitionServices() async {
|
||||
await RecognitionImportService.configureNativeContext();
|
||||
await ScreenshotChannel.waitForAccessibilityConnection();
|
||||
await ScreenshotChannel.ensureRecognitionKeepAlive();
|
||||
await RecognitionImportService.importAutomatic();
|
||||
unawaited(_runSafely(_importAfterAccessibilityReconnect));
|
||||
}
|
||||
|
||||
Future<void> _importAfterAccessibilityReconnect() async {
|
||||
final status = await ScreenshotChannel.waitForAccessibilityConnection();
|
||||
if (status.accessibilityConnected) {
|
||||
await RecognitionImportService.importAutomatic();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refreshRemoteState() async {
|
||||
@@ -231,6 +239,10 @@ class _MiaoJiAppState extends State<MiaoJiApp> with WidgetsBindingObserver {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 180));
|
||||
final context = _rootNavigatorKey.currentContext;
|
||||
if (!mounted || context == null || !context.mounted) return;
|
||||
if (action['action'] == 'open_recognition_settings') {
|
||||
router.push('/screenshot-settings');
|
||||
return;
|
||||
}
|
||||
await RecognitionImportService.handleAction(context, action);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,12 +27,15 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
|
||||
Timer? _diagnosticRefreshTimer;
|
||||
Timer? _previewExpiryTimer;
|
||||
bool _diagnosticRefreshInFlight = false;
|
||||
bool _connectionCheckInFlight = false;
|
||||
bool _keepAliveStarting = false;
|
||||
bool _recentsProtectionNoticeChecked = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_check(waitForConnection: true);
|
||||
_check();
|
||||
_diagnosticRefreshTimer = Timer.periodic(
|
||||
const Duration(seconds: 2),
|
||||
(_) => _refreshRunningDiagnostic(),
|
||||
@@ -60,18 +63,16 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
|
||||
void _refreshRunningDiagnostic() {
|
||||
if (!mounted ||
|
||||
_diagnosticRefreshInFlight ||
|
||||
_status?.latestDiagnostic?.result != 'started') {
|
||||
!{'started', 'waiting'}.contains(_status?.latestDiagnostic?.result)) {
|
||||
return;
|
||||
}
|
||||
_diagnosticRefreshInFlight = true;
|
||||
_check().whenComplete(() => _diagnosticRefreshInFlight = false);
|
||||
}
|
||||
|
||||
Future<void> _check({bool waitForConnection = false}) async {
|
||||
Future<void> _check({bool monitorConnection = true}) async {
|
||||
try {
|
||||
var status = waitForConnection
|
||||
? await ScreenshotChannel.waitForAccessibilityConnection()
|
||||
: await ScreenshotChannel.recognitionStatus();
|
||||
var status = await ScreenshotChannel.recognitionStatus();
|
||||
final invalid = <String>[
|
||||
if (status.accessibilityEvents &&
|
||||
(!status.accessibilityAuthorized ||
|
||||
@@ -99,6 +100,14 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
|
||||
_error = null;
|
||||
});
|
||||
_schedulePreviewExpiry(status);
|
||||
unawaited(_showRecentsProtectionNotice(status));
|
||||
if (monitorConnection &&
|
||||
status.accessibilityAuthorized &&
|
||||
!status.accessibilityConnected &&
|
||||
!status.taskCleanerRecoveryNeeded &&
|
||||
(status.accessibilityEvents || status.aiScreenshot)) {
|
||||
unawaited(_monitorAccessibilityConnection());
|
||||
}
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
@@ -108,6 +117,49 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showRecentsProtectionNotice(RecognitionStatus status) async {
|
||||
if (_recentsProtectionNoticeChecked || !status.recentsProtectionActive) {
|
||||
return;
|
||||
}
|
||||
_recentsProtectionNoticeChecked = true;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
const key = 'originos_recents_protection_notice_v1';
|
||||
if (prefs.getBool(key) == true || !mounted) return;
|
||||
await prefs.setBool(key, true);
|
||||
if (!mounted) return;
|
||||
_showMessage('记之已从最近任务隐藏,可从桌面图标重新打开;关闭全部识别后恢复。');
|
||||
}
|
||||
|
||||
Future<void> _monitorAccessibilityConnection({
|
||||
bool ensureKeepAlive = false,
|
||||
}) async {
|
||||
if (_connectionCheckInFlight) return;
|
||||
_connectionCheckInFlight = true;
|
||||
if (mounted) setState(() {});
|
||||
try {
|
||||
if (ensureKeepAlive) {
|
||||
_keepAliveStarting = true;
|
||||
if (mounted) setState(() {});
|
||||
await ScreenshotChannel.ensureRecognitionKeepAlive();
|
||||
}
|
||||
await ScreenshotChannel.waitForAccessibilityConnection();
|
||||
final status = await ScreenshotChannel.recognitionStatus();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_status = status;
|
||||
_error = null;
|
||||
});
|
||||
_schedulePreviewExpiry(status);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = '连接状态复核失败,请重试');
|
||||
} finally {
|
||||
_connectionCheckInFlight = false;
|
||||
_keepAliveStarting = false;
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
void _schedulePreviewExpiry(RecognitionStatus status) {
|
||||
_previewExpiryTimer?.cancel();
|
||||
final expiresAt = status.ocrDiagnosticPreviewExpiresAt;
|
||||
@@ -182,7 +234,7 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
|
||||
|
||||
Future<void> _resumeAuthorization() async {
|
||||
if (_authorizing) return;
|
||||
await _check(waitForConnection: true);
|
||||
await _check();
|
||||
final key = _pendingAuthorizationKey;
|
||||
final status = _status;
|
||||
if (key == null || status == null) return;
|
||||
@@ -433,12 +485,31 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
|
||||
'仅在微信和支付宝疑似支付流程中读取可见文字,并按需在内存中进行本地截图 OCR;不保存图片、完整控件树,也不监听按键。',
|
||||
authorized: status!.accessibilityAuthorized,
|
||||
connected: status.accessibilityConnected,
|
||||
statusLabel: status.accessibilityConnectionLabel,
|
||||
statusLabel:
|
||||
_connectionCheckInFlight && !status.accessibilityConnected
|
||||
? '正在连接'
|
||||
: status.accessibilityConnectionLabel,
|
||||
recoveryMessage: status.accessibilityNeedsRecovery
|
||||
? status.taskCleanerRecoveryNeeded
|
||||
? 'OriginOS 清理了识别进程,系统仍保留授权,但无障碍服务已被标记故障。'
|
||||
: '系统仍显示已授权,但 OriginOS 未重新绑定服务。请前往系统设置,将记之无障碍服务关闭后重新开启。'
|
||||
: (status.accessibilityEvents || status.aiScreenshot) &&
|
||||
status.keepAliveNeedsRecovery
|
||||
? _keepAliveStarting
|
||||
? '正在启动后台保护…'
|
||||
: status.keepAliveError ?? '后台保护未运行,划掉应用后识别可能中断。'
|
||||
: null,
|
||||
onOpenSettings: ScreenshotChannel.openAccessibilitySettings,
|
||||
onRetry:
|
||||
status.accessibilityAuthorized &&
|
||||
!status.accessibilityConnected
|
||||
? () => _check(waitForConnection: true)
|
||||
settingsLabel: '前往无障碍设置',
|
||||
onRetry: status.accessibilityNeedsRecovery
|
||||
? () => _monitorAccessibilityConnection()
|
||||
: null,
|
||||
onStartKeepAlive:
|
||||
(status.accessibilityEvents || status.aiScreenshot) &&
|
||||
status.keepAliveNeedsRecovery &&
|
||||
!_keepAliveStarting
|
||||
? () =>
|
||||
_monitorAccessibilityConnection(ensureKeepAlive: true)
|
||||
: null,
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -546,7 +617,12 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
|
||||
status.notificationEvents ||
|
||||
status.aiScreenshot) ...[
|
||||
const SizedBox(height: 10),
|
||||
_BackgroundKeepAliveCard(status: status),
|
||||
_BackgroundKeepAliveCard(
|
||||
status: status,
|
||||
starting: _keepAliveStarting,
|
||||
onStart: () =>
|
||||
_monitorAccessibilityConnection(ensureKeepAlive: true),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
_RecognitionCard(
|
||||
@@ -844,8 +920,14 @@ class _EvidencePill extends StatelessWidget {
|
||||
|
||||
class _BackgroundKeepAliveCard extends StatelessWidget {
|
||||
final RecognitionStatus status;
|
||||
final bool starting;
|
||||
final VoidCallback onStart;
|
||||
|
||||
const _BackgroundKeepAliveCard({required this.status});
|
||||
const _BackgroundKeepAliveCard({
|
||||
required this.status,
|
||||
required this.starting,
|
||||
required this.onStart,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -883,9 +965,18 @@ class _BackgroundKeepAliveCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
status.batteryOptimizationIgnored ? '后台限制较少' : '需要设置',
|
||||
status.recentsProtectionActive
|
||||
? '任务清理防护中'
|
||||
: status.keepAliveRunning
|
||||
? '保护运行中'
|
||||
: status.keepAliveExpected
|
||||
? '保护未运行'
|
||||
: status.batteryOptimizationIgnored
|
||||
? '后台限制较少'
|
||||
: '需要设置',
|
||||
style: TextStyle(
|
||||
color: status.batteryOptimizationIgnored
|
||||
color:
|
||||
status.keepAliveRunning || status.recentsProtectionActive
|
||||
? AppTheme.primaryDeep
|
||||
: AppTheme.orange,
|
||||
fontSize: 10,
|
||||
@@ -896,7 +987,9 @@ class _BackgroundKeepAliveCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'无障碍和通知监听由 Android 系统持续绑定。请允许记之后台活动,'
|
||||
status.recentsProtectionActive
|
||||
? 'OriginOS 最近任务保护已开启。记之不会显示在最近任务中,请从桌面图标重新打开;关闭全部识别后会自动恢复任务卡。'
|
||||
: '无障碍和通知监听由 Android 系统持续绑定。请允许记之后台活动,'
|
||||
'${isVivo ? '并在 OriginOS 中开启自启动,' : ''}'
|
||||
'否则系统清理进程后可能暂时收不到支付事件。',
|
||||
style: TextStyle(
|
||||
@@ -905,6 +998,18 @@ class _BackgroundKeepAliveCard extends StatelessWidget {
|
||||
height: 1.55,
|
||||
),
|
||||
),
|
||||
if (status.keepAliveNeedsRecovery) ...[
|
||||
const SizedBox(height: 10),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: JzActionButton(
|
||||
label: starting ? '正在启动…' : '启动后台保护',
|
||||
secondary: true,
|
||||
icon: const Icon(Icons.shield_outlined, size: 18),
|
||||
onPressed: starting ? null : onStart,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
@@ -941,6 +1046,9 @@ class _RecognitionCard extends StatelessWidget {
|
||||
final Widget child;
|
||||
final VoidCallback? onOpenSettings;
|
||||
final VoidCallback? onRetry;
|
||||
final VoidCallback? onStartKeepAlive;
|
||||
final String? recoveryMessage;
|
||||
final String settingsLabel;
|
||||
|
||||
const _RecognitionCard({
|
||||
required this.icon,
|
||||
@@ -952,12 +1060,15 @@ class _RecognitionCard extends StatelessWidget {
|
||||
this.statusLabel,
|
||||
this.onOpenSettings,
|
||||
this.onRetry,
|
||||
this.onStartKeepAlive,
|
||||
this.recoveryMessage,
|
||||
this.settingsLabel = '前往系统设置',
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = context.jz;
|
||||
final color = connected ? AppTheme.primary : AppTheme.ai;
|
||||
final color = connected ? AppTheme.primary : AppTheme.orange;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
@@ -976,7 +1087,7 @@ class _RecognitionCard extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: connected
|
||||
? palette.primaryBackground
|
||||
: palette.aiBackground,
|
||||
: palette.warningBackground,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 21),
|
||||
@@ -996,7 +1107,7 @@ class _RecognitionCard extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: connected
|
||||
? palette.primaryBackground
|
||||
: palette.aiBackground,
|
||||
: palette.warningBackground,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
@@ -1020,9 +1131,35 @@ class _RecognitionCard extends StatelessWidget {
|
||||
description,
|
||||
style: TextStyle(color: palette.text2, fontSize: 12, height: 1.55),
|
||||
),
|
||||
if (recoveryMessage != null) ...[
|
||||
const SizedBox(height: 9),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.info_outline_rounded,
|
||||
color: AppTheme.orange,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 7),
|
||||
Expanded(
|
||||
child: Text(
|
||||
recoveryMessage!,
|
||||
style: TextStyle(
|
||||
color: palette.text2,
|
||||
fontSize: 11.5,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 6),
|
||||
child,
|
||||
if (onOpenSettings != null || onRetry != null) ...[
|
||||
if (onOpenSettings != null ||
|
||||
onRetry != null ||
|
||||
onStartKeepAlive != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
@@ -1034,11 +1171,17 @@ class _RecognitionCard extends StatelessWidget {
|
||||
icon: const Icon(Icons.refresh_rounded, size: 18),
|
||||
label: const Text('重新检测'),
|
||||
),
|
||||
if (onStartKeepAlive != null)
|
||||
TextButton.icon(
|
||||
onPressed: onStartKeepAlive,
|
||||
icon: const Icon(Icons.shield_outlined, size: 18),
|
||||
label: const Text('启动后台保护'),
|
||||
),
|
||||
if (onOpenSettings != null)
|
||||
TextButton.icon(
|
||||
onPressed: onOpenSettings,
|
||||
icon: const Icon(Icons.settings_outlined, size: 18),
|
||||
label: const Text('前往系统设置'),
|
||||
label: Text(settingsLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -49,6 +49,7 @@ class RecognitionDiagnosticDisplay {
|
||||
'matched' || 'auto_ready' => '已识别',
|
||||
'confirm' => '待确认',
|
||||
'started' => '处理中',
|
||||
'waiting' => '等待结果页',
|
||||
'failed' => '失败',
|
||||
_ => '未触发入账',
|
||||
};
|
||||
@@ -56,6 +57,7 @@ class RecognitionDiagnosticDisplay {
|
||||
|
||||
static String _summaryLabel(String result, String reason) {
|
||||
if (reason == 'duplicate_result_surface') return '已合并';
|
||||
if (result == 'waiting') return '等待结果页';
|
||||
if (_captureFailureReasons.contains(reason)) return '截图失败';
|
||||
if (_ocrNoResultReasons.contains(reason)) return 'OCR 无结果';
|
||||
if (_ruleRejectedReasons.contains(reason) || result == 'rejected') {
|
||||
@@ -65,6 +67,7 @@ class RecognitionDiagnosticDisplay {
|
||||
'matched' || 'auto_ready' => '已识别',
|
||||
'confirm' => '待确认',
|
||||
'started' => '处理中',
|
||||
'waiting' => '等待结果页',
|
||||
'failed' => '失败',
|
||||
_ => '没事件',
|
||||
};
|
||||
@@ -75,8 +78,9 @@ class RecognitionDiagnosticDisplay {
|
||||
'history_page' => '当前是账单或交易历史页',
|
||||
'blocked_status' => '当前状态为失败、处理中或已取消',
|
||||
'no_text' => '截图中没有识别到文字',
|
||||
'no_success_status' => '没有找到明确或弱完成状态,可开启诊断预览查看脱敏结果',
|
||||
'payment_input_page' => '当前仍是付款输入或确认页面,已拒绝入账',
|
||||
'no_success_status' => '暂未找到完成状态,正在按计划复核结果页',
|
||||
'payment_input_page' => '当前仍是付款输入或确认页面,等待结果页,不会提前入账',
|
||||
'result_page_timeout' => '90 秒内未等到明确结果页,本次流程已停止追踪',
|
||||
'direction_unknown' => '识别到完成状态,但无法确认收支方向',
|
||||
'missing_amount' => '成功状态已识别,但没有找到金额',
|
||||
'expected_amount_missing' => '结果页没有金额,且付款前金额不唯一或未捕获',
|
||||
@@ -167,8 +171,7 @@ class RecognitionDiagnosticDisplay {
|
||||
static const _ruleRejectedReasons = {
|
||||
'history_page',
|
||||
'blocked_status',
|
||||
'no_success_status',
|
||||
'payment_input_page',
|
||||
'result_page_timeout',
|
||||
'direction_unknown',
|
||||
'missing_amount',
|
||||
'expected_amount_missing',
|
||||
|
||||
@@ -95,6 +95,14 @@ class RecognitionStatus {
|
||||
final DateTime? accessibilityLastConnectedAt;
|
||||
final DateTime? accessibilityLastDisconnectedAt;
|
||||
final DateTime? recognitionProcessStartedAt;
|
||||
final bool keepAliveExpected;
|
||||
final bool keepAliveRunning;
|
||||
final String? keepAliveError;
|
||||
final bool recentsProtectionExpected;
|
||||
final bool recentsProtectionActive;
|
||||
final DateTime? lastRecognitionExitAt;
|
||||
final String? lastRecognitionExitReason;
|
||||
final bool taskCleanerRecoveryNeeded;
|
||||
final bool notificationAuthorized;
|
||||
final bool notificationConnected;
|
||||
final bool postNotificationsGranted;
|
||||
@@ -116,6 +124,14 @@ class RecognitionStatus {
|
||||
this.accessibilityLastConnectedAt,
|
||||
this.accessibilityLastDisconnectedAt,
|
||||
this.recognitionProcessStartedAt,
|
||||
this.keepAliveExpected = false,
|
||||
this.keepAliveRunning = false,
|
||||
this.keepAliveError,
|
||||
this.recentsProtectionExpected = false,
|
||||
this.recentsProtectionActive = false,
|
||||
this.lastRecognitionExitAt,
|
||||
this.lastRecognitionExitReason,
|
||||
this.taskCleanerRecoveryNeeded = false,
|
||||
required this.notificationAuthorized,
|
||||
required this.notificationConnected,
|
||||
required this.postNotificationsGranted,
|
||||
@@ -158,6 +174,17 @@ class RecognitionStatus {
|
||||
'accessibilityLastDisconnectedAt',
|
||||
),
|
||||
recognitionProcessStartedAt: epochDate('recognitionProcessStartedAt'),
|
||||
keepAliveExpected: value['keepAliveExpected'] as bool? ?? false,
|
||||
keepAliveRunning: value['keepAliveRunning'] as bool? ?? false,
|
||||
keepAliveError: value['keepAliveError']?.toString(),
|
||||
recentsProtectionExpected:
|
||||
value['recentsProtectionExpected'] as bool? ?? false,
|
||||
recentsProtectionActive:
|
||||
value['recentsProtectionActive'] as bool? ?? false,
|
||||
lastRecognitionExitAt: epochDate('lastRecognitionExitAt'),
|
||||
lastRecognitionExitReason: value['lastRecognitionExitReason']?.toString(),
|
||||
taskCleanerRecoveryNeeded:
|
||||
value['taskCleanerRecoveryNeeded'] as bool? ?? false,
|
||||
notificationAuthorized: value['notificationAuthorized'] as bool? ?? false,
|
||||
notificationConnected: value['notificationConnected'] as bool? ?? false,
|
||||
postNotificationsGranted:
|
||||
@@ -189,6 +216,8 @@ class RecognitionStatus {
|
||||
!accessibilityConnected &&
|
||||
accessibilityConnectionState == AccessibilityConnectionState.disconnected;
|
||||
|
||||
bool get keepAliveNeedsRecovery => keepAliveExpected && !keepAliveRunning;
|
||||
|
||||
String get accessibilityConnectionLabel =>
|
||||
switch (accessibilityConnectionState) {
|
||||
AccessibilityConnectionState.unauthorized => '未授权',
|
||||
@@ -320,6 +349,7 @@ class SpeechEvent {
|
||||
/// Android native capabilities for screenshots, AI progress and speech.
|
||||
class ScreenshotChannel {
|
||||
static const _channel = MethodChannel('com.miaoji/screenshot');
|
||||
static Future<RecognitionStatus>? _activeAccessibilityConnectionWait;
|
||||
static void Function(String path)? _screenshotReady;
|
||||
static void Function(String error)? _screenshotError;
|
||||
static void Function(SpeechEvent event)? _speechEvent;
|
||||
@@ -472,13 +502,32 @@ class ScreenshotChannel {
|
||||
static Future<RecognitionStatus> waitForAccessibilityConnection({
|
||||
Duration timeout = const Duration(seconds: 5),
|
||||
Duration interval = const Duration(milliseconds: 500),
|
||||
}) {
|
||||
final active = _activeAccessibilityConnectionWait;
|
||||
if (active != null) return active;
|
||||
final operation = _waitForAccessibilityConnection(
|
||||
timeout: timeout,
|
||||
interval: interval,
|
||||
);
|
||||
_activeAccessibilityConnectionWait = operation;
|
||||
return operation.whenComplete(() {
|
||||
if (identical(_activeAccessibilityConnectionWait, operation)) {
|
||||
_activeAccessibilityConnectionWait = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static Future<RecognitionStatus> _waitForAccessibilityConnection({
|
||||
required Duration timeout,
|
||||
required Duration interval,
|
||||
}) async {
|
||||
var status = await recognitionStatus();
|
||||
final recognitionEnabled =
|
||||
status.accessibilityEvents || status.aiScreenshot;
|
||||
if (!recognitionEnabled ||
|
||||
!status.accessibilityAuthorized ||
|
||||
status.accessibilityConnected) {
|
||||
status.accessibilityConnected ||
|
||||
status.taskCleanerRecoveryNeeded) {
|
||||
return status;
|
||||
}
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
@@ -487,6 +536,7 @@ class ScreenshotChannel {
|
||||
status = await recognitionStatus();
|
||||
if (!status.accessibilityAuthorized ||
|
||||
status.accessibilityConnected ||
|
||||
status.taskCleanerRecoveryNeeded ||
|
||||
!(status.accessibilityEvents || status.aiScreenshot)) {
|
||||
return status;
|
||||
}
|
||||
@@ -494,6 +544,15 @@ class ScreenshotChannel {
|
||||
return status;
|
||||
}
|
||||
|
||||
static Future<bool> ensureRecognitionKeepAlive() async {
|
||||
try {
|
||||
return await _channel.invokeMethod<bool>('ensureRecognitionKeepAlive') ??
|
||||
false;
|
||||
} on MissingPluginException {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> clearRecognitionDiagnostic() async {
|
||||
try {
|
||||
return await _channel.invokeMethod<bool>('clearRecognitionDiagnostic') ??
|
||||
|
||||
@@ -183,6 +183,14 @@ void main() {
|
||||
'accessibilityConnected': false,
|
||||
'accessibilityConnectionState': 'reconnecting',
|
||||
'accessibilityLastConnectedAt': 1724472000000,
|
||||
'keepAliveExpected': true,
|
||||
'keepAliveRunning': false,
|
||||
'keepAliveError': '系统限制了后台保护启动',
|
||||
'recentsProtectionExpected': true,
|
||||
'recentsProtectionActive': true,
|
||||
'lastRecognitionExitAt': 1788077518000,
|
||||
'lastRecognitionExitReason': 'low_memory: single-cleaner',
|
||||
'taskCleanerRecoveryNeeded': true,
|
||||
'settings': jsonEncode({'accessibilityEvents': true}),
|
||||
});
|
||||
final connected = RecognitionStatus.fromMap({
|
||||
@@ -204,6 +212,15 @@ void main() {
|
||||
);
|
||||
expect(reconnecting.accessibilityConnectionLabel, '正在重连');
|
||||
expect(reconnecting.accessibilityLastConnectedAt, isNotNull);
|
||||
expect(reconnecting.keepAliveExpected, isTrue);
|
||||
expect(reconnecting.keepAliveRunning, isFalse);
|
||||
expect(reconnecting.keepAliveNeedsRecovery, isTrue);
|
||||
expect(reconnecting.keepAliveError, '系统限制了后台保护启动');
|
||||
expect(reconnecting.recentsProtectionExpected, isTrue);
|
||||
expect(reconnecting.recentsProtectionActive, isTrue);
|
||||
expect(reconnecting.lastRecognitionExitAt, isNotNull);
|
||||
expect(reconnecting.lastRecognitionExitReason, contains('single-cleaner'));
|
||||
expect(reconnecting.taskCleanerRecoveryNeeded, isTrue);
|
||||
expect(connected.accessibilityConnectionLabel, '已连接');
|
||||
expect(disconnected.accessibilityConnectionLabel, '服务未连接');
|
||||
expect(disconnected.accessibilityNeedsRecovery, isTrue);
|
||||
|
||||
@@ -153,9 +153,21 @@ void main() {
|
||||
);
|
||||
expect(
|
||||
RecognitionDiagnosticDisplay.from(
|
||||
diagnostic('rejected', 'payment_input_page'),
|
||||
diagnostic('waiting', 'payment_input_page'),
|
||||
).summaryLabel,
|
||||
'规则拒绝',
|
||||
'等待结果页',
|
||||
);
|
||||
expect(
|
||||
RecognitionDiagnosticDisplay.from(
|
||||
diagnostic('waiting', 'payment_input_page'),
|
||||
).reasonLabel,
|
||||
'当前仍是付款输入或确认页面,等待结果页,不会提前入账',
|
||||
);
|
||||
expect(
|
||||
RecognitionDiagnosticDisplay.from(
|
||||
diagnostic('rejected', 'result_page_timeout'),
|
||||
).reasonLabel,
|
||||
'90 秒内未等到明确结果页,本次流程已停止追踪',
|
||||
);
|
||||
expect(
|
||||
RecognitionDiagnosticDisplay.from(
|
||||
|
||||
@@ -280,7 +280,7 @@ void main() {
|
||||
expect(source, contains('_BackgroundKeepAliveCard'));
|
||||
});
|
||||
|
||||
test('长期识别组件与系统绑定使用默认进程并支持重连状态', () {
|
||||
test('长期识别组件使用独立保活进程并支持非阻塞重连', () {
|
||||
final manifest = File(
|
||||
'android/app/src/main/AndroidManifest.xml',
|
||||
).readAsStringSync();
|
||||
@@ -290,6 +290,12 @@ void main() {
|
||||
final activity = File(
|
||||
'android/app/src/main/kotlin/com/nx/miaoji/MainActivity.kt',
|
||||
).readAsStringSync();
|
||||
final settingsPage = File(
|
||||
'lib/features/settings/screenshot_settings_page.dart',
|
||||
).readAsStringSync();
|
||||
final keepAlive = File(
|
||||
'android/app/src/main/kotlin/com/nx/miaoji/RecognitionKeepAliveService.kt',
|
||||
).readAsStringSync();
|
||||
|
||||
for (final component in [
|
||||
'ScreenshotTileService',
|
||||
@@ -301,18 +307,40 @@ void main() {
|
||||
).firstMatch(manifest)?.group(0);
|
||||
expect(declaration, isNotNull);
|
||||
expect(declaration, contains('android:stopWithTask="false"'));
|
||||
expect(declaration, isNot(contains('android:process=')));
|
||||
expect(declaration, contains('android:process=":recognition"'));
|
||||
}
|
||||
final provider = RegExp(
|
||||
'<provider\\s+android:name="\\.RecognitionBridgeProvider"[\\s\\S]*?/>',
|
||||
).firstMatch(manifest)?.group(0);
|
||||
expect(provider, isNotNull);
|
||||
expect(provider, isNot(contains('android:process=')));
|
||||
expect(provider, contains('android:process=":recognition"'));
|
||||
expect(manifest, contains('android:name=".OneShotProjectionService"'));
|
||||
expect(manifest, contains('android:process=":recognition"'));
|
||||
final projection = RegExp(
|
||||
'<service\\s+android:name="\\.OneShotProjectionService"[\\s\\S]*?/>',
|
||||
).firstMatch(manifest)?.group(0);
|
||||
expect(projection, isNotNull);
|
||||
expect(projection, contains('android:process=":projection"'));
|
||||
expect(manifest, contains('android:name=".RecognitionKeepAliveService"'));
|
||||
expect(manifest, contains('android:foregroundServiceType="specialUse"'));
|
||||
expect(manifest, contains('FOREGROUND_SERVICE_SPECIAL_USE'));
|
||||
expect(manifest, contains('PROPERTY_SPECIAL_USE_FGS_SUBTYPE'));
|
||||
expect(keepAlive, contains('START_STICKY'));
|
||||
expect(keepAlive, contains('智能识别运行中'));
|
||||
expect(channel, contains('waitForAccessibilityConnection'));
|
||||
expect(channel, contains('_activeAccessibilityConnectionWait'));
|
||||
expect(channel, contains('ensureRecognitionKeepAlive'));
|
||||
expect(channel, contains('AccessibilityConnectionState.reconnecting'));
|
||||
expect(activity, contains('"reconnecting"'));
|
||||
expect(activity, contains('"disconnected"'));
|
||||
expect(activity, contains('setExcludeFromRecents'));
|
||||
expect(activity, contains('getHistoricalProcessExitReasons'));
|
||||
expect(activity, contains('single-cleaner'));
|
||||
expect(settingsPage, contains('_check();'));
|
||||
expect(settingsPage, isNot(contains('_check(waitForConnection: true)')));
|
||||
expect(settingsPage, contains('系统仍显示已授权,但 OriginOS 未重新绑定服务'));
|
||||
expect(settingsPage, contains('启动后台保护'));
|
||||
expect(settingsPage, contains('OriginOS 清理了识别进程'));
|
||||
expect(settingsPage, contains('记之已从最近任务隐藏'));
|
||||
expect(channel, contains('taskCleanerRecoveryNeeded'));
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user