fix: isolate PDD activity evidence by package (#110)
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
+6
-5
@@ -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]
|
||||
}
|
||||
|
||||
+5
-3
@@ -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
|
||||
|
||||
+67
-10
@@ -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("尺码", "尺寸", "套餐"),
|
||||
|
||||
Reference in New Issue
Block a user