Add transfer tracking and secure admin access
This commit is contained in:
@@ -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
|
||||
|
||||
+87
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user