From fcb5cf5e789de933051d0c64416908997e243898 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 1 Sep 2026 17:41:06 +0800 Subject: [PATCH] fix(android): reveal unique address entry (#193) --- .../automation/GoAutoAccessibilityService.kt | 41 +++++++++++++- .../automation/PurchaseLiveAutomation.kt | 34 ++++++++++-- .../agent/PurchaseLiveAutomationTest.kt | 54 ++++++++++++++++++- .../GoAutoAccessibilityServicePolicyTest.kt | 12 +++++ 4 files changed, 132 insertions(+), 9 deletions(-) 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 714c328..809744a 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 @@ -328,8 +328,19 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto ) candidates += node } if (candidates.isEmpty()) return FreshActionResult.NOT_FOUND - if (candidates.size != 1) return FreshActionResult.AMBIGUOUS - val bounds = Rect().also(candidates.single()::getBoundsInScreen) + val candidate = when { + candidates.size == 1 -> candidates.single() + candidates.shareClickableAncestor() || FreshTapDuplicatePolicy.isSingleVisualTarget(candidates.map { node -> + val bounds = Rect().also(node::getBoundsInScreen) + NodeBounds(bounds.left, bounds.top, bounds.right, bounds.bottom) + }) -> candidates.minBy { node -> + val bounds = Rect().also(node::getBoundsInScreen) + kotlin.math.abs(bounds.centerX() - target.bounds.centerX) + + kotlin.math.abs(bounds.centerY() - target.bounds.centerY) + } + else -> return FreshActionResult.AMBIGUOUS + } + val bounds = Rect().also(candidate::getBoundsInScreen) if (bounds.width() < 2 || bounds.height() < 2 || Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { return FreshActionResult.FAILED } @@ -617,6 +628,15 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto return latch.await(1500, TimeUnit.MILLISECONDS) && completed.get() } + private fun List.shareClickableAncestor(): Boolean { + val ancestors = map { source -> + var node: AccessibilityNodeInfo? = source.parent + while (node != null && !node.isClickable) node = node.parent + node + } + return ancestors.all { it != null } && ancestors.distinct().size == 1 + } + private fun walk(node: AccessibilityNodeInfo, visit: (AccessibilityNodeInfo) -> Unit) { visit(node) for (index in 0 until node.childCount) node.getChild(index)?.let { walk(it, visit) } @@ -665,3 +685,20 @@ internal object SpecSwipeSafety { fun preferAccessibilityScrollAction(direction: SwipeDirection): Boolean = direction == SwipeDirection.UP || direction == SwipeDirection.DOWN } + +internal object FreshTapDuplicatePolicy { + fun isSingleVisualTarget(bounds: List): Boolean { + if (bounds.isEmpty()) return false + return bounds.indices.all { first -> + ((first + 1) until bounds.size).all { second -> nearDuplicate(bounds[first], bounds[second]) } + } + } + + private fun nearDuplicate(first: NodeBounds, second: NodeBounds): Boolean { + val overlapWidth = (minOf(first.right, second.right) - maxOf(first.left, second.left)).coerceAtLeast(0) + val overlapHeight = (minOf(first.bottom, second.bottom) - maxOf(first.top, second.top)).coerceAtLeast(0) + val overlap = overlapWidth.toLong() * overlapHeight + val smaller = minOf(first.width.toLong() * first.height, second.width.toLong() * second.height) + return smaller > 0 && overlap * 100 >= smaller * 98 + } +} 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 b55e9ea..8842a56 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 @@ -43,7 +43,9 @@ class PurchaseLiveAutomation( var unchangedCount = 0 for (attempt in 0 until 5) { pageProblem(snapshot) - val entries = mergedDirect(snapshot.nodes.filter { it.visible && it.enabled && MASKED_PHONE.containsMatchIn(it.label) }) + val panel = purchasePanelScrollTargets(snapshot) + if (panel.size != 1) fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "没有找到唯一的规格面板滚动区域,未创建订单") + val entries = addressEntryTargets(snapshot, panel.single()) if (entries.size == 1) { when (driver.tapPurchaseFresh(entries.single())) { FreshActionResult.SUCCESS -> Unit @@ -56,12 +58,12 @@ class PurchaseLiveAutomation( } return editAndVerifyAddress(snapshot, addressSuffix) } - if (entries.size > 1) fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单") - val panel = purchasePanelScrollTargets(snapshot) - if (panel.size != 1) fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "没有找到唯一的规格面板滚动区域,未创建订单") val signature = viewportSignature(snapshot, panel.single()) unchangedCount = if (signature == previousSignature) unchangedCount + 1 else 0 - if (unchangedCount >= 2 || attempt == 4) break + if (unchangedCount >= 2 || attempt == 4) { + if (entries.size > 1) fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单") + break + } previousSignature = signature if (!driver.swipePurchaseIn(panel.single(), SwipeDirection.DOWN, 350)) { fail("PURCHASE_ADDRESS_PANEL_TIMEOUT", "规格面板无法下拉显示收货地址,未创建订单") @@ -294,6 +296,25 @@ class PurchaseLiveAutomation( }.distinctBy { it.path } } + private fun addressEntryTargets(snapshot: UiSnapshot, panel: SnapshotNode): List { + val candidates = snapshot.nodes.filter { node -> + node.visible && node.enabled && MASKED_PHONE.containsMatchIn(node.label) && fullyInside(node.bounds, panel.bounds) + }.distinctBy { it.path } + if (candidates.size <= 1) return candidates + val byPath = snapshot.nodes.associateBy { it.path } + val cards = candidates.map { source -> + var ancestor = source.parentPath?.let(byPath::get) + while (ancestor != null && !ancestor.clickable) ancestor = ancestor.parentPath?.let(byPath::get) + ancestor?.path?.let { it to source } + } + if (cards.all { it != null }) { + return cards.filterNotNull().groupBy({ it.first }, { it.second }).values.map { sources -> + sources.minBy { it.bounds.width.toLong() * it.bounds.height } + } + } + return mergedDirect(candidates) + } + private fun viewportSignature(snapshot: UiSnapshot, panel: SnapshotNode): String = snapshot.nodes .filter { node -> node.visible && inside(node.bounds, panel.bounds) } .joinToString("|") { node -> @@ -303,6 +324,9 @@ class PurchaseLiveAutomation( private fun inside(child: NodeBounds, parent: NodeBounds): Boolean = child.centerX in parent.left..parent.right && child.centerY in parent.top..parent.bottom + private fun fullyInside(child: NodeBounds, parent: NodeBounds): Boolean = + child.left >= parent.left && child.top >= parent.top && child.right <= parent.right && child.bottom <= parent.bottom + private fun uniqueClickable(snapshot: UiSnapshot, nodes: List): List { val byPath = snapshot.nodes.associateBy { it.path } return nodes.mapNotNull { source -> 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 1f21c1a..ff89b89 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 @@ -303,6 +303,37 @@ class PurchaseLiveAutomationTest { assertEquals(0, driver.genericSwipes) } + @Test + fun `duplicate partial address hints are scrolled before choosing the complete entry`() { + val driver = LiveDriver(addressClipped = true, duplicateAddressHintsBeforeReveal = true) + val address = PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg38") + + assertEquals("广东省广州市天园街道骏景花园骏晖轩1202_cg38", address.expectedAddress) + assertEquals(1, driver.scopedSwipes) + assertEquals(1, driver.addressTaps) + } + + @Test + fun `duplicate phone nodes in one clickable address card are treated as one entry`() { + val driver = LiveDriver(duplicateAddressNodesSameCard = true) + val address = PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg39") + + assertEquals("广东省广州市天园街道骏景花园骏晖轩1202_cg39", address.expectedAddress) + assertEquals(0, driver.scopedSwipes) + assertEquals(1, driver.addressTaps) + } + + @Test + fun `two distinct address cards remain ambiguous after bounded panel review`() { + val driver = LiveDriver(distinctAddressCards = true) + val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg40") } + .exceptionOrNull() as PurchaseLiveException + + assertEquals("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", error.code) + assertEquals(2, driver.scopedSwipes) + assertEquals(0, driver.addressTaps) + } + @Test fun `ambiguous purchase panels fail without swiping or clicking`() { val driver = LiveDriver(addressClipped = true, duplicatePanels = true) @@ -331,6 +362,9 @@ class PurchaseLiveAutomationTest { private class LiveDriver( private val addressClipped: Boolean = false, private val duplicatePanels: Boolean = false, + private val duplicateAddressHintsBeforeReveal: Boolean = false, + private val duplicateAddressNodesSameCard: Boolean = false, + private val distinctAddressCards: Boolean = false, private val chooserAfterSubmit: Boolean = false, private val trustedChooser: Boolean = true, private val splitConfirmationAddress: Boolean = false, @@ -411,7 +445,20 @@ class PurchaseLiveAutomationTest { ) if (duplicatePanels) nodes += node("panel2", "", scrollable = true, bounds = NodeBounds(0, 500, 1080, 2000)) if (addressVisible) { - nodes += node("phone", "138****5678") + when { + distinctAddressCards -> { + nodes += node("address-card-1", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 820)) + nodes += node("phone-1", "138****5678", bounds = NodeBounds(20, 650, 300, 710), parentPath = "address-card-1") + nodes += node("address-card-2", "", clickable = true, bounds = NodeBounds(0, 850, 1080, 1050)) + nodes += node("phone-2", "139****5678", bounds = NodeBounds(20, 880, 300, 940), parentPath = "address-card-2") + } + duplicateAddressNodesSameCard -> { + nodes += node("address-card", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 850)) + nodes += node("phone", "138****5678", bounds = NodeBounds(20, 650, 300, 710), parentPath = "address-card") + nodes += node("phone-duplicate", "138****5678", bounds = NodeBounds(20, 735, 300, 795), parentPath = "address-card") + } + else -> nodes += node("phone", "138****5678", bounds = NodeBounds(20, 650, 300, 710)) + } val suffixStart = address.lastIndexOf("_cg") val addressBody = if (suffixStart >= 0) address.substring(0, suffixStart) else address val addressSuffix = if (suffixStart >= 0) address.substring(suffixStart) else "" @@ -424,8 +471,11 @@ class PurchaseLiveAutomationTest { nodes += node("address-suffix-2", addressSuffix, bounds = NodeBounds(500, 780, 780, 840)) } } - else -> nodes += node("address", address) + else -> nodes += node("address", address, bounds = NodeBounds(20, 720, 900, 800)) } + } else if (duplicateAddressHintsBeforeReveal) { + nodes += node("phone-hint-1", "138****5678", bounds = NodeBounds(20, 420, 300, 480)) + nodes += node("phone-hint-2", "138****5678", bounds = NodeBounds(20, 510, 300, 570)) } snapshot(nodes) } diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityServicePolicyTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityServicePolicyTest.kt index 4cd5610..c324fac 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityServicePolicyTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityServicePolicyTest.kt @@ -18,4 +18,16 @@ class GoAutoAccessibilityServicePolicyTest { assertTrue(SpecSwipeSafety.preferAccessibilityScrollAction(SwipeDirection.UP)) assertTrue(SpecSwipeSafety.preferAccessibilityScrollAction(SwipeDirection.DOWN)) } + + @Test + fun freshTapCollapsesOnlyNodesOccupyingTheSameVisualTarget() { + assertTrue(FreshTapDuplicatePolicy.isSingleVisualTarget(listOf( + NodeBounds(20, 650, 300, 710), + NodeBounds(20, 650, 300, 710), + ))) + assertFalse(FreshTapDuplicatePolicy.isSingleVisualTarget(listOf( + NodeBounds(20, 650, 300, 710), + NodeBounds(20, 850, 300, 910), + ))) + } }