fix(android): exit photo search layer before returning home #250

This commit is contained in:
QiuSW
2026-09-09 15:23:41 +08:00
parent c292e58f75
commit fe321b668c
4 changed files with 108 additions and 20 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 83
versionName = "0.9.70"
versionCode = 84
versionName = "0.9.71"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -5,31 +5,50 @@ internal class PddCollectionHomeNavigator(
private val driver: PddCollectorDriver,
private val pause: (Long) -> Unit = Thread::sleep,
) {
var reason: String = "not_started"
private set
var actions: Int = 0
private set
private fun finish(value: String, success: Boolean = false): Boolean {
reason = value
return success
}
fun returnHome(rule: CollectionRule): Boolean = runCatching {
actions = 0
var screen = driver.capture()
repeat(MAX_ACTIONS) {
if (!safe(screen)) return false
if (isHome(screen)) return true
if (!safe(screen)) return finish("unsafe_page")
if (isHome(screen)) return finish("home_confirmed", true)
val before = signature(screen)
val home = homeTab(screen)
val photoSearch = isPhotoSearchLayer(screen)
val home = if (photoSearch) null else homeTab(screen)
actions++
if (home != null) {
if (driver.clickFresh(home) != FreshActionResult.SUCCESS) return false
if (driver.clickFresh(home) != FreshActionResult.SUCCESS) return finish("home_click_failed")
} else {
val parsed = rule.collector?.let { PddScreenParser.parse(screen, it, "", rule.pageEvidence) }
val knownProduct = parsed != null && parsed.pageEvidenceMatched &&
(parsed.specPanelOpen || parsed.specEntry != null)
if (!knownProduct && topBack(screen) == null) return false
if (!driver.back()) return false
if (!photoSearch && !knownProduct && topBack(screen) == null) {
actions--
return finish("unknown_page")
}
if (!driver.back()) return finish("back_failed")
}
// Read after each individual action; never send a sequence of blind Back events.
pause(500)
screen = driver.capture()
if (!safe(screen)) return false
if (isHome(screen)) return true
if (signature(screen) == before) return false
for (read in 1..3) {
pause(500)
screen = driver.capture()
if (!safe(screen)) return finish("unsafe_page")
if (isHome(screen)) return finish("home_confirmed", true)
if (signature(screen) != before) break
}
if (signature(screen) == before) return finish(if (photoSearch) "search_back_unchanged" else "page_unchanged")
}
false
}.getOrDefault(false)
finish("action_limit")
}.getOrElse { finish("navigation_exception") }
private fun topBack(screen: UiSnapshot): SnapshotNode? {
val height = screen.nodes.maxOfOrNull { it.bounds.bottom } ?: return null
@@ -44,6 +63,15 @@ internal class PddCollectionHomeNavigator(
const val MAX_ACTIONS = 4
private const val PDD = "com.xunmeng.pinduoduo"
internal fun isPhotoSearchLayer(screen: UiSnapshot): Boolean {
val nodes = screen.nodes.filter { it.visible && it.enabled }
val height = nodes.maxOfOrNull { it.bounds.bottom } ?: return false
val width = nodes.maxOfOrNull { it.bounds.right } ?: return false
if (height <= 0 || width <= 0) return false
return nodes.any { it.label == "拍照搜索" && it.className == "android.widget.FrameLayout" &&
!it.clickable && it.bounds.height >= height * 4 / 5 && it.bounds.width >= width * 4 / 5 }
}
internal fun safe(screen: UiSnapshot): Boolean = screen.packageName == PDD &&
!screen.activityName.isNullOrBlank() &&
PddPageClassifier.classify(screen.packageName, screen.activityName, screen.nodes.filter { it.visible }.map { it.label }) == null &&
@@ -63,7 +91,7 @@ internal class PddCollectionHomeNavigator(
}
internal fun isHome(screen: UiSnapshot): Boolean {
if (!safe(screen)) return false
if (!safe(screen) || isPhotoSearchLayer(screen)) return false
val home = homeTab(screen) ?: return false
val height = screen.nodes.maxOfOrNull { it.bounds.bottom } ?: return false
return home.selected && screen.nodes.any { it.visible && it.enabled && it.clickable &&
@@ -667,11 +667,12 @@ class AgentForegroundService : Service() {
cancelIdleReturn("连续采集保留 PDD 页面")
ContinuousCollectionPolicy.finish(continuous, outcome) {
val driver = GoAutoAccessibilityService.instance ?: return@finish false
val home = PddCollectionHomeNavigator(driver).returnHome(RuleParser.parse(initialTask.ruleSnapshot))
val navigator = PddCollectionHomeNavigator(driver)
val home = navigator.returnHome(RuleParser.parse(initialTask.ruleSnapshot))
runCatching {
val hash = java.security.MessageDigest.getInstance("SHA-256")
.digest(initialTask.ruleSnapshot.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) }
Log.i("GoAutoCollector", "task=${initialTask.taskId};attempt=${initialTask.attemptNumber};device=${stateStore.read().deviceId};rule=$hash;continuousHome=$home")
Log.i("GoAutoCollector", "task=${initialTask.taskId};attempt=${initialTask.attemptNumber};device=${stateStore.read().deviceId};rule=$hash;continuousHome=$home;reason=${navigator.reason};actions=${navigator.actions}")
}
home
}.also { result ->
@@ -109,6 +109,60 @@ class ContinuousCollectionTest {
assertFalse(PddCollectionHomeNavigator(failure, {}).returnHome(rule))
}
@Test fun `photo search layer takes priority over underlying home tabs`() {
val search = photoSearch(home())
assertFalse(PddCollectionHomeNavigator.isHome(search))
val d = Driver(listOf(search, home(selected = false), home()))
val navigator = PddCollectionHomeNavigator(d, {})
assertTrue(navigator.returnHome(rule))
assertEquals(listOf("capture", "back", "capture", "click", "capture"), d.calls)
assertEquals("home_confirmed", navigator.reason)
assertEquals(2, navigator.actions)
}
@Test fun `search without labelled back returns but ordinary camera entry does not`() {
val d = Driver(listOf(photoSearch(page()), home()))
assertTrue(PddCollectionHomeNavigator(d, {}).returnHome(rule))
assertEquals(1, d.backs)
assertFalse(PddCollectionHomeNavigator.isPhotoSearchLayer(home()))
}
@Test fun `unchanged search waits bounded time without repeated back`() {
val d = Driver(listOf(photoSearch(page())))
val waits = mutableListOf<Long>()
val navigator = PddCollectionHomeNavigator(d, { waits += it })
assertFalse(navigator.returnHome(rule))
assertEquals("search_back_unchanged", navigator.reason)
assertEquals(1, d.backs)
assertEquals(listOf(500L, 500L, 500L), waits)
}
@Test fun `delayed search exit is read again without another navigation action`() {
val search = photoSearch(page())
val d = Driver(listOf(search, search, home())).apply { advanceOnCapture = true }
val navigator = PddCollectionHomeNavigator(d, {})
assertTrue(navigator.returnHome(rule))
assertEquals(1, d.backs)
assertEquals(listOf("capture", "back", "capture", "capture"), d.calls)
}
@Test fun `search overlay never bypasses risky page or foreground guard`() {
for (screen in listOf(photoSearch(page(node("risk", "订单详情"))),
photoSearch(page()).copy(packageName = "other"))) {
val d = Driver(listOf(screen))
val navigator = PddCollectionHomeNavigator(d, {})
assertFalse(navigator.returnHome(rule))
assertEquals("unsafe_page", navigator.reason)
assertEquals(0, d.backs + d.clicks)
}
val d = Driver(listOf(photoSearch(page()), home().copy(packageName = "other")))
assertFalse(PddCollectionHomeNavigator(d, {}).returnHome(rule))
assertEquals(1, d.backs)
}
private fun photoSearch(base: UiSnapshot) = base.copy(nodes = base.nodes +
node("search-layer", "拍照搜索").copy(className = "android.widget.FrameLayout", bounds = NodeBounds(0, 0, 1080, 2354)))
private fun node(path: String, label: String, y: Int = 30, selected: Boolean = false, clickable: Boolean = false, parent: String? = null) =
SnapshotNode(path, parent, label, null, null, "android.widget.TextView", NodeBounds(20, y, 120, y + 40),
clickable, false, selected, false, true, true)
@@ -130,12 +184,17 @@ class ContinuousCollectionTest {
var backs = 0
var clicks = 0
var throwOnBack = false
var advanceOnCapture = false
val calls = mutableListOf<String>()
override fun capture(): UiSnapshot { calls += "capture"; return screens[index.coerceAtMost(screens.lastIndex)] }
override fun capture(): UiSnapshot {
calls += "capture"
return screens[index.coerceAtMost(screens.lastIndex)].also { if (advanceOnCapture) index++ }
}
override fun back(): Boolean {
calls += "back"; backs++
if (throwOnBack) error("simulated")
index++; return true
if (!advanceOnCapture) index++
return true
}
override fun clickFresh(target: SnapshotNode): FreshActionResult { calls += "click"; clicks++; index++; return FreshActionResult.SUCCESS }
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?) = error("unexpected swipe")