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)) + } +}