fix(agent): replace mandatory spec panel preswipe with on-demand search (#238)
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
+25
-7
@@ -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<AccessibilityNodeInfo>()
|
||||
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 {
|
||||
|
||||
+15
-8
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<Long>()
|
||||
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<String>()
|
||||
val pauses = mutableListOf<Long>()
|
||||
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<String>()
|
||||
val pauses = mutableListOf<Long>()
|
||||
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<String>()
|
||||
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<String>()
|
||||
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<String> = 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) {
|
||||
|
||||
Reference in New Issue
Block a user