From 37714d39b5ac8ff720cfa1d0d2609f9a8af3fbd9 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 8 Oct 2026 11:20:42 +0800 Subject: [PATCH] fix(android): extend passive order observation after payment back (#365) --- .../automation/PurchaseLiveAutomation.kt | 91 ++++-- .../agent/PurchaseLiveAutomationTest.kt | 277 ++++++++++++++++++ 2 files changed, 341 insertions(+), 27 deletions(-) diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt index 72e9495..3ff828e 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt @@ -42,6 +42,7 @@ class PurchaseLiveAutomation( private val pause: (Long) -> Unit = Thread::sleep, /** Boolean/count-only diagnostics; never receives address text. */ private val panelDiagnostic: (String) -> Unit = {}, + private val monotonicClockMs: () -> Long = { System.nanoTime() / 1_000_000L }, ) { private var submitAttempted = false var lastOrderReadFailure: PurchaseOrderReadFailure? = null @@ -366,48 +367,83 @@ class PurchaseLiveAutomation( var consecutiveEmptySnapshots = 0 var unpaidStartSample: Int? = null var unpaidSwipes = 0 - repeat(ORDER_RESULT_MAX_SAMPLES + ORDER_RESULT_UNPAID_MAX_SAMPLES) { index -> + var paymentBackSucceededAt: Long? = null + var orderContextObserved = false + // Extra observations cannot reopen navigation/scroll budgets. Late unpaid evidence + // still receives its existing independent budget below. + var passiveExtension = false + fun observationRemainingMs(): Long? { + val startedAt = paymentBackSucceededAt ?: return null + if (unpaidStartSample != null || (orderContextObserved && !passiveExtension)) return null + return ORDER_RESULT_POST_PAYMENT_OBSERVATION_MS - (monotonicClockMs() - startedAt) + } + fun observePause(durationMs: Long) { + val remaining = observationRemainingMs() + val duration = if (remaining == null) durationMs else minOf(durationMs, remaining.coerceAtLeast(0)) + if (duration > 0) pause(duration) + } + var nextSample = 0 + while (true) { + val index = nextSample++ // The first unpaid page receives its own bounded budget, even after a long handoff. // Never reset it on repeated labels or navigation back to payment. val unpaidStart = unpaidStartSample - if ((unpaidStart == null && index >= ORDER_RESULT_MAX_SAMPLES) || + if (unpaidStart == null && index >= ORDER_RESULT_MAX_SAMPLES && + paymentBackSucceededAt != null && !orderContextObserved) { + passiveExtension = true + } + if ((observationRemainingMs()?.let { it <= 0 } == true) || + (unpaidStart == null && index >= ORDER_RESULT_MAX_SAMPLES && !passiveExtension) || (unpaidStart != null && index - unpaidStart >= ORDER_RESULT_UNPAID_MAX_SAMPLES)) { val failure = orderEvidenceFailure(labels) return unknown(failure.code, failure.message) } val snapshot = driver.capture() + // A blocking capture can finish after the deadline. Do not act on or accept that frame. + if (observationRemainingMs()?.let { it <= 0 } == true) { + val failure = orderEvidenceFailure(labels) + return unknown(failure.code, failure.message) + } if (snapshot.packageName.isNullOrBlank()) { consecutiveEmptySnapshots++ if (consecutiveEmptySnapshots >= ORDER_RESULT_MAX_EMPTY_SAMPLES) { return unknown("PURCHASE_ORDER_EMPTY_TIMEOUT", "等待订单页面时无障碍窗口持续为空") } - pause(ORDER_RESULT_SAMPLE_INTERVAL_MS) - return@repeat + observePause(ORDER_RESULT_SAMPLE_INTERVAL_MS) + continue } consecutiveEmptySnapshots = 0 val currentLabels = snapshot.nodes.filter { it.visible }.map { it.label } if (isKnownAndroidWechatChooser(snapshot, currentLabels)) { + if (passiveExtension && !backedOutOfChooser) { + val failure = orderEvidenceFailure(labels) + return unknown(failure.code, failure.message) + } if (backedOutOfChooser || !driver.backPurchase()) { return unknown("PURCHASE_ORDER_CHOOSER_BACK_FAILED", "系统应用选择页无法安全返回") } backedOutOfChooser = true - pause(500) - return@repeat + observePause(500) + continue } if (snapshot.packageName == WECHAT_PACKAGE) { if (!restoredFromWechat) { + if (passiveExtension) { + val failure = orderEvidenceFailure(labels) + return unknown(failure.code, failure.message) + } if (!driver.bringPddToForeground()) { return unknown("PURCHASE_ORDER_WECHAT_RESTORE_FAILED", "从微信恢复拼多多的请求失败") } restoredFromWechat = true - pause(500) - return@repeat + observePause(500) + continue } if (pddObservedAfterWechatRestore || ++wechatRestorePendingSamples >= ORDER_RESULT_WECHAT_RESTORE_MAX_SAMPLES) { return unknown("PURCHASE_ORDER_WECHAT_RESTORE_TIMEOUT", "从微信恢复拼多多后未在限定时间到达订单页面") } - pause(ORDER_RESULT_SAMPLE_INTERVAL_MS) - return@repeat + observePause(ORDER_RESULT_SAMPLE_INTERVAL_MS) + continue } if (snapshot.packageName != PDD_PACKAGE) { return unknown("PURCHASE_ORDER_UNEXPECTED_APP", "核单期间出现未授权应用") @@ -426,7 +462,8 @@ class PurchaseLiveAutomation( if (!driver.backPurchase()) { return unknown("PURCHASE_ORDER_PAYMENT_BACK_FAILED", "支付页无法安全返回订单详情") } - pause(500) + paymentBackSucceededAt = monotonicClockMs() + observePause(500) } else { consecutivePaymentSamplesAfterBack++ if (consecutivePaymentSamplesAfterBack >= ORDER_RESULT_PAYMENT_POST_BACK_MAX_SAMPLES) { @@ -437,20 +474,21 @@ class PurchaseLiveAutomation( "consecutivePaymentSamplesAfterBack=$consecutivePaymentSamplesAfterBack]", ) } - pause(ORDER_RESULT_SAMPLE_INTERVAL_MS) + observePause(ORDER_RESULT_SAMPLE_INTERVAL_MS) } - return@repeat + continue } consecutivePaymentSamplesAfterBack = 0 + if (orderContextVisible) orderContextObserved = true if (unpaidContextVisible) { paymentPageObserved = true if (unpaidStartSample == null) unpaidStartSample = index } if (!orderContextVisible && !unpaidContextVisible) { - if (unpaidStartSample != null) { - // After reaching the unpaid page only observe; never click a newly exposed control. - pause(ORDER_RESULT_SAMPLE_INTERVAL_MS) - return@repeat + if (unpaidStartSample != null || passiveExtension) { + // Neither unpaid reading nor the added observation budget permits a new click. + observePause(ORDER_RESULT_SAMPLE_INTERVAL_MS) + continue } val entries = orderDetailEntryTargets(snapshot) if (entries.size > 1) { @@ -466,27 +504,25 @@ class PurchaseLiveAutomation( else -> return unknown("PURCHASE_ORDER_DETAIL_ENTRY_FAILED", "订单详情入口点击失败,已停止只读核单") } orderDetailEntryOpened = true - pause(500) - return@repeat + observePause(500) + continue } - pause(ORDER_RESULT_SAMPLE_INTERVAL_MS) - return@repeat + observePause(ORDER_RESULT_SAMPLE_INTERVAL_MS) + continue } currentLabels.forEach(labels::add) parseOrderEvidence(labels)?.let { return it } if (unpaidStartSample != null && unpaidSwipes < ORDER_RESULT_UNPAID_MAX_SWIPES) { unpaidSwipes++ driver.swipePurchase(SwipeDirection.UP, 400) - pause(ORDER_RESULT_UNPAID_SETTLE_MS) - return@repeat + observePause(ORDER_RESULT_UNPAID_SETTLE_MS) + continue } - if (unpaidStartSample == null && index > 0 && index % ORDER_RESULT_SCROLL_SAMPLE_INTERVAL == 0) { + if (!passiveExtension && unpaidStartSample == null && index > 0 && index % ORDER_RESULT_SCROLL_SAMPLE_INTERVAL == 0) { driver.swipePurchase(SwipeDirection.UP, 400) } - pause(ORDER_RESULT_SAMPLE_INTERVAL_MS) + observePause(ORDER_RESULT_SAMPLE_INTERVAL_MS) } - val failure = orderEvidenceFailure(labels) - return unknown(failure.code, failure.message) } // orderEvidenceFailure 之外的路径(选择器卡住、微信恢复超时等)同样要带上 @@ -1038,6 +1074,7 @@ class PurchaseLiveAutomation( val ANDROID_CHOOSER_TITLES = setOf("选择要使用的应用", "使用以下应用打开", "完成操作时使用") val PDD_PAYMENT_ACTIVITIES = setOf("com.xunmeng.pinduoduo.app_pay.core.PayActivity") const val ORDER_RESULT_MAX_SAMPLES = 60 + const val ORDER_RESULT_POST_PAYMENT_OBSERVATION_MS = 30_000L const val ORDER_RESULT_MAX_EMPTY_SAMPLES = 15 const val ORDER_RESULT_WECHAT_RESTORE_MAX_SAMPLES = 15 const val ORDER_RESULT_PAYMENT_POST_BACK_MAX_SAMPLES = 25 diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseLiveAutomationTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseLiveAutomationTest.kt index a1521d9..8b91c9b 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseLiveAutomationTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseLiveAutomationTest.kt @@ -18,6 +18,283 @@ import org.junit.Assert.assertTrue import org.junit.Test class PurchaseLiveAutomationTest { + @Test + fun `payment back observes late complete order beyond original sample budget`() { + val driver = OrderObservationDriver { sample -> + when { + sample == 1 -> observationPage("payment") + sample < 100 -> observationPage("loading") + else -> observationPage("complete") + } + } + val order = observationAutomation(driver).readOrderResult() + + assertEquals("PDD-DEMO-365", order?.orderNo) + assertEquals(100, driver.captures) + assertEquals(1, driver.backs) + assertEquals(0, driver.swipes) + assertEquals(0, driver.clicks) + assertEquals(0, driver.foregroundRequests) + } + + @Test + fun `deadline starts at late successful back and never resets on incomplete evidence or jitter`() { + val driver = OrderObservationDriver { sample -> + observationPage(when { + sample < 58 -> "loading" + sample == 58 -> "payment" + sample > 60 && sample % 3 == 0 -> "incomplete" + else -> "loading" + }) + }.apply { backDuration = 4_000; captureDuration = 25 } + val automation = observationAutomation(driver, pauseOverhead = 17) + + assertEquals(null, automation.readOrderResult()) + assertTrue(driver.captures > 100) + assertEquals(driver.backCompletedAt!! + 30_000 + 17, driver.elapsed) + assertTrue(automation.lastOrderReadFailure!!.paymentPageObserved) + assertObservationActions(driver, backs = 1) + } + + @Test + fun `capture that crosses deadline cannot accept late complete evidence or trigger unpaid scroll`() { + for (latePage in listOf("complete", "unpaid", "entry", "chooser", "wechat")) { + lateinit var driver: OrderObservationDriver + driver = OrderObservationDriver { sample -> + if (sample == 1) { + driver.captureDuration = 30_000 + observationPage("payment") + } else observationPage(latePage) + } + val automation = observationAutomation(driver) + + assertEquals(null, automation.readOrderResult()) + assertEquals(2, driver.captures) + assertEquals(30_500L, driver.elapsed) + assertObservationActions(driver, backs = 1) + } + } + + @Test + fun `slow captures exhaust fixed observation deadline before original sixty samples`() { + val driver = OrderObservationDriver { sample -> observationPage(if (sample == 1) "payment" else "loading") } + .apply { captureDuration = 1_000 } + val automation = observationAutomation(driver) + + assertEquals(null, automation.readOrderResult()) + assertTrue(driver.captures < 60) + assertTrue(driver.elapsed >= driver.backCompletedAt!! + 30_000) + assertTrue(driver.elapsed < driver.backCompletedAt!! + 31_000) + assertObservationActions(driver, backs = 1) + } + + @Test + fun `incomplete context first appearing during extension stays passive until late time arrives`() { + val driver = OrderObservationDriver { sample -> observationPage(when { + sample == 1 -> "payment" + sample < 80 -> "loading" + sample < 100 -> "incomplete" + else -> "complete" + }) } + + assertEquals("PDD-DEMO-365", observationAutomation(driver).readOrderResult()?.orderNo) + assertEquals(100, driver.captures) + assertObservationActions(driver, backs = 1) + } + + @Test + fun `late detail entry chooser and WeChat cannot add navigation during extension`() { + for (latePage in listOf("entry", "chooser", "wechat")) { + val driver = OrderObservationDriver { sample -> observationPage(when { + sample == 1 -> "payment" + sample <= 60 -> "loading" + else -> latePage + }) } + val automation = observationAutomation(driver) + assertEquals(null, automation.readOrderResult()) + assertEquals("PURCHASE_ORDER_CONTEXT_NOT_FOUND", automation.lastOrderReadFailure?.code) + assertTrue(driver.captures > 60) + assertObservationActions(driver, backs = 1) + } + } + + @Test + fun `previous chooser and WeChat recovery retain repeat failures during extension`() { + for (kind in listOf("chooser", "wechat")) { + val driver = OrderObservationDriver { sample -> observationPage(when { + sample == 1 || sample > 60 -> kind + sample == 2 -> "payment" + else -> "loading" + }) } + val automation = observationAutomation(driver) + assertEquals(null, automation.readOrderResult()) + assertEquals( + if (kind == "chooser") "PURCHASE_ORDER_CHOOSER_BACK_FAILED" else "PURCHASE_ORDER_WECHAT_RESTORE_TIMEOUT", + automation.lastOrderReadFailure?.code, + ) + assertEquals(61, driver.captures) + assertEquals(if (kind == "chooser") 2 else 1, driver.backs) + assertEquals(if (kind == "wechat") 1 else 0, driver.foregroundRequests) + assertEquals(0, driver.swipes) + assertEquals(0, driver.clicks) + } + } + + @Test + fun `late unpaid context retains its independent thirty samples and four swipes`() { + val driver = OrderObservationDriver { sample -> observationPage(when { + sample == 1 -> "payment" + sample < 140 -> "loading" + else -> "unpaid" + }) } + val automation = observationAutomation(driver) + + assertEquals(null, automation.readOrderResult()) + assertEquals(169, driver.captures) + assertTrue(driver.elapsed > 30_000) + assertObservationActions(driver, backs = 1, swipes = 4) + } + + @Test + fun `unpaid markers alone never activate payment back extension`() { + val driver = OrderObservationDriver { observationPage("unpaid") } + val automation = observationAutomation(driver) + + assertEquals(null, automation.readOrderResult()) + assertEquals(30, driver.captures) + assertTrue(automation.lastOrderReadFailure!!.paymentPageObserved) + assertObservationActions(driver, swipes = 4) + } + + @Test + fun `missing payment back retains original sixty samples and existing context scroll budget`() { + for (kind in listOf("loading", "incomplete")) { + val driver = OrderObservationDriver { observationPage(kind) } + assertEquals(null, observationAutomation(driver).readOrderResult()) + assertEquals(60, driver.captures) + assertObservationActions(driver, swipes = if (kind == "incomplete") 3 else 0) + } + } + + @Test + fun `order context within original budget retains original sampling and gestures after back`() { + val driver = OrderObservationDriver { sample -> observationPage(if (sample == 1) "payment" else "incomplete") } + assertEquals(null, observationAutomation(driver).readOrderResult()) + assertEquals(60, driver.captures) + assertObservationActions(driver, backs = 1, swipes = 3) + } + + @Test + fun `failed payment back stops immediately without activating observation`() { + val driver = OrderObservationDriver { observationPage("payment") }.apply { backSucceeds = false } + val automation = observationAutomation(driver) + assertEquals(null, automation.readOrderResult()) + assertEquals("PURCHASE_ORDER_PAYMENT_BACK_FAILED", automation.lastOrderReadFailure?.code) + assertEquals(1, driver.captures) + assertEquals(0L, driver.elapsed) + assertObservationActions(driver, backs = 1) + } + + @Test + fun `extension preserves repeated payment empty and unexpected app early failures`() { + for ((kind, code, expectedCaptures) in listOf( + Triple("payment", "PURCHASE_ORDER_PAYMENT_REPEATED", 85), + Triple("empty", "PURCHASE_ORDER_EMPTY_TIMEOUT", 75), + Triple("unexpected", "PURCHASE_ORDER_UNEXPECTED_APP", 61), + )) { + val driver = OrderObservationDriver { sample -> observationPage(when { + sample == 1 -> "payment" + sample <= 60 -> "loading" + else -> kind + }) } + val automation = observationAutomation(driver) + assertEquals(null, automation.readOrderResult()) + assertEquals(code, automation.lastOrderReadFailure?.code) + assertEquals(expectedCaptures, driver.captures) + assertObservationActions(driver, backs = 1) + } + } + + private fun observationAutomation(driver: OrderObservationDriver, pauseOverhead: Long = 0) = PurchaseLiveAutomation( + driver, + pause = { driver.elapsed += it + pauseOverhead }, + monotonicClockMs = { driver.elapsed }, + ) + + private fun assertObservationActions(driver: OrderObservationDriver, backs: Int = 0, swipes: Int = 0) { + assertEquals(backs, driver.backs) + assertEquals(swipes, driver.swipes) + assertEquals(0, driver.clicks) + assertEquals(0, driver.foregroundRequests) + } + + private class OrderObservationDriver( + val frame: (Int) -> UiSnapshot, + ) : PurchaseUiDriver by LiveDriver() { + var elapsed = 0L + var captures = 0 + var backs = 0 + var swipes = 0 + var clicks = 0 + var foregroundRequests = 0 + var captureDuration = 0L + var backDuration = 0L + var backCompletedAt: Long? = null + var backSucceeds = true + override fun capture(): UiSnapshot { + elapsed += captureDuration + return frame(++captures) + } + override fun backPurchase(): Boolean { + backs++ + elapsed += backDuration + if (backSucceeds) backCompletedAt = elapsed + return backSucceeds + } + override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean { + swipes++ + return true + } + override fun clickFresh(target: SnapshotNode): FreshActionResult { + clicks++ + return FreshActionResult.SUCCESS + } + override fun bringPddToForeground(): Boolean { + foregroundRequests++ + return true + } + } + + private fun observationPage(kind: String): UiSnapshot { + val labels = when (kind) { + "payment" -> listOf("立即支付") + "complete" -> listOf("订单号:PDD-DEMO-365", "下单时间:2026-10-08 11:21:43", "立即支付") + "incomplete" -> listOf("订单号:PDD-DEMO-365", "立即支付") + "unpaid" -> listOf("待付款") + "entry" -> listOf("查看订单") + "chooser" -> listOf("选择要使用的应用", "微信") + else -> emptyList() + } + return UiSnapshot( + when (kind) { + "empty" -> null + "wechat" -> "com.tencent.mm" + "unexpected" -> "com.example.unexpected" + "chooser" -> "android" + else -> PDD + }, + when (kind) { + "payment" -> "com.xunmeng.pinduoduo.app_pay.core.PayActivity" + "chooser" -> "com.android.internal.app.ChooserActivity" + else -> ACTIVITY + }, + labels.map { text -> + SnapshotNode(text, null, text, null, null, "android.widget.TextView", + NodeBounds(0, 100, 500, 180), true, false, false, false, true, true) + }, + ) + } + @Test fun `two second payment transition and two needed scrolls yield order and payable amount without clicks`() { val driver = ReadOnlyOrderDriver(paymentMs = 2500)