fix(android): reveal unique address entry (#193)

This commit is contained in:
QiuSW
2026-09-01 17:41:06 +08:00
parent 9eb28c225c
commit fcb5cf5e78
4 changed files with 132 additions and 9 deletions
@@ -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<AccessibilityNodeInfo>.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<NodeBounds>): 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
}
}
@@ -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<SnapshotNode> {
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<SnapshotNode>): List<SnapshotNode> {
val byPath = snapshot.nodes.associateBy { it.path }
return nodes.mapNotNull { source ->
@@ -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)
}
@@ -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),
)))
}
}