diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 40007a1..fccd663 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddCollectionHomeNavigator.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddCollectionHomeNavigator.kt index 5cab79f..b7fcd5b 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddCollectionHomeNavigator.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddCollectionHomeNavigator.kt @@ -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 && diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt index 6aa76b3..e18405e 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt @@ -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 -> diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/ContinuousCollectionTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/ContinuousCollectionTest.kt index be54fde..a6dcf21 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/ContinuousCollectionTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/ContinuousCollectionTest.kt @@ -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() + 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() - 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")