From 1d1618ce0221856864faf674666a0c27e836bd7d Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 27 Aug 2026 11:57:44 +0800 Subject: [PATCH] fix(agent): stabilize PDD share action (#106) --- android/app/build.gradle.kts | 4 +- .../goauto/agent/ClipboardRelayActivity.kt | 51 ++- .../agent/automation/CurrentPageCollection.kt | 328 +++++++++++++++++- .../automation/GoAutoAccessibilityService.kt | 26 +- .../automation/PddProductDetailCollector.kt | 21 ++ .../agent/persistence/AgentDiagnosticStore.kt | 153 ++++++++ .../agent/service/AgentForegroundService.kt | 21 ++ .../agent/AgentDiagnosticRecorderTest.kt | 36 ++ .../goauto/agent/CurrentPageCollectionTest.kt | 56 ++- 9 files changed, 658 insertions(+), 38 deletions(-) create mode 100644 android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt create mode 100644 android/app/src/test/java/cn/ilapage/goauto/agent/AgentDiagnosticRecorderTest.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 176cbdd..48ab44f 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 = 15 - versionName = "0.9.2" + versionCode = 16 + versionName = "0.9.3" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/ClipboardRelayActivity.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/ClipboardRelayActivity.kt index 7e5f3bc..75fd221 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/ClipboardRelayActivity.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/ClipboardRelayActivity.kt @@ -10,6 +10,7 @@ import android.os.Bundle import android.os.Handler import android.os.Looper import android.os.SystemClock +import cn.ilapage.goauto.agent.automation.ClipboardReadOutcome import java.util.UUID import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CountDownLatch @@ -21,6 +22,11 @@ class ClipboardRelayActivity : Activity() { private val pollRunnable = Runnable { readOrRetry() } private var completed = false 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?) { super.onCreate(savedInstanceState) @@ -38,7 +44,7 @@ class ClipboardRelayActivity : Activity() { handler.removeCallbacks(pollRunnable) if (!completed) { completed = true - deliver(intent.getStringExtra(EXTRA_REQUEST_ID).orEmpty(), null) + deliver(intent.getStringExtra(EXTRA_REQUEST_ID).orEmpty(), outcome(null)) } super.onDestroy() } @@ -56,6 +62,16 @@ class ClipboardRelayActivity : Activity() { val hasPlainText = description?.hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN) == true val clip = if (hasPlainText) clipboard.primaryClip else null 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( descriptionPresent = description != null, timestampMillis = if (timestampSupported) description?.timestamp ?: 0L else 0L, @@ -80,10 +96,19 @@ class ClipboardRelayActivity : Activity() { if (completed) return completed = true handler.removeCallbacks(pollRunnable) - deliver(intent.getStringExtra(EXTRA_REQUEST_ID).orEmpty(), value) + deliver(intent.getStringExtra(EXTRA_REQUEST_ID).orEmpty(), outcome(value)) finishRelay() } + private fun outcome(value: String?) = ClipboardReadOutcome( + text = value, + pollCount = pollCount, + mimeAccepted = lastMimeAccepted, + itemCount = lastItemCount, + contentLength = lastContentLength, + timestampFresh = lastTimestampFresh, + ) + private fun finishRelay() { finishAndRemoveTask() overridePendingTransition(0, 0) @@ -96,7 +121,7 @@ class ClipboardRelayActivity : Activity() { private const val POLL_INTERVAL_MILLIS = 100L private val pending = ConcurrentHashMap() - 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 requestId = UUID.randomUUID().toString() val request = PendingRead() @@ -112,14 +137,14 @@ class ClipboardRelayActivity : Activity() { }.getOrDefault(false) if (!started) { pending.remove(requestId) - return null + return ClipboardReadOutcome(text = null) } request.latch.await(boundedTimeout, TimeUnit.MILLISECONDS) 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 -> request.value.set(value) request.latch.countDown() @@ -129,7 +154,7 @@ class ClipboardRelayActivity : Activity() { private class PendingRead { val latch = CountDownLatch(1) - val value = AtomicReference(null) + val value = AtomicReference(null) } } @@ -175,7 +200,17 @@ internal object ClipboardContentPolicy { minTimestampMillis: Long, ): String? { 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 } } + + fun isTimestampFresh( + descriptionPresent: Boolean, + timestampMillis: Long, + timestampSupported: Boolean, + minTimestampMillis: Long, + ): Boolean { + if (!descriptionPresent) return false + return !timestampSupported || (timestampMillis > 0L && timestampMillis >= minTimestampMillis - TIMESTAMP_TOLERANCE_MILLIS) + } } diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/CurrentPageCollection.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/CurrentPageCollection.kt index cecd1a3..2aa436d 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/CurrentPageCollection.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/CurrentPageCollection.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.net.URI data class CurrentPageIdentityResult( @@ -9,6 +12,15 @@ data class CurrentPageIdentityResult( 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 { READY, BACK_FROM_AGENT, @@ -49,26 +61,40 @@ object PddShareLinkExtractor { class CurrentPageIdentityRunner( 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 pause: (Long) -> Unit = Thread::sleep, ) { fun identify(rule: CollectionRule): CurrentPageIdentityResult { + val startedAt = now() val evidence = rule.pageEvidence ?: return failure("RULE_INVALID", "规则缺少商品页证据") val config = rule.currentPageIdentity - val detail = waitForPageEvidence(evidence, config.pageTimeoutMs) - ?: return failure("CURRENT_PDD_PAGE_NOT_FOUND", "没有找到商品详情页") - detailProblem(detail)?.let { return failure(it.code, it.message) } - val share = uniqueAction(detail, config.shareAliases, topOnly = true) - ?: return failure("PDD_SHARE_UNAVAILABLE", "没有找到商品分享入口") - when (driver.clickFresh(share)) { - FreshActionResult.SUCCESS -> Unit - FreshActionResult.AMBIGUOUS -> return failure("PDD_SHARE_UNAVAILABLE", "商品分享入口不唯一") - else -> return failure("PDD_SHARE_UNAVAILABLE", "无法打开商品分享") + val prepared = waitForStableShare(evidence, config.shareAliases, config.pageTimeoutMs, startedAt) + prepared.problem?.let { return failure(it.code, it.message) } + if (prepared.failureMessage != null) { + return failure(prepared.failureCode ?: "PDD_SHARE_UNAVAILABLE", prepared.failureMessage) + } + val clicked = clickShareWithRetry(evidence, config.shareAliases, startedAt) + if (!clicked.successful) { + return failure("PDD_SHARE_UNAVAILABLE", clicked.message) } try { 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) ?: return failure("PDD_COPY_LINK_UNAVAILABLE", "复制链接入口不唯一") val copiedAt = now() @@ -77,7 +103,14 @@ class CurrentPageIdentityRunner( FreshActionResult.AMBIGUOUS -> 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", "无法读取刚复制的商品链接") val shareUrl = PddShareLinkExtractor.extract(raw) ?: 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, + timeoutMillis: Long, + startedAt: Long, + ): SharePreparation { val deadline = now() + timeoutMillis + var previous: SnapshotNode? = null + var stableSince = 0L + var attempts = 0 + var lastCandidateCount = 0 + var lastSnapshot: UiSnapshot? = null do { val snapshot = driver.capture() - detailProblem(snapshot)?.let { return snapshot } - if (matchesEvidence(snapshot, evidence)) return snapshot + lastSnapshot = 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) } 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, + 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, timeoutMillis: Long): UiSnapshot? { @@ -119,6 +302,10 @@ class CurrentPageIdentityRunner( } private fun uniqueAction(snapshot: UiSnapshot, aliases: List, topOnly: Boolean): SnapshotNode? { + return actionCandidates(snapshot, aliases, topOnly).singleOrNull() + } + + private fun actionCandidates(snapshot: UiSnapshot, aliases: List, topOnly: Boolean): List { val screenHeight = snapshot.nodes.maxOfOrNull { it.bounds.bottom } ?: 0 val candidates = snapshot.nodes.filter { node -> 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 } - 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? = @@ -147,8 +420,29 @@ class CurrentPageIdentityRunner( 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 { 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" } } 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 4863cef..cdd94ad 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 @@ -206,8 +206,10 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto return UiSnapshot(root.packageName?.toString(), currentActivity(), nodes) } - override fun clickFresh(target: SnapshotNode): FreshActionResult { - val root = rootInActiveWindow ?: return FreshActionResult.NOT_FOUND + override fun clickFresh(target: SnapshotNode): FreshActionResult = clickFreshDetailed(target).result + + override fun clickFreshDetailed(target: SnapshotNode): FreshClickOutcome { + val root = rootInActiveWindow ?: return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.ROOT_UNAVAILABLE) val candidates = mutableListOf() walk(root) { node -> val label = node.preferredOrDescendantLabel() @@ -218,11 +220,23 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto kotlin.math.abs(bounds.centerY() - target.bounds.centerY) <= 32 ) candidates += node } - if (candidates.isEmpty()) return FreshActionResult.NOT_FOUND - if (candidates.size != 1) return FreshActionResult.AMBIGUOUS + if (candidates.isEmpty()) return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.TARGET_NOT_FOUND) + if (candidates.size != 1) return FreshClickOutcome(FreshActionResult.AMBIGUOUS, FreshClickReason.TARGET_AMBIGUOUS, candidates.size) var node = candidates.single() - while (!node.isClickable) node = node.parent ?: return FreshActionResult.FAILED - return if (node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) FreshActionResult.SUCCESS else FreshActionResult.FAILED + var ancestorDepth = 0 + 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 { 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 1ff3bf5..75bd7cd 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 @@ -37,9 +37,30 @@ data class UiSnapshot( 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 { fun capture(): UiSnapshot 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 pullDownGoodsPage(): Boolean fun back(): Boolean diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt new file mode 100644 index 0000000..bb20b8d --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt @@ -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") + } +} 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 de8f8fa..297c01f 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 @@ -49,6 +49,8 @@ import cn.ilapage.goauto.agent.network.PurchaseAgentTask import cn.ilapage.goauto.agent.network.ServerUrlPolicy import cn.ilapage.goauto.agent.persistence.PurchaseTaskStore 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.JSONObject import java.util.concurrent.Executors @@ -63,6 +65,7 @@ import java.util.UUID class AgentForegroundService : Service() { private val executor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor() private val taskExecutor: ExecutorService = Executors.newSingleThreadExecutor() + private val diagnosticExecutor: ExecutorService = Executors.newSingleThreadExecutor() private val taskMutex = TaskExecutionMutex() private val runningTaskId = AtomicReference(null) private val working = AtomicBoolean(false) @@ -79,6 +82,8 @@ class AgentForegroundService : Service() { private lateinit var settingsStore: AgentSettingsStore private lateinit var stateStore: AgentStateStore private lateinit var purchaseStore: PurchaseTaskStore + private lateinit var diagnosticStore: AgentDiagnosticStore + private lateinit var diagnosticRecorder: SafeAgentDiagnosticRecorder private lateinit var connectivityManager: ConnectivityManager 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. stateStore.setKeepScreenOn(false) 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() runningTaskId.set(restoredPurchaseTaskId) stateStore.clearActiveTask() @@ -126,7 +141,11 @@ class AgentForegroundService : Service() { releaseTaskWakeLock() executor.shutdownNow() taskExecutor.shutdownNow() + diagnosticExecutor.shutdown() + runCatching { diagnosticExecutor.awaitTermination(1, TimeUnit.SECONDS) } + diagnosticExecutor.shutdownNow() purchaseStore.close() + diagnosticStore.close() super.onDestroy() } @@ -592,6 +611,8 @@ class AgentForegroundService : Service() { readFreshClipboard = { copiedAt, timeout -> ClipboardRelayActivity.readFresh(this, copiedAt, timeout) }, + taskId = task.taskId, + diagnostic = diagnosticRecorder::record, ).identify(rule) if (!identityResult.successful) { throw TaskFailure(identityResult.code, identityResult.message) diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/AgentDiagnosticRecorderTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/AgentDiagnosticRecorderTest.kt new file mode 100644 index 0000000..6a90816 --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/AgentDiagnosticRecorderTest.kt @@ -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) + } +} diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/CurrentPageCollectionTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/CurrentPageCollectionTest.kt index b7900cf..772d653 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/CurrentPageCollectionTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/CurrentPageCollectionTest.kt @@ -1,11 +1,14 @@ package cn.ilapage.goauto.agent 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.CurrentPageIdentityRunner import cn.ilapage.goauto.agent.automation.CurrentPageNavigationPolicy import cn.ilapage.goauto.agent.automation.CurrentPageRestoreDecision 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.NodeSelector 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.SwipeDirection 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.assertFalse import org.junit.Assert.assertNull @@ -78,12 +83,13 @@ class CurrentPageCollectionTest { @Test fun identifiesCurrentDetailThroughUniqueShareAndFreshClipboard() { - val driver = FakeDriver(listOf(detailSnapshot(), sharePanelSnapshot(), sharePanelSnapshot())) + var currentTime = 0L + val driver = FakeDriver(List(6) { detailSnapshot() } + sharePanelSnapshot()) val result = CurrentPageIdentityRunner( driver = driver, - readFreshClipboard = { _, _ -> "复制成功 https://p.pinduoduo.com/abc123" }, - now = { 1_000L }, - pause = {}, + readFreshClipboard = { _, _ -> ClipboardReadOutcome("复制成功 https://p.pinduoduo.com/abc123") }, + now = { currentTime }, + pause = { currentTime += it }, ).identify(rule()) assertTrue(result.successful) @@ -107,7 +113,39 @@ class CurrentPageCollectionTest { assertEquals("PDD_SHARE_UNAVAILABLE", result.code) } - private class FakeDriver(private val snapshots: List) : PddCollectorDriver { + @Test + fun retriesFreshShareLookupWithinBudgetAndRecordsStructuredReasons() { + var currentTime = 0L + val events = mutableListOf() + 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, + private val freshOutcomes: ArrayDeque = ArrayDeque(), + ) : PddCollectorDriver { private var index = 0 val clicked = mutableListOf() var backCalled = false @@ -117,6 +155,14 @@ class CurrentPageCollectionTest { clicked += target.label 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 pullDownGoodsPage() = false override fun back(): Boolean { backCalled = true; return true }