diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 6c88213..616ce4d 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 = 79 - versionName = "0.9.66" + versionCode = 80 + versionName = "0.9.67" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt index ba0b73c..a8862d2 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt @@ -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): 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)) } diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/PurchaseDiagnosticStore.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/PurchaseDiagnosticStore.kt new file mode 100644 index 0000000..2b7a7f4 --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/PurchaseDiagnosticStore.kt @@ -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 { 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 + } + } +} 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 5afaaec..e23c503 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 @@ -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 -> diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt index c7e970d..bbd7de4 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt @@ -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) diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/persistence/PurchaseDiagnosticStoreTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/persistence/PurchaseDiagnosticStoreTest.kt new file mode 100644 index 0000000..1768cb8 --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/persistence/PurchaseDiagnosticStoreTest.kt @@ -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()) + } +}