fix(agent): stabilize PDD share action (#106)

This commit is contained in:
QiuSW
2026-08-27 11:57:44 +08:00
parent f0d016678c
commit 1d1618ce02
9 changed files with 658 additions and 38 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent" applicationId = "cn.ilapage.goauto.agent"
minSdk = 23 minSdk = 23
targetSdk = 34 targetSdk = 34
versionCode = 15 versionCode = 16
versionName = "0.9.2" versionName = "0.9.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -10,6 +10,7 @@ import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.SystemClock import android.os.SystemClock
import cn.ilapage.goauto.agent.automation.ClipboardReadOutcome
import java.util.UUID import java.util.UUID
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch import java.util.concurrent.CountDownLatch
@@ -21,6 +22,11 @@ class ClipboardRelayActivity : Activity() {
private val pollRunnable = Runnable { readOrRetry() } private val pollRunnable = Runnable { readOrRetry() }
private var completed = false private var completed = false
private lateinit var pollSession: ClipboardPollSession private lateinit var pollSession: ClipboardPollSession
private var pollCount = 0
private var lastMimeAccepted = false
private var lastItemCount = 0
private var lastContentLength: Int? = null
private var lastTimestampFresh = false
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -38,7 +44,7 @@ class ClipboardRelayActivity : Activity() {
handler.removeCallbacks(pollRunnable) handler.removeCallbacks(pollRunnable)
if (!completed) { if (!completed) {
completed = true completed = true
deliver(intent.getStringExtra(EXTRA_REQUEST_ID).orEmpty(), null) deliver(intent.getStringExtra(EXTRA_REQUEST_ID).orEmpty(), outcome(null))
} }
super.onDestroy() super.onDestroy()
} }
@@ -56,6 +62,16 @@ class ClipboardRelayActivity : Activity() {
val hasPlainText = description?.hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN) == true val hasPlainText = description?.hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN) == true
val clip = if (hasPlainText) clipboard.primaryClip else null val clip = if (hasPlainText) clipboard.primaryClip else null
val timestampSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O val timestampSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
pollCount++
lastMimeAccepted = hasPlainText
lastItemCount = clip?.itemCount ?: 0
lastContentLength = clip?.takeIf { it.itemCount == 1 }?.getItemAt(0)?.text?.length
lastTimestampFresh = ClipboardContentPolicy.isTimestampFresh(
descriptionPresent = description != null,
timestampMillis = if (timestampSupported) description?.timestamp ?: 0L else 0L,
timestampSupported = timestampSupported,
minTimestampMillis = minTimestamp,
)
val text = ClipboardContentPolicy.freshText( val text = ClipboardContentPolicy.freshText(
descriptionPresent = description != null, descriptionPresent = description != null,
timestampMillis = if (timestampSupported) description?.timestamp ?: 0L else 0L, timestampMillis = if (timestampSupported) description?.timestamp ?: 0L else 0L,
@@ -80,10 +96,19 @@ class ClipboardRelayActivity : Activity() {
if (completed) return if (completed) return
completed = true completed = true
handler.removeCallbacks(pollRunnable) handler.removeCallbacks(pollRunnable)
deliver(intent.getStringExtra(EXTRA_REQUEST_ID).orEmpty(), value) deliver(intent.getStringExtra(EXTRA_REQUEST_ID).orEmpty(), outcome(value))
finishRelay() finishRelay()
} }
private fun outcome(value: String?) = ClipboardReadOutcome(
text = value,
pollCount = pollCount,
mimeAccepted = lastMimeAccepted,
itemCount = lastItemCount,
contentLength = lastContentLength,
timestampFresh = lastTimestampFresh,
)
private fun finishRelay() { private fun finishRelay() {
finishAndRemoveTask() finishAndRemoveTask()
overridePendingTransition(0, 0) overridePendingTransition(0, 0)
@@ -96,7 +121,7 @@ class ClipboardRelayActivity : Activity() {
private const val POLL_INTERVAL_MILLIS = 100L private const val POLL_INTERVAL_MILLIS = 100L
private val pending = ConcurrentHashMap<String, PendingRead>() private val pending = ConcurrentHashMap<String, PendingRead>()
fun readFresh(context: Context, minTimestamp: Long, timeoutMillis: Long): CharSequence? { fun readFresh(context: Context, minTimestamp: Long, timeoutMillis: Long): ClipboardReadOutcome {
val boundedTimeout = timeoutMillis.coerceIn(500L, 10_000L) val boundedTimeout = timeoutMillis.coerceIn(500L, 10_000L)
val requestId = UUID.randomUUID().toString() val requestId = UUID.randomUUID().toString()
val request = PendingRead() val request = PendingRead()
@@ -112,14 +137,14 @@ class ClipboardRelayActivity : Activity() {
}.getOrDefault(false) }.getOrDefault(false)
if (!started) { if (!started) {
pending.remove(requestId) pending.remove(requestId)
return null return ClipboardReadOutcome(text = null)
} }
request.latch.await(boundedTimeout, TimeUnit.MILLISECONDS) request.latch.await(boundedTimeout, TimeUnit.MILLISECONDS)
pending.remove(requestId) pending.remove(requestId)
return request.value.get() return request.value.get() ?: ClipboardReadOutcome(text = null)
} }
private fun deliver(requestId: String, value: String?) { private fun deliver(requestId: String, value: ClipboardReadOutcome) {
pending[requestId]?.let { request -> pending[requestId]?.let { request ->
request.value.set(value) request.value.set(value)
request.latch.countDown() request.latch.countDown()
@@ -129,7 +154,7 @@ class ClipboardRelayActivity : Activity() {
private class PendingRead { private class PendingRead {
val latch = CountDownLatch(1) val latch = CountDownLatch(1)
val value = AtomicReference<String?>(null) val value = AtomicReference<ClipboardReadOutcome?>(null)
} }
} }
@@ -175,7 +200,17 @@ internal object ClipboardContentPolicy {
minTimestampMillis: Long, minTimestampMillis: Long,
): String? { ): String? {
if (!descriptionPresent || !hasPlainText || itemCount != 1) return null if (!descriptionPresent || !hasPlainText || itemCount != 1) return null
if (timestampSupported && (timestampMillis <= 0L || timestampMillis < minTimestampMillis - TIMESTAMP_TOLERANCE_MILLIS)) return null if (!isTimestampFresh(descriptionPresent, timestampMillis, timestampSupported, minTimestampMillis)) return null
return text?.takeIf { it.length <= MAX_CLIPBOARD_CHARS } return text?.takeIf { it.length <= MAX_CLIPBOARD_CHARS }
} }
fun isTimestampFresh(
descriptionPresent: Boolean,
timestampMillis: Long,
timestampSupported: Boolean,
minTimestampMillis: Long,
): Boolean {
if (!descriptionPresent) return false
return !timestampSupported || (timestampMillis > 0L && timestampMillis >= minTimestampMillis - TIMESTAMP_TOLERANCE_MILLIS)
}
} }
@@ -1,5 +1,8 @@
package cn.ilapage.goauto.agent.automation 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.net.URI import java.net.URI
data class CurrentPageIdentityResult( data class CurrentPageIdentityResult(
@@ -9,6 +12,15 @@ data class CurrentPageIdentityResult(
val shareUrl: String? = null, val shareUrl: String? = null,
) )
data class ClipboardReadOutcome(
val text: CharSequence?,
val pollCount: Int = 0,
val mimeAccepted: Boolean = false,
val itemCount: Int = 0,
val contentLength: Int? = null,
val timestampFresh: Boolean = false,
)
enum class CurrentPageRestoreDecision { enum class CurrentPageRestoreDecision {
READY, READY,
BACK_FROM_AGENT, BACK_FROM_AGENT,
@@ -49,26 +61,40 @@ object PddShareLinkExtractor {
class CurrentPageIdentityRunner( class CurrentPageIdentityRunner(
private val driver: PddCollectorDriver, private val driver: PddCollectorDriver,
private val readFreshClipboard: (copiedAtMillis: Long, timeoutMillis: Long) -> CharSequence?, private val readFreshClipboard: (copiedAtMillis: Long, timeoutMillis: Long) -> ClipboardReadOutcome,
private val taskId: Long = 0,
private val diagnostic: (AgentDiagnosticEvent) -> Unit = {},
private val now: () -> Long = System::currentTimeMillis, private val now: () -> Long = System::currentTimeMillis,
private val pause: (Long) -> Unit = Thread::sleep, private val pause: (Long) -> Unit = Thread::sleep,
) { ) {
fun identify(rule: CollectionRule): CurrentPageIdentityResult { fun identify(rule: CollectionRule): CurrentPageIdentityResult {
val startedAt = now()
val evidence = rule.pageEvidence ?: return failure("RULE_INVALID", "规则缺少商品页证据") val evidence = rule.pageEvidence ?: return failure("RULE_INVALID", "规则缺少商品页证据")
val config = rule.currentPageIdentity val config = rule.currentPageIdentity
val detail = waitForPageEvidence(evidence, config.pageTimeoutMs) val prepared = waitForStableShare(evidence, config.shareAliases, config.pageTimeoutMs, startedAt)
?: return failure("CURRENT_PDD_PAGE_NOT_FOUND", "没有找到商品详情页") prepared.problem?.let { return failure(it.code, it.message) }
detailProblem(detail)?.let { return failure(it.code, it.message) } if (prepared.failureMessage != null) {
val share = uniqueAction(detail, config.shareAliases, topOnly = true) return failure(prepared.failureCode ?: "PDD_SHARE_UNAVAILABLE", prepared.failureMessage)
?: return failure("PDD_SHARE_UNAVAILABLE", "没有找到商品分享入口") }
when (driver.clickFresh(share)) { val clicked = clickShareWithRetry(evidence, config.shareAliases, startedAt)
FreshActionResult.SUCCESS -> Unit if (!clicked.successful) {
FreshActionResult.AMBIGUOUS -> return failure("PDD_SHARE_UNAVAILABLE", "商品分享入口不唯一") return failure("PDD_SHARE_UNAVAILABLE", clicked.message)
else -> return failure("PDD_SHARE_UNAVAILABLE", "无法打开商品分享")
} }
try { try {
val panel = waitForAction(config.copyLinkAliases, config.sharePanelTimeoutMs) val panel = waitForAction(config.copyLinkAliases, config.sharePanelTimeoutMs)
?: return failure("PDD_COPY_LINK_UNAVAILABLE", "没有找到复制链接") if (panel == null) {
diagnose(
AgentDiagnosticStage.SHARE_PANEL,
AgentDiagnosticReason.PANEL_TIMEOUT,
elapsedMs = now() - startedAt,
)
return failure("PDD_COPY_LINK_UNAVAILABLE", "没有找到复制链接")
}
diagnose(
AgentDiagnosticStage.SHARE_PANEL,
AgentDiagnosticReason.PANEL_FOUND,
elapsedMs = now() - startedAt,
)
val copy = uniqueAction(panel, config.copyLinkAliases, topOnly = false) val copy = uniqueAction(panel, config.copyLinkAliases, topOnly = false)
?: return failure("PDD_COPY_LINK_UNAVAILABLE", "复制链接入口不唯一") ?: return failure("PDD_COPY_LINK_UNAVAILABLE", "复制链接入口不唯一")
val copiedAt = now() val copiedAt = now()
@@ -77,7 +103,14 @@ class CurrentPageIdentityRunner(
FreshActionResult.AMBIGUOUS -> return failure("PDD_COPY_LINK_UNAVAILABLE", "复制链接入口不唯一") FreshActionResult.AMBIGUOUS -> return failure("PDD_COPY_LINK_UNAVAILABLE", "复制链接入口不唯一")
else -> return failure("PDD_COPY_LINK_UNAVAILABLE", "复制商品链接失败") else -> return failure("PDD_COPY_LINK_UNAVAILABLE", "复制商品链接失败")
} }
val raw = readFreshClipboard(copiedAt, config.clipboardTimeoutMs) val clipboard = readFreshClipboard(copiedAt, config.clipboardTimeoutMs)
diagnose(
stage = AgentDiagnosticStage.CLIPBOARD,
reason = if (clipboard.text != null) AgentDiagnosticReason.CLIPBOARD_SUCCESS else AgentDiagnosticReason.CLIPBOARD_TIMEOUT,
elapsedMs = now() - startedAt,
clipboard = clipboard,
)
val raw = clipboard.text
?: return failure("PDD_CLIPBOARD_UNAVAILABLE", "无法读取刚复制的商品链接") ?: return failure("PDD_CLIPBOARD_UNAVAILABLE", "无法读取刚复制的商品链接")
val shareUrl = PddShareLinkExtractor.extract(raw) val shareUrl = PddShareLinkExtractor.extract(raw)
?: return failure("PDD_SHARE_LINK_INVALID", "无法识别商品链接") ?: return failure("PDD_SHARE_LINK_INVALID", "无法识别商品链接")
@@ -87,15 +120,165 @@ class CurrentPageIdentityRunner(
} }
} }
private fun waitForPageEvidence(evidence: PageEvidence, timeoutMillis: Long): UiSnapshot? { private fun waitForStableShare(
evidence: PageEvidence,
aliases: List<String>,
timeoutMillis: Long,
startedAt: Long,
): SharePreparation {
val deadline = now() + timeoutMillis val deadline = now() + timeoutMillis
var previous: SnapshotNode? = null
var stableSince = 0L
var attempts = 0
var lastCandidateCount = 0
var lastSnapshot: UiSnapshot? = null
do { do {
val snapshot = driver.capture() val snapshot = driver.capture()
detailProblem(snapshot)?.let { return snapshot } lastSnapshot = snapshot
if (matchesEvidence(snapshot, evidence)) return snapshot attempts++
detailProblem(snapshot)?.let { return SharePreparation(problem = it) }
if (!matchesEvidence(snapshot, evidence)) {
previous = null
stableSince = 0L
pause(POLL_MILLIS)
continue
}
val candidates = actionCandidates(snapshot, aliases, topOnly = true)
lastCandidateCount = candidates.size
if (candidates.size > 1) {
diagnoseTarget(
AgentDiagnosticStage.PAGE_STABILITY,
AgentDiagnosticReason.TARGET_AMBIGUOUS,
snapshot,
evidence,
candidates.size,
null,
attempts,
now() - startedAt,
)
return SharePreparation(failureMessage = "商品分享入口不唯一")
}
val target = candidates.singleOrNull()
val currentNow = now()
if (target != null && previous != null && targetsStable(previous, target)) {
if (stableSince == 0L) stableSince = currentNow
if (currentNow - stableSince >= SHARE_STABLE_MILLIS) {
diagnoseTarget(
AgentDiagnosticStage.PAGE_STABILITY,
AgentDiagnosticReason.STABLE,
snapshot,
evidence,
1,
target,
attempts,
currentNow - startedAt,
)
return SharePreparation(target = target)
}
} else {
stableSince = if (target != null) currentNow else 0L
}
previous = target
pause(POLL_MILLIS) pause(POLL_MILLIS)
} while (now() <= deadline) } while (now() <= deadline)
return null val reason = if (lastCandidateCount == 0) AgentDiagnosticReason.TARGET_NOT_FOUND else AgentDiagnosticReason.PAGE_CHANGED
diagnoseTarget(
AgentDiagnosticStage.PAGE_STABILITY,
reason,
lastSnapshot,
evidence,
lastCandidateCount,
previous,
attempts,
now() - startedAt,
)
return if (lastSnapshot == null || !matchesEvidence(lastSnapshot, evidence)) {
SharePreparation(failureCode = "CURRENT_PDD_PAGE_NOT_FOUND", failureMessage = "没有找到商品详情页")
} else {
SharePreparation(failureMessage = "商品分享入口尚未稳定,请重试")
}
}
private fun clickShareWithRetry(
evidence: PageEvidence,
aliases: List<String>,
startedAt: Long,
): ShareClickResult {
val deadline = now() + SHARE_CLICK_RETRY_MILLIS
var attempt = 0
var lastReason = AgentDiagnosticReason.TARGET_NOT_FOUND
do {
attempt++
val snapshot = driver.capture()
detailProblem(snapshot)?.let { return ShareClickResult(false, it.message) }
if (!matchesEvidence(snapshot, evidence)) {
diagnoseTarget(
AgentDiagnosticStage.SHARE_CLICK,
AgentDiagnosticReason.PAGE_CHANGED,
snapshot,
evidence,
0,
null,
attempt,
now() - startedAt,
)
return ShareClickResult(false, "商品详情页发生变化,请重新打开商品")
}
val candidates = actionCandidates(snapshot, aliases, topOnly = true)
if (candidates.size > 1) {
diagnoseTarget(
AgentDiagnosticStage.SHARE_CLICK,
AgentDiagnosticReason.TARGET_AMBIGUOUS,
snapshot,
evidence,
candidates.size,
null,
attempt,
now() - startedAt,
)
return ShareClickResult(false, "商品分享入口不唯一")
}
val target = candidates.singleOrNull()
if (target == null) {
lastReason = AgentDiagnosticReason.TARGET_NOT_FOUND
diagnoseTarget(
AgentDiagnosticStage.SHARE_CLICK,
lastReason,
snapshot,
evidence,
0,
null,
attempt,
now() - startedAt,
)
} else {
val outcome = driver.clickFreshDetailed(target)
lastReason = outcome.reason.toDiagnosticReason()
diagnoseTarget(
AgentDiagnosticStage.SHARE_CLICK,
lastReason,
snapshot,
evidence,
outcome.candidateCount,
target,
attempt,
now() - startedAt,
outcome.clickableAncestorDepth,
)
when (outcome.result) {
FreshActionResult.SUCCESS -> return ShareClickResult(true, "")
FreshActionResult.AMBIGUOUS -> return ShareClickResult(false, "商品分享入口不唯一")
FreshActionResult.NOT_FOUND, FreshActionResult.FAILED -> Unit
}
}
pause(POLL_MILLIS)
} while (now() <= deadline)
val message = when (lastReason) {
AgentDiagnosticReason.TARGET_NOT_FOUND, AgentDiagnosticReason.ROOT_UNAVAILABLE -> "商品分享入口发生变化,请重试"
AgentDiagnosticReason.NO_CLICKABLE_ANCESTOR -> "商品分享入口暂时无法点击,请重试"
else -> "商品分享入口点击失败,请重试"
}
return ShareClickResult(false, message)
} }
private fun waitForAction(aliases: List<String>, timeoutMillis: Long): UiSnapshot? { private fun waitForAction(aliases: List<String>, timeoutMillis: Long): UiSnapshot? {
@@ -119,6 +302,10 @@ class CurrentPageIdentityRunner(
} }
private fun uniqueAction(snapshot: UiSnapshot, aliases: List<String>, topOnly: Boolean): SnapshotNode? { private fun uniqueAction(snapshot: UiSnapshot, aliases: List<String>, topOnly: Boolean): SnapshotNode? {
return actionCandidates(snapshot, aliases, topOnly).singleOrNull()
}
private fun actionCandidates(snapshot: UiSnapshot, aliases: List<String>, topOnly: Boolean): List<SnapshotNode> {
val screenHeight = snapshot.nodes.maxOfOrNull { it.bounds.bottom } ?: 0 val screenHeight = snapshot.nodes.maxOfOrNull { it.bounds.bottom } ?: 0
val candidates = snapshot.nodes.filter { node -> val candidates = snapshot.nodes.filter { node ->
node.visible && node.enabled && node.label in aliases && (!topOnly || screenHeight <= 0 || node.bounds.centerY <= screenHeight * 45 / 100) node.visible && node.enabled && node.label in aliases && (!topOnly || screenHeight <= 0 || node.bounds.centerY <= screenHeight * 45 / 100)
@@ -132,7 +319,93 @@ class CurrentPageIdentityRunner(
} }
) distinct += candidate ) distinct += candidate
} }
return distinct.singleOrNull() return distinct
}
private fun targetsStable(previous: SnapshotNode, current: SnapshotNode): Boolean {
if (previous.label != current.label || previous.className != current.className) return false
val intersectionWidth = (minOf(previous.bounds.right, current.bounds.right) - maxOf(previous.bounds.left, current.bounds.left)).coerceAtLeast(0)
val intersectionHeight = (minOf(previous.bounds.bottom, current.bounds.bottom) - maxOf(previous.bounds.top, current.bounds.top)).coerceAtLeast(0)
val intersection = intersectionWidth.toLong() * intersectionHeight
val union = previous.bounds.width.toLong() * previous.bounds.height +
current.bounds.width.toLong() * current.bounds.height - intersection
return union > 0 && intersection * 100 / union >= 80
}
private fun diagnoseTarget(
stage: AgentDiagnosticStage,
reason: AgentDiagnosticReason,
snapshot: UiSnapshot?,
evidence: PageEvidence,
candidateCount: Int,
target: SnapshotNode?,
attempt: Int,
elapsedMs: Long,
clickableAncestorDepth: Int? = null,
) {
diagnose(
stage = stage,
reason = reason,
attempt = attempt,
elapsedMs = elapsedMs,
packageMatched = snapshot?.packageName == evidence.packageName,
activityMatched = snapshot?.activityName == evidence.activityName,
candidateCount = candidateCount,
target = target,
snapshot = snapshot,
clickableAncestorDepth = clickableAncestorDepth,
)
}
private fun diagnose(
stage: AgentDiagnosticStage,
reason: AgentDiagnosticReason,
attempt: Int = 0,
elapsedMs: Long = 0,
packageMatched: Boolean? = null,
activityMatched: Boolean? = null,
candidateCount: Int? = null,
target: SnapshotNode? = null,
snapshot: UiSnapshot? = null,
clickableAncestorDepth: Int? = null,
clipboard: ClipboardReadOutcome? = null,
) {
if (taskId <= 0) return
runCatching {
diagnostic(
AgentDiagnosticEvent(
taskId = taskId,
stage = stage,
reason = reason,
attempt = attempt,
elapsedMs = elapsedMs.coerceAtLeast(0),
packageMatched = packageMatched,
activityMatched = activityMatched,
candidateCount = candidateCount,
targetClassName = target?.className,
targetClickable = target?.clickable,
clickableAncestorDepth = clickableAncestorDepth,
targetZone = target?.let { targetZone(it, snapshot) },
clipboardPollCount = clipboard?.pollCount,
clipboardMimeAccepted = clipboard?.mimeAccepted,
clipboardItemCount = clipboard?.itemCount,
clipboardContentLength = clipboard?.contentLength,
clipboardTimestampFresh = clipboard?.timestampFresh,
),
)
}
}
private fun targetZone(target: SnapshotNode, snapshot: UiSnapshot?): String {
val width = snapshot?.nodes?.maxOfOrNull { it.bounds.right }?.coerceAtLeast(1) ?: 1
val height = snapshot?.nodes?.maxOfOrNull { it.bounds.bottom }?.coerceAtLeast(1) ?: 1
return when {
target.bounds.centerY > height * 2 / 3 -> "bottom"
target.bounds.centerY > height / 3 -> "middle"
target.bounds.centerX < width / 3 -> "top-left"
target.bounds.centerX > width * 2 / 3 -> "top-right"
else -> "top-center"
}
} }
private fun detailProblem(snapshot: UiSnapshot): PageProblem? = private fun detailProblem(snapshot: UiSnapshot): PageProblem? =
@@ -147,8 +420,29 @@ class CurrentPageIdentityRunner(
private fun failure(code: String, message: String) = CurrentPageIdentityResult(false, code, message) private fun failure(code: String, message: String) = CurrentPageIdentityResult(false, code, message)
private fun FreshClickReason.toDiagnosticReason(): AgentDiagnosticReason = when (this) {
FreshClickReason.SUCCESS -> AgentDiagnosticReason.CLICK_SUCCESS
FreshClickReason.ROOT_UNAVAILABLE -> AgentDiagnosticReason.ROOT_UNAVAILABLE
FreshClickReason.TARGET_NOT_FOUND -> AgentDiagnosticReason.TARGET_NOT_FOUND
FreshClickReason.TARGET_AMBIGUOUS -> AgentDiagnosticReason.TARGET_AMBIGUOUS
FreshClickReason.NO_CLICKABLE_ANCESTOR -> AgentDiagnosticReason.NO_CLICKABLE_ANCESTOR
FreshClickReason.ACTION_CLICK_FALSE -> AgentDiagnosticReason.ACTION_CLICK_FALSE
FreshClickReason.UNKNOWN -> AgentDiagnosticReason.UNKNOWN
}
private data class SharePreparation(
val target: SnapshotNode? = null,
val problem: PageProblem? = null,
val failureCode: String? = null,
val failureMessage: String? = null,
)
private data class ShareClickResult(val successful: Boolean, val message: String)
private companion object { private companion object {
const val POLL_MILLIS = 100L const val POLL_MILLIS = 100L
const val SHARE_STABLE_MILLIS = 300L
const val SHARE_CLICK_RETRY_MILLIS = 1_000L
const val PDD_PACKAGE = "com.xunmeng.pinduoduo" const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
} }
} }
@@ -206,8 +206,10 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
return UiSnapshot(root.packageName?.toString(), currentActivity(), nodes) return UiSnapshot(root.packageName?.toString(), currentActivity(), nodes)
} }
override fun clickFresh(target: SnapshotNode): FreshActionResult { override fun clickFresh(target: SnapshotNode): FreshActionResult = clickFreshDetailed(target).result
val root = rootInActiveWindow ?: return FreshActionResult.NOT_FOUND
override fun clickFreshDetailed(target: SnapshotNode): FreshClickOutcome {
val root = rootInActiveWindow ?: return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.ROOT_UNAVAILABLE)
val candidates = mutableListOf<AccessibilityNodeInfo>() val candidates = mutableListOf<AccessibilityNodeInfo>()
walk(root) { node -> walk(root) { node ->
val label = node.preferredOrDescendantLabel() val label = node.preferredOrDescendantLabel()
@@ -218,11 +220,23 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
kotlin.math.abs(bounds.centerY() - target.bounds.centerY) <= 32 kotlin.math.abs(bounds.centerY() - target.bounds.centerY) <= 32
) candidates += node ) candidates += node
} }
if (candidates.isEmpty()) return FreshActionResult.NOT_FOUND if (candidates.isEmpty()) return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.TARGET_NOT_FOUND)
if (candidates.size != 1) return FreshActionResult.AMBIGUOUS if (candidates.size != 1) return FreshClickOutcome(FreshActionResult.AMBIGUOUS, FreshClickReason.TARGET_AMBIGUOUS, candidates.size)
var node = candidates.single() var node = candidates.single()
while (!node.isClickable) node = node.parent ?: return FreshActionResult.FAILED var ancestorDepth = 0
return if (node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) FreshActionResult.SUCCESS else FreshActionResult.FAILED while (!node.isClickable) {
node = node.parent ?: return FreshClickOutcome(
FreshActionResult.FAILED,
FreshClickReason.NO_CLICKABLE_ANCESTOR,
candidates.size,
)
ancestorDepth++
}
return if (node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) {
FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS, candidates.size, ancestorDepth)
} else {
FreshClickOutcome(FreshActionResult.FAILED, FreshClickReason.ACTION_CLICK_FALSE, candidates.size, ancestorDepth)
}
} }
override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult { override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult {
@@ -37,9 +37,30 @@ data class UiSnapshot(
enum class FreshActionResult { SUCCESS, NOT_FOUND, AMBIGUOUS, FAILED } enum class FreshActionResult { SUCCESS, NOT_FOUND, AMBIGUOUS, FAILED }
enum class FreshClickReason {
SUCCESS,
ROOT_UNAVAILABLE,
TARGET_NOT_FOUND,
TARGET_AMBIGUOUS,
NO_CLICKABLE_ANCESTOR,
ACTION_CLICK_FALSE,
UNKNOWN,
}
data class FreshClickOutcome(
val result: FreshActionResult,
val reason: FreshClickReason,
val candidateCount: Int = 0,
val clickableAncestorDepth: Int? = null,
)
interface PddCollectorDriver { interface PddCollectorDriver {
fun capture(): UiSnapshot fun capture(): UiSnapshot
fun clickFresh(target: SnapshotNode): FreshActionResult fun clickFresh(target: SnapshotNode): FreshActionResult
fun clickFreshDetailed(target: SnapshotNode): FreshClickOutcome = FreshClickOutcome(
result = clickFresh(target),
reason = FreshClickReason.UNKNOWN,
)
fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode? = null): Boolean fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode? = null): Boolean
fun pullDownGoodsPage(): Boolean fun pullDownGoodsPage(): Boolean
fun back(): Boolean fun back(): Boolean
@@ -0,0 +1,153 @@
package cn.ilapage.goauto.agent.persistence
import android.content.ContentValues
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import cn.ilapage.goauto.agent.BuildConfig
enum class AgentDiagnosticStage {
PAGE_STABILITY,
SHARE_CLICK,
SHARE_PANEL,
CLIPBOARD,
}
enum class AgentDiagnosticReason {
STABLE,
PAGE_CHANGED,
TARGET_NOT_FOUND,
TARGET_AMBIGUOUS,
ROOT_UNAVAILABLE,
NO_CLICKABLE_ANCESTOR,
ACTION_CLICK_FALSE,
CLICK_SUCCESS,
PANEL_FOUND,
PANEL_TIMEOUT,
CLIPBOARD_SUCCESS,
CLIPBOARD_TIMEOUT,
UNKNOWN,
}
data class AgentDiagnosticEvent(
val taskId: Long,
val stage: AgentDiagnosticStage,
val reason: AgentDiagnosticReason,
val attempt: Int = 0,
val elapsedMs: Long = 0,
val packageMatched: Boolean? = null,
val activityMatched: Boolean? = null,
val candidateCount: Int? = null,
val targetClassName: String? = null,
val targetClickable: Boolean? = null,
val clickableAncestorDepth: Int? = null,
val targetZone: String? = null,
val clipboardPollCount: Int? = null,
val clipboardMimeAccepted: Boolean? = null,
val clipboardItemCount: Int? = null,
val clipboardContentLength: Int? = null,
val clipboardTimestampFresh: Boolean? = null,
val createdAt: Long = System.currentTimeMillis(),
)
class SafeAgentDiagnosticRecorder(
private val persist: (AgentDiagnosticEvent) -> Unit,
private val onFailure: (Throwable) -> Unit = {},
) {
fun record(event: AgentDiagnosticEvent) {
runCatching { persist(event) }.onFailure { runCatching { onFailure(it) } }
}
}
internal object AgentDiagnosticRetentionPolicy {
const val MAX_RECORDS = 50
const val RETENTION_MILLIS = 7L * 24 * 60 * 60 * 1_000
fun cutoff(createdAt: Long): Long = createdAt - RETENTION_MILLIS
}
class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
override fun onCreate(db: SQLiteDatabase) {
db.execSQL(
"""CREATE TABLE agent_diagnostic (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
stage TEXT NOT NULL,
reason TEXT NOT NULL,
attempt INTEGER NOT NULL,
elapsed_ms INTEGER NOT NULL,
package_matched INTEGER,
activity_matched INTEGER,
candidate_count INTEGER,
target_class_name TEXT,
target_clickable INTEGER,
clickable_ancestor_depth INTEGER,
target_zone TEXT,
clipboard_poll_count INTEGER,
clipboard_mime_accepted INTEGER,
clipboard_item_count INTEGER,
clipboard_content_length INTEGER,
clipboard_timestamp_fresh INTEGER,
agent_version TEXT NOT NULL,
created_at INTEGER NOT NULL
)""".trimIndent(),
)
db.execSQL("CREATE INDEX idx_agent_diagnostic_task ON agent_diagnostic(task_id, id)")
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = Unit
@Synchronized
fun record(event: AgentDiagnosticEvent) {
require(event.taskId > 0)
val db = writableDatabase
db.beginTransaction()
try {
val values = ContentValues().apply {
put("task_id", event.taskId)
put("stage", event.stage.name)
put("reason", event.reason.name)
put("attempt", event.attempt.coerceAtLeast(0))
put("elapsed_ms", event.elapsedMs.coerceAtLeast(0))
putNullableBoolean("package_matched", event.packageMatched)
putNullableBoolean("activity_matched", event.activityMatched)
putNullableInt("candidate_count", event.candidateCount)
event.targetClassName?.let { put("target_class_name", it.take(MAX_CLASS_NAME_CHARS)) }
putNullableBoolean("target_clickable", event.targetClickable)
putNullableInt("clickable_ancestor_depth", event.clickableAncestorDepth)
event.targetZone?.takeIf(ALLOWED_ZONES::contains)?.let { put("target_zone", it) }
putNullableInt("clipboard_poll_count", event.clipboardPollCount)
putNullableBoolean("clipboard_mime_accepted", event.clipboardMimeAccepted)
putNullableInt("clipboard_item_count", event.clipboardItemCount)
putNullableInt("clipboard_content_length", event.clipboardContentLength)
putNullableBoolean("clipboard_timestamp_fresh", event.clipboardTimestampFresh)
put("agent_version", BuildConfig.VERSION_NAME)
put("created_at", event.createdAt)
}
check(db.insert("agent_diagnostic", null, values) != -1L)
db.delete("agent_diagnostic", "created_at < ?", arrayOf(AgentDiagnosticRetentionPolicy.cutoff(event.createdAt).toString()))
db.execSQL(
"DELETE FROM agent_diagnostic WHERE id NOT IN " +
"(SELECT id FROM agent_diagnostic ORDER BY id DESC LIMIT ${AgentDiagnosticRetentionPolicy.MAX_RECORDS})",
)
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
}
private fun ContentValues.putNullableBoolean(key: String, value: Boolean?) {
value?.let { put(key, if (it) 1 else 0) }
}
private fun ContentValues.putNullableInt(key: String, value: Int?) {
value?.let { put(key, it.coerceAtLeast(0)) }
}
companion object {
private const val DATABASE_NAME = "goauto_diagnostics.db"
private const val DATABASE_VERSION = 1
private const val MAX_CLASS_NAME_CHARS = 160
private val ALLOWED_ZONES = setOf("top-left", "top-center", "top-right", "middle", "bottom")
}
}
@@ -49,6 +49,8 @@ import cn.ilapage.goauto.agent.network.PurchaseAgentTask
import cn.ilapage.goauto.agent.network.ServerUrlPolicy import cn.ilapage.goauto.agent.network.ServerUrlPolicy
import cn.ilapage.goauto.agent.persistence.PurchaseTaskStore import cn.ilapage.goauto.agent.persistence.PurchaseTaskStore
import cn.ilapage.goauto.agent.persistence.PurchaseOutboxUploader import cn.ilapage.goauto.agent.persistence.PurchaseOutboxUploader
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStore
import cn.ilapage.goauto.agent.persistence.SafeAgentDiagnosticRecorder
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.util.concurrent.Executors import java.util.concurrent.Executors
@@ -63,6 +65,7 @@ import java.util.UUID
class AgentForegroundService : Service() { class AgentForegroundService : Service() {
private val executor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor() private val executor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
private val taskExecutor: ExecutorService = Executors.newSingleThreadExecutor() private val taskExecutor: ExecutorService = Executors.newSingleThreadExecutor()
private val diagnosticExecutor: ExecutorService = Executors.newSingleThreadExecutor()
private val taskMutex = TaskExecutionMutex() private val taskMutex = TaskExecutionMutex()
private val runningTaskId = AtomicReference<Long?>(null) private val runningTaskId = AtomicReference<Long?>(null)
private val working = AtomicBoolean(false) private val working = AtomicBoolean(false)
@@ -79,6 +82,8 @@ class AgentForegroundService : Service() {
private lateinit var settingsStore: AgentSettingsStore private lateinit var settingsStore: AgentSettingsStore
private lateinit var stateStore: AgentStateStore private lateinit var stateStore: AgentStateStore
private lateinit var purchaseStore: PurchaseTaskStore private lateinit var purchaseStore: PurchaseTaskStore
private lateinit var diagnosticStore: AgentDiagnosticStore
private lateinit var diagnosticRecorder: SafeAgentDiagnosticRecorder
private lateinit var connectivityManager: ConnectivityManager private lateinit var connectivityManager: ConnectivityManager
private val networkCallback = object : ConnectivityManager.NetworkCallback() { private val networkCallback = object : ConnectivityManager.NetworkCallback() {
@@ -95,6 +100,16 @@ class AgentForegroundService : Service() {
// Wake locks and cooldown tickets are process-local. Never restore a stale UI flag. // Wake locks and cooldown tickets are process-local. Never restore a stale UI flag.
stateStore.setKeepScreenOn(false) stateStore.setKeepScreenOn(false)
purchaseStore = PurchaseTaskStore(this) purchaseStore = PurchaseTaskStore(this)
diagnosticStore = AgentDiagnosticStore(this)
diagnosticRecorder = SafeAgentDiagnosticRecorder(
persist = { event ->
diagnosticExecutor.execute {
runCatching { diagnosticStore.record(event) }
.onFailure { error -> Log.w("GoAutoDiagnostic", "agent diagnostic write failed: ${error.javaClass.simpleName}") }
}
},
onFailure = { error -> Log.w("GoAutoDiagnostic", "agent diagnostic write failed: ${error.javaClass.simpleName}") },
)
val restoredPurchaseTaskId = purchaseStore.activeTaskId() val restoredPurchaseTaskId = purchaseStore.activeTaskId()
runningTaskId.set(restoredPurchaseTaskId) runningTaskId.set(restoredPurchaseTaskId)
stateStore.clearActiveTask() stateStore.clearActiveTask()
@@ -126,7 +141,11 @@ class AgentForegroundService : Service() {
releaseTaskWakeLock() releaseTaskWakeLock()
executor.shutdownNow() executor.shutdownNow()
taskExecutor.shutdownNow() taskExecutor.shutdownNow()
diagnosticExecutor.shutdown()
runCatching { diagnosticExecutor.awaitTermination(1, TimeUnit.SECONDS) }
diagnosticExecutor.shutdownNow()
purchaseStore.close() purchaseStore.close()
diagnosticStore.close()
super.onDestroy() super.onDestroy()
} }
@@ -592,6 +611,8 @@ class AgentForegroundService : Service() {
readFreshClipboard = { copiedAt, timeout -> readFreshClipboard = { copiedAt, timeout ->
ClipboardRelayActivity.readFresh(this, copiedAt, timeout) ClipboardRelayActivity.readFresh(this, copiedAt, timeout)
}, },
taskId = task.taskId,
diagnostic = diagnosticRecorder::record,
).identify(rule) ).identify(rule)
if (!identityResult.successful) { if (!identityResult.successful) {
throw TaskFailure(identityResult.code, identityResult.message) throw TaskFailure(identityResult.code, identityResult.message)
@@ -0,0 +1,36 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticEvent
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticReason
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticRetentionPolicy
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage
import cn.ilapage.goauto.agent.persistence.SafeAgentDiagnosticRecorder
import org.junit.Assert.assertEquals
import org.junit.Test
class AgentDiagnosticRecorderTest {
@Test
fun diagnosticsAreBoundedToFiftyRecordsAndSevenDays() {
assertEquals(50, AgentDiagnosticRetentionPolicy.MAX_RECORDS)
assertEquals(1_000L, AgentDiagnosticRetentionPolicy.cutoff(AgentDiagnosticRetentionPolicy.RETENTION_MILLIS + 1_000L))
}
@Test
fun diagnosticWriteFailureNeverEscapesIntoCollectionFlow() {
var failureCount = 0
val recorder = SafeAgentDiagnosticRecorder(
persist = { error("database unavailable") },
onFailure = { failureCount++ },
)
recorder.record(
AgentDiagnosticEvent(
taskId = 70,
stage = AgentDiagnosticStage.SHARE_CLICK,
reason = AgentDiagnosticReason.ACTION_CLICK_FALSE,
),
)
assertEquals(1, failureCount)
}
}
@@ -1,11 +1,14 @@
package cn.ilapage.goauto.agent package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.CollectionRule import cn.ilapage.goauto.agent.automation.CollectionRule
import cn.ilapage.goauto.agent.automation.ClipboardReadOutcome
import cn.ilapage.goauto.agent.automation.CurrentPageIdentityConfig import cn.ilapage.goauto.agent.automation.CurrentPageIdentityConfig
import cn.ilapage.goauto.agent.automation.CurrentPageIdentityRunner import cn.ilapage.goauto.agent.automation.CurrentPageIdentityRunner
import cn.ilapage.goauto.agent.automation.CurrentPageNavigationPolicy import cn.ilapage.goauto.agent.automation.CurrentPageNavigationPolicy
import cn.ilapage.goauto.agent.automation.CurrentPageRestoreDecision import cn.ilapage.goauto.agent.automation.CurrentPageRestoreDecision
import cn.ilapage.goauto.agent.automation.FreshActionResult import cn.ilapage.goauto.agent.automation.FreshActionResult
import cn.ilapage.goauto.agent.automation.FreshClickOutcome
import cn.ilapage.goauto.agent.automation.FreshClickReason
import cn.ilapage.goauto.agent.automation.NodeBounds import cn.ilapage.goauto.agent.automation.NodeBounds
import cn.ilapage.goauto.agent.automation.NodeSelector import cn.ilapage.goauto.agent.automation.NodeSelector
import cn.ilapage.goauto.agent.automation.PageEvidence import cn.ilapage.goauto.agent.automation.PageEvidence
@@ -14,6 +17,8 @@ import cn.ilapage.goauto.agent.automation.PddShareLinkExtractor
import cn.ilapage.goauto.agent.automation.SnapshotNode import cn.ilapage.goauto.agent.automation.SnapshotNode
import cn.ilapage.goauto.agent.automation.SwipeDirection import cn.ilapage.goauto.agent.automation.SwipeDirection
import cn.ilapage.goauto.agent.automation.UiSnapshot import cn.ilapage.goauto.agent.automation.UiSnapshot
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticEvent
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticReason
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
@@ -78,12 +83,13 @@ class CurrentPageCollectionTest {
@Test @Test
fun identifiesCurrentDetailThroughUniqueShareAndFreshClipboard() { fun identifiesCurrentDetailThroughUniqueShareAndFreshClipboard() {
val driver = FakeDriver(listOf(detailSnapshot(), sharePanelSnapshot(), sharePanelSnapshot())) var currentTime = 0L
val driver = FakeDriver(List(6) { detailSnapshot() } + sharePanelSnapshot())
val result = CurrentPageIdentityRunner( val result = CurrentPageIdentityRunner(
driver = driver, driver = driver,
readFreshClipboard = { _, _ -> "复制成功 https://p.pinduoduo.com/abc123" }, readFreshClipboard = { _, _ -> ClipboardReadOutcome("复制成功 https://p.pinduoduo.com/abc123") },
now = { 1_000L }, now = { currentTime },
pause = {}, pause = { currentTime += it },
).identify(rule()) ).identify(rule())
assertTrue(result.successful) assertTrue(result.successful)
@@ -107,7 +113,39 @@ class CurrentPageCollectionTest {
assertEquals("PDD_SHARE_UNAVAILABLE", result.code) assertEquals("PDD_SHARE_UNAVAILABLE", result.code)
} }
private class FakeDriver(private val snapshots: List<UiSnapshot>) : PddCollectorDriver { @Test
fun retriesFreshShareLookupWithinBudgetAndRecordsStructuredReasons() {
var currentTime = 0L
val events = mutableListOf<AgentDiagnosticEvent>()
val driver = FakeDriver(
snapshots = List(7) { detailSnapshot() } + sharePanelSnapshot(),
freshOutcomes = ArrayDeque(
listOf(
FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.TARGET_NOT_FOUND),
FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS, candidateCount = 1, clickableAncestorDepth = 1),
),
),
)
val result = CurrentPageIdentityRunner(
driver = driver,
readFreshClipboard = { _, _ -> ClipboardReadOutcome("https://p.pinduoduo.com/retry", pollCount = 2, mimeAccepted = true, itemCount = 1, contentLength = 36, timestampFresh = true) },
taskId = 70,
diagnostic = events::add,
now = { currentTime },
pause = { currentTime += it },
).identify(rule())
assertTrue(result.successful)
assertTrue(events.any { it.reason == AgentDiagnosticReason.TARGET_NOT_FOUND })
assertTrue(events.any { it.reason == AgentDiagnosticReason.CLICK_SUCCESS && it.clickableAncestorDepth == 1 })
assertTrue(events.any { it.reason == AgentDiagnosticReason.CLIPBOARD_SUCCESS && it.clipboardPollCount == 2 })
}
private class FakeDriver(
private val snapshots: List<UiSnapshot>,
private val freshOutcomes: ArrayDeque<FreshClickOutcome> = ArrayDeque(),
) : PddCollectorDriver {
private var index = 0 private var index = 0
val clicked = mutableListOf<String>() val clicked = mutableListOf<String>()
var backCalled = false var backCalled = false
@@ -117,6 +155,14 @@ class CurrentPageCollectionTest {
clicked += target.label clicked += target.label
return FreshActionResult.SUCCESS return FreshActionResult.SUCCESS
} }
override fun clickFreshDetailed(target: SnapshotNode): FreshClickOutcome {
clicked += target.label
return if (freshOutcomes.isEmpty()) {
FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS, candidateCount = 1, clickableAncestorDepth = 0)
} else {
freshOutcomes.removeFirst()
}
}
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?) = false override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?) = false
override fun pullDownGoodsPage() = false override fun pullDownGoodsPage() = false
override fun back(): Boolean { backCalled = true; return true } override fun back(): Boolean { backCalled = true; return true }