From 5859a819c8d39ae91a1bca238a6ca720436ad7fe Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 5 Sep 2026 10:01:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent):=20=E6=98=BE=E7=A4=BA=E9=87=87?= =?UTF-8?q?=E8=B4=AD=E7=BB=93=E6=9E=9C=E6=B0=94=E6=B3=A1=20(#218)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/build.gradle.kts | 4 +- .../cn/ilapage/goauto/agent/MainActivity.kt | 1 + .../automation/GoAutoAccessibilityService.kt | 26 +++- .../agent/service/AgentForegroundService.kt | 17 ++- .../ui/PurchaseResultBubbleController.kt | 114 ++++++++++++++++++ .../agent/ui/PurchaseResultBubblePolicy.kt | 86 +++++++++++++ android/app/src/main/res/values/colors.xml | 4 + .../agent/PurchaseResultBubblePolicyTest.kt | 72 +++++++++++ server/app/goauto/apprelease/apk_test.go | 2 +- 9 files changed, 317 insertions(+), 9 deletions(-) create mode 100644 android/app/src/main/java/cn/ilapage/goauto/agent/ui/PurchaseResultBubbleController.kt create mode 100644 android/app/src/main/java/cn/ilapage/goauto/agent/ui/PurchaseResultBubblePolicy.kt create mode 100644 android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseResultBubblePolicyTest.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index ff3d6d4..8c083e7 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 = 53 - versionName = "0.9.40" + versionCode = 54 + versionName = "0.9.41" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/MainActivity.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/MainActivity.kt index 49f93ec..2c441ce 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/MainActivity.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/MainActivity.kt @@ -49,6 +49,7 @@ class MainActivity : AppCompatActivity() { override fun onResume() { super.onResume() + cn.ilapage.goauto.agent.automation.GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble() screenPolicyHandler.removeCallbacks(screenPolicyRefresh) screenPolicyHandler.post(screenPolicyRefresh) } diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt index efc3bd7..477285a 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt @@ -18,6 +18,8 @@ import android.util.Log import android.view.Display import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityNodeInfo +import cn.ilapage.goauto.agent.ui.PurchaseResultBubbleController +import cn.ilapage.goauto.agent.ui.PurchaseResultBubblePresentation import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean @@ -32,6 +34,9 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto ActivityEvidenceTracker { packageName, className -> isDeclaredActivity(packageName, className) } } private var accessibilityButtonCallback: AccessibilityButtonController.AccessibilityButtonCallback? = null + private val purchaseResultBubble by lazy { + PurchaseResultBubbleController(this) { currentPackage() == PDD_PACKAGE } + } override fun onServiceConnected() { serviceInfo = serviceInfo.apply { @@ -48,18 +53,19 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto override fun onAccessibilityEvent(event: AccessibilityEvent?) { if (event?.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) { - val packageName = event.packageName?.toString() - if (packageName != null && packageName != lastForegroundPackage) { - lastForegroundPackage = packageName + val foregroundPackage = event.packageName?.toString() + if (foregroundPackage != null && foregroundPackage != lastForegroundPackage) { + lastForegroundPackage = foregroundPackage foregroundRevision.incrementAndGet() } - if (packageName == PDD_PACKAGE) lastPddForegroundAt.set(SystemClock.elapsedRealtime()) - activityTracker.observe(packageName, event.className?.toString()) + if (foregroundPackage == PDD_PACKAGE) lastPddForegroundAt.set(SystemClock.elapsedRealtime()) + activityTracker.observe(foregroundPackage, event.className?.toString()) } } override fun onInterrupt() = Unit override fun onDestroy() { + dismissPurchaseResultBubble() unregisterAccessibilityButton() if (instance === this) instance = null super.onDestroy() @@ -71,6 +77,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto runCatching { val callback = object : AccessibilityButtonController.AccessibilityButtonCallback() { override fun onClicked(controller: AccessibilityButtonController) { + dismissPurchaseResultBubble() AccessibilityButtonPolicy.handleClick(::openAgentPreservingTab) } } @@ -115,6 +122,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto ).restore(packageName, timeoutMillis) fun openAgentPreservingTab(): Boolean = runCatching { + dismissPurchaseResultBubble() startActivity( android.content.Intent(this, cn.ilapage.goauto.agent.MainActivity::class.java).apply { addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP or android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP) @@ -123,6 +131,14 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto true }.getOrDefault(false) + fun showPurchaseResultBubble(presentation: PurchaseResultBubblePresentation) { + purchaseResultBubble.show(presentation) + } + + fun dismissPurchaseResultBubble() { + purchaseResultBubble.dismiss() + } + override fun visibleTexts(): List { val root = rootInActiveWindow ?: return emptyList() val texts = mutableListOf() 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 4743593..7ee100e 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 @@ -57,6 +57,7 @@ import cn.ilapage.goauto.agent.persistence.AgentDiagnosticEvent import cn.ilapage.goauto.agent.persistence.AgentDiagnosticReason import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage import cn.ilapage.goauto.agent.persistence.SafeAgentDiagnosticRecorder +import cn.ilapage.goauto.agent.ui.PurchaseResultBubblePolicy import org.json.JSONArray import org.json.JSONObject import java.util.concurrent.Executors @@ -434,8 +435,10 @@ class AgentForegroundService : Service() { } private fun executePurchaseTask(api: AgentApiClient, initial: PurchaseAgentTask, token: String) { + GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble() acquireTaskWakeLock() var resultSafelyStored = false + val lastStep = AtomicReference("started") try { val claimed = if (initial.status == "pending") { api.claimPurchaseTask(initial.taskId, UUID.randomUUID().toString(), token) @@ -472,7 +475,10 @@ class AgentForegroundService : Service() { driver = accessibility, openLink = { PddLinkLauncher(this).open(it) }, probeSpecs = { collectPurchaseProbe(accessibility, task, parsedRule) }, - stepChanged = { step -> purchaseStore.updateStep(task.taskId, task.taskAttemptId, step) }, + stepChanged = { step -> + lastStep.set(step) + purchaseStore.updateStep(task.taskId, task.taskAttemptId, step) + }, panelDiagnostic = { evidence -> Log.i("GoAutoPurchasePanel", "task=${task.taskId};$evidence") }, beforeOrderSubmit = { evidence -> val boundaryRequestId = UUID.randomUUID().toString() @@ -513,6 +519,14 @@ class AgentForegroundService : Service() { val payload = purchaseResultPayload(requestId, task.taskAttemptId, outcome) purchaseStore.completeAndEnqueue(task.taskId, task.taskAttemptId, requestId, payload) resultSafelyStored = true + PurchaseResultBubblePolicy.create( + taskId = task.taskId, + resultType = outcome.resultType, + lastStep = lastStep.get(), + resultMessage = outcome.message, + )?.let { presentation -> + GoAutoAccessibilityService.instance?.showPurchaseResultBubble(presentation) + } beginIdleReturnCooldown() flushPurchaseOutbox(api, token) val message = if (outcome.resultType == "failed") "${outcome.errorCode}:${outcome.message}" else outcome.message @@ -611,6 +625,7 @@ class AgentForegroundService : Service() { initialTask: cn.ilapage.goauto.agent.network.AgentTask, token: String, ): TaskExecutionSummary { + GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble() acquireTaskWakeLock() return try { executeTaskWhileAwake(api, initialTask, token) diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/ui/PurchaseResultBubbleController.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/PurchaseResultBubbleController.kt new file mode 100644 index 0000000..b1c6774 --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/PurchaseResultBubbleController.kt @@ -0,0 +1,114 @@ +package cn.ilapage.goauto.agent.ui + +import android.content.Context +import android.graphics.PixelFormat +import android.graphics.drawable.GradientDrawable +import android.os.Handler +import android.os.Looper +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.LinearLayout +import android.widget.TextView +import cn.ilapage.goauto.agent.R + +class PurchaseResultBubbleController( + private val context: Context, + private val canShow: () -> Boolean, +) { + private val mainHandler = Handler(Looper.getMainLooper()) + private val windowManager = context.getSystemService(WindowManager::class.java) + private val session = PurchaseResultBubbleSession() + private var bubbleView: View? = null + + fun show(presentation: PurchaseResultBubblePresentation) { + val revision = session.replace() + mainHandler.post { + if (!session.isCurrent(revision)) return@post + removeCurrentView() + if (!canShow()) return@post + val view = buildView(presentation) + runCatching { windowManager.addView(view, layoutParams()) } + .onSuccess { + bubbleView = view + mainHandler.postDelayed({ + if (session.isCurrent(revision)) { + session.dismiss() + removeCurrentView() + } + }, presentation.durationMillis) + } + } + } + + fun dismiss() { + session.dismiss() + mainHandler.post(::removeCurrentView) + } + + private fun buildView(presentation: PurchaseResultBubblePresentation): View { + val horizontalPadding = context.dp(16) + val verticalPadding = context.dp(12) + return LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + setPadding(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding) + elevation = context.dp(8).toFloat() + background = GradientDrawable().apply { + cornerRadius = context.dp(16).toFloat() + setColor(context.getColor(if (presentation.isFailure) R.color.purchase_result_failure_background else R.color.purchase_result_success_background)) + } + contentDescription = "${presentation.title}。${presentation.message}" + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES + descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS + addView(TextView(context).apply { + text = presentation.title + setTextColor(context.getColor(R.color.purchase_result_text)) + textSize = 14f + setTypeface(typeface, android.graphics.Typeface.BOLD) + maxLines = 2 + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + }, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)) + addView(TextView(context).apply { + text = presentation.message + setTextColor(context.getColor(R.color.purchase_result_text_secondary)) + textSize = 13f + maxLines = 3 + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + }, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply { + topMargin = context.dp(4) + }) + } + } + + private fun layoutParams(): WindowManager.LayoutParams { + val availableWidth = context.resources.displayMetrics.widthPixels - context.dp(32) + return WindowManager.LayoutParams( + availableWidth.coerceAtMost(context.dp(360)), + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or + WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or + WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL, + PixelFormat.TRANSLUCENT, + ).apply { + gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL + y = context.statusBarHeight() + context.dp(8) + windowAnimations = android.R.style.Animation_Toast + } + } + + private fun removeCurrentView() { + val view = bubbleView ?: return + bubbleView = null + runCatching { windowManager.removeViewImmediate(view) } + } + + private fun Context.dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() + + @Suppress("DiscouragedApi") + private fun Context.statusBarHeight(): Int { + val resourceId = resources.getIdentifier("status_bar_height", "dimen", "android") + return if (resourceId > 0) resources.getDimensionPixelSize(resourceId) else dp(24) + } +} diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/ui/PurchaseResultBubblePolicy.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/PurchaseResultBubblePolicy.kt new file mode 100644 index 0000000..08b8d4d --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/PurchaseResultBubblePolicy.kt @@ -0,0 +1,86 @@ +package cn.ilapage.goauto.agent.ui + +data class PurchaseResultBubblePresentation( + val title: String, + val message: String, + val isFailure: Boolean, + val durationMillis: Long, +) + +object PurchaseResultBubblePolicy { + const val SUCCESS_DURATION_MILLIS = 3_000L + const val FAILURE_DURATION_MILLIS = 8_000L + private const val MAX_MESSAGE_LENGTH = 96 + + private val stepLabels = mapOf( + "started" to "准备采购", + "openProduct" to "打开商品", + "verifyProduct" to "核对商品", + "openSpecPanel" to "打开规格面板", + "selectSpec" to "选择颜色与尺码", + "setQuantity" to "设置数量", + "verifyUnitPrice" to "核对价格", + "verifyOrderSummary" to "核对订单", + "probeSpecs" to "采集规格", + "updateShippingAddress" to "更新收货地址", + "createOrder" to "创建待付款订单", + "order_submit_started" to "创建待付款订单", + "readOrderResult" to "读取订单编号和下单时间", + ) + + fun create( + taskId: Long, + resultType: String, + lastStep: String?, + resultMessage: String?, + ): PurchaseResultBubblePresentation? { + if (resultType == "spec_probe_completed") return null + val failure = resultType == "failed" || resultType == "order_result_unknown" + if (failure) { + val label = stepLabels[lastStep] ?: "处理采购任务" + return PurchaseResultBubblePresentation( + title = if (resultType == "order_result_unknown") "CG-$taskId 待人工核对|$label" else "CG-$taskId 失败于:$label", + message = sanitize(resultMessage, "采购失败,请返回 Agent 查看详情"), + isFailure = true, + durationMillis = FAILURE_DURATION_MILLIS, + ) + } + val successMessage = when (resultType) { + "order_created" -> "已获取订单编号和下单时间" + "rehearsal_completed" -> "商品、规格、数量和价格复核完成" + else -> sanitize(resultMessage, "采购结果已安全保存") + } + return PurchaseResultBubblePresentation( + title = "CG-$taskId 采购完成", + message = successMessage, + isFailure = false, + durationMillis = SUCCESS_DURATION_MILLIS, + ) + } + + private fun sanitize(value: String?, fallback: String): String { + val normalized = value.orEmpty() + .replace(Regex("https?://\\S+", RegexOption.IGNORE_CASE), "[链接已隐藏]") + .replace(Regex("(?i)(token|authorization|cookie)\\s*[:=]\\s*\\S+")) { match -> + "${match.groupValues[1]}=[已隐藏]" + } + .replace(Regex("[\\r\\n\\t]+"), " ") + .replace(Regex("\\s{2,}"), " ") + .trim() + .ifBlank { fallback } + return normalized.take(MAX_MESSAGE_LENGTH) + } +} + +class PurchaseResultBubbleSession { + private var revision = 0L + + @Synchronized + fun replace(): Long = ++revision + + @Synchronized + fun dismiss(): Long = ++revision + + @Synchronized + fun isCurrent(candidate: Long): Boolean = candidate == revision +} diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index 8f66706..865bfa3 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -8,4 +8,8 @@ #CBD5E1 #FBBF24 #F87171 + #166534 + #991B1B + #FFFFFF + #F1F5F9 diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseResultBubblePolicyTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseResultBubblePolicyTest.kt new file mode 100644 index 0000000..bc5716c --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseResultBubblePolicyTest.kt @@ -0,0 +1,72 @@ +package cn.ilapage.goauto.agent + +import cn.ilapage.goauto.agent.ui.PurchaseResultBubblePolicy +import cn.ilapage.goauto.agent.ui.PurchaseResultBubbleSession +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class PurchaseResultBubblePolicyTest { + @Test + fun `spec probe does not display a final result bubble`() { + assertNull(PurchaseResultBubblePolicy.create(69, "spec_probe_completed", "probeSpecs", "规格已回传")) + } + + @Test + fun `failure includes the real step and sanitized reason for eight seconds`() { + val result = requireNotNull(PurchaseResultBubblePolicy.create( + 69, + "failed", + "selectSpec", + "没有找到精确规格\n请重试 token=secret-value", + )) + + assertEquals("CG-69 失败于:选择颜色与尺码", result.title) + assertEquals("没有找到精确规格 请重试 token=[已隐藏]", result.message) + assertTrue(result.isFailure) + assertEquals(8_000L, result.durationMillis) + } + + @Test + fun `unknown order result is presented as a failure requiring manual check`() { + val result = requireNotNull(PurchaseResultBubblePolicy.create( + 70, + "order_result_unknown", + "readOrderResult", + "无法确认订单是否创建,请人工检查", + )) + + assertEquals("CG-70 待人工核对|读取订单编号和下单时间", result.title) + assertTrue(result.isFailure) + assertEquals(8_000L, result.durationMillis) + } + + @Test + fun `successful order uses a short factual summary for three seconds`() { + val result = requireNotNull(PurchaseResultBubblePolicy.create( + 71, + "order_created", + "readOrderResult", + "ignored", + )) + + assertEquals("CG-71 采购完成", result.title) + assertEquals("已获取订单编号和下单时间", result.message) + assertFalse(result.isFailure) + assertEquals(3_000L, result.durationMillis) + } + + @Test + fun `new result and dismiss invalidate an older scheduled bubble`() { + val session = PurchaseResultBubbleSession() + val first = session.replace() + val second = session.replace() + assertFalse(session.isCurrent(first)) + assertTrue(session.isCurrent(second)) + + session.dismiss() + assertFalse(session.isCurrent(second)) + } +} diff --git a/server/app/goauto/apprelease/apk_test.go b/server/app/goauto/apprelease/apk_test.go index d6ddbf7..2d4d18c 100644 --- a/server/app/goauto/apprelease/apk_test.go +++ b/server/app/goauto/apprelease/apk_test.go @@ -18,7 +18,7 @@ func TestParseBuiltAgentAPKWhenAvailable(t *testing.T) { if err != nil { t.Fatal(err) } - if metadata.VersionCode != 53 || metadata.VersionName != "0.9.40" { + if metadata.VersionCode != 54 || metadata.VersionName != "0.9.41" { t.Fatalf("metadata=%+v", metadata) } }