fix(agent): stabilize PDD share action (#106)
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+311
-17
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-6
@@ -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 {
|
||||||
|
|||||||
+21
@@ -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
|
||||||
|
|||||||
+153
@@ -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 }
|
||||||
|
|||||||
Reference in New Issue
Block a user