diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 3164439..f9f5eb8 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 = 72 - versionName = "0.9.59" + versionCode = 73 + versionName = "0.9.60" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt index 3f8bedb..58ff028 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt @@ -434,8 +434,16 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto } else FreshActionResult.FAILED } + private var lastPurchaseSwipeFailure = PurchaseSwipeFailureReason.UNKNOWN + + override fun purchaseSwipeFailureReason(): PurchaseSwipeFailureReason = lastPurchaseSwipeFailure + override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean { - val root = rootInActiveWindow ?: return false + lastPurchaseSwipeFailure = PurchaseSwipeFailureReason.UNKNOWN + val root = rootInActiveWindow ?: run { + lastPurchaseSwipeFailure = PurchaseSwipeFailureReason.ROOT_UNAVAILABLE + return false + } val candidates = mutableListOf() walk(root) { node -> if (node.isVisibleToUser && node.isScrollable) candidates += node } val horizontal = direction == SwipeDirection.LEFT || direction == SwipeDirection.RIGHT @@ -444,8 +452,12 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto } val target = (directional.ifEmpty { candidates }).maxByOrNull { candidate -> Rect().also(candidate::getBoundsInScreen).let { it.width().toLong() * it.height() } - } ?: return false - return swipeNode(target, direction, durationMs, preferScrollAction = false) + } ?: run { + lastPurchaseSwipeFailure = PurchaseSwipeFailureReason.NO_SCROLLABLE + return false + } + return swipeNode(target, direction, durationMs, preferScrollAction = false, + onFailure = { lastPurchaseSwipeFailure = it }) } override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean { @@ -646,16 +658,21 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto preferScrollAction: Boolean = true, downStartPercent: Int = 25, downEndPercent: Int = 75, + onFailure: (PurchaseSwipeFailureReason) -> Unit = {}, ): Boolean { + fun failed(reason: PurchaseSwipeFailureReason): Boolean { + onFailure(reason) + return false + } val bounds = Rect().also(node::getBoundsInScreen) - if (bounds.width() < 2 || bounds.height() < 2) return false + if (bounds.width() < 2 || bounds.height() < 2) return failed(PurchaseSwipeFailureReason.INVALID_BOUNDS) val scrollAction = if (direction == SwipeDirection.UP || direction == SwipeDirection.LEFT) { AccessibilityNodeInfo.ACTION_SCROLL_FORWARD } else { AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD } if (preferScrollAction && node.performAction(scrollAction)) return true - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return false + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return failed(PurchaseSwipeFailureReason.GESTURE_UNSUPPORTED) val left = bounds.left + bounds.width() * 25 / 100 val right = bounds.left + bounds.width() * 75 / 100 val top = bounds.top + bounds.height() * 25 / 100 @@ -693,8 +710,9 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto }, null, ) - if (!queued) return false - return latch.await(1500, TimeUnit.MILLISECONDS) && completed.get() + if (!queued) return failed(PurchaseSwipeFailureReason.GESTURE_REJECTED) + if (!latch.await(1500, TimeUnit.MILLISECONDS)) return failed(PurchaseSwipeFailureReason.GESTURE_TIMEOUT) + return completed.get() || failed(PurchaseSwipeFailureReason.GESTURE_CANCELLED) } private fun dispatchCenterTap(bounds: Rect): Boolean { 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 aa484fa..cf39d3e 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 @@ -3,6 +3,11 @@ package cn.ilapage.goauto.agent.automation import java.net.URI import java.net.URLDecoder +enum class PurchaseSwipeFailureReason { + UNKNOWN, ROOT_UNAVAILABLE, NO_SCROLLABLE, INVALID_BOUNDS, GESTURE_UNSUPPORTED, + GESTURE_REJECTED, GESTURE_CANCELLED, GESTURE_TIMEOUT, +} + interface PurchaseUiDriver { fun capture(): UiSnapshot fun clickFresh(target: SnapshotNode): FreshActionResult @@ -32,6 +37,7 @@ interface PurchaseUiDriver { fun specRowSwipeFailureReason(): String = "gestureFailed" fun inputFresh(target: SnapshotNode, value: String): FreshActionResult fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean + fun purchaseSwipeFailureReason(): PurchaseSwipeFailureReason = PurchaseSwipeFailureReason.UNKNOWN fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean fun pullDownGoodsPage(): Boolean = swipePurchase(SwipeDirection.DOWN, 550) fun backPurchase(): Boolean @@ -1094,16 +1100,17 @@ class PurchaseRehearsalExecutor( private fun applyPostAction(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? { if (action.waitAfterMs > 0) pause(action.waitAfterMs) action.swipeAfter?.let { swipe -> - // The stock purchase rule asks to reveal additional selector rows after - // opening the sheet. A fully-evidenced non-scrollable selector has no - // scroll target, and treating that absence as an action failure blocks - // an otherwise safe exact-spec flow. Keep all other configured swipes - // mandatory; this exception is limited to that confirmed panel state. - if (action.type == PurchaseActionType.OPEN_SPEC_PANEL && - currentScreen(input).specPanelType == SpecPanelType.NON_SCROLLABLE_CONFIRMATION - ) return null + // Legacy rules prescribe a blind scroll immediately after opening. + // Opening has already been verified; probe/select owns any necessary + // bounded scrolling. Do not let an unnecessary gesture block either + // phase, or move already-visible exact specs out of the viewport. + if (action.type == PurchaseActionType.OPEN_SPEC_PANEL) { + panelDiagnostic("postSwipe=skipped;action=${action.type.wireName};reason=spec_panel_on_demand;panel=${currentScreen(input).specPanelType.name}") + return null + } repeat(swipe.count) { index -> if (!driver.swipePurchase(swipe.direction, swipe.durationMs)) { + panelDiagnostic("postSwipe=failed;action=${action.type.wireName};direction=${swipe.direction.name};swipeIndex=${index + 1};reason=${driver.purchaseSwipeFailureReason().name.lowercase()}") return failure("RULE_ACTION_FAILED", "规则要求的有限滑动失败") } if (index < swipe.count - 1 && swipe.intervalMs > 0) pause(swipe.intervalMs) diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt index d9d6068..5afaaec 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt @@ -478,6 +478,8 @@ class AgentForegroundService : Service() { if (accessibility == null) { PurchaseExecutionOutcome("failed", "ACCESSIBILITY_NOT_READY", "GoAuto 无障碍服务未开启") } else { + val diagnosticDeviceId = runCatching { identityStore.credentials()?.deviceId ?: 0L }.getOrDefault(0L) + val diagnosticAttempt = task.taskAttemptId.takeIf { it.matches(Regex("^[a-zA-Z0-9-]{1,80}$")) } ?: "invalid" PurchaseRehearsalExecutor( driver = accessibility, openLink = { PddLinkLauncher(this).open(it, preferDirect = true) }, @@ -486,7 +488,9 @@ class AgentForegroundService : Service() { lastStep.set(step) purchaseStore.updateStep(task.taskId, task.taskAttemptId, step) }, - panelDiagnostic = { evidence -> Log.i("GoAutoPurchasePanel", "task=${task.taskId};$evidence") }, + panelDiagnostic = { evidence -> + Log.i("GoAutoPurchasePanel", "task=${task.taskId};attempt=$diagnosticAttempt;device=$diagnosticDeviceId;rule=$snapshotHash;$evidence") + }, beforeOrderSubmit = { evidence -> val boundaryRequestId = UUID.randomUUID().toString() val finalEvidence = JSONObject() diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt index e6faabe..de6e7f5 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt @@ -9,6 +9,7 @@ import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput import cn.ilapage.goauto.agent.automation.PurchaseRehearsalExecutor import cn.ilapage.goauto.agent.automation.PurchaseRuleParser import cn.ilapage.goauto.agent.automation.PurchaseSpecGesturePolicy +import cn.ilapage.goauto.agent.automation.PurchaseSwipeFailureReason import cn.ilapage.goauto.agent.automation.PurchaseUiDriver import cn.ilapage.goauto.agent.automation.RuleValidationException import cn.ilapage.goauto.agent.automation.SnapshotNode @@ -98,7 +99,7 @@ class PurchaseRehearsalExecutorTest { } @Test - fun `rule parameters drive aliases waits and bounded swipes without dangerous clicks`() { + fun `rule parameters drive aliases and waits while deprecated panel swipes are ignored`() { val driver = FakePurchaseDriver() val pauses = mutableListOf() var openCount = 0 @@ -112,7 +113,7 @@ class PurchaseRehearsalExecutorTest { assertEquals("rehearsal_completed", outcome.resultType) assertEquals(2_000L, outcome.actualUnitPriceCent) assertEquals(0, openCount) - assertEquals(2, driver.swipeCount) + assertEquals(0, driver.swipeCount) assertEquals(2L, driver.quantity) assertTrue(driver.clicked.containsAll(listOf("选择规格", "黑色", "XL"))) assertFalse(driver.clicked.any { it.contains("订单") || it.contains("支付") }) @@ -764,7 +765,7 @@ class PurchaseRehearsalExecutorTest { } @Test - fun `open spec panel skips required follow-up swipe only for confirmed non-scrollable panel`() { + fun `open spec panel still skips legacy follow-up swipe for confirmed non-scrollable panel`() { val driver = FakePurchaseDriver(nonScrollablePanel = true, purchaseSwipeSucceeds = false) val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) @@ -773,6 +774,86 @@ class PurchaseRehearsalExecutorTest { assertEquals(0, driver.swipeCount) } + @Test + fun `legacy panel preswipe cannot block visible specs in probe or purchase phase`() { + for (nonScrollable in listOf(false, true)) { + for (phase in listOf("spec_probe", "purchase")) { + val driver = FakePurchaseDriver(nonScrollablePanel = nonScrollable, purchaseSwipeSucceeds = false) + val diagnostics = mutableListOf() + val pauses = mutableListOf() + var probeCount = 0 + val raw = rule().replace("\"type\":\"openSpecPanel\"", "\"type\":\"openSpecPanel\",\"waitAfterMs\":321") + val outcome = PurchaseRehearsalExecutor( + driver, { true }, { probeCount++; "{}" }, pause = pauses::add, + panelDiagnostic = diagnostics::add, + ).execute(input().copy(phase = phase), PurchaseRuleParser.parse(raw), PurchaseAgentCapabilities.supported) + assertEquals(outcome.message, if (phase == "spec_probe") "spec_probe_completed" else "rehearsal_completed", outcome.resultType) + assertEquals(0, driver.swipeCount) + assertTrue(pauses.contains(321L)) + assertFalse(pauses.contains(1000L)) + assertTrue(diagnostics.any { it.contains("reason=spec_panel_on_demand") }) + assertEquals(if (phase == "spec_probe") 1 else 0, probeCount) + assertEquals(if (phase == "spec_probe") 0 else 1, driver.clicked.count { it == "黑色" }) + assertFalse(driver.clicked.any { it.contains("订单") || it.contains("支付") }) + } + } + } + + @Test + fun `non panel mandatory swipes still execute and fail closed`() { + val raw = rule().replace("\"type\":\"selectSpec\"", "\"type\":\"selectSpec\",\"swipeAfter\":{\"direction\":\"up\",\"count\":2,\"durationMs\":500,\"intervalMs\":1000}") + for (succeeds in listOf(false, true)) { + val driver = FakePurchaseDriver(purchaseSwipeSucceeds = succeeds) + val diagnostics = mutableListOf() + val pauses = mutableListOf() + val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = pauses::add, panelDiagnostic = diagnostics::add) + .execute(input(), PurchaseRuleParser.parse(raw), PurchaseAgentCapabilities.supported) + assertEquals(if (succeeds) "rehearsal_completed" else "failed", outcome.resultType) + assertEquals(if (succeeds) 2 else 1, driver.swipeCount) + assertEquals(succeeds, pauses.contains(1000L)) + if (!succeeds) { + assertEquals("RULE_ACTION_FAILED", outcome.errorCode) + assertTrue(diagnostics.any { it.contains("action=selectSpec") && it.contains("reason=unknown") }) + } + } + } + + @Test + fun `quick and order confirmation selectors also skip legacy panel preswipe`() { + for (kind in listOf("QUICK_CONFIRMATION", "ORDER_CONFIRMATION")) { + val driver = FakePurchaseDriver(confirmationPanelKind = kind, purchaseSwipeSucceeds = false) + val diagnostics = mutableListOf() + var probed = false + val outcome = PurchaseRehearsalExecutor( + driver, { true }, { probed = true; "{}" }, pause = {}, panelDiagnostic = diagnostics::add, + ).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + assertEquals(outcome.message, "spec_probe_completed", outcome.resultType) + assertTrue(probed) + assertEquals(0, driver.swipeCount) + assertTrue(diagnostics.any { it.contains("postSwipe=skipped") && it.contains("panel=$kind") }) + assertFalse(driver.clicked.any { it.contains("订单") || it.contains("支付") || it == "现在买" }) + } + } + + @Test + fun `mandatory swipe diagnostics use fixed reasons without spec text`() { + val raw = rule().replace("\"type\":\"selectSpec\"", "\"type\":\"selectSpec\",\"swipeAfter\":{\"direction\":\"up\",\"count\":1,\"durationMs\":500}") + for (reason in PurchaseSwipeFailureReason.values()) { + val driver = FakePurchaseDriver(purchaseSwipeSucceeds = false, purchaseSwipeFailure = reason) + val diagnostics = mutableListOf() + val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = diagnostics::add) + .execute(input(), PurchaseRuleParser.parse(raw), PurchaseAgentCapabilities.supported) + assertEquals("RULE_ACTION_FAILED", outcome.errorCode) + assertTrue(diagnostics.contains("postSwipe=failed;action=selectSpec;direction=UP;swipeIndex=1;reason=${reason.name.lowercase()}")) + assertFalse(diagnostics.any { it.contains("黑色") || it.contains("XL") }) + } + } + + @Test(expected = RuleValidationException::class) + fun `ignored legacy panel swipe still rejects invalid rule parameters`() { + PurchaseRuleParser.parse(rule().replace("\"count\":2", "\"count\":0")) + } + @Test fun `unrecognized opened panel returns only scalar panel evidence`() { val driver = FakePurchaseDriver(unrecognizedPanel = true) @@ -1246,8 +1327,10 @@ class PurchaseRehearsalExecutorTest { private val unavailableSizes: Set = emptySet(), allSpecsUnavailable: Boolean = false, private val nonScrollablePanel: Boolean = false, + private val confirmationPanelKind: String? = null, private val unrecognizedPanel: Boolean = false, private val purchaseSwipeSucceeds: Boolean = true, + private val purchaseSwipeFailure: PurchaseSwipeFailureReason = PurchaseSwipeFailureReason.UNKNOWN, initiallyInAgent: Boolean = false, private val panelBecomesUnknownAfterSizeProof: Boolean = false, ) : PurchaseUiDriver { @@ -1437,7 +1520,16 @@ class PurchaseRehearsalExecutorTest { nodes += if (singleHeading) node("info/quantity", quantity.toString(), 400, 360, 600, 390, className = "android.widget.EditText", parentPath = "info") else node("quantity", quantity.toString(), 400, 800, 600, 870, className = "android.widget.EditText") if (!singleHeading) nodes += node("confirm", "确定", 20, 900, 500, 980, clickable = true) - nodes += node("order", "提交订单", 20, if (singleHeading) 2000 else 1100, 500, if (singleHeading) 2080 else 1180, clickable = true) + if (confirmationPanelKind != null) { + nodes += node("close", "关闭", 980, 300, 1060, 350, clickable = true) + nodes += node("minus", "减少数量", 300, 800, 380, 870, clickable = true) + nodes += node("plus", "增加数量", 620, 800, 700, 870, clickable = true) + nodes += node("payment", "微信支付", 600, 1000, 900, 1050) + if (confirmationPanelKind == "QUICK_CONFIRMATION") { + nodes += node("quick", "现在买", 520, 2000, 1020, 2080, clickable = true) + } + } + nodes += node("order", "提交订单", 20, if (singleHeading || confirmationPanelKind != null) 2000 else 1100, 500, if (singleHeading || confirmationPanelKind != null) 2080 else 1180, clickable = true) nodes += node("pay", "立即支付", 520, 1100, 1020, 1180, clickable = true) return UiSnapshot(PDD, ACTIVITY, nodes) } @@ -1531,6 +1623,8 @@ class PurchaseRehearsalExecutorTest { return purchaseSwipeSucceeds } + override fun purchaseSwipeFailureReason(): PurchaseSwipeFailureReason = purchaseSwipeFailure + override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean { swipeInPaths += target.path if (restoreHiddenColorOnDownSwipe && quantity == 2L && direction == SwipeDirection.DOWN) {