From 2ea32fd6786f8cdd2ff26e99db41bf310089c7b2 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 10 Sep 2026 17:11:51 +0800 Subject: [PATCH] fix(android): retain one-use probe panel handoff for purchase #262 --- android/app/build.gradle.kts | 4 +- .../automation/PurchaseRehearsalExecutor.kt | 41 +++++++++++++++--- .../persistence/PurchaseDiagnosticStore.kt | 5 ++- .../agent/service/AgentForegroundService.kt | 16 ++++++- .../agent/service/PurchaseProbeHandoff.kt | 23 ++++++++++ .../agent/PurchaseRehearsalExecutorTest.kt | 43 ++++++++++++++++++- .../agent/service/PurchaseProbeHandoffTest.kt | 39 +++++++++++++++++ 7 files changed, 157 insertions(+), 14 deletions(-) create mode 100644 android/app/src/main/java/cn/ilapage/goauto/agent/service/PurchaseProbeHandoff.kt create mode 100644 android/app/src/test/java/cn/ilapage/goauto/agent/service/PurchaseProbeHandoffTest.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b7d19bb..6282ab0 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 = 88 - versionName = "0.9.75" + versionCode = 89 + versionName = "0.9.76" 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 d076bfb..e5e3c9d 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 @@ -93,6 +93,8 @@ class PurchaseRehearsalExecutor( private val stepChanged: (String) -> Unit = {}, private val panelDiagnostic: (String) -> Unit = {}, private val beforeOrderSubmit: (FinalConfirmationEvidence) -> Unit = { throw PurchaseLiveException("PURCHASE_MODE_NOT_ALLOWED", "当前执行器没有正式采购授权") }, + private val probeHandoffActivity: String? = null, + private val onProbeHandoff: (String) -> Unit = {}, ) { private var purchasePanelContext: PurchasePanelContext? = null private var pageIdentity: Pair = null to null @@ -132,7 +134,7 @@ class PurchaseRehearsalExecutor( } PurchaseActionType.VERIFY_ORDER_SUMMARY -> if (input.phase == "spec_probe") null else verifySummary(input, observedPrice, specSelectionProofs) PurchaseActionType.PROBE_SPECS -> { - if (input.phase == "spec_probe") return probeOutcome() + if (input.phase == "spec_probe") return probeOutcome(input) null } PurchaseActionType.UPDATE_SHIPPING_ADDRESS -> try { @@ -177,7 +179,7 @@ class PurchaseRehearsalExecutor( input.specResolutionAllowed && PurchaseActionType.PROBE_SPECS in rule.actions.map { it.type } ) { - return probeOutcome() + return probeOutcome(input) } return failure } @@ -188,10 +190,33 @@ class PurchaseRehearsalExecutor( else failure("PURCHASE_RULE_INVALID", "正式采购规则缺少核单动作") } - private fun canReuseCurrentProduct(input: PurchaseExecutionInput): Boolean = - currentScreen(input).let { screen -> - screen.problem == null && screen.hasPurchaseProductEvidence() + private fun canReuseCurrentProduct(input: PurchaseExecutionInput): Boolean { + if (probeHandoffActivity == null) { + val screen = currentScreen(input) + val reuse = screen.problem == null && screen.hasPurchaseProductEvidence() + panelDiagnostic("handoffPresent=false;productReused=$reuse;reusePolls=1;reuseReason=legacy_evidence") + return reuse } + var consecutive = 0 + repeat(10) { poll -> + purchaseEntryIdentity = PDD_PACKAGE to probeHandoffActivity + val screen = currentScreen(input) + if ((pageIdentity.first != null && pageIdentity != (PDD_PACKAGE to probeHandoffActivity)) || screen.problem != null) { + purchaseEntryIdentity = null + panelDiagnostic("handoffPresent=true;productReused=false;reusePolls=${poll + 1};reuseReason=${if (screen.problem != null) "page_problem" else "page_changed"}") + return false + } + consecutive = if (screen.hasPurchaseProductEvidence()) consecutive + 1 else 0 + if (consecutive >= 2) { + panelDiagnostic("handoffPresent=true;productReused=true;reusePolls=${poll + 1};reuseReason=handoff_ready") + return true + } + if (poll < 9) pause(200) + } + purchaseEntryIdentity = null + panelDiagnostic("handoffPresent=true;productReused=false;reusePolls=10;reuseReason=evidence_timeout") + return false + } private fun validateBeforeDeviceAction( input: PurchaseExecutionInput, @@ -1199,9 +1224,13 @@ class PurchaseRehearsalExecutor( return null } - private fun probeOutcome(): PurchaseExecutionOutcome { + private fun probeOutcome(input: PurchaseExecutionInput): PurchaseExecutionOutcome { stepChanged("probeSpecs") val result = probeSpecs() ?: return failure("PURCHASE_SPEC_NOT_MATCHED", "商品规格探测失败") + val screen = currentScreen(input) + if (input.phase == "spec_probe" && screen.problem == null && screen.isPddPackage && screen.specPanelOpen) { + pageIdentity.second?.takeIf(String::isNotBlank)?.let(onProbeHandoff) + } return PurchaseExecutionOutcome("spec_probe_completed", message = "商品规格已回传,等待服务端匹配", probedSpecs = result) } 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 index 6a181df..6843010 100644 --- 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 @@ -60,10 +60,11 @@ class PurchaseDiagnosticStore(private val directory: File, private val now: () - private val numbers = setOf("specEntryCandidates", "explicit", "nested", "bottomPurchase", "entryReadyWaitPolls", "entryReadyWaitMillis", "scrollables", "headings", "options", "entryWaitPolls", "entryWaitMillis", "addressEditorCandidates", "addressEditorPolls", "savedLegacySubmitCandidates", "savedAddressCards", - "savedAddressTargets", "savedScrollCandidates", "savedConfirmationPolls") + "savedAddressTargets", "savedScrollCandidates", "savedConfirmationPolls", "reusePolls") private val booleans = setOf("panelAlreadyOpen", "reviewPage", "pageEvidence", "pageChanged", "summary", "quantity", - "orderAction", "closeControl", "paymentArea", "savedAddressMatched", "savedPanelMatched") + "orderAction", "closeControl", "paymentArea", "savedAddressMatched", "savedPanelMatched", "handoffPresent", "productReused") private val enums = mapOf( + "reuseReason" to setOf("legacy_evidence", "page_problem", "page_changed", "handoff_ready", "evidence_timeout"), "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(), 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 02ed1b2..006c3d6 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 @@ -87,6 +87,7 @@ class AgentForegroundService : Service() { private val collectionCooldownWakeActive = AtomicBoolean(false) private val currentPageResultNotificationUntil = AtomicLong(0L) private val idleReturn = IdleReturnCoordinator() + private val purchaseProbeHandoff = PurchaseProbeHandoff() private var taskWakeLock: PowerManager.WakeLock? = null private var cooldownWakeLock: PowerManager.WakeLock? = null private var collectionCooldownWakeLock: PowerManager.WakeLock? = null @@ -283,7 +284,7 @@ class AgentForegroundService : Service() { evaluateIdleReturn() return "$MANUAL_COLLECTION_COOLDOWN_PREFIX${CollectionCooldownPolicy.remainingSeconds(System.currentTimeMillis(), ticket)}" } - TaskDispatchDecision.CHECK_COLLECTION -> Unit + TaskDispatchDecision.CHECK_COLLECTION -> purchaseProbeHandoff.clear() } val task = api.nextTask(token) if (task == null) { @@ -291,6 +292,7 @@ class AgentForegroundService : Service() { return MANUAL_EMPTY } cancelIdleReturn("收到新的采集任务") + purchaseProbeHandoff.clear() if (!taskMutex.tryAcquire(task.taskId)) return MANUAL_BUSY if (task.status == "running") runningTaskId.set(task.taskId) stateStore.setActiveTask(task.taskId, "collection") @@ -495,6 +497,9 @@ class AgentForegroundService : Service() { val diagnosticContext = PurchaseDiagnosticContext(task.taskId, task.taskAttemptId, diagnosticDeviceId, snapshotHash, BuildConfig.VERSION_NAME, task.phase) val diagnosticStarted = SystemClock.elapsedRealtime() + val handoffKey = PurchaseProbeHandoff.Key(task.taskId, task.pddGoodsId, snapshotHash, diagnosticDeviceId) + val handoffActivity = purchaseProbeHandoff.consume(handoffKey, task.phase, + accessibility.currentForegroundRevision(), diagnosticStarted) val recordDiagnostic: (String) -> Unit = { evidence -> val elapsed = SystemClock.elapsedRealtime() - diagnosticStarted runCatching { diagnosticExecutor.execute { purchaseDiagnostics.record(diagnosticContext, elapsed, evidence) } } @@ -502,6 +507,9 @@ class AgentForegroundService : Service() { } recordDiagnostic("event=started") PurchaseRehearsalExecutor( + probeHandoffActivity = handoffActivity, + onProbeHandoff = { activity -> purchaseProbeHandoff.remember(handoffKey, activity, + accessibility.currentForegroundRevision(), SystemClock.elapsedRealtime()) }, driver = accessibility, openLink = { PddLinkLauncher(this).open(it, preferDirect = true) }, probeSpecs = { collectPurchaseProbe(accessibility, task, parsedRule) }, @@ -550,6 +558,7 @@ class AgentForegroundService : Service() { } val requestId = UUID.randomUUID().toString() val payload = purchaseResultPayload(requestId, task.taskAttemptId, outcome) + if (outcome.resultType != "spec_probe_completed") purchaseProbeHandoff.clear() purchaseStore.completeAndEnqueue(task.taskId, task.taskAttemptId, requestId, payload) resultSafelyStored = true PurchaseResultBubblePolicy.create( @@ -570,7 +579,10 @@ class AgentForegroundService : Service() { } catch (error: Exception) { stateStore.update("TASK_ERROR", error.message ?: "采购演练执行异常", tokenStored = true) } finally { - if (!resultSafelyStored) cancelIdleReturn("采购结果未安全保存") + if (!resultSafelyStored) { + purchaseProbeHandoff.clear() + cancelIdleReturn("采购结果未安全保存") + } releaseTaskWakeLock() } } diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/PurchaseProbeHandoff.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/PurchaseProbeHandoff.kt new file mode 100644 index 0000000..d06c271 --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/PurchaseProbeHandoff.kt @@ -0,0 +1,23 @@ +package cn.ilapage.goauto.agent.service + +/** One-use, process-local recognition context; never contains nodes or selection proofs. */ +internal class PurchaseProbeHandoff { + data class Key(val taskId: Long, val goodsId: String, val ruleHash: String, val deviceId: Long) + private data class Ticket(val key: Key, val activity: String, val revision: Long, val time: Long) + private var ticket: Ticket? = null + + @Synchronized fun remember(key: Key, activity: String, revision: Long, now: Long) { + ticket = Ticket(key, activity, revision, now) + } + + @Synchronized fun consume(key: Key, phase: String, revision: Long, now: Long): String? { + val previous = ticket + ticket = null + return previous?.takeIf { + phase == "purchase" && it.key == key && it.revision == revision && + now - it.time in 0..180_000 && it.activity.isNotBlank() + }?.activity + } + + @Synchronized fun clear() { ticket = null } +} 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 b0fded3..dfb900b 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 @@ -28,6 +28,45 @@ import org.junit.Assert.assertTrue import org.junit.Test class PurchaseRehearsalExecutorTest { + @Test fun `single heading probe hands recognition to new purchase executor without opening link`() { + val driver = FakePurchaseDriver(singleHeadingOnEntry = true) + var handoff: String? = null + val probe = PurchaseRehearsalExecutor(driver, { true }, { "{}" }, pause = {}, onProbeHandoff = { handoff = it }) + .execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + assertEquals("spec_probe_completed", probe.resultType) + assertTrue(handoff != null) + var opens = 0 + val logs = mutableListOf() + PurchaseRehearsalExecutor(driver, { opens++; true }, { null }, pause = {}, + probeHandoffActivity = handoff, panelDiagnostic = logs::add) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + assertEquals(0, opens) + assertTrue(logs.any { it.contains("handoffPresent=true;productReused=true;reusePolls=2;") }) + } + + @Test fun `handoff tolerates transient empty tree but never reuses another app`() { + for (mode in listOf("transient", "otherApp", "timeout")) { + val original = FakePurchaseDriver(singleHeadingOnEntry = true) + var handoff: String? = null + PurchaseRehearsalExecutor(original, { true }, { "{}" }, pause = {}, onProbeHandoff = { handoff = it }) + .execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + var captures = 0 + var opens = 0 + val driver = object : PurchaseUiDriver by original { + override fun capture(): UiSnapshot { + captures++ + return if (captures <= if (mode == "timeout") 10 else 2) UiSnapshot(if (mode == "otherApp") "cn.ilapage.goauto.agent" else null, null, emptyList()) else original.capture() + } + } + val logs = mutableListOf() + PurchaseRehearsalExecutor(driver, { opens++; true }, { null }, pause = {}, + probeHandoffActivity = handoff, panelDiagnostic = logs::add) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + assertEquals(if (mode == "transient") 0 else 1, opens) + assertTrue(logs.any { it.contains("productReused=${mode == "transient"}") }) + if (mode == "timeout") assertTrue(logs.any { it.contains("reusePolls=10;reuseReason=evidence_timeout") }) + } + } @Test fun `safe entry with one visible heading completes probe without reopening`() { val driver = FakePurchaseDriver(singleHeadingOnEntry = true) val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = {}) @@ -1054,10 +1093,10 @@ class PurchaseRehearsalExecutorTest { assertEquals("PURCHASE_SPEC_ENTRY_NOT_FOUND", outcome.errorCode) assertEquals( "specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=20;entryReadyWaitMillis=2000", - diagnostics.single(), + diagnostics.single { it.startsWith("specEntryCandidates=") }, ) assertEquals(21, pauses.count { it == 100L }) - assertFalse(diagnostics.single().contains("选择规格")) + assertFalse(diagnostics.any { it.contains("选择规格") }) } @Test diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/service/PurchaseProbeHandoffTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/service/PurchaseProbeHandoffTest.kt new file mode 100644 index 0000000..718a183 --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/service/PurchaseProbeHandoffTest.kt @@ -0,0 +1,39 @@ +package cn.ilapage.goauto.agent.service + +import org.junit.Assert.* +import org.junit.Test + +class PurchaseProbeHandoffTest { + private val key = PurchaseProbeHandoff.Key(107, "123456", "a".repeat(64), 7) + + @Test fun `same task consumes once and manual retry cannot inherit`() { + val store = PurchaseProbeHandoff() + store.remember(key, "PddActivity", 3, 100) + assertEquals("PddActivity", store.consume(key, "purchase", 3, 60_100)) + assertNull(store.consume(key, "purchase", 3, 60_101)) + } + + @Test fun `mismatched task goods rule device phase revision and expiry reject and consume`() { + for (other in listOf(key.copy(taskId = 108), key.copy(goodsId = "654321"), + key.copy(ruleHash = "b".repeat(64)), key.copy(deviceId = 8))) { + val store = PurchaseProbeHandoff() + store.remember(key, "PddActivity", 3, 100) + assertNull(store.consume(other, "purchase", 3, 101)) + assertNull(store.consume(key, "purchase", 3, 102)) + } + for ((phase, revision, now) in listOf(Triple("spec_probe", 3L, 101L), + Triple("purchase", 4L, 101L), Triple("purchase", 3L, 180_101L), Triple("purchase", 3L, 99L))) { + val store = PurchaseProbeHandoff() + store.remember(key, "PddActivity", 3, 100) + assertNull(store.consume(key, phase, revision, now)) + } + } + + @Test fun `clear and process recreation discard context`() { + val store = PurchaseProbeHandoff() + store.remember(key, "PddActivity", 3, 100) + store.clear() + assertNull(store.consume(key, "purchase", 3, 101)) + assertNull(PurchaseProbeHandoff().consume(key, "purchase", 3, 101)) + } +}