fix: improve transfer recognition and local data safety

This commit is contained in:
2026-07-26 21:36:46 +08:00
parent 7df25edd96
commit 882293343e
21 changed files with 505 additions and 128 deletions
+10 -3
View File
@@ -1,4 +1,5 @@
import java.io.FileInputStream
import java.net.URI
import java.util.Base64
import java.util.Properties
@@ -120,15 +121,21 @@ val internalReleaseRequested = gradle.startParameter.taskNames.any { requestedTa
taskName.contains("InternalRelease", ignoreCase = true)
}
if (internalReleaseRequested) {
val expectedApiUrl = "https://lt.frp-say.com:38012"
if (dartDefines["INTERNAL_BUILD"] != "true") {
throw GradleException(
"Internal release requires --dart-define=INTERNAL_BUILD=true",
)
}
if (dartDefines["API_BASE_URL"] != expectedApiUrl) {
val apiBaseUrl = dartDefines["API_BASE_URL"]?.trim().orEmpty()
val validApiBaseUrl = runCatching {
val uri = URI(apiBaseUrl)
uri.scheme in setOf("http", "https") &&
!uri.host.isNullOrBlank() &&
uri.userInfo == null
}.getOrDefault(false)
if (!validApiBaseUrl) {
throw GradleException(
"Internal release requires --dart-define=API_BASE_URL=$expectedApiUrl",
"Internal release requires an explicit HTTP(S) --dart-define=API_BASE_URL=<url>",
)
}
if (dartDefines["APP_VERSION"].isNullOrBlank()) {
@@ -1,20 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIDOzCCAiOgAwIBAgIELZVPvzANBgkqhkiG9w0BAQsFADBQMQswCQYDVQQGEwJD
TjEtMCsGA1UEAxMkU2FrdXJhRnJwIEF1dG9tYXRpYyBUTFMgc24uNzY0NzU5OTk5
MRIwEAYDVQQFEwk3NjQ3NTk5OTkwHhcNMjYwNzIwMDQ0NDM4WhcNMjcwNzIwMDQ0
NDM4WjBQMQswCQYDVQQGEwJDTjEtMCsGA1UEAxMkU2FrdXJhRnJwIEF1dG9tYXRp
YyBUTFMgc24uNzY0NzU5OTk5MRIwEAYDVQQFEwk3NjQ3NTk5OTkwggEiMA0GCSqG
SIb3DQEBAQUAA4IBDwAwggEKAoIBAQC0poNmZC+UWeDWGRjHot86lnA3J5Ueujml
GvapcAWUoQBNs1p+tlVedz1Dwo/Hq1u7tHdp3WBDax5naKLFKIz0kQIbCWxDrDTH
YNVQ9O7MHcf8dcDeayvo6q9z7PzhVXH/CJTlWx2634RGYbaU5jBjWX4fPFHyXJe0
57zQqSIYrxZFozEd9NewELavkjCydI8atSFQNEDtlHziiXXLKlvUk7Sk6Drc2AxU
ZYk0/mz6GFbBIeIKLUeDlvoocHQvzC3kK+pn1Ggq+ky2DViGPkzekh8HYiOo+/Wv
VRXP9LHp17AsmvsQQM4y2NzSkX/la4R1pgq5OwHw6pHHCeWwvBWTAgMBAAGjHTAb
MBkGA1UdEQQSMBCCDmx0LmZycC1zYXkuY29tMA0GCSqGSIb3DQEBCwUAA4IBAQBg
I4grSvEqI2RRXlwbRjKlBnlBWgiw51sEuM7Sjq6P8t2IoaGJ5/F3PeT0XWwyTopg
hV5hNPU+wOKtVilyNqepljrPQ5XAm3uWp68aIHBuCxh3XOfjetPBPXoisY67AUHH
9gilTg24GjZ7koJGfiS0iHmfLtf1rEDUgCl27pX9e2NzMRr9aVAsRkdp6D3esXZL
e2aUTgBWRg45PU+26dd/JN738F85nqwdRc16MeTMDDbqyIVmcUnZZVPeGXAmTotk
b9pHfmaT3h3YqPDv/rWa9w+AMdc2mBIckPQ3ZV4H95xHEOR56uFQwkfiPQABlBKO
2oBDbJB1yhK++6XkhmLj
-----END CERTIFICATE-----
@@ -5,11 +5,4 @@
<certificates src="system"/>
</trust-anchors>
</base-config>
<domain-config cleartextTrafficPermitted="false">
<domain includeSubdomains="false">lt.frp-say.com</domain>
<trust-anchors>
<certificates src="system"/>
<certificates src="@raw/sakura_frp_test_ca"/>
</trust-anchors>
</domain-config>
</network-security-config>
</network-security-config>
@@ -374,7 +374,7 @@ object LocalPaymentOcr {
packageName = packageName,
channel = "local_ocr",
amountCents = selectedCents,
type = direction,
type = if (kind == "transfer") "transfer" else direction,
merchant = merchant,
orderId = orderId,
occurredAtEpochMs = capturedAt,
@@ -395,6 +395,10 @@ object LocalPaymentOcr {
orderId,
PaymentParser.sha256(allText),
),
transferDirection = direction.takeIf { kind == "transfer" }?.let {
if (it == "income") "in" else "out"
},
counterparty = merchant.takeIf { kind == "transfer" },
)
val reason = when {
highConfidence && amountSource == "expected" -> "expected_amount_fallback"
@@ -480,4 +484,4 @@ object LocalPaymentOcr {
private const val OCR_IDLE_CLOSE_SECONDS = 60L
private const val MAX_SCREENSHOT_AGE_MS = 3_000L
private const val MAX_AMOUNT = 100_000_000.0
}
}
@@ -812,7 +812,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
)
return
}
val retryable = outcome.reason in setOf("no_text", "missing_amount")
val retryable = isRetryableOcrOutcome(outcome.reason)
if (retryable && flow.retryCount < MAX_VISUAL_RETRIES) {
flow.retryCount += 1
scheduleVisualRecognition(
@@ -1205,6 +1205,14 @@ class ScreenshotAccessibilityService : AccessibilityService() {
internal fun shouldSuppressCompletedResult(resultSurfaceExited: Boolean): Boolean =
!resultSurfaceExited
internal fun isRetryableOcrOutcome(reason: String): Boolean = reason in setOf(
"no_text",
"missing_amount",
"expected_amount_missing",
"no_success_status",
"payment_input_page",
)
internal fun completedResultStartReason(
hasObservedResult: Boolean,
resultFingerprintChanged: Boolean,
@@ -307,6 +307,15 @@ class PaymentParserTest {
assertFalse(ScreenshotAccessibilityService.shouldSuppressCompletedResult(true))
}
@Test
fun transientWechatOcrSurfacesAreRetriedInsteadOfFinallyRejected() {
assertTrue(ScreenshotAccessibilityService.isRetryableOcrOutcome("no_success_status"))
assertTrue(ScreenshotAccessibilityService.isRetryableOcrOutcome("payment_input_page"))
assertTrue(ScreenshotAccessibilityService.isRetryableOcrOutcome("missing_amount"))
assertFalse(ScreenshotAccessibilityService.isRetryableOcrOutcome("blocked_status"))
assertFalse(ScreenshotAccessibilityService.isRetryableOcrOutcome("history_page"))
}
@Test
fun consecutiveIdenticalTransfersKeepDistinctFlowIdentities() {
val first = paymentSignal(