diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9d010d6..2ae7790 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "cn.ilapage.goauto.agent" minSdk = 23 targetSdk = 34 - versionCode = 41 - versionName = "0.9.28" + versionCode = 42 + versionName = "0.9.29" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 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 9fcee48..e457f2a 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 @@ -106,14 +106,22 @@ class PurchaseLiveAutomation( } } - /** Only reads the current PDD order/payment flow; the sole navigation is one safe Back. */ + /** Reads the order flow with bounded chooser/payment Back actions and one verified PDD foreground request. */ fun readOrderResult(): PurchaseOrderEvidence? { val labels = linkedSetOf() var backedOutOfPayment = false var backedOutOfChooser = false var restoredFromWechat = false - repeat(60) { index -> + var consecutiveEmptySnapshots = 0 + repeat(ORDER_RESULT_MAX_SAMPLES) { index -> val snapshot = driver.capture() + if (snapshot.packageName.isNullOrBlank()) { + consecutiveEmptySnapshots++ + if (consecutiveEmptySnapshots >= ORDER_RESULT_MAX_EMPTY_SAMPLES) return null + pause(ORDER_RESULT_SAMPLE_INTERVAL_MS) + return@repeat + } + consecutiveEmptySnapshots = 0 val currentLabels = snapshot.nodes.filter { it.visible }.map { it.label } if (isKnownAndroidWechatChooser(snapshot, currentLabels)) { if (backedOutOfChooser || !driver.backPurchase()) return null @@ -128,10 +136,15 @@ class PurchaseLiveAutomation( return@repeat } if (snapshot.packageName != PDD_PACKAGE) return null - currentLabels.forEach(labels::add) - parseOrderEvidence(labels)?.let { return it } val paymentVisible = isKnownPddPaymentActivity(snapshot) || currentLabels.any { label -> PAYMENT_MARKERS.any(label::contains) } + val orderContextVisible = currentLabels.any { label -> ORDER_CONTEXT_MARKERS.any(label::contains) } + if (!paymentVisible && !orderContextVisible) { + pause(ORDER_RESULT_SAMPLE_INTERVAL_MS) + return@repeat + } + currentLabels.forEach(labels::add) + parseOrderEvidence(labels)?.let { return it } if (paymentVisible && !backedOutOfPayment) { backedOutOfPayment = true if (!driver.backPurchase()) return null @@ -141,7 +154,7 @@ class PurchaseLiveAutomation( } else if (index > 0 && index % 15 == 0) { driver.swipePurchase(SwipeDirection.UP, 400) } - pause(200) + pause(ORDER_RESULT_SAMPLE_INTERVAL_MS) } return null } @@ -357,6 +370,7 @@ class PurchaseLiveAutomation( val FINAL_SUBMIT_MARKERS = listOf("提交订单", "现在买,仅", "确认购买") val PAYMENT_MARKERS = listOf("立即支付", "确认支付", "输入支付密码") val UNPAID_MARKERS = listOf("待付款", "待支付", "去支付") + val ORDER_CONTEXT_MARKERS = listOf("订单编号", "订单号", "下单时间", "创建时间") val ORDER_NO = Regex("(?:订单编号|订单号)\\s*[::]?\\s*([A-Za-z0-9-]{6,64})") val ORDER_TIME = Regex("(?:下单时间|创建时间)\\s*[::]?\\s*(20[0-9]{2}[-/.年][0-9]{1,2}[-/.月][0-9]{1,2}日?\\s+[0-9]{1,2}:[0-9]{2}(?::[0-9]{2})?)") val ANDROID_CHOOSER_PACKAGES = setOf("android", "com.android.intentresolver") @@ -367,5 +381,8 @@ class PurchaseLiveAutomation( val ANDROID_CHOOSER_ACTIVITY_SUFFIXES = listOf(".ChooserActivity", ".ResolverActivity") 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_MAX_EMPTY_SAMPLES = 15 + const val ORDER_RESULT_SAMPLE_INTERVAL_MS = 200L } } diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt index cb074f1..80b88ba 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt @@ -16,6 +16,7 @@ interface PurchaseUiDriver { fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean fun pullDownGoodsPage(): Boolean = swipePurchase(SwipeDirection.DOWN, 550) fun backPurchase(): Boolean + /** Requests the existing PDD task stack in the foreground; callers must verify the observed package afterwards. */ fun bringPddToForeground(): Boolean = false } 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 dd0c11b..52b6530 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 @@ -138,6 +138,69 @@ class PurchaseLiveAutomationTest { assertFalse(driver.clicked.any { it.contains("微信") || it.contains("登录") || it.contains("支付") }) } + @Test + fun `empty accessibility transitions wait for WeChat and verify delayed PDD foreground`() { + val driver = LiveDriver( + postSubmitCaptureSequence = listOf("empty", "wechat-login", "empty", "payment", "order"), + ) + val automation = PurchaseLiveAutomation(driver, pause = {}) + val address = automation.updateShippingAddress("_cg29") + automation.finalConfirmation(input().copy(addressSuffix = "_cg29"), address) + automation.submitOrderOnce() + + val order = automation.readOrderResult() + + assertEquals("PDD-202608210001", order?.orderNo) + assertEquals(1, driver.pddRestoreCount) + assertEquals(1, driver.postSubmitBackCount) + assertEquals("order", driver.currentPage) + assertFalse(driver.clicked.any { it.contains("微信") || it.contains("登录") || it.contains("支付") }) + } + + @Test + fun `continuous empty accessibility transition has a fixed sample limit`() { + val driver = LiveDriver(postSubmitCaptureSequence = List(15) { "empty" }) + val automation = PurchaseLiveAutomation(driver, pause = {}) + val address = automation.updateShippingAddress("_cg30") + automation.finalConfirmation(input().copy(addressSuffix = "_cg30"), address) + automation.submitOrderOnce() + + assertEquals(null, automation.readOrderResult()) + assertEquals(15, driver.postSubmitCaptureCount) + assertEquals(0, driver.pddRestoreCount) + assertEquals(0, driver.postSubmitBackCount) + assertEquals(0, driver.genericSwipes) + } + + @Test + fun `stable unknown application still stops reconciliation immediately`() { + val driver = LiveDriver(postSubmitCaptureSequence = listOf("unknown")) + val automation = PurchaseLiveAutomation(driver, pause = {}) + val address = automation.updateShippingAddress("_cg31") + automation.finalConfirmation(input().copy(addressSuffix = "_cg31"), address) + automation.submitOrderOnce() + + assertEquals(null, automation.readOrderResult()) + assertEquals(1, driver.postSubmitCaptureCount) + assertEquals(0, driver.pddRestoreCount) + assertEquals(0, driver.postSubmitBackCount) + assertEquals(0, driver.genericSwipes) + } + + @Test + fun `PDD home after WeChat restore is never scrolled as order context`() { + val driver = LiveDriver(postSubmitCaptureSequence = listOf("wechat-login", "pdd-home")) + val automation = PurchaseLiveAutomation(driver, pause = {}) + val address = automation.updateShippingAddress("_cg32") + automation.finalConfirmation(input().copy(addressSuffix = "_cg32"), address) + automation.submitOrderOnce() + + assertEquals(null, automation.readOrderResult()) + assertEquals(1, driver.pddRestoreCount) + assertEquals(0, driver.postSubmitBackCount) + assertEquals(0, driver.genericSwipes) + } + @Test fun `WeChat still foreground after one restore stays result unknown without gestures`() { val driver = LiveDriver(wechatLoginAfterSubmit = true, wechatRestoreStuck = true) @@ -212,8 +275,11 @@ class PurchaseLiveAutomationTest { private val savedTransitionHidesSuffix: Boolean = false, private val wechatLoginAfterSubmit: Boolean = false, private val wechatRestoreStuck: Boolean = false, + postSubmitCaptureSequence: List = emptyList(), ) : PurchaseUiDriver { private var page = "confirmation" + private val sequencedPostSubmit = postSubmitCaptureSequence.isNotEmpty() + private val pendingPostSubmitPages = postSubmitCaptureSequence.toMutableList() private var addressVisible = !addressClipped private var address = "广东省广州市天园街道骏景花园骏晖轩1202-old" val clicked = mutableListOf() @@ -225,9 +291,18 @@ class PurchaseLiveAutomationTest { var backCount = 0 var postSubmitBackCount = 0 var pddRestoreCount = 0 + var postSubmitCaptureCount = 0 val currentPage: String get() = page - override fun capture(): UiSnapshot = when (page) { + override fun capture(): UiSnapshot { + if (submitClicks > 0) { + postSubmitCaptureCount++ + if (pendingPostSubmitPages.isNotEmpty()) page = pendingPostSubmitPages.removeAt(0) + } + return currentSnapshot() + } + + private fun currentSnapshot(): UiSnapshot = when (page) { "panel" -> snapshot(listOf( node("title", "收货地址"), node("modify-parent", "", clickable = true), @@ -254,6 +329,9 @@ class PurchaseLiveAutomationTest { )) "payment" -> UiSnapshot(PDD, "com.xunmeng.pinduoduo.app_pay.core.PayActivity", listOf(node("pay", "立即支付", clickable = true))) "wechat-login" -> UiSnapshot("com.tencent.mm", "com.tencent.mm.plugin.account.ui.SimpleLoginUI", listOf(node("wechat-login", "微信登录"))) + "empty" -> UiSnapshot(null, null, emptyList()) + "unknown" -> UiSnapshot("example.untrusted", "example.untrusted.UnknownActivity", emptyList()) + "pdd-home" -> UiSnapshot(PDD, "com.xunmeng.pinduoduo.ui.activity.MainFrameActivity", listOf(node("home", "拼多多首页"))) else -> { val nodes = mutableListOf( node("root", "", bounds = NodeBounds(0, 0, 1080, 2200)), @@ -333,7 +411,7 @@ class PurchaseLiveAutomationTest { override fun bringPddToForeground(): Boolean { pddRestoreCount++ - if (!wechatRestoreStuck) page = "payment" + if (!wechatRestoreStuck && !sequencedPostSubmit) page = "payment" return true }