diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 27035da..f73ead9 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 = 19 - versionName = "0.9.6" + versionCode = 20 + versionName = "0.9.7" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/ActivityEvidenceTracker.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/ActivityEvidenceTracker.kt index ea7d537..08e279c 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/ActivityEvidenceTracker.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/ActivityEvidenceTracker.kt @@ -1,17 +1,18 @@ package cn.ilapage.goauto.agent.automation -/** Keeps the last window class that Android confirms is an Activity. */ +import java.util.concurrent.ConcurrentHashMap + +/** Keeps the last window class that Android confirms is an Activity, isolated by package. */ class ActivityEvidenceTracker( private val isDeclaredActivity: (packageName: String, className: String) -> Boolean, ) { - @Volatile - private var activityName: String? = null + private val activityNames = ConcurrentHashMap() fun observe(packageName: String?, className: String?) { if (packageName.isNullOrBlank() || className.isNullOrBlank()) return val normalized = if (className.startsWith('.')) packageName + className else className - if (isDeclaredActivity(packageName, normalized)) activityName = normalized + if (isDeclaredActivity(packageName, normalized)) activityNames[packageName] = normalized } - fun current(): String? = activityName + fun current(packageName: String): String? = activityNames[packageName] } diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt index a4157c1..1cc5169 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt @@ -90,7 +90,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto } override fun currentPackage(): String? = rootInActiveWindow?.packageName?.toString() - override fun currentActivity(): String? = activityTracker.current() + override fun currentActivity(): String? = currentPackage()?.let(activityTracker::current) fun currentForegroundRevision(): Long = foregroundRevision.get() @@ -177,7 +177,9 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto } override fun capture(): UiSnapshot { - val root = rootInActiveWindow ?: return UiSnapshot(currentPackage(), currentActivity(), emptyList()) + val root = rootInActiveWindow ?: return UiSnapshot(null, null, emptyList()) + val rootPackage = root.packageName?.toString() + val rootActivity = rootPackage?.let(activityTracker::current) val nodes = mutableListOf() fun snapshot(node: AccessibilityNodeInfo, path: String, parentPath: String?) { val bounds = Rect().also(node::getBoundsInScreen) @@ -201,7 +203,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto } } snapshot(root, "0", null) - return UiSnapshot(root.packageName?.toString(), currentActivity(), nodes) + return UiSnapshot(rootPackage, rootActivity, nodes) } override fun clickFresh(target: SnapshotNode): FreshActionResult = clickFreshDetailed(target).result diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt index 75bd7cd..783acc4 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt @@ -1,5 +1,8 @@ package cn.ilapage.goauto.agent.automation +import cn.ilapage.goauto.agent.persistence.AgentDiagnosticEvent +import cn.ilapage.goauto.agent.persistence.AgentDiagnosticReason +import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage import java.math.BigDecimal import java.math.RoundingMode @@ -80,6 +83,10 @@ data class ParsedPddScreen( val quickConfirmationEntry: SnapshotNode?, val reviewPageOpen: Boolean, val pageEvidenceMatched: Boolean, + val rootAvailable: Boolean, + val packageMatched: Boolean, + val activityMatched: Boolean, + val selectorMatchCount: Int, val problem: PageProblem?, val sourceNodes: List, ) { @@ -200,6 +207,12 @@ object PddScreenParser { } val reviewPageOpen = isReviewPage(visibleNodes, visible, screenHeight, specEntry) val quickConfirmationEntry = if (panelOpen) null else quickConfirmationSpecEntry(visibleNodes, visible, config) + val rootAvailable = snapshot.packageName != null || snapshot.activityName != null || snapshot.nodes.isNotEmpty() + val packageMatched = evidence == null || snapshot.packageName == evidence.packageName + val activityMatched = evidence == null || snapshot.activityName == evidence.activityName + val selectorMatchCount = evidence?.let { pageEvidence -> + snapshot.nodes.count { it.visible && it.matches(pageEvidence.selector) } + } ?: 0 return ParsedPddScreen( summary = ProductSummary( pddGoodsId = goodsId, @@ -217,11 +230,11 @@ object PddScreenParser { specEntrySource = specEntrySource, quickConfirmationEntry = quickConfirmationEntry, reviewPageOpen = reviewPageOpen, - pageEvidenceMatched = evidence == null || ( - snapshot.packageName == evidence.packageName && - snapshot.activityName == evidence.activityName && - snapshot.nodes.any { it.visible && it.matches(evidence.selector) } - ), + pageEvidenceMatched = evidence == null || (packageMatched && activityMatched && selectorMatchCount > 0), + rootAvailable = rootAvailable, + packageMatched = packageMatched, + activityMatched = activityMatched, + selectorMatchCount = selectorMatchCount, problem = problem, sourceNodes = visibleNodes, ) @@ -439,12 +452,14 @@ class PddProductDetailCollector( private val now: () -> Long = System::currentTimeMillis, private val pause: (Long) -> Unit = Thread::sleep, private val trace: (String) -> Unit = {}, + private val taskId: Long = 0, + private val diagnostic: (AgentDiagnosticEvent) -> Unit = {}, ) { fun collect(goodsId: String, rule: CollectionRule): PddCollectorResult { val config = rule.collector ?: return failure("RULE_INVALID", "v2 规则缺少采集器配置") val evidence = rule.pageEvidence ?: return failure("RULE_INVALID", "v2 规则缺少商品页证据") val deadline = now() + config.timeoutsMs.getValue("overall") - var current = waitFor({ screen -> screen.pageEvidenceMatched }, config.timeoutsMs.getValue("page"), goodsId, config, evidence) + var current = waitForPageEvidence(config.timeoutsMs.getValue("page"), goodsId, config, evidence) ?: return failure("PDD_DETAIL_ENTRY_FAILED", "未进入 PDD 商品详情页") val recovery = rule.transientSoldOutRecovery if (recovery?.enabled == true && current.isTransientSoldOut(recovery.exactText, recovery.fallbackTopText)) { @@ -887,22 +902,64 @@ class PddProductDetailCollector( return StablePriceResult(null, reason = reason, selectionEvidence = selectionEvidence) } - private fun waitFor( - predicate: (ParsedPddScreen) -> Boolean, + private fun waitForPageEvidence( timeoutMs: Int, goodsId: String, config: PddCollectorConfig, evidence: PageEvidence, ): ParsedPddScreen? { - val deadline = now() + timeoutMs + val startedAt = now() + val deadline = startedAt + timeoutMs + var attempts = 0 + var lastScreen: ParsedPddScreen? do { val screen = parse(goodsId, config, evidence) - if (screen.problem != null || predicate(screen)) return screen + attempts++ + lastScreen = screen + if (screen.pageEvidenceMatched) { + recordDetailEntry(AgentDiagnosticReason.DETAIL_ENTRY_MATCHED, screen, attempts, now() - startedAt) + return screen + } + if (screen.problem != null) { + recordDetailEntry(detailEntryMismatchReason(screen), screen, attempts, now() - startedAt) + return screen + } pause(100) } while (now() <= deadline) + val reason = detailEntryMismatchReason(lastScreen) + recordDetailEntry(reason, lastScreen, attempts, now() - startedAt) return null } + private fun detailEntryMismatchReason(screen: ParsedPddScreen?): AgentDiagnosticReason = when { + screen == null || !screen.rootAvailable -> AgentDiagnosticReason.ROOT_UNAVAILABLE + !screen.packageMatched -> AgentDiagnosticReason.PACKAGE_MISMATCH + !screen.activityMatched -> AgentDiagnosticReason.ACTIVITY_MISMATCH + screen.selectorMatchCount == 0 -> AgentDiagnosticReason.SELECTOR_MISMATCH + else -> AgentDiagnosticReason.UNKNOWN + } + + private fun recordDetailEntry( + reason: AgentDiagnosticReason, + screen: ParsedPddScreen?, + attempts: Int, + elapsedMs: Long, + ) { + if (taskId <= 0) return + diagnostic( + AgentDiagnosticEvent( + taskId = taskId, + stage = AgentDiagnosticStage.DETAIL_ENTRY, + reason = reason, + attempt = attempts, + elapsedMs = elapsedMs, + packageMatched = screen?.packageMatched, + activityMatched = screen?.activityMatched, + candidateCount = screen?.selectorMatchCount, + ), + ) + } + private fun parse(goodsId: String, config: PddCollectorConfig, evidence: PageEvidence) = PddScreenParser.parse(driver.capture(), config, goodsId, evidence) 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 19cef21..8d5108b 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 { + DETAIL_ENTRY, PAGE_STABILITY, SHARE_CLICK, SHARE_PANEL, @@ -15,6 +16,10 @@ enum class AgentDiagnosticStage { } enum class AgentDiagnosticReason { + DETAIL_ENTRY_MATCHED, + PACKAGE_MISMATCH, + ACTIVITY_MISMATCH, + SELECTOR_MISMATCH, STABLE, PAGE_CHANGED, TARGET_NOT_FOUND, 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 eb8494d..5ea88bb 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 @@ -656,7 +656,12 @@ class AgentForegroundService : Service() { throw TaskFailure(error.code, error.message) } val trace: (String) -> Unit = { message -> Log.i("GoAutoCollector", message) } - val collection = PddProductDetailCollector(accessibility, trace = trace).collect(identity.goodsId, rule) + val collection = PddProductDetailCollector( + accessibility, + trace = trace, + taskId = task.taskId, + diagnostic = diagnosticRecorder::record, + ).collect(identity.goodsId, rule) if (!collection.successful) throw TaskFailure(collection.code, collection.message) requireNotNull(collection.payload) } else if (rule.schemaVersion == 2) { diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/ActivityEvidenceTrackerTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/ActivityEvidenceTrackerTest.kt index f5057b7..4c861ea 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/ActivityEvidenceTrackerTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/ActivityEvidenceTrackerTest.kt @@ -13,7 +13,7 @@ class ActivityEvidenceTrackerTest { tracker.observe(PDD_PACKAGE, ACTIVITY) tracker.observe(PDD_PACKAGE, "android.widget.PopupWindow${'$'}PopupDecorView") - assertEquals(ACTIVITY, tracker.current()) + assertEquals(ACTIVITY, tracker.current(PDD_PACKAGE)) } @Test @@ -27,7 +27,7 @@ class ActivityEvidenceTrackerTest { tracker.observe(PDD_PACKAGE, ".activity.NewPageActivity") assertEquals(ACTIVITY, observed) - assertEquals(ACTIVITY, tracker.current()) + assertEquals(ACTIVITY, tracker.current(PDD_PACKAGE)) } @Test @@ -36,11 +36,36 @@ class ActivityEvidenceTrackerTest { tracker.observe(PDD_PACKAGE, "android.widget.FrameLayout") - assertNull(tracker.current()) + assertNull(tracker.current(PDD_PACKAGE)) + } + + @Test + fun `agent activity does not replace pdd activity evidence`() { + val tracker = ActivityEvidenceTracker { _, className -> className.endsWith("Activity") } + + tracker.observe(PDD_PACKAGE, ACTIVITY) + tracker.observe(AGENT_PACKAGE, AGENT_ACTIVITY) + + assertEquals(ACTIVITY, tracker.current(PDD_PACKAGE)) + assertEquals(AGENT_ACTIVITY, tracker.current(AGENT_PACKAGE)) + assertNull(tracker.current("example.unknown")) + } + + @Test + fun `new declared activity replaces evidence only for same package`() { + val tracker = ActivityEvidenceTracker { _, className -> className.endsWith("Activity") } + + tracker.observe(PDD_PACKAGE, ACTIVITY) + tracker.observe(PDD_PACKAGE, SECOND_ACTIVITY) + + assertEquals(SECOND_ACTIVITY, tracker.current(PDD_PACKAGE)) } private companion object { const val PDD_PACKAGE = "com.xunmeng.pinduoduo" const val ACTIVITY = "com.xunmeng.pinduoduo.activity.NewPageActivity" + const val SECOND_ACTIVITY = "com.xunmeng.pinduoduo.activity.OtherActivity" + const val AGENT_PACKAGE = "cn.ilapage.goauto.agent" + const val AGENT_ACTIVITY = "cn.ilapage.goauto.agent.ClipboardRelayActivity" } } diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt index 2b40a25..3105880 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt @@ -1,5 +1,6 @@ package cn.ilapage.goauto.agent +import cn.ilapage.goauto.agent.automation.ActivityEvidenceTracker import cn.ilapage.goauto.agent.automation.CollectionRule import cn.ilapage.goauto.agent.automation.FreshActionResult import cn.ilapage.goauto.agent.automation.NodeBounds @@ -13,6 +14,9 @@ import cn.ilapage.goauto.agent.automation.SnapshotNode import cn.ilapage.goauto.agent.automation.SwipeDirection import cn.ilapage.goauto.agent.automation.TransientSoldOutRecovery import cn.ilapage.goauto.agent.automation.UiSnapshot +import cn.ilapage.goauto.agent.persistence.AgentDiagnosticEvent +import cn.ilapage.goauto.agent.persistence.AgentDiagnosticReason +import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -574,6 +578,72 @@ class PddProductDetailCollectorTest { assertEquals(2, driver.entryClickCount) } + @Test + fun `detail entry success records bounded safe evidence`() { + val events = mutableListOf() + var clock = 0L + + val result = PddProductDetailCollector( + FakeCollectorDriver(), + { clock }, + { clock += it }, + taskId = 110, + diagnostic = events::add, + ).collect(GOODS_ID, rule()) + + assertTrue(result.successful) + val event = events.single { it.stage == AgentDiagnosticStage.DETAIL_ENTRY } + assertEquals(AgentDiagnosticReason.DETAIL_ENTRY_MATCHED, event.reason) + assertTrue(event.elapsedMs < 3_000) + assertEquals(true, event.packageMatched) + assertEquals(true, event.activityMatched) + assertEquals(1, event.candidateCount) + } + + @Test + fun `pdd snapshot still matches after agent relay activity is observed`() { + val tracker = ActivityEvidenceTracker { _, className -> className.endsWith("Activity") } + tracker.observe(PDD_PACKAGE, ACTIVITY) + tracker.observe("cn.ilapage.goauto.agent", "cn.ilapage.goauto.agent.ClipboardRelayActivity") + + val parsed = PddScreenParser.parse( + detailSnapshot(activityName = requireNotNull(tracker.current(PDD_PACKAGE))), + config(), + GOODS_ID, + evidence(), + ) + + assertTrue(parsed.pageEvidenceMatched) + assertEquals(ACTIVITY, tracker.current(PDD_PACKAGE)) + } + + @Test + fun `detail entry failure identifies each evidence mismatch without content`() { + val cases = listOf( + UiSnapshot(null, null, emptyList()) to AgentDiagnosticReason.ROOT_UNAVAILABLE, + detailSnapshot(packageName = "example.other") to AgentDiagnosticReason.PACKAGE_MISMATCH, + detailSnapshot(activityName = "$PDD_PACKAGE.activity.OtherActivity") to AgentDiagnosticReason.ACTIVITY_MISMATCH, + detailSnapshot(includeSelector = false) to AgentDiagnosticReason.SELECTOR_MISMATCH, + ) + + cases.forEach { (snapshot, expectedReason) -> + val events = mutableListOf() + var clock = 0L + val result = PddProductDetailCollector( + FakeCollectorDriver(fixedSnapshot = snapshot), + { clock }, + { clock += it }, + taskId = 110, + diagnostic = events::add, + ).collect(GOODS_ID, rule()) + + assertFalse(result.successful) + val event = events.single { it.stage == AgentDiagnosticStage.DETAIL_ENTRY } + assertEquals(expectedReason, event.reason) + assertTrue(event.attempt > 0) + } + } + private class FakeCollectorDriver( colors: List = listOf("红色"), sizes: List = listOf("S"), @@ -596,6 +666,7 @@ class PddProductDetailCollectorTest { private val staleSelectionEvidence: Boolean = false, private val priceDelayReads: Map = emptyMap(), private val acceptedClicksWithoutEffect: Set = emptySet(), + private val fixedSnapshot: UiSnapshot? = null, ) : PddCollectorDriver { var captureCount = 0 var clickCount = 0 @@ -614,6 +685,7 @@ class PddProductDetailCollectorTest { override fun capture(): UiSnapshot { captureCount++ + fixedSnapshot?.let { return it } if (pullDownCount < soldOutUntilPulls) return UiSnapshot(PDD_PACKAGE, ACTIVITY, listOf( node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"), node("sold-out", "商品已售罄", 200, 700, 880, 800), @@ -765,6 +837,18 @@ class PddProductDetailCollectorTest { private fun evidence() = PageEvidence(PDD_PACKAGE, ACTIVITY, NodeSelector(resourceId = "android:id/content", className = "android.widget.FrameLayout")) + private fun detailSnapshot( + packageName: String = PDD_PACKAGE, + activityName: String = ACTIVITY, + includeSelector: Boolean = true, + ) = UiSnapshot( + packageName, + activityName, + if (includeSelector) listOf( + node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"), + ) else listOf(node("other", "", 0, 0, 1080, 2200)), + ) + private fun config(maxSkuCount: Int = 500) = PddCollectorConfig( "pddProductDetailV1", "safeBottomSpecEntryV1", "pddRmbPriceV1", "color", listOf("颜色分类", "颜色", "款式"), listOf("尺码", "尺寸", "套餐"),