fix: isolate PDD activity evidence by package (#110)

This commit is contained in:
QiuSW
2026-08-27 16:16:45 +08:00
parent daf7406ec9
commit e606096034
8 changed files with 203 additions and 24 deletions
+2 -2
View File
@@ -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"
@@ -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<String, String>()
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]
}
@@ -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<SnapshotNode>()
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
@@ -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<SnapshotNode>,
) {
@@ -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)
@@ -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,
@@ -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) {
@@ -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"
}
}
@@ -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<AgentDiagnosticEvent>()
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<AgentDiagnosticEvent>()
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<String> = listOf("红色"),
sizes: List<String> = listOf("S"),
@@ -596,6 +666,7 @@ class PddProductDetailCollectorTest {
private val staleSelectionEvidence: Boolean = false,
private val priceDelayReads: Map<String, Int> = emptyMap(),
private val acceptedClicksWithoutEffect: Set<String> = 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("尺码", "尺寸", "套餐"),