From 2c58d5817cda45243d2f4f5925fe7e6e87598e21 Mon Sep 17 00:00:00 2001
From: QiuSW <105186638@qq.com>
Date: Fri, 9 Oct 2026 14:44:47 +0800
Subject: [PATCH] feat(android): add bounded navigation diagnostics without
purchase actions (#373)
---
android/app/build.gradle.kts | 2 +-
android/app/src/debug/AndroidManifest.xml | 5 +-
.../diagnostics/BrowserProbeClickPolicy.kt | 9 ++
.../agent/diagnostics/NavigationProbe.kt | 94 +++++++++++++++
.../diagnostics/NavigationProbeReceiver.kt | 109 ++++++++++++++++++
.../agent/automation/PddLaunchFallback.kt | 24 +++-
.../goauto/agent/automation/PddNavigation.kt | 8 +-
.../agent/persistence/AgentDiagnosticStore.kt | 7 ++
.../agent/service/AgentForegroundService.kt | 40 ++++++-
.../goauto/agent/PddLaunchFallbackTest.kt | 33 ++++++
.../agent/BrowserProbeClickPolicyTest.kt | 25 ++++
.../goauto/agent/NavigationProbeTest.kt | 107 +++++++++++++++++
12 files changed, 454 insertions(+), 9 deletions(-)
create mode 100644 android/app/src/debug/java/cn/ilapage/goauto/agent/diagnostics/BrowserProbeClickPolicy.kt
create mode 100644 android/app/src/debug/java/cn/ilapage/goauto/agent/diagnostics/NavigationProbe.kt
create mode 100644 android/app/src/debug/java/cn/ilapage/goauto/agent/diagnostics/NavigationProbeReceiver.kt
create mode 100644 android/app/src/testDebug/java/cn/ilapage/goauto/agent/BrowserProbeClickPolicyTest.kt
create mode 100644 android/app/src/testDebug/java/cn/ilapage/goauto/agent/NavigationProbeTest.kt
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 4effa7a..af2f678 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -31,7 +31,7 @@ android {
buildTypes {
debug {
- versionNameSuffix = "-370-diag1"
+ versionNameSuffix = "-373-nav-diag3"
}
release {
isMinifyEnabled = false
diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml
index 36f3d2c..7059f45 100644
--- a/android/app/src/debug/AndroidManifest.xml
+++ b/android/app/src/debug/AndroidManifest.xml
@@ -1,4 +1,7 @@
-
+
+
+
diff --git a/android/app/src/debug/java/cn/ilapage/goauto/agent/diagnostics/BrowserProbeClickPolicy.kt b/android/app/src/debug/java/cn/ilapage/goauto/agent/diagnostics/BrowserProbeClickPolicy.kt
new file mode 100644
index 0000000..ac3192f
--- /dev/null
+++ b/android/app/src/debug/java/cn/ilapage/goauto/agent/diagnostics/BrowserProbeClickPolicy.kt
@@ -0,0 +1,9 @@
+package cn.ilapage.goauto.agent.diagnostics
+
+internal object BrowserProbeClickPolicy {
+ fun allowed(rootPackage: String?, nodePackage: String?, label: String, visible: Boolean,
+ enabled: Boolean, clickable: Boolean, rootWindow: Int, nodeWindow: Int): Boolean =
+ rootPackage in setOf("com.heytap.browser", "com.android.chrome", "com.android.browser") &&
+ nodePackage == rootPackage && label in setOf("打开拼多多APP", "打开拼多多 App") &&
+ visible && enabled && clickable && rootWindow >= 0 && nodeWindow == rootWindow
+}
diff --git a/android/app/src/debug/java/cn/ilapage/goauto/agent/diagnostics/NavigationProbe.kt b/android/app/src/debug/java/cn/ilapage/goauto/agent/diagnostics/NavigationProbe.kt
new file mode 100644
index 0000000..1491597
--- /dev/null
+++ b/android/app/src/debug/java/cn/ilapage/goauto/agent/diagnostics/NavigationProbe.kt
@@ -0,0 +1,94 @@
+package cn.ilapage.goauto.agent.diagnostics
+
+import cn.ilapage.goauto.agent.automation.*
+import cn.ilapage.goauto.agent.automation.SnapshotNode as UiSnapshotNode
+
+internal class NavigationProbe(
+ private val capture: () -> UiSnapshot,
+ private val launch: ((PddLaunchEvent) -> Unit) -> Boolean,
+ private val clickBrowserEntry: (UiSnapshotNode) -> FreshClickOutcome,
+ private val now: () -> Long,
+ private val pause: (Long) -> Unit,
+ private val emit: (String) -> Unit,
+ private val windowId: () -> Int? = { null },
+ private val isActive: () -> Boolean = { true },
+) {
+ fun run() {
+ val started = now()
+ fun canAct() = isActive() && !Thread.currentThread().isInterrupted && now() - started < 20_000
+ if (!canAct()) { emit("stopped=inactive_or_expired"); return }
+ val before = capture()
+ if (!canAct()) { emit("stopped=inactive_or_expired"); return }
+ fun problem(page: UiSnapshot) = PddPageClassifier.classify(page.packageName, page.activityName,
+ page.nodes.filter { it.visible }.map { it.label }) != null
+ if (problem(before)) { emit("stopped=page_problem"); return }
+ val initial = PddScreenParser.parse(before, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
+ // Values remain in memory. Neither text nor its hash is logged/persisted.
+ val oldTitle = initial.summary.title
+ val oldWindow = windowId()
+ val initiallyPdd = before.packageName == "com.xunmeng.pinduoduo"
+ emit("baseline;pdd=$initiallyPdd;root=${initial.rootAvailable};product=${initiallyPdd && initial.hasPurchaseProductEvidence()};title=${oldTitle != null};window=$oldWindow")
+ if (!canAct()) { emit("stopped=inactive_or_expired"); return }
+ if (!launch { event ->
+ emit("launch;t=${now() - started};path=${event.path};status=${event.status};exception=${event.exceptionClass ?: "none"}")
+ }) { emit("stopped=launch_failed"); return }
+ var stable = 0
+ var legacyAccepted = false
+ var browserClicks = 0
+ var nextBrowserClick = 0L
+ var polls = 0
+ var lastSignal: String? = null
+ while (canAct() && polls < 201) {
+ val readStarted = now()
+ val page = capture()
+ if (!canAct()) { emit("stopped=inactive_or_expired;t=${now() - started}"); return }
+ if (problem(page)) { emit("stopped=page_problem;t=${now() - started}"); return }
+ val screen = PddScreenParser.parse(page, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
+ val product = page.packageName == "com.xunmeng.pinduoduo" && screen.hasPurchaseProductEvidence()
+ stable = if (product) stable + 1 else 0
+ val titleComparison = when {
+ oldTitle == null || screen.summary.title == null -> "unknown"
+ oldTitle == screen.summary.title -> "same"
+ else -> "different"
+ }
+ val category = when (page.packageName) {
+ "com.xunmeng.pinduoduo" -> "pdd"
+ in BROWSERS -> "browser"
+ null -> "none"
+ else -> "other"
+ }
+ val signal = "pkg=$category;root=${screen.rootAvailable};product=$product;panel=${screen.specPanelOpen};entry=${screen.specEntry != null};nodes=${page.nodes.size};title=$titleComparison;window=${windowId()}"
+ if (signal != lastSignal || polls % 10 == 0) {
+ emit("sample;t=${now() - started};readMs=${now() - readStarted};$signal")
+ lastSignal = signal
+ }
+ // Observation only: never treats this as proof of goods identity or calls the executor.
+ if (stable >= 2 && !legacyAccepted) {
+ legacyAccepted = true
+ emit("legacy_would_accept;t=${now() - started};title=$titleComparison;identityVerified=false")
+ }
+ if (category == "browser") {
+ val entries = page.nodes.filter { it.visible && it.enabled && it.label in OPEN_PDD_ALIASES }
+ if (entries.size > 1) { emit("stopped=browser_ambiguous"); return }
+ if (entries.size == 1 && browserClicks < 3 && now() >= nextBrowserClick) {
+ if (!canAct()) { emit("stopped=inactive_or_expired"); return }
+ val result = clickBrowserEntry(entries.single())
+ browserClicks++
+ emit("browser_click;t=${now() - started};attempt=$browserClicks;result=${result.result};reason=${result.reason}")
+ if (result.result == FreshActionResult.AMBIGUOUS || result.result == FreshActionResult.BLOCKED) {
+ emit("stopped=browser_click_unsafe"); return
+ }
+ nextBrowserClick = now() + 1_000
+ }
+ }
+ polls++
+ pause(100)
+ }
+ emit("finished;t=${now() - started};samples=$polls;legacyAccepted=$legacyAccepted;identityVerified=false")
+ }
+
+ companion object {
+ private val BROWSERS = setOf("com.heytap.browser", "com.android.chrome", "com.android.browser")
+ private val OPEN_PDD_ALIASES = setOf("打开拼多多APP", "打开拼多多 App")
+ }
+}
diff --git a/android/app/src/debug/java/cn/ilapage/goauto/agent/diagnostics/NavigationProbeReceiver.kt b/android/app/src/debug/java/cn/ilapage/goauto/agent/diagnostics/NavigationProbeReceiver.kt
new file mode 100644
index 0000000..77d5ba0
--- /dev/null
+++ b/android/app/src/debug/java/cn/ilapage/goauto/agent/diagnostics/NavigationProbeReceiver.kt
@@ -0,0 +1,109 @@
+package cn.ilapage.goauto.agent.diagnostics
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.ContextWrapper
+import android.content.Intent
+import android.os.SystemClock
+import android.util.Log
+import android.view.accessibility.AccessibilityNodeInfo
+import cn.ilapage.goauto.agent.automation.GoAutoAccessibilityService
+import cn.ilapage.goauto.agent.automation.PddLinkLauncher
+import cn.ilapage.goauto.agent.automation.FreshClickOutcome
+import cn.ilapage.goauto.agent.automation.FreshActionResult
+import cn.ilapage.goauto.agent.automation.FreshClickReason
+import cn.ilapage.goauto.agent.service.AgentForegroundService
+import java.util.UUID
+
+/** Debug APK only; manifest requires the shell/system DUMP permission. No arbitrary URL or action. */
+class NavigationProbeReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ val goodsId = intent.getStringExtra("goodsId").orEmpty()
+ val mode = intent.getStringExtra("mode").orEmpty()
+ if (!goodsId.matches(Regex("[1-9][0-9]{0,19}")) || mode !in setOf("direct", "browser")) {
+ Log.i(TAG, "rejected=invalid_arguments")
+ return
+ }
+ val service = AgentForegroundService.diagnosticInstance
+ val accessibility = GoAutoAccessibilityService.instance
+ if (service == null || accessibility == null) {
+ Log.i(TAG, "rejected=service_unavailable")
+ return
+ }
+ val runId = UUID.randomUUID().toString()
+ val accepted = service.runNavigationProbe { serviceContext ->
+ // Delayed execution must not use a destroyed accessibility instance.
+ if (GoAutoAccessibilityService.instance !== accessibility) {
+ Log.i(TAG, "run=$runId;stopped=accessibility_changed")
+ return@runNavigationProbe
+ }
+ Log.i(TAG, "run=$runId;started;mode=$mode;synthetic=true;noBusinessTask=true")
+ val deadline = SystemClock.elapsedRealtime() + 20_000
+ val canAct = { AgentForegroundService.diagnosticInstance === service &&
+ GoAutoAccessibilityService.instance === accessibility && !Thread.currentThread().isInterrupted &&
+ SystemClock.elapsedRealtime() < deadline }
+ val guardedContext = object : ContextWrapper(serviceContext) {
+ override fun startActivity(intent: Intent) {
+ // Includes direct-failure fallback and package-manager Binder delays.
+ check(canAct()) { "probe_inactive" }
+ super.startActivity(intent)
+ }
+ }
+ NavigationProbe(
+ capture = {
+ check(canAct()) { "probe_inactive" }
+ accessibility.capture()
+ },
+ launch = { report -> canAct() && PddLinkLauncher(guardedContext).open(
+ "https://mobile.yangkeduo.com/goods.html?goods_id=$goodsId", mode == "direct", report,
+ ) },
+ clickBrowserEntry = { safeBrowserClick(accessibility, canAct) },
+ now = SystemClock::elapsedRealtime,
+ pause = { Thread.sleep(it) },
+ emit = { Log.i(TAG, "run=$runId;$it") },
+ windowId = { accessibility.rootInActiveWindow?.let { root ->
+ try { root.windowId } finally { @Suppress("DEPRECATION") root.recycle() }
+ } },
+ isActive = canAct,
+ ).run()
+ }
+ if (!accepted) Log.i(TAG, "run=$runId;rejected=device_busy")
+ }
+
+ /** Fresh native node only: no ancestors, gestures, coordinates, or PDD-package actions. */
+ private fun safeBrowserClick(service: GoAutoAccessibilityService, canAct: () -> Boolean): FreshClickOutcome {
+ fun blocked() = FreshClickOutcome(FreshActionResult.BLOCKED, FreshClickReason.TARGET_NOT_FOUND)
+ if (!canAct()) return blocked()
+ val root = service.rootInActiveWindow ?: return blocked()
+ val nodes = mutableListOf()
+ fun visit(node: AccessibilityNodeInfo) {
+ nodes += node
+ if (nodes.size >= 2_000 || !canAct()) return
+ for (i in 0 until node.childCount) {
+ if (nodes.size >= 2_000 || !canAct()) break
+ node.getChild(i)?.let(::visit)
+ }
+ }
+ try {
+ visit(root)
+ if (nodes.size >= 2_000 || !canAct()) return blocked()
+ val candidates = nodes.filter { node ->
+ val label = node.text?.toString()?.trim().takeUnless { it.isNullOrEmpty() }
+ ?: node.contentDescription?.toString()?.trim().orEmpty()
+ BrowserProbeClickPolicy.allowed(root.packageName?.toString(), node.packageName?.toString(), label,
+ node.isVisibleToUser, node.isEnabled, node.isClickable, root.windowId, node.windowId)
+ }
+ if (candidates.size != 1) return blocked()
+ val current = service.rootInActiveWindow ?: return blocked()
+ val sameWindow = try { current.windowId == root.windowId && current.packageName == root.packageName }
+ finally { @Suppress("DEPRECATION") current.recycle() }
+ if (!sameWindow || !canAct()) return blocked()
+ val clicked = candidates.single().performAction(AccessibilityNodeInfo.ACTION_CLICK)
+ return FreshClickOutcome(if (clicked) FreshActionResult.SUCCESS else FreshActionResult.FAILED,
+ if (clicked) FreshClickReason.SUCCESS else FreshClickReason.ACTION_CLICK_FALSE)
+ } finally {
+ nodes.forEach { @Suppress("DEPRECATION") it.recycle() }
+ }
+ }
+ private companion object { const val TAG = "GoAutoNavProbe" }
+}
diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddLaunchFallback.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddLaunchFallback.kt
index 533bdf5..31825fc 100644
--- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddLaunchFallback.kt
+++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddLaunchFallback.kt
@@ -1,8 +1,26 @@
package cn.ilapage.goauto.agent.automation
+enum class PddLaunchPath { DIRECT, BROWSER }
+enum class PddLaunchStatus { ACCEPTED, REJECTED, EXCEPTION }
+
+/** No URL, exception message, or page content may enter this event. */
+data class PddLaunchEvent(val path: PddLaunchPath, val status: PddLaunchStatus, val exceptionClass: String? = null)
+
internal object PddLaunchFallback {
- fun open(preferDirect: Boolean, direct: () -> Boolean, browser: () -> Boolean): Boolean {
- if (preferDirect && runCatching(direct).getOrDefault(false)) return true
- return runCatching(browser).getOrDefault(false)
+ fun open(preferDirect: Boolean, direct: () -> Boolean, browser: () -> Boolean,
+ diagnostic: (PddLaunchEvent) -> Unit = {}): Boolean {
+ fun attempt(path: PddLaunchPath, launch: () -> Boolean): Boolean {
+ val result = runCatching(launch)
+ val event = PddLaunchEvent(path, when {
+ result.isFailure -> PddLaunchStatus.EXCEPTION
+ result.getOrDefault(false) -> PddLaunchStatus.ACCEPTED
+ else -> PddLaunchStatus.REJECTED
+ }, result.exceptionOrNull()?.javaClass?.name)
+ // Observability must never change routing or turn an accepted launch into a retry.
+ runCatching { diagnostic(event) }
+ return result.getOrDefault(false)
+ }
+ if (preferDirect && attempt(PddLaunchPath.DIRECT, direct)) return true
+ return attempt(PddLaunchPath.BROWSER, browser)
}
}
diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddNavigation.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddNavigation.kt
index a8b9625..6248208 100644
--- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddNavigation.kt
+++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddNavigation.kt
@@ -31,7 +31,7 @@ object PddPageClassifier {
}
class PddLinkLauncher(private val context: Context) {
- fun open(url: String, preferDirect: Boolean = false): Boolean {
+ fun open(url: String, preferDirect: Boolean = false, diagnostic: (PddLaunchEvent) -> Unit = {}): Boolean {
val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return false
if (uri.scheme !in setOf("http", "https") || !isPddHost(uri.host) || uri.getQueryParameter("goods_id").isNullOrBlank()) {
return false
@@ -42,7 +42,7 @@ class PddLinkLauncher(private val context: Context) {
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
context.startActivity(direct)
true
- }, browser = { openBrowser(uri) })
+ }, browser = { openBrowser(uri) }, diagnostic = diagnostic)
}
private fun openBrowser(uri: Uri): Boolean {
@@ -51,7 +51,9 @@ class PddLinkLauncher(private val context: Context) {
runCatching { context.packageManager.getPackageInfo(packageName, 0) }.isSuccess
}
if (browser != null) base.setPackage(browser)
- return runCatching { context.startActivity(base); true }.getOrDefault(false)
+ // The outer fallback boundary retains the previous false-on-error behavior and records only the class.
+ context.startActivity(base)
+ return true
}
private fun isPddHost(host: String?): Boolean =
diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt
index 0b18a06..27224a3 100644
--- a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt
+++ b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt
@@ -7,6 +7,7 @@ import android.database.sqlite.SQLiteOpenHelper
import cn.ilapage.goauto.agent.BuildConfig
enum class AgentDiagnosticStage {
+ PRODUCT_NAVIGATION,
SPEC_ENTRY_CLICK,
SPEC_ENTRY_GESTURE,
QUICK_CONFIRMATION_CLICK,
@@ -24,6 +25,12 @@ enum class AgentDiagnosticStage {
}
enum class AgentDiagnosticReason {
+ NAV_DIRECT_ACCEPTED,
+ NAV_DIRECT_REJECTED,
+ NAV_DIRECT_EXCEPTION,
+ NAV_BROWSER_ACCEPTED,
+ NAV_BROWSER_REJECTED,
+ NAV_BROWSER_EXCEPTION,
SIZE_ADVICE_CLICK_BLOCKED,
DETAIL_ENTRY_MATCHED,
SPEC_PANEL_OPENED,
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 a38c531..c84644a 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
@@ -154,6 +154,7 @@ class AgentForegroundService : Service() {
// Separate, serialized I/O ticks keep history reads and result observation off the
// heartbeat scheduler and, especially, off the single purchase execution thread.
repurchaseExecutor.scheduleWithFixedDelay(::tickRepurchase, 2, 2, TimeUnit.SECONDS)
+ diagnosticInstance = this
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -197,6 +198,7 @@ class AgentForegroundService : Service() {
}
override fun onDestroy() {
+ if (diagnosticInstance === this) diagnosticInstance = null
repurchaseClosed.set(true)
repurchase.requestStop(repurchase.state.roundId)
repurchaseExecutor.shutdownNow()
@@ -220,6 +222,29 @@ class AgentForegroundService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
+ /** Shell-protected debug receiver only. Never claims/resets a server task or invokes a purchase executor. */
+ internal fun runNavigationProbe(probe: (Context) -> Unit): Boolean {
+ if (!BuildConfig.DEBUG) return false
+ val reservation = Long.MAX_VALUE - 2
+ val acquired = synchronized(taskMutex) {
+ !repurchaseClosed.get() && repurchaseLocalIdle() && !repurchaseState.busy &&
+ activeCollectionCooldown() == null && taskMutex.tryAcquire(reservation)
+ }
+ if (!acquired) return false
+ cancelIdleReturn("导航诊断")
+ try {
+ taskExecutor.execute {
+ try { probe(this) }
+ catch (error: Exception) { Log.w("GoAutoNavProbe", "probe_exception=${error.javaClass.name}") }
+ finally { taskMutex.release(reservation) }
+ }
+ } catch (error: java.util.concurrent.RejectedExecutionException) {
+ taskMutex.release(reservation)
+ return false
+ }
+ return true
+ }
+
private fun triggerSync() {
if (!synchronized(taskMutex) { working.compareAndSet(false, true) }) return
executor.execute {
@@ -783,7 +808,18 @@ class AgentForegroundService : Service() {
} else {
PurchaseRehearsalExecutor(
driver = accessibility,
- openLink = { PddLinkLauncher(this).open(it, preferDirect = true) },
+ openLink = { url ->
+ val navigationStarted = SystemClock.elapsedRealtime()
+ PddLinkLauncher(this).open(url, preferDirect = true) { event ->
+ probeDiagnostic(AgentDiagnosticEvent(
+ taskId = task.taskId,
+ stage = AgentDiagnosticStage.PRODUCT_NAVIGATION,
+ reason = AgentDiagnosticReason.valueOf("NAV_${event.path}_${event.status}"),
+ elapsedMs = SystemClock.elapsedRealtime() - navigationStarted,
+ targetClassName = event.exceptionClass,
+ ))
+ }
+ },
probeSpecs = { collectPurchaseProbe(accessibility, task, parsedRule, probeDiagnostic) },
probeClickDiagnostic = { stage, target, outcome ->
probeDiagnostic(specClickDiagnostic(task.taskId, stage, target, outcome))
@@ -1586,6 +1622,8 @@ class AgentForegroundService : Service() {
}
companion object {
+ @Volatile internal var diagnosticInstance: AgentForegroundService? = null
+ private set
const val ACTION_REPURCHASE_PREPARE = "cn.ilapage.goauto.agent.REPURCHASE_PREPARE"
const val ACTION_REPURCHASE_CONFIRM = "cn.ilapage.goauto.agent.REPURCHASE_CONFIRM"
const val ACTION_REPURCHASE_STOP = "cn.ilapage.goauto.agent.REPURCHASE_STOP"
diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PddLaunchFallbackTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PddLaunchFallbackTest.kt
index 31811bc..3bedb1c 100644
--- a/android/app/src/test/java/cn/ilapage/goauto/agent/PddLaunchFallbackTest.kt
+++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PddLaunchFallbackTest.kt
@@ -1,10 +1,43 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.PddLaunchFallback
+import cn.ilapage.goauto.agent.automation.PddLaunchEvent
+import cn.ilapage.goauto.agent.automation.PddLaunchPath
+import cn.ilapage.goauto.agent.automation.PddLaunchStatus
import org.junit.Assert.*
import org.junit.Test
class PddLaunchFallbackTest {
+ @Test fun reportsDirectAcceptanceWithoutCallingBrowser() {
+ val events = mutableListOf()
+ assertTrue(PddLaunchFallback.open(true, { true }, { error("unexpected") }, events::add))
+ assertEquals(listOf(PddLaunchEvent(PddLaunchPath.DIRECT, PddLaunchStatus.ACCEPTED)), events)
+ }
+ @Test fun reportsOnlyExceptionClassThenBrowserAcceptance() {
+ val events = mutableListOf()
+ assertTrue(PddLaunchFallback.open(true, { throw SecurityException("secret URL and address") }, { true }, events::add))
+ assertEquals(listOf(
+ PddLaunchEvent(PddLaunchPath.DIRECT, PddLaunchStatus.EXCEPTION, "java.lang.SecurityException"),
+ PddLaunchEvent(PddLaunchPath.BROWSER, PddLaunchStatus.ACCEPTED),
+ ), events)
+ assertFalse(events.toString().contains("secret"))
+ }
+ @Test fun reportsFalseAndBrowserExceptionWithoutChangingResult() {
+ val events = mutableListOf()
+ assertFalse(PddLaunchFallback.open(true, { false }, { throw IllegalStateException("private") }, events::add))
+ assertEquals(PddLaunchStatus.REJECTED, events[0].status)
+ assertEquals(PddLaunchStatus.EXCEPTION, events[1].status)
+ }
+ @Test fun diagnosticFailureDoesNotCauseBrowserFallbackOrChangeResult() {
+ var browserCalls = 0
+ assertTrue(PddLaunchFallback.open(true, { true }, { browserCalls++; true }, { error("storage failed") }))
+ assertEquals(0, browserCalls)
+ }
+ @Test fun browserOnlyReportsNoDirectAttempt() {
+ val events = mutableListOf()
+ assertFalse(PddLaunchFallback.open(false, { error("unexpected") }, { false }, events::add))
+ assertEquals(listOf(PddLaunchEvent(PddLaunchPath.BROWSER, PddLaunchStatus.REJECTED)), events)
+ }
@Test fun directSuccessDoesNotStartBrowser() {
assertTrue(PddLaunchFallback.open(true, { true }, { error("browser must not start") }))
}
diff --git a/android/app/src/testDebug/java/cn/ilapage/goauto/agent/BrowserProbeClickPolicyTest.kt b/android/app/src/testDebug/java/cn/ilapage/goauto/agent/BrowserProbeClickPolicyTest.kt
new file mode 100644
index 0000000..8cee10d
--- /dev/null
+++ b/android/app/src/testDebug/java/cn/ilapage/goauto/agent/BrowserProbeClickPolicyTest.kt
@@ -0,0 +1,25 @@
+package cn.ilapage.goauto.agent
+
+import cn.ilapage.goauto.agent.diagnostics.BrowserProbeClickPolicy
+import org.junit.Assert.*
+import org.junit.Test
+
+class BrowserProbeClickPolicyTest {
+ @Test fun onlyExactNativeBrowserEntryIsAllowed() {
+ assertTrue(BrowserProbeClickPolicy.allowed("com.heytap.browser", "com.heytap.browser", "打开拼多多APP", true, true, true, 3, 3))
+ }
+ @Test fun rejectsPackageSwitchHiddenDisabledNonClickableAndStaleWindow() {
+ fun allowed(root: String? = "com.heytap.browser", node: String? = root, label: String = "打开拼多多APP",
+ visible: Boolean = true, enabled: Boolean = true, clickable: Boolean = true, window: Int = 3) =
+ BrowserProbeClickPolicy.allowed(root, node, label, visible, enabled, clickable, 3, window)
+ assertFalse(allowed(root = "com.xunmeng.pinduoduo"))
+ assertFalse(allowed(node = "com.xunmeng.pinduoduo"))
+ assertFalse(allowed(root = null))
+ assertFalse(allowed(label = "打开"))
+ assertFalse(allowed(label = "立即支付"))
+ assertFalse(allowed(visible = false))
+ assertFalse(allowed(enabled = false))
+ assertFalse(allowed(clickable = false))
+ assertFalse(allowed(window = 4))
+ }
+}
diff --git a/android/app/src/testDebug/java/cn/ilapage/goauto/agent/NavigationProbeTest.kt b/android/app/src/testDebug/java/cn/ilapage/goauto/agent/NavigationProbeTest.kt
new file mode 100644
index 0000000..6838950
--- /dev/null
+++ b/android/app/src/testDebug/java/cn/ilapage/goauto/agent/NavigationProbeTest.kt
@@ -0,0 +1,107 @@
+package cn.ilapage.goauto.agent
+
+import cn.ilapage.goauto.agent.automation.*
+import cn.ilapage.goauto.agent.diagnostics.NavigationProbe
+import org.junit.Assert.*
+import org.junit.Test
+
+class NavigationProbeTest {
+ @Test fun cancellationDuringInitialCapturePreventsLaunch() {
+ var active = true
+ var launches = 0
+ val page = UiSnapshot("com.xunmeng.pinduoduo", null, emptyList())
+ NavigationProbe(capture = { active = false; page }, launch = { launches++; true },
+ clickBrowserEntry = { error("no click") }, now = { 0 }, pause = {}, emit = {},
+ isActive = { active }).run()
+ assertEquals(0, launches)
+ }
+ @Test fun deadlineCrossedDuringCapturePreventsLaunch() {
+ var clock = 0L
+ var launches = 0
+ NavigationProbe(capture = { clock = 20_001; UiSnapshot(null, null, emptyList()) },
+ launch = { launches++; true }, clickBrowserEntry = { error("no click") }, now = { clock },
+ pause = {}, emit = {}).run()
+ assertEquals(0, launches)
+ }
+ @Test fun cancellationDuringBrowserCapturePreventsClick() {
+ var active = true
+ var reads = 0
+ var clock = 0L
+ var clicks = 0
+ val page = UiSnapshot("com.heytap.browser", null, listOf(node("打开拼多多APP")))
+ NavigationProbe(capture = { reads++; if (reads > 1) active = false; page }, launch = { true },
+ clickBrowserEntry = { clicks++; FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS) },
+ now = { clock }, pause = { clock += it }, emit = {}, isActive = { active }).run()
+ assertEquals(0, clicks)
+ }
+ @Test fun baselineUsesActualPackageRatherThanOptionalParserEvidence() {
+ val rig = Rig(UiSnapshot("com.heytap.browser", null, listOf(node("发起拼单"))))
+ rig.run()
+ assertTrue(rig.lines.first().contains("pdd=false"))
+ assertFalse(rig.lines.any { it.contains("legacy_would_accept") })
+ }
+ private class Rig(var page: UiSnapshot) {
+ var clock = 0L
+ var launches = 0
+ var clicks = 0
+ val lines = mutableListOf()
+ fun run(accepted: Boolean = true) = NavigationProbe(
+ capture = { page },
+ launch = { report -> launches++; report(PddLaunchEvent(PddLaunchPath.DIRECT, PddLaunchStatus.ACCEPTED)); accepted },
+ clickBrowserEntry = { clicks++; FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS) },
+ now = { clock }, pause = { clock += it }, emit = lines::add,
+ ).run()
+ }
+ private fun node(text: String, path: String = "0") = SnapshotNode(path, null, text, null, null,
+ "android.widget.TextView", NodeBounds(0, 0, 100, 100), true, false, false, false, true, true)
+
+ @Test fun pddIsReadOnlyAndSamplingStopsWithinBound() {
+ val rig = Rig(UiSnapshot("com.xunmeng.pinduoduo", null, listOf(node("立即下单"))))
+ rig.run()
+ assertEquals(1, rig.launches)
+ assertEquals(0, rig.clicks)
+ assertEquals(20_000L, rig.clock)
+ assertTrue(rig.lines.last().contains("finished"))
+ }
+ @Test fun failedLaunchStopsWithoutClicking() {
+ val rig = Rig(UiSnapshot("com.xunmeng.pinduoduo", null, emptyList()))
+ rig.run(false)
+ assertEquals(1, rig.launches)
+ assertEquals(0L, rig.clock)
+ assertEquals(0, rig.clicks)
+ assertTrue(rig.lines.last().contains("launch_failed"))
+ }
+ @Test fun browserRequiresUniqueExplicitPddEntryAndBoundedRetries() {
+ val rig = Rig(UiSnapshot("com.heytap.browser", null, listOf(node("打开拼多多APP"))))
+ rig.run()
+ assertEquals(3, rig.clicks)
+ }
+ @Test fun ambiguousBrowserEntryStopsWithoutGuessing() {
+ val rig = Rig(UiSnapshot("com.heytap.browser", null, listOf(node("打开拼多多APP"), node("打开拼多多APP", "1"))))
+ rig.run()
+ assertEquals(0, rig.clicks)
+ assertTrue(rig.lines.last().contains("browser_ambiguous"))
+ }
+ @Test fun genericOpenAndPddControlsAreNeverClicked() {
+ for (pkg in listOf("com.heytap.browser", "com.xunmeng.pinduoduo", "other.package")) {
+ val rig = Rig(UiSnapshot(pkg, null, listOf(node("打开"), node("立即支付"), node("确认订单"))))
+ rig.run()
+ assertEquals(0, rig.clicks)
+ assertTrue(rig.lines.isNotEmpty())
+ }
+ }
+ @Test fun loginOrCaptchaStopsBeforeLaunch() {
+ val rig = Rig(UiSnapshot("com.xunmeng.pinduoduo", null, listOf(node("手机号登录"))))
+ rig.run()
+ assertEquals(0, rig.launches)
+ assertEquals(0, rig.clicks)
+ assertTrue(rig.lines.last().contains("page_problem"))
+ }
+ @Test fun pageTextAndExceptionMessagesNeverAppearInOutput() {
+ val secret = "PRIVATE_ACCOUNT_ADDRESS_DO_NOT_LOG"
+ val rig = Rig(UiSnapshot("com.xunmeng.pinduoduo", null, listOf(node(secret))))
+ rig.run()
+ assertTrue(rig.lines.isNotEmpty())
+ assertFalse(rig.lines.joinToString().contains(secret))
+ }
+}