fix(android): reveal unique address entry (#193)
This commit is contained in:
+39
-2
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+29
-5
@@ -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)
|
||||
}
|
||||
|
||||
+12
@@ -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),
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user