Add transfer tracking and secure admin access

This commit is contained in:
2026-07-26 11:57:57 +08:00
parent 0738953e6d
commit 7df25edd96
111 changed files with 6379 additions and 1934 deletions
@@ -151,13 +151,20 @@ class MainActivity : FlutterActivity() {
)
result.success(response?.getBoolean("success") == true)
}
"drainRecognitionCandidates" -> {
"drainRecognitionCandidates" -> {
val response = RecognitionBridge.call(
this,
RecognitionBridgeProvider.METHOD_DRAIN,
)
result.success(response?.getStringArrayList("candidates") ?: arrayListOf<String>())
}
result.success(response?.getStringArrayList("candidates") ?: arrayListOf<String>())
}
"listRecognitionCandidates" -> {
val response = RecognitionBridge.call(
this,
RecognitionBridgeProvider.METHOD_CANDIDATES,
)
result.success(response?.getStringArrayList("candidates") ?: arrayListOf<String>())
}
"ackRecognitionCandidate" -> acknowledgeRecognition(call, result)
"listRecognitionBatches" -> {
val response = RecognitionBridge.call(
@@ -22,6 +22,9 @@ data class PaymentSignal(
val categoryHint: String? = null,
val amountSource: String = "result",
val resultFingerprint: String? = null,
val identityConfidence: String = "strong",
val transferDirection: String? = null,
val counterparty: String? = null,
)
enum class PaymentStatusStrength(val wireValue: String) {
@@ -189,11 +192,14 @@ object PaymentParser {
val kind = flowKind ?: recognitionKind(text, direction)
val merchant = extractMerchant(text)
val orderId = extractOrderId(text)
val transferDirection = direction.takeIf { kind == "transfer" }?.let {
if (it == "income") "in" else "out"
}
return PaymentSignal(
packageName = packageName,
channel = "accessibility",
amountCents = amountCents,
type = direction,
type = if (kind == "transfer") "transfer" else direction,
merchant = merchant,
orderId = orderId,
occurredAtEpochMs = System.currentTimeMillis(),
@@ -214,6 +220,8 @@ object PaymentParser {
orderId,
sha256(normalize(text)),
),
transferDirection = transferDirection,
counterparty = merchant.takeIf { kind == "transfer" },
)
}
fun fromNotification(notification: StatusBarNotification): PaymentSignal? {
@@ -443,18 +451,21 @@ object PaymentParser {
if (packageName !in supportedPackages) return null
val normalized = normalize(text)
if (containsBlockedStatus(normalized) || isHistoryPage(normalized)) return null
val type = detectDirection(normalized) ?: return null
val direction = detectDirection(normalized) ?: return null
val amount = extractAmount(normalized) ?: return null
if (!amount.isFinite() || amount <= 0 || amount > 100_000_000) return null
val orderId = extractOrderId(normalized)
val merchant = extractMerchant(normalized)
val kind = recognitionKind(normalized, type)
val kind = recognitionKind(normalized, direction)
val transferDirection = direction.takeIf { kind == "transfer" }?.let {
if (it == "income") "in" else "out"
}
return PaymentSignal(
packageName = packageName,
channel = channel,
amountCents = (amount * 100).roundToLong(),
type = type,
type = if (kind == "transfer") "transfer" else direction,
merchant = merchant,
orderId = orderId,
occurredAtEpochMs = occurredAt,
@@ -464,17 +475,19 @@ object PaymentParser {
flowSessionId = flowSessionId,
evidenceConfidence = evidenceConfidence,
recognitionKind = kind,
categoryHint = categoryHint(kind, type),
categoryHint = categoryHint(kind, direction),
amountSource = "result",
resultFingerprint = resultFingerprint(
packageName,
kind,
type,
direction,
(amount * 100).roundToLong(),
merchant,
orderId,
sha256(normalized),
),
transferDirection = transferDirection,
counterparty = merchant.takeIf { kind == "transfer" },
)
}
@@ -538,4 +551,4 @@ object PaymentParser {
private const val MAX_SUCCESS_HEADING_CHARS = 48
private const val MAX_PAYMENT_SCAN_LINES = 48
private const val MAX_HISTORY_TITLE_LINES = 3
}
}
@@ -63,6 +63,12 @@ class RecognitionBridgeProvider : ContentProvider() {
),
)
}
METHOD_CANDIDATES -> Bundle().apply {
putStringArrayList(
"candidates",
ArrayList(RecognitionCoordinator.get(appContext).recentCandidates()),
)
}
METHOD_ACK -> Bundle().apply {
val candidate = RecognitionCoordinator.get(appContext).acknowledge(
extras?.getString("id").orEmpty(),
@@ -144,6 +150,7 @@ class RecognitionBridgeProvider : ContentProvider() {
const val METHOD_REQUEST_SCREENSHOT = "requestScreenshot"
const val METHOD_SCREENSHOT_RESULT = "screenshotResult"
const val METHOD_DRAIN = "drain"
const val METHOD_CANDIDATES = "candidates"
const val METHOD_ACK = "ack"
const val METHOD_BATCHES = "batches"
const val METHOD_RESTORE_DROPPED = "restoreDropped"
@@ -114,6 +114,8 @@ class RecognitionCoordinator private constructor(private val context: Context) {
fun recentBatches(): List<String> = store.recentBatches()
fun recentCandidates(): List<String> = store.recentCandidates()
fun restoreDropped(candidateId: String): StoredCandidate? {
return store.restoreDropped(candidateId)
}
@@ -9,7 +9,6 @@ import org.json.JSONObject
import org.json.JSONArray
import java.time.Instant
import java.util.UUID
import kotlin.math.abs
data class StoredCandidate(
val id: String,
@@ -169,8 +168,9 @@ class RecognitionStore(context: Context) :
val strongKey = orderStrongKey ?: flowStrongKey
val clientRequestId = clientRequestIdFor(signal)
val signalHigh = signal.channel in setOf("accessibility", "local_ocr") &&
signal.evidenceConfidence == "high"
val existing = findMergeCandidate(signal, channelBit, merchantHash, strongKey, now)
signal.evidenceConfidence == "high" &&
signal.identityConfidence == "strong"
val existing = findMergeCandidate(strongKey)
?: findByClientRequestId(clientRequestId)
val batchId = existing?.batchId ?: if (batchMode) activeBatch(now) else null
val id: String
@@ -458,6 +458,12 @@ class RecognitionStore(context: Context) :
.put("categoryHint", payload.optString("categoryHint").takeIf(String::isNotBlank))
.put("confidence", if (cursor.getInt(cursor.getColumnIndexOrThrow("high_confidence")) == 1) "auto" else "confirm")
.put("sourceText", payload.optString("sourceText").takeIf(String::isNotBlank))
.put("transferDirection", payload.optString("transferDirection").takeIf(String::isNotBlank))
.put("counterparty", payload.optString("counterparty").takeIf(String::isNotBlank))
.put("provider", payload.optString("provider").takeIf(String::isNotBlank))
.put("providerTransactionId", payload.optString("providerTransactionId").takeIf(String::isNotBlank))
.put("recognitionOccurrenceId", payload.optString("recognitionOccurrenceId").takeIf(String::isNotBlank))
.put("identityConfidence", payload.optString("identityConfidence").takeIf(String::isNotBlank))
.put(
"evidenceIds",
JSONArray(evidenceByCandidate[cursor.getString(cursor.getColumnIndexOrThrow("id"))]
@@ -564,13 +570,18 @@ class RecognitionStore(context: Context) :
} ?: continue
val amount = action.optDouble("amount", 0.0)
val type = action.optString("type")
if (amount <= 0 || type !in setOf("income", "expense")) continue
val transferDirection = action.optionalString("transferDirection")
?.takeIf { it in setOf("in", "out") }
if (amount <= 0 || type !in setOf("income", "expense", "transfer") ||
type == "transfer" && transferDirection == null) continue
val candidateId = UUID.randomUUID().toString()
val requestId = "recognition-" + PaymentParser.sha256("$batchId|create|$evidenceId").take(52)
val payload = JSONObject()
.put("packageName", image.first)
.put("appName", PaymentParser.appName(image.first))
.put("type", type)
.put("transferDirection", transferDirection)
.put("counterparty", action.optionalString("counterparty"))
.put("amount", amount)
.put("merchant", action.optionalString("note"))
.put("orderId", JSONObject.NULL)
@@ -578,11 +589,13 @@ class RecognitionStore(context: Context) :
.put("sourceText", "AI 批次补全 · ${PaymentParser.appName(image.first)}")
.put("flowSessionId", image.third)
.put("evidenceConfidence", "high")
.put("recognitionKind", "payment")
.put("recognitionKind", if (type == "transfer") "transfer" else "payment")
.put("categoryHint", action.optionalString("categoryName"))
.put("categoryId", action.optLong("categoryId").takeIf { it > 0 })
.put("amountSource", "ai_batch")
.put("resultFingerprint", PaymentParser.sha256("$batchId|$evidenceId"))
.put("recognitionOccurrenceId", image.third ?: candidateId)
.put("identityConfidence", if (image.third == null) "weak" else "strong")
.put("note", action.optionalString("note") ?: PaymentParser.appName(image.first))
.put("paymentMethod", action.optionalString("paymentMethod"))
.put("sourceOverride", "recognition_ai")
@@ -704,9 +717,13 @@ class RecognitionStore(context: Context) :
}
private fun applyActionFields(payload: JSONObject, action: JSONObject) {
action.optionalString("type")?.takeIf { it in setOf("income", "expense") }?.let {
action.optionalString("type")?.takeIf { it in setOf("income", "expense", "transfer") }?.let {
payload.put("type", it)
}
action.optionalString("transferDirection")?.takeIf { it in setOf("in", "out") }?.let {
payload.put("transferDirection", it)
}
action.optionalString("counterparty")?.let { payload.put("counterparty", it.take(100)) }
action.optDouble("amount", 0.0).takeIf { it > 0 }?.let { payload.put("amount", it) }
action.optionalString("note")?.let {
payload.put("merchant", it.take(40))
@@ -750,6 +767,7 @@ class RecognitionStore(context: Context) :
.put("action", action)
.put("reason", if (candidates.isNull(reasonIndex)) "" else candidates.getString(reasonIndex))
.put("type", payload.optString("type"))
.put("transferDirection", payload.optString("transferDirection").takeIf(String::isNotBlank))
.put("amount", payload.optDouble("amount"))
.put("merchant", payload.optString("merchant").takeIf(String::isNotBlank))
.put("state", candidates.getString(candidates.getColumnIndexOrThrow("state")))
@@ -892,13 +910,20 @@ class RecognitionStore(context: Context) :
.toString()
}
private fun findMergeCandidate(
signal: PaymentSignal,
channelBit: Int,
merchantHash: String,
strongKey: String?,
now: Long,
): CandidateRow? {
@Synchronized
fun recentCandidates(): List<String> {
expireOld(System.currentTimeMillis())
return readableDatabase.rawQuery(
"SELECT * FROM candidates ORDER BY updated_at DESC LIMIT 100",
null,
).use { cursor ->
buildList {
while (cursor.moveToNext()) decode(cursor)?.json?.let(::add)
}
}
}
private fun findMergeCandidate(strongKey: String?): CandidateRow? {
if (strongKey != null) {
readableDatabase.rawQuery(
"SELECT * FROM candidates WHERE strong_key = ? AND state NOT IN ('undone','expired') LIMIT 1",
@@ -910,39 +935,8 @@ class RecognitionStore(context: Context) :
// counterparty are identical to another transaction in the same window.
return null
}
val since = signal.occurredAtEpochMs - NO_ORDER_WINDOW_MS
val until = signal.occurredAtEpochMs + NO_ORDER_WINDOW_MS
readableDatabase.rawQuery(
"""
SELECT * FROM candidates
WHERE package_name = ? AND amount_cents = ? AND direction = ?
AND merchant_hash = ? AND occurred_at BETWEEN ? AND ?
AND state IN ('pending_merge','auto_ready','pending_confirm','imported')
ORDER BY ABS(occurred_at - ?) LIMIT 4
""".trimIndent(),
arrayOf(
signal.packageName,
signal.amountCents.toString(),
signal.type,
merchantHash,
since.toString(),
until.toString(),
signal.occurredAtEpochMs.toString(),
),
).use { cursor ->
while (cursor.moveToNext()) {
val candidate = row(cursor)
val hasSameChannel = candidate.channelMask and channelBit != 0
val sameResult = signal.resultFingerprint != null &&
signal.resultFingerprint == candidate.resultFingerprint
if (sameResult ||
!hasSameChannel ||
now - candidate.updatedAt <= SAME_CHANNEL_DEBOUNCE_MS
) {
return candidate
}
}
}
// Amount, merchant and time are correlation hints, not transaction identity.
// Updated notifications are already collapsed by their source-event hash.
return null
}
@@ -1018,6 +1012,21 @@ class RecognitionStore(context: Context) :
.put("categoryHint", signal.categoryHint)
.put("amountSource", signal.amountSource)
.put("resultFingerprint", signal.resultFingerprint)
.put("identityConfidence", signal.identityConfidence)
.put("transferDirection", signal.transferDirection)
.put("counterparty", signal.counterparty)
.put(
"provider",
when (signal.packageName) {
PaymentParser.WECHAT -> "wechat"
PaymentParser.ALIPAY -> "alipay"
else -> null
},
)
.put("providerTransactionId", signal.orderId)
.put("recognitionOccurrenceId", signal.flowSessionId)
.put("evidenceFingerprint", signal.resultFingerprint)
.put("recognitionConfidence", signal.identityConfidence)
.put(
"note",
signal.merchant?.take(40) ?: when (signal.recognitionKind) {
@@ -1038,6 +1047,12 @@ class RecognitionStore(context: Context) :
if (existing.isNull("resultFingerprint") && signal.resultFingerprint != null) {
existing.put("resultFingerprint", signal.resultFingerprint)
}
if (existing.isNull("transferDirection") && signal.transferDirection != null) {
existing.put("transferDirection", signal.transferDirection)
}
if (existing.isNull("counterparty") && signal.counterparty != null) {
existing.put("counterparty", signal.counterparty)
}
if (existing.optString("note").isBlank() && signal.merchant != null) existing.put("note", signal.merchant.take(40))
existing.put("occurredAtEpochMs", minOf(existing.optLong("occurredAtEpochMs"), signal.occurredAtEpochMs))
return existing
@@ -1089,8 +1104,6 @@ class RecognitionStore(context: Context) :
companion object {
private const val ACCESSIBILITY_NOTIFICATION_MASK = 3
private const val MERGE_DELAY_MS = 1_500L
private const val NO_ORDER_WINDOW_MS = 90_000L
private const val SAME_CHANNEL_DEBOUNCE_MS = 10_000L
private const val EXPIRE_MS = 7L * 24L * 60L * 60L * 1000L
private const val BATCH_IDLE_MS = 30_000L
private const val BATCH_HARD_LIMIT_MS = 120_000L
@@ -50,6 +50,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
var expectedAmountCents: Long? = null,
var expectedType: String? = null,
var committedAt: Long? = null,
var completedAt: Long? = null,
var resultTransitionObserved: Boolean = false,
var resultPageHash: String? = null,
var resultFingerprint: String? = null,
@@ -142,6 +143,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
val clickedPaymentAction =
currentEvent.eventType == AccessibilityEvent.TYPE_VIEW_CLICKED &&
PaymentParser.hasPaymentAction(eventText.ifBlank { combined })
val majorWindowChange = currentEvent.eventType in MAJOR_WINDOW_EVENTS
val inferredKind = PaymentParser.detectFlowKind(combined)
?: existingFlow?.kind
?: "payment"
@@ -165,6 +167,71 @@ class ScreenshotAccessibilityService : AccessibilityService() {
val sameOutgoingResult =
currentKind in setOf("payment", "transfer") &&
existingFlow.kind in setOf("payment", "transfer")
if (clickedPaymentAction) {
val nextFlow = armPaymentFlow(
recognizedPackage,
currentEvent.windowId,
now,
forceNew = true,
kind = inferredKind,
)
nextFlow.committedAt = now
nextFlow.expectedType = "expense"
RecognitionDiagnostics.record(
this,
recognizedPackage,
stage = "event",
result = "armed",
nodeCount = page.nodeCount,
reason = "new_payment_action",
recognitionKind = inferredKind,
)
return
}
val observed = PaymentParser.fromAccessibility(
packageName = recognizedPackage,
text = combined,
eventTime = currentEvent.eventTime,
windowId = currentEvent.windowId,
expectedType = status.direction ?: existingFlow.expectedType,
flowKind = currentKind,
)
val resultChanged = observed?.resultFingerprint != null &&
existingFlow.resultFingerprint != null &&
observed.resultFingerprint != existingFlow.resultFingerprint
val elapsedSinceCompletion = now - (existingFlow.completedAt ?: now)
val startReason = completedResultStartReason(
hasObservedResult = observed != null,
resultFingerprintChanged = resultChanged,
majorWindowChange = majorWindowChange,
elapsedSinceCompletionMs = elapsedSinceCompletion,
)
val ambiguousRepeat = startReason == CompletedResultStartReason.AMBIGUOUS_REPEAT
if (startReason != null) {
val nextFlow = armPaymentFlow(
recognizedPackage,
currentEvent.windowId,
now,
forceNew = true,
trusted = resultChanged,
kind = currentKind,
).apply { resultTransitionObserved = true }
submitOnce(
requireNotNull(observed).copy(
flowSessionId = nextFlow.id,
evidenceConfidence = "confirm",
identityConfidence = if (ambiguousRepeat) {
"ambiguous_repeat"
} else {
"strong"
},
),
nextFlow,
page.nodeCount,
"tree",
)
return
}
if ((currentKind == existingFlow.kind || sameOutgoingResult) &&
shouldSuppressCompletedResult(existingFlow.resultSurfaceExited)
) {
@@ -250,7 +317,6 @@ class ScreenshotAccessibilityService : AccessibilityService() {
return
}
val majorWindowChange = currentEvent.eventType in MAJOR_WINDOW_EVENTS
val flow = paymentFlow?.takeIf { !it.completed } ?: return
val shouldProbe = flow.committedAt != null &&
(majorWindowChange ||
@@ -408,6 +474,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
return
}
flow.completed = true
flow.completedAt = System.currentTimeMillis()
flow.resultFingerprint = signal.resultFingerprint
val coordinator = RecognitionCoordinator.get(this)
val settings = RecognitionSettings.snapshot(this)
@@ -1105,6 +1172,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
private const val CONTENT_DEBOUNCE_MS = 2_000L
private const val PAYMENT_FLOW_TTL_MS = 90_000L
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
@@ -1137,6 +1205,19 @@ class ScreenshotAccessibilityService : AccessibilityService() {
internal fun shouldSuppressCompletedResult(resultSurfaceExited: Boolean): Boolean =
!resultSurfaceExited
internal fun completedResultStartReason(
hasObservedResult: Boolean,
resultFingerprintChanged: Boolean,
majorWindowChange: Boolean,
elapsedSinceCompletionMs: Long,
): CompletedResultStartReason? = when {
!hasObservedResult -> null
resultFingerprintChanged -> CompletedResultStartReason.CHANGED_RESULT
majorWindowChange && elapsedSinceCompletionMs >= AMBIGUOUS_REPEAT_GAP_MS ->
CompletedResultStartReason.AMBIGUOUS_REPEAT
else -> null
}
@Volatile
var isConnected = false
private set
@@ -1156,3 +1237,8 @@ class ScreenshotAccessibilityService : AccessibilityService() {
}
}
}
internal enum class CompletedResultStartReason {
CHANGED_RESULT,
AMBIGUOUS_REPEAT,
}
@@ -342,6 +342,36 @@ class PaymentParserTest {
)
}
@Test
fun completedTransferStartsANewFlowForChangedOrAmbiguousRepeatedResults() {
assertEquals(
CompletedResultStartReason.CHANGED_RESULT,
ScreenshotAccessibilityService.completedResultStartReason(
hasObservedResult = true,
resultFingerprintChanged = true,
majorWindowChange = false,
elapsedSinceCompletionMs = 100L,
),
)
assertEquals(
CompletedResultStartReason.AMBIGUOUS_REPEAT,
ScreenshotAccessibilityService.completedResultStartReason(
hasObservedResult = true,
resultFingerprintChanged = false,
majorWindowChange = true,
elapsedSinceCompletionMs = 3_000L,
),
)
assertNull(
ScreenshotAccessibilityService.completedResultStartReason(
hasObservedResult = true,
resultFingerprintChanged = false,
majorWindowChange = false,
elapsedSinceCompletionMs = 30_000L,
),
)
}
private fun paymentSignal(
channel: String,
sourceEventId: String,