feat: add status pull task check (#98)

This commit is contained in:
QiuSW
2026-08-26 15:53:44 +08:00
parent 50df464707
commit e6b9dbf915
5 changed files with 241 additions and 57 deletions
+3 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 10
versionName = "0.6.0"
versionCode = 11
versionName = "0.7.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -49,6 +49,7 @@ android {
dependencies {
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.fragment:fragment-ktx:1.8.5")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
implementation("com.google.android.material:material:1.12.0")
testImplementation("junit:junit:4.13.2")
@@ -63,6 +63,7 @@ class AgentForegroundService : Service() {
private val taskMutex = TaskExecutionMutex()
private val runningTaskId = AtomicReference<Long?>(null)
private val working = AtomicBoolean(false)
private val manualCheckRequested = AtomicBoolean(false)
private val registeredThisProcess = AtomicBoolean(false)
private val taskWakeActive = AtomicBoolean(false)
private val idleReturn = IdleReturnCoordinator()
@@ -101,6 +102,7 @@ class AgentForegroundService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_RECONNECT) registeredThisProcess.set(false)
if (intent?.action == ACTION_CHECK_NOW) manualCheckRequested.set(true)
triggerSync()
return START_STICKY
}
@@ -120,16 +122,19 @@ class AgentForegroundService : Service() {
private fun triggerSync() {
if (!working.compareAndSet(false, true)) return
executor.execute {
val manualCheck = manualCheckRequested.getAndSet(false)
try {
synchronizeAgent()
val outcome = synchronizeAgent()
if (manualCheck) publishManualCheckResult(outcome)
} finally {
working.set(false)
if (manualCheckRequested.get()) triggerSync()
}
}
}
private fun synchronizeAgent() {
try {
private fun synchronizeAgent(): String {
return try {
val serverUrl = ServerUrlPolicy.normalize(settingsStore.serverUrl(), BuildConfig.DEBUG)
val api = AgentApiClient(serverUrl)
var credentials = identityStore.credentials()
@@ -183,6 +188,7 @@ class AgentForegroundService : Service() {
tokenStored = storedCredentials != null,
)
updateNotification(if (authenticationError) "设备认证失败" else "连接失败,等待网络恢复")
if (authenticationError) MANUAL_AUTH_ERROR else MANUAL_NETWORK_ERROR
} catch (error: Exception) {
cancelIdleReturn("Agent 运行异常")
val configured = settingsStore.serverUrl().isNotBlank()
@@ -194,26 +200,27 @@ class AgentForegroundService : Service() {
tokenStored = storedCredentials != null,
)
updateNotification(if (configured) "运行异常" else "等待配置服务端")
if (configured) MANUAL_ERROR else MANUAL_CONFIG_REQUIRED
}
}
private fun scheduleTask(api: AgentApiClient, token: String) {
if (taskMutex.currentTaskId() != null) return
private fun scheduleTask(api: AgentApiClient, token: String): String {
if (taskMutex.currentTaskId() != null) return MANUAL_BUSY
recoverInterruptedPurchases(api, token)
flushPurchaseOutbox(api, token)
val purchaseTask = api.nextPurchaseTask(token)
if (purchaseTask != null) {
cancelIdleReturn("收到新的采购任务")
schedulePurchaseTask(api, purchaseTask, token)
return
return MANUAL_PURCHASE_TASK
}
val task = api.nextTask(token)
if (task == null) {
evaluateIdleReturn()
return
return MANUAL_EMPTY
}
cancelIdleReturn("收到新的采集任务")
if (!taskMutex.tryAcquire(task.taskId)) return
if (!taskMutex.tryAcquire(task.taskId)) return MANUAL_BUSY
if (task.status == "running") runningTaskId.set(task.taskId)
stateStore.setActiveTask(task.taskId, "collection")
taskExecutor.execute {
@@ -226,6 +233,14 @@ class AgentForegroundService : Service() {
triggerSync()
}
}
return MANUAL_COLLECTION_TASK
}
private fun publishManualCheckResult(result: String) {
sendBroadcast(Intent(ACTION_CHECK_RESULT).apply {
setPackage(packageName)
putExtra(EXTRA_CHECK_RESULT, result)
})
}
private fun schedulePurchaseTask(api: AgentApiClient, task: PurchaseAgentTask, token: String) {
@@ -669,6 +684,17 @@ class AgentForegroundService : Service() {
companion object {
const val ACTION_RECONNECT = "cn.ilapage.goauto.agent.RECONNECT"
const val ACTION_CHECK_NOW = "cn.ilapage.goauto.agent.CHECK_NOW"
const val ACTION_CHECK_RESULT = "cn.ilapage.goauto.agent.CHECK_RESULT"
const val EXTRA_CHECK_RESULT = "check_result"
const val MANUAL_EMPTY = "empty"
const val MANUAL_COLLECTION_TASK = "collection_task"
const val MANUAL_PURCHASE_TASK = "purchase_task"
const val MANUAL_BUSY = "busy"
const val MANUAL_CONFIG_REQUIRED = "config_required"
const val MANUAL_AUTH_ERROR = "auth_error"
const val MANUAL_NETWORK_ERROR = "network_error"
const val MANUAL_ERROR = "error"
private const val CHANNEL_ID = "agent_connection"
private const val NOTIFICATION_ID = 1001
private const val HEARTBEAT_SECONDS = 15L
@@ -686,6 +712,17 @@ class AgentForegroundService : Service() {
context.startService(intent)
}
}
fun checkNow(context: Context) {
val intent = Intent(context, AgentForegroundService::class.java).apply {
action = ACTION_CHECK_NOW
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
}
}
@@ -1,88 +1,152 @@
package cn.ilapage.goauto.agent.ui
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import cn.ilapage.goauto.agent.R
import cn.ilapage.goauto.agent.identity.SecureDeviceStore
import cn.ilapage.goauto.agent.service.AgentForegroundService
import cn.ilapage.goauto.agent.service.AgentSettingsStore
import cn.ilapage.goauto.agent.service.AgentStateStore
import com.google.android.material.button.MaterialButton
import java.text.DateFormat
import java.util.Date
internal object ManualTaskCheckPolicy {
fun blockedMessage(stateCode: String, currentTaskId: Long?, readiness: AccessibilityReadiness): String? = when {
currentTaskId != null || stateCode == "BUSY" -> "当前任务执行中,无需重复检查"
readiness != AccessibilityReadiness.READY -> "请先到“设置”开启采集采购助手"
stateCode == "CONFIG_REQUIRED" -> "请先到“设置”配置服务地址"
stateCode == "AUTH_ERROR" -> "设备身份校验失败,请先检查设置"
else -> null
}
fun resultMessage(result: String): String = when (result) {
AgentForegroundService.MANUAL_EMPTY -> "暂无新任务,Agent 会继续自动检查"
AgentForegroundService.MANUAL_COLLECTION_TASK -> "已领取采集任务"
AgentForegroundService.MANUAL_PURCHASE_TASK -> "已领取采购任务"
AgentForegroundService.MANUAL_BUSY -> "当前任务执行中,无需重复检查"
AgentForegroundService.MANUAL_CONFIG_REQUIRED -> "请先到“设置”配置服务地址"
AgentForegroundService.MANUAL_AUTH_ERROR -> "设备身份校验失败,请先检查设置"
AgentForegroundService.MANUAL_NETWORK_ERROR -> "检查失败,Agent 将继续自动重试"
else -> "检查失败,Agent 将继续自动重试"
}
}
class AgentStatusFragment : Fragment() {
private val handler = Handler(Looper.getMainLooper())
private lateinit var stateStore: AgentStateStore
private lateinit var settingsStore: AgentSettingsStore
private lateinit var identityStore: SecureDeviceStore
private lateinit var refreshLayout: SwipeRefreshLayout
private lateinit var manualFeedback: TextView
private lateinit var connectionTitle: TextView
private lateinit var connectionDetail: TextView
private lateinit var taskText: TextView
private lateinit var accessibilityTitle: TextView
private lateinit var accessibilityDetail: TextView
private lateinit var deviceText: TextView
private var receiverRegistered = false
private val refresh = object : Runnable {
override fun run() {
refreshStatus()
handler.postDelayed(this, REFRESH_MILLIS)
}
}
private val manualTimeout = Runnable {
if (::refreshLayout.isInitialized && refreshLayout.isRefreshing) {
finishManualCheck("检查超时,Agent 将继续自动重试", error = true)
}
}
private val hideFeedback = Runnable {
if (::manualFeedback.isInitialized && !refreshLayout.isRefreshing) manualFeedback.visibility = View.GONE
}
private val checkReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action != AgentForegroundService.ACTION_CHECK_RESULT) return
val result = intent.getStringExtra(AgentForegroundService.EXTRA_CHECK_RESULT).orEmpty()
finishManualCheck(
ManualTaskCheckPolicy.resultMessage(result),
error = result in setOf(
AgentForegroundService.MANUAL_AUTH_ERROR,
AgentForegroundService.MANUAL_CONFIG_REQUIRED,
AgentForegroundService.MANUAL_NETWORK_ERROR,
AgentForegroundService.MANUAL_ERROR,
),
)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
stateStore = AgentStateStore(requireContext())
settingsStore = AgentSettingsStore(requireContext())
identityStore = SecureDeviceStore(requireContext())
}
override fun onCreateView(inflater: android.view.LayoutInflater, container: ViewGroup?, state: Bundle?): View {
val context = requireContext()
val content = context.column().apply {
addView(context.screenTitle("设备状态", "查看连接、任务与无障碍是否就绪"))
addView(context.screenTitle("设备状态", "在页面顶部下拉可立即检查新任务"))
deviceText = context.label("正在读取设备信息", 13f, context.getColor(R.color.agent_text_muted), true)
addView(deviceText, deviceText.fullWidth())
manualFeedback = context.label("", 14f, context.getColor(R.color.agent_primary_light), true).apply {
visibility = View.GONE
setPadding(0, context.dp(12), 0, context.dp(12))
}
addView(manualFeedback)
addView(context.card(context.cardColumn().apply {
addView(context.label("服务端连接", 14f, context.getColor(R.color.agent_text_muted)))
connectionTitle = context.label("正在读取", 21f, context.getColor(R.color.agent_warning), true)
connectionDetail = context.label("—", 14f, context.getColor(R.color.agent_text_muted))
connectionDetail.setPadding(0, context.dp(6), 0, 0)
connectionDetail = context.label("—", 14f, context.getColor(R.color.agent_text_muted)).apply {
setPadding(0, context.dp(6), 0, 0)
}
addView(connectionTitle)
addView(connectionDetail)
}))
addView(context.card(context.cardColumn().apply {
addView(context.label("当前任务", 14f, context.getColor(R.color.agent_text_muted)))
taskText = context.label("设备空闲", 18f, context.getColor(R.color.agent_text), true)
taskText.setPadding(0, context.dp(6), 0, 0)
taskText = context.label("等待任务", 18f, context.getColor(R.color.agent_text), true).apply {
setPadding(0, context.dp(6), 0, 0)
}
addView(taskText)
}))
addView(context.card(context.cardColumn().apply {
addView(context.label("无障碍服务", 14f, context.getColor(R.color.agent_text_muted)))
addView(context.label("无障碍", 14f, context.getColor(R.color.agent_text_muted)))
accessibilityTitle = context.label("正在检查", 18f, context.getColor(R.color.agent_warning), true)
accessibilityDetail = context.label("—", 14f, context.getColor(R.color.agent_text_muted))
accessibilityDetail.setPadding(0, context.dp(6), 0, 0)
accessibilityDetail = context.label("—", 14f, context.getColor(R.color.agent_text_muted)).apply {
setPadding(0, context.dp(6), 0, 0)
}
addView(accessibilityTitle)
addView(accessibilityDetail)
addView(MaterialButton(context).apply {
text = "打开无障碍设置"
contentDescription = "打开系统无障碍设置"
minHeight = context.dp(48)
setOnClickListener { startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) }
}, fullWidth(12))
}))
addView(context.card(context.cardColumn().apply {
addView(context.label("设备信息", 14f, context.getColor(R.color.agent_text_muted)))
deviceText = context.label("—", 14f, context.getColor(R.color.agent_text))
deviceText.setPadding(0, context.dp(6), 0, 0)
addView(deviceText)
}))
}
return context.page(content)
refreshLayout = SwipeRefreshLayout(context).apply {
setColorSchemeResources(R.color.agent_primary_light)
setProgressBackgroundColorSchemeResource(R.color.agent_surface)
setOnRefreshListener(::checkNow)
addView(context.page(content), ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
))
}
return refreshLayout
}
override fun onStart() {
super.onStart()
registerCheckReceiver()
}
override fun onStop() {
unregisterCheckReceiver()
super.onStop()
}
override fun onResume() {
@@ -98,56 +162,108 @@ class AgentStatusFragment : Fragment() {
override fun onDestroyView() {
handler.removeCallbacks(refresh)
handler.removeCallbacks(manualTimeout)
handler.removeCallbacks(hideFeedback)
super.onDestroyView()
}
private fun checkNow() {
val state = stateStore.read()
val blocked = ManualTaskCheckPolicy.blockedMessage(
state.code,
state.currentTaskId,
AccessibilityReadinessDetector.current(requireContext()),
)
if (blocked != null) {
finishManualCheck(blocked, error = true)
return
}
showManualFeedback("正在检查新任务…", error = false)
handler.removeCallbacks(manualTimeout)
handler.postDelayed(manualTimeout, MANUAL_TIMEOUT_MILLIS)
AgentForegroundService.checkNow(requireContext())
}
private fun finishManualCheck(message: String, error: Boolean) {
handler.removeCallbacks(manualTimeout)
if (::refreshLayout.isInitialized) refreshLayout.isRefreshing = false
showManualFeedback(message, error)
refreshStatus()
handler.removeCallbacks(hideFeedback)
handler.postDelayed(hideFeedback, FEEDBACK_MILLIS)
}
private fun showManualFeedback(message: String, error: Boolean) {
manualFeedback.text = message
manualFeedback.setTextColor(requireContext().getColor(if (error) R.color.agent_warning else R.color.agent_primary_light))
manualFeedback.visibility = View.VISIBLE
manualFeedback.announceForAccessibility(message)
}
private fun refreshStatus() {
if (!isAdded || view == null) return
val context = requireContext()
val state = stateStore.read()
val connected = state.code in setOf("ONLINE", "BUSY")
connectionTitle.text = when (state.code) {
"ONLINE" -> "已连接 · 设备空闲"
"BUSY" -> "已连接 · 正在执行"
"ONLINE" -> "在线 · 空闲"
"BUSY" -> "在线 · 执行中"
"CONNECTING" -> "正在连接"
"CONFIG_REQUIRED" -> "等待配置服务端"
"AUTH_ERROR" -> "设备身份校验失败"
"CONFIG_REQUIRED" -> "等待配置"
"AUTH_ERROR" -> "身份校验失败"
else -> "连接异常"
}
connectionTitle.setTextColor(context.getColor(if (connected) R.color.agent_primary_light else R.color.agent_warning))
val heartbeat = state.lastHeartbeatAt.takeIf { it > 0L }?.let {
DateFormat.getDateTimeInstance().format(Date(it))
} ?: "尚无成功心跳"
} ?: "尚无心跳"
connectionDetail.text = "${state.message}\n最近心跳:$heartbeat"
taskText.text = state.currentTaskId?.let { id ->
val type = if (state.currentTaskType == "purchase") "采购" else "采集"
"正在执行${type}任务 ${if (type == "采购") "CG-" else "#"}$id"
} ?: "设备空闲,等待新任务"
val purchase = state.currentTaskType == "purchase"
"${if (purchase) "采购" else "采集"}任务 ${if (purchase) "CG-" else "#"}$id"
} ?: "等待任务"
when (AccessibilityReadinessDetector.current(context)) {
AccessibilityReadiness.READY -> {
accessibilityTitle.text = "已开启并就绪"
accessibilityTitle.text = "已开启"
accessibilityTitle.setTextColor(context.getColor(R.color.agent_primary_light))
accessibilityDetail.text = "服务已绑定,可以执行采集和采购任务。"
accessibilityDetail.text = "可以执行任务"
}
AccessibilityReadiness.ENABLED_WAITING -> {
accessibilityTitle.text = "已开启,等待服务连接"
accessibilityTitle.text = "等待连接"
accessibilityTitle.setTextColor(context.getColor(R.color.agent_warning))
accessibilityDetail.text = "系统开关已打开,但 Agent 尚未连接服务。请返回本页稍候。"
accessibilityDetail.text = "请到“设置”检查无障碍服务"
}
AccessibilityReadiness.DISABLED -> {
accessibilityTitle.text = "未开启"
accessibilityTitle.setTextColor(context.getColor(R.color.agent_error))
accessibilityDetail.text = "请打开 GoAuto 采集服务,否则任务会失败。"
accessibilityDetail.text = "请到“设置”开启"
}
}
val displayName = settingsStore.deviceName().ifBlank { "本机" }
deviceText.text = "$displayName · ${state.deviceId.takeIf { it > 0 }?.let { "#$it" } ?: "未注册"}"
}
val installId = runCatching { identityStore.installId() }.getOrElse { "读取失败" }
val displayName = settingsStore.deviceName().ifBlank { "使用手机型号" }
deviceText.text = "设备名称:$displayName\ninstallId:$installId\n设备编号:${state.deviceId.takeIf { it > 0 } ?: "未注册"}"
private fun registerCheckReceiver() {
if (receiverRegistered) return
val filter = IntentFilter(AgentForegroundService.ACTION_CHECK_RESULT)
if (Build.VERSION.SDK_INT >= 33) {
requireContext().registerReceiver(checkReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("DEPRECATION")
requireContext().registerReceiver(checkReceiver, filter)
}
receiverRegistered = true
}
private fun unregisterCheckReceiver() {
if (!receiverRegistered) return
runCatching { requireContext().unregisterReceiver(checkReceiver) }
receiverRegistered = false
}
private companion object {
const val REFRESH_MILLIS = 1_000L
const val MANUAL_TIMEOUT_MILLIS = 20_000L
const val FEEDBACK_MILLIS = 3_000L
}
}
@@ -2,6 +2,7 @@ package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.ui.AccessibilityReadiness
import cn.ilapage.goauto.agent.ui.AccessibilityReadinessResolver
import cn.ilapage.goauto.agent.ui.ManualTaskCheckPolicy
import cn.ilapage.goauto.agent.ui.SettingsAvailabilityResolver
import org.junit.Assert.assertEquals
import org.junit.Test
@@ -37,4 +38,25 @@ class AccessibilityReadinessTest {
assertEquals(false, SettingsAvailabilityResolver.editable("ONLINE", null, testing = true))
assertEquals(true, SettingsAvailabilityResolver.editable("ONLINE", null, testing = false))
}
@Test
fun manualCheckExplainsBusyAndAccessibilityBlocks() {
assertEquals(
"当前任务执行中,无需重复检查",
ManualTaskCheckPolicy.blockedMessage("BUSY", 35L, AccessibilityReadiness.READY),
)
assertEquals(
"请先到“设置”开启采集采购助手",
ManualTaskCheckPolicy.blockedMessage("ONLINE", null, AccessibilityReadiness.DISABLED),
)
assertEquals(null, ManualTaskCheckPolicy.blockedMessage("ONLINE", null, AccessibilityReadiness.READY))
}
@Test
fun manualCheckUsesPlainResultMessages() {
assertEquals("暂无新任务,Agent 会继续自动检查", ManualTaskCheckPolicy.resultMessage("empty"))
assertEquals("已领取采集任务", ManualTaskCheckPolicy.resultMessage("collection_task"))
assertEquals("已领取采购任务", ManualTaskCheckPolicy.resultMessage("purchase_task"))
assertEquals("检查失败,Agent 将继续自动重试", ManualTaskCheckPolicy.resultMessage("network_error"))
}
}
+10 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: ca5286cc8b94d2338109552599da8a91b339127c
synchronized_at: 2026-08-26T06:52:02Z
wiki_revision: 18b8c41f545d2c2e883913a57647680a87a570b6
synchronized_at: 2026-08-26T07:52:55Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -191,3 +191,11 @@ Android Portal/Agent
- Android 8.0 及以上的 `GoAutoAccessibilityService` 请求系统 `flagRequestAccessibilityButton`,服务连接后向 `AccessibilityButtonController` 注册单一回调,销毁时注销;服务重连不会叠加回调。
- 单击由系统分配给 GoAuto 的辅助功能按钮时,只调用既有 `openAgentStatus()`,以前台 `MainActivity` 打开“状态”Tab;不领取、创建、重置或重试任务,不打开 PDD,也不改变 15 秒空闲返回规则。
- 不支持或未分配系统辅助功能按钮的 ROM 保持安全降级;项目不创建自定义悬浮窗。
## Android Agent 状态页手动检查(#98)
- `AgentStatusFragment` 使用 Android 原生下拉刷新容器;只有状态页顶部下拉会请求一次立即检查,采集/采购历史页的下拉只刷新记录,两者语义不同。
- `AgentForegroundService.checkNow` 只向既有单线程同步与调度入口提交一个可合并请求;不建立第二套轮询器,也不改变 15 秒自动轮询、采购优先、设备互斥或服务端租约。
- 手动请求在已有同步运行时保留一个待处理标记,当前同步结束后再执行一次;重复手势不会并发请求。
- 结果通过仅限当前应用包的广播返回状态页,区分无任务、采集任务、采购任务、设备忙、配置/认证和网络错误。状态页无障碍信息只读,唯一系统设置入口保留在设置 Tab。
- 手动检查不创建、重置或重试任务,不绕过无障碍与身份校验,不打开 PDD,不修改地址、不创建订单、不支付。