feat(agent): 显示采购结果气泡 (#218)
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+21
-5
@@ -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<String> {
|
||||
val root = rootInActiveWindow ?: return emptyList()
|
||||
val texts = mutableListOf<String>()
|
||||
|
||||
+16
-1
@@ -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)
|
||||
|
||||
+114
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -8,4 +8,8 @@
|
||||
<color name="agent_text_muted">#CBD5E1</color>
|
||||
<color name="agent_warning">#FBBF24</color>
|
||||
<color name="agent_error">#F87171</color>
|
||||
<color name="purchase_result_success_background">#166534</color>
|
||||
<color name="purchase_result_failure_background">#991B1B</color>
|
||||
<color name="purchase_result_text">#FFFFFF</color>
|
||||
<color name="purchase_result_text_secondary">#F1F5F9</color>
|
||||
</resources>
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user