feat(android): persist bounded sanitized purchase entry diagnostics #249
This commit is contained in:
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "cn.ilapage.goauto.agent"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 79
|
||||
versionName = "0.9.66"
|
||||
versionCode = 80
|
||||
versionName = "0.9.67"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
+3
-2
@@ -425,6 +425,7 @@ class PurchaseRehearsalExecutor(
|
||||
wait.failure?.let { return it }
|
||||
if (wait.opened) return null
|
||||
if (wait.changed && recoverableSpecEntry(screen, wait.screen, action, beforeIdentity) == null) {
|
||||
panelDiagnostic("entryRecovery=rejected;reason=$entryRecoveryReason;pageChanged=${wait.changed};${panelEvidence(wait.screen)}")
|
||||
return failure(
|
||||
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
|
||||
"规格入口手势后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
|
||||
@@ -500,9 +501,9 @@ class PurchaseRehearsalExecutor(
|
||||
private fun waitForSpecPanel(input: PurchaseExecutionInput, beforeSignature: List<SpecActionNodeSignature>): SpecPanelWait {
|
||||
var last = currentScreen(input)
|
||||
var changed = false
|
||||
repeat(SPEC_POST_CLICK_VERIFY_POLLS) {
|
||||
repeat(SPEC_POST_CLICK_VERIFY_POLLS) { poll ->
|
||||
last = currentScreen(input)
|
||||
panelDiagnostic(panelEvidence(last))
|
||||
panelDiagnostic("${panelEvidence(last)};entryWaitPolls=${poll + 1};entryWaitMillis=${poll * SPEC_SELECTION_POLL_MILLIS};closeControl=${last.hasCloseControl};paymentArea=${last.hasPaymentArea}")
|
||||
if (last.reviewPageOpen) {
|
||||
return SpecPanelWait(last, false, true, leaveUnexpectedReviewPage(input))
|
||||
}
|
||||
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
package cn.ilapage.goauto.agent.persistence
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.FreshActionResult
|
||||
import cn.ilapage.goauto.agent.automation.FreshClickReason
|
||||
import java.io.File
|
||||
import org.json.JSONObject
|
||||
|
||||
data class PurchaseDiagnosticContext(
|
||||
val taskId: Long,
|
||||
val attemptId: String,
|
||||
val deviceId: Long,
|
||||
val ruleHash: String,
|
||||
val agentVersion: String,
|
||||
val phase: String,
|
||||
)
|
||||
|
||||
/** Private, bounded diagnostics only. Neither raw evidence nor write errors escape this store. */
|
||||
class PurchaseDiagnosticStore(private val directory: File, private val now: () -> Long = System::currentTimeMillis) {
|
||||
@Synchronized
|
||||
fun record(context: PurchaseDiagnosticContext, elapsedMs: Long, evidence: String): Boolean = runCatching {
|
||||
require(context.taskId > 0 && context.deviceId > 0 && ID.matches(context.attemptId))
|
||||
require(HASH.matches(context.ruleHash) && VERSION.matches(context.agentVersion))
|
||||
require(context.phase in setOf("purchase", "spec_probe"))
|
||||
val fields = sanitize(evidence)
|
||||
if (fields.length() == 0) return false
|
||||
val timestamp = now()
|
||||
val line = JSONObject().put("taskId", context.taskId).put("attemptId", context.attemptId)
|
||||
.put("deviceId", context.deviceId).put("ruleHash", context.ruleHash)
|
||||
.put("agentVersion", context.agentVersion).put("phase", context.phase)
|
||||
.put("timestamp", timestamp).put("elapsedMs", elapsedMs.coerceAtLeast(0))
|
||||
.put("evidence", fields).toString()
|
||||
check(directory.isDirectory || directory.mkdirs())
|
||||
val file = File(directory, "${context.taskId}_${context.attemptId}.jsonl")
|
||||
file.appendText(line + "\n", Charsets.UTF_8)
|
||||
val lines = file.readLines(Charsets.UTF_8)
|
||||
if (lines.size > MAX_EVENTS) file.writeText(lines.takeLast(MAX_EVENTS).joinToString("\n", postfix = "\n"), Charsets.UTF_8)
|
||||
file.setLastModified(timestamp)
|
||||
prune(timestamp)
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
|
||||
@Synchronized
|
||||
fun prune(timestamp: Long = now()) {
|
||||
runCatching {
|
||||
val files = directory.listFiles()?.filter { it.isFile && FILE_NAME.matches(it.name) }.orEmpty()
|
||||
files.filter { it.lastModified() < timestamp - RETENTION_MS }.forEach { it.delete() }
|
||||
files.filter(File::exists).sortedWith(compareByDescending<File> { it.lastModified() }.thenBy { it.name })
|
||||
.drop(MAX_ATTEMPTS).forEach { it.delete() }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MAX_ATTEMPTS = 5
|
||||
const val MAX_EVENTS = 128
|
||||
const val RETENTION_MS = 7L * 24 * 60 * 60 * 1000
|
||||
private val ID = Regex("[A-Za-z0-9-]{1,80}")
|
||||
private val HASH = Regex("[0-9a-f]{64}")
|
||||
private val VERSION = Regex("[0-9]+\\.[0-9]+\\.[0-9]+")
|
||||
private val FILE_NAME = Regex("[1-9][0-9]*_[A-Za-z0-9-]{1,80}\\.jsonl")
|
||||
private val numbers = setOf("specEntryCandidates", "explicit", "nested", "bottomPurchase", "entryReadyWaitPolls",
|
||||
"entryReadyWaitMillis", "scrollables", "headings", "options", "entryWaitPolls", "entryWaitMillis")
|
||||
private val booleans = setOf("panelAlreadyOpen", "reviewPage", "pageEvidence", "pageChanged", "summary", "quantity",
|
||||
"orderAction", "closeControl", "paymentArea")
|
||||
private val enums = mapOf(
|
||||
"event" to setOf("started"),
|
||||
"entrySource" to setOf("unknown", "explicit_selection", "nested_selection", "bottom_purchase", "bottom_purchase_rightmost"),
|
||||
"entryClick" to FreshActionResult.values().map { it.name }.toSet(),
|
||||
"entryReason" to FreshClickReason.values().map { it.name }.toSet(),
|
||||
"gesture" to FreshActionResult.values().map { it.name }.toSet(),
|
||||
"entryRecovery" to setOf("rejected", "attempted"),
|
||||
"type" to setOf("UNKNOWN", "NORMAL_SCROLLABLE", "NON_SCROLLABLE_CONFIRMATION", "ORDER_CONFIRMATION", "QUICK_CONFIRMATION"),
|
||||
"reason" to setOf("identity_missing", "identity_changed", "product_context_missing", "page_problem", "panel_open",
|
||||
"close_control", "payment_area", "order_action", "quantity_controls", "selection_summary", "title_missing",
|
||||
"title_changed", "entry_missing", "entry_source_missing", "entry_source_changed", "entry_label_changed",
|
||||
"entry_ambiguous", "rule_alias_mismatch", "entry_unavailable", "eligible"),
|
||||
)
|
||||
|
||||
internal fun sanitize(evidence: String): JSONObject {
|
||||
val result = JSONObject()
|
||||
evidence.take(4096).split(';').take(40).forEach { part ->
|
||||
val key = part.substringBefore('=')
|
||||
val value = part.substringAfter('=', "")
|
||||
when {
|
||||
key in numbers && value.matches(Regex("[0-9]{1,8}")) -> result.put(key, value.toLong())
|
||||
key in booleans && value in setOf("true", "false") -> result.put(key, value == "true")
|
||||
value in enums[key].orEmpty() -> result.put(key, value)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,9 @@ import android.os.PowerManager
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import cn.ilapage.goauto.agent.BuildConfig
|
||||
import cn.ilapage.goauto.agent.persistence.PurchaseDiagnosticContext
|
||||
import cn.ilapage.goauto.agent.persistence.PurchaseDiagnosticStore
|
||||
import java.io.File
|
||||
import cn.ilapage.goauto.agent.ClipboardRelayActivity
|
||||
import cn.ilapage.goauto.agent.MainActivity
|
||||
import cn.ilapage.goauto.agent.R
|
||||
@@ -91,6 +94,7 @@ class AgentForegroundService : Service() {
|
||||
private lateinit var settingsStore: AgentSettingsStore
|
||||
private lateinit var stateStore: AgentStateStore
|
||||
private lateinit var purchaseStore: PurchaseTaskStore
|
||||
private lateinit var purchaseDiagnostics: PurchaseDiagnosticStore
|
||||
private lateinit var diagnosticStore: AgentDiagnosticStore
|
||||
private lateinit var diagnosticRecorder: SafeAgentDiagnosticRecorder
|
||||
private lateinit var connectivityManager: ConnectivityManager
|
||||
@@ -109,6 +113,8 @@ class AgentForegroundService : Service() {
|
||||
// Wake locks and cooldown tickets are process-local. Never restore a stale UI flag.
|
||||
stateStore.setKeepScreenOn(false)
|
||||
purchaseStore = PurchaseTaskStore(this)
|
||||
purchaseDiagnostics = PurchaseDiagnosticStore(File(filesDir, "purchase_diagnostics"))
|
||||
runCatching { diagnosticExecutor.execute { purchaseDiagnostics.prune() } }
|
||||
diagnosticStore = AgentDiagnosticStore(this)
|
||||
diagnosticRecorder = SafeAgentDiagnosticRecorder(
|
||||
persist = { event ->
|
||||
@@ -480,6 +486,15 @@ class AgentForegroundService : Service() {
|
||||
} else {
|
||||
val diagnosticDeviceId = runCatching { identityStore.credentials()?.deviceId ?: 0L }.getOrDefault(0L)
|
||||
val diagnosticAttempt = task.taskAttemptId.takeIf { it.matches(Regex("^[a-zA-Z0-9-]{1,80}$")) } ?: "invalid"
|
||||
val diagnosticContext = PurchaseDiagnosticContext(task.taskId, task.taskAttemptId, diagnosticDeviceId,
|
||||
snapshotHash, BuildConfig.VERSION_NAME, task.phase)
|
||||
val diagnosticStarted = SystemClock.elapsedRealtime()
|
||||
val recordDiagnostic: (String) -> Unit = { evidence ->
|
||||
val elapsed = SystemClock.elapsedRealtime() - diagnosticStarted
|
||||
runCatching { diagnosticExecutor.execute { purchaseDiagnostics.record(diagnosticContext, elapsed, evidence) } }
|
||||
Unit
|
||||
}
|
||||
recordDiagnostic("event=started")
|
||||
PurchaseRehearsalExecutor(
|
||||
driver = accessibility,
|
||||
openLink = { PddLinkLauncher(this).open(it, preferDirect = true) },
|
||||
@@ -489,6 +504,7 @@ class AgentForegroundService : Service() {
|
||||
purchaseStore.updateStep(task.taskId, task.taskAttemptId, step)
|
||||
},
|
||||
panelDiagnostic = { evidence ->
|
||||
recordDiagnostic(evidence)
|
||||
Log.i("GoAutoPurchasePanel", "task=${task.taskId};attempt=$diagnosticAttempt;device=$diagnosticDeviceId;rule=$snapshotHash;$evidence")
|
||||
},
|
||||
beforeOrderSubmit = { evidence ->
|
||||
|
||||
@@ -16,13 +16,42 @@ 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.PendingPurchaseOutbox
|
||||
import cn.ilapage.goauto.agent.persistence.PurchaseDiagnosticContext
|
||||
import cn.ilapage.goauto.agent.persistence.PurchaseDiagnosticStore
|
||||
import cn.ilapage.goauto.agent.persistence.PurchaseOutboxUploader
|
||||
import org.json.JSONObject
|
||||
import org.junit.Rule
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseRehearsalExecutorTest {
|
||||
@get:Rule val diagnosticTemporary = TemporaryFolder()
|
||||
|
||||
@Test
|
||||
fun `entry rejection persists without changing outcome when diagnostic storage fails`() {
|
||||
for (writable in listOf(true, false)) {
|
||||
val location = if (writable) diagnosticTemporary.newFolder() else diagnosticTemporary.newFile()
|
||||
val store = PurchaseDiagnosticStore(location)
|
||||
val context = PurchaseDiagnosticContext(81, "attempt-14", 7, "a".repeat(64), "0.9.67", "purchase")
|
||||
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true, productOverlay = true,
|
||||
specTapResult = FreshActionResult.SUCCESS)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {},
|
||||
panelDiagnostic = { store.record(context, 0, it); Unit })
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
|
||||
assertEquals(0, driver.specTapCount)
|
||||
if (writable) {
|
||||
val events = location.listFiles()!!.single().readLines().map { JSONObject(it).getJSONObject("evidence") }
|
||||
assertEquals("close_control", events.last().getString("reason"))
|
||||
assertTrue(events.any { it.optInt("entryWaitPolls") == 30 })
|
||||
assertTrue(events.any { it.optString("entryClick") == "SUCCESS" })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `color selection then single size heading completes without selecting color again`() {
|
||||
val driver = FakePurchaseDriver(prefixlessSingleHeadingAfterColor = true)
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package cn.ilapage.goauto.agent.persistence
|
||||
|
||||
import java.io.File
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
|
||||
class PurchaseDiagnosticStoreTest {
|
||||
@get:Rule val temporary = TemporaryFolder()
|
||||
private val context = PurchaseDiagnosticContext(81, "attempt-14", 7, "a".repeat(64), "0.9.67", "purchase")
|
||||
|
||||
@Test fun `only known structural fields and fixed values survive`() {
|
||||
val output = PurchaseDiagnosticStore.sanitize(
|
||||
"entryClick=SUCCESS;entryRecovery=rejected;reason=title_changed;summary=false;options=20;" +
|
||||
"title=敏感标题;address=测试地址;token=secret;entrySource=private-value;gesture=private-value;" +
|
||||
"quantity=private-value;scrollables=-1;unknown=private-value",
|
||||
)
|
||||
assertEquals(5, output.length())
|
||||
assertEquals("title_changed", output.getString("reason"))
|
||||
assertFalse(output.getBoolean("summary"))
|
||||
assertFalse(output.toString().contains("private"))
|
||||
assertFalse(output.toString().contains("secret"))
|
||||
assertEquals(0, PurchaseDiagnosticStore.sanitize("reason=测试地址;entryReason=secret").length())
|
||||
}
|
||||
|
||||
@Test fun `events persist across store recreation with identity and elapsed time`() {
|
||||
val dir = temporary.newFolder()
|
||||
assertTrue(PurchaseDiagnosticStore(dir) { 1000 }.record(context, 2300,
|
||||
"entryRecovery=rejected;reason=close_control;entryWaitPolls=30;entryWaitMillis=2900;closeControl=true"))
|
||||
assertTrue(PurchaseDiagnosticStore(dir) { 2000 }.record(context, 2500, "entryClick=SUCCESS"))
|
||||
val lines = dir.listFiles()!!.single().readLines()
|
||||
assertEquals(2, lines.size)
|
||||
val entry = JSONObject(lines.first())
|
||||
assertEquals(81, entry.getLong("taskId"))
|
||||
assertEquals("attempt-14", entry.getString("attemptId"))
|
||||
assertEquals(7, entry.getLong("deviceId"))
|
||||
assertEquals(context.ruleHash, entry.getString("ruleHash"))
|
||||
assertEquals(2300, entry.getLong("elapsedMs"))
|
||||
assertEquals("0.9.67", entry.getString("agentVersion"))
|
||||
assertEquals("close_control", entry.getJSONObject("evidence").getString("reason"))
|
||||
}
|
||||
|
||||
@Test fun `retains last five attempts and last 128 events including failure`() {
|
||||
val dir = temporary.newFolder()
|
||||
var time = 1000L
|
||||
val store = PurchaseDiagnosticStore(dir) { time++ }
|
||||
repeat(7) { store.record(context.copy(attemptId = "attempt-$it"), 0, "event=started") }
|
||||
assertEquals(5, dir.listFiles()!!.size)
|
||||
assertFalse(File(dir, "81_attempt-0.jsonl").exists())
|
||||
repeat(150) { store.record(context, it.toLong(), "entryWaitPolls=$it") }
|
||||
store.record(context, 999, "entryRecovery=rejected;reason=entry_label_changed")
|
||||
val lines = File(dir, "81_attempt-14.jsonl").readLines()
|
||||
assertEquals(128, lines.size)
|
||||
assertEquals("entry_label_changed", JSONObject(lines.last()).getJSONObject("evidence").getString("reason"))
|
||||
assertEquals(5, dir.listFiles()!!.size)
|
||||
}
|
||||
|
||||
@Test fun `expiry deletes only owned files and can run at startup`() {
|
||||
val dir = temporary.newFolder()
|
||||
PurchaseDiagnosticStore(dir) { 1000 }.record(context, 0, "event=started")
|
||||
File(dir, "unrelated.txt").writeText("preserve")
|
||||
File(dir, "unrelated.txt").setLastModified(1000)
|
||||
PurchaseDiagnosticStore(dir) { PurchaseDiagnosticStore.RETENTION_MS + 1001 }.prune()
|
||||
assertEquals(listOf("unrelated.txt"), dir.listFiles()!!.map { it.name })
|
||||
}
|
||||
|
||||
@Test fun `invalid context and filesystem failures never throw`() {
|
||||
val store = PurchaseDiagnosticStore(temporary.newFile())
|
||||
assertFalse(store.record(context, 1, "event=started"))
|
||||
store.prune()
|
||||
val dir = temporary.newFolder()
|
||||
val validStore = PurchaseDiagnosticStore(dir)
|
||||
assertFalse(validStore.record(context.copy(attemptId = "../outside"), 1, "event=started"))
|
||||
assertFalse(validStore.record(context.copy(ruleHash = "secret"), 1, "event=started"))
|
||||
assertFalse(validStore.record(context.copy(phase = "secret"), 1, "event=started"))
|
||||
assertFalse(validStore.record(context, 1, "unknown=secret"))
|
||||
assertTrue(dir.listFiles()!!.isEmpty())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user