feat(android): implement #88 agent four-tab shell

This commit is contained in:
QiuSW
2026-08-25 16:55:43 +08:00
parent 91c0b641f5
commit cb2c34afbb
23 changed files with 863 additions and 155 deletions
+8 -2
View File
@@ -7,10 +7,16 @@
- 首次安装生成并持久化随机 UUID `installId`,不读取硬件唯一标识。
- 自动注册和已认证的版本信息更新,无注册码输入页。
- Android Keystore AES-GCM 加密保存 Device Token,界面和日志不显示明文。
- 前台服务、15 秒心跳、网络恢复触发重连和最小诊断状态页。
- 前台服务、15 秒心跳、网络恢复触发重连。
- Agent 0.3.0 的状态、采集、采购、设置四 Tab 外壳;默认进入状态页。
- 状态页区分无障碍未开启、系统已开启但服务未绑定、已绑定就绪三态。
- 设置页可测试服务器连接、保存服务器地址和设备名称并重新连接;任务执行中禁止修改。
- 设置页只显示 Device Token 是否配置,不显示 Token 明文;采集和采购 Tab 在 #90 接入当前设备任务历史。
- 清除 App 数据后按新的安装实例注册;Token 无效时明确报错,不降级为无认证注册。
服务端地址可在诊断页保存,也可在构建时通过 `GOAUTO_SERVER_URL` 环境变量或同名 Gradle 属性写入默认值。Release 版本只接受 HTTPS;只有 Debug 构建的独立清单允许 HTTP,便于局域网真机联调。诊断页在前台每秒刷新连接状态,但不会显示 Token 明文。
服务端地址可在设置页保存,也可在构建时通过 `GOAUTO_SERVER_URL` 环境变量或同名 Gradle 属性写入默认值。Release 版本只接受 HTTPS;只有 Debug 构建的独立清单允许 HTTP,便于局域网真机联调。设置页的“测试连接”只访问 `/api/v1/health`,不会注册设备或创建任务;“保存并重新连接”沿用当前 installId 和 Device Token。
设备执行任务时,状态页显示当前任务,设置页会禁用服务器地址、设备名称、测试和保存操作。返回系统无障碍设置后,状态页和设置页会自动刷新真实就绪状态。
## T05 真机验证记录
+6 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 2
versionName = "0.2.0"
versionCode = 3
versionName = "0.3.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -47,6 +47,10 @@ android {
}
dependencies {
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.fragment:fragment-ktx:1.8.5")
implementation("com.google.android.material:material:1.12.0")
testImplementation("junit:junit:4.13.2")
testImplementation("org.json:json:20240303")
}
@@ -1,177 +1,104 @@
package cn.ilapage.goauto.agent
import android.Manifest
import android.app.Activity
import android.content.pm.PackageManager
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.os.Handler
import android.os.Looper
import android.text.InputType
import android.view.Gravity
import android.view.ViewGroup
import android.widget.Button
import android.widget.EditText
import android.view.Menu
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import cn.ilapage.goauto.agent.identity.SecureDeviceStore
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import cn.ilapage.goauto.agent.service.AgentForegroundService
import cn.ilapage.goauto.agent.service.AgentSettingsStore
import cn.ilapage.goauto.agent.service.AgentStateStore
import java.text.DateFormat
import java.util.Date
import cn.ilapage.goauto.agent.ui.AgentSettingsFragment
import cn.ilapage.goauto.agent.ui.AgentStatusFragment
import cn.ilapage.goauto.agent.ui.TaskPlaceholderFragment
import com.google.android.material.bottomnavigation.BottomNavigationView
class MainActivity : Activity() {
private val statusHandler = Handler(Looper.getMainLooper())
private val statusRefresher = object : Runnable {
override fun run() {
refreshStatus()
statusHandler.postDelayed(this, STATUS_REFRESH_MILLIS)
}
}
private lateinit var settingsStore: AgentSettingsStore
private lateinit var stateStore: AgentStateStore
private lateinit var identityStore: SecureDeviceStore
private lateinit var serverUrlInput: EditText
private lateinit var stateText: TextView
private lateinit var detailText: TextView
private lateinit var identityText: TextView
class MainActivity : AppCompatActivity() {
private var selectedTab = TAB_STATUS
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
settingsStore = AgentSettingsStore(this)
stateStore = AgentStateStore(this)
identityStore = SecureDeviceStore(this)
selectedTab = savedInstanceState?.getInt(STATE_SELECTED_TAB) ?: TAB_STATUS
setContentView(buildContent())
requestNotificationPermission()
if (settingsStore.serverUrl().isNotBlank()) AgentForegroundService.start(this)
if (AgentSettingsStore(this).serverUrl().isNotBlank()) AgentForegroundService.start(this)
}
override fun onResume() {
super.onResume()
statusHandler.removeCallbacks(statusRefresher)
statusHandler.post(statusRefresher)
override fun onSaveInstanceState(outState: Bundle) {
outState.putInt(STATE_SELECTED_TAB, selectedTab)
super.onSaveInstanceState(outState)
}
override fun onPause() {
statusHandler.removeCallbacks(statusRefresher)
super.onPause()
}
private fun buildContent(): ScrollView {
val density = resources.displayMetrics.density
val padding = (20 * density).toInt()
private fun buildContent(): LinearLayout {
val content = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(padding, padding, padding, padding)
setBackgroundColor(BACKGROUND)
setBackgroundColor(getColor(R.color.agent_background))
}
content.addView(text("Mobile Agent", 28f, Color.WHITE, true))
content.addView(text("PDD 采集执行端", 15f, MUTED))
content.addView(sectionTitle("连接状态"))
stateText = text("—", 22f, GREEN, true)
detailText = text("—", 15f, MUTED)
content.addView(stateText)
content.addView(detailText)
content.addView(sectionTitle("设备身份"))
identityText = text("—", 14f, Color.WHITE)
content.addView(identityText)
content.addView(sectionTitle("服务端地址"))
serverUrlInput = EditText(this).apply {
hint = if (BuildConfig.DEBUG) "https://example.com(调试版也允许 http)" else "https://example.com"
setText(settingsStore.serverUrl())
setTextColor(Color.WHITE)
setHintTextColor(MUTED)
setBackgroundColor(CARD)
setPadding(padding / 2, padding / 2, padding / 2, padding / 2)
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI
contentDescription = "GoAuto 服务端地址"
}
content.addView(serverUrlInput, matchWrap())
content.addView(button("保存并连接") {
runCatching {
settingsStore.saveServerUrl(serverUrlInput.text.toString())
AgentForegroundService.start(this, reconnect = true)
"已保存,正在连接"
}.onSuccess {
stateText.text = "正在连接"
detailText.text = it
}.onFailure {
stateText.text = "配置错误"
detailText.text = it.message
val container = FrameLayout(this).apply { id = R.id.agent_tab_content }
content.addView(container, LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
0,
1f,
))
val navigation = BottomNavigationView(this).apply {
setBackgroundColor(getColor(R.color.agent_surface))
itemIconTintList = getColorStateList(R.color.agent_navigation_item)
itemTextColor = getColorStateList(R.color.agent_navigation_item)
labelVisibilityMode = BottomNavigationView.LABEL_VISIBILITY_LABELED
menu.add(Menu.NONE, TAB_STATUS, 0, "状态").setIcon(R.drawable.ic_agent_status)
menu.add(Menu.NONE, TAB_COLLECTION, 1, "采集").setIcon(R.drawable.ic_agent_collection)
menu.add(Menu.NONE, TAB_PURCHASE, 2, "采购").setIcon(R.drawable.ic_agent_purchase)
menu.add(Menu.NONE, TAB_SETTINGS, 3, "设置").setIcon(R.drawable.ic_agent_settings)
setOnItemSelectedListener { item ->
showTab(item.itemId)
true
}
})
content.addView(button("刷新诊断状态") { refreshStatus() })
content.addView(button("打开无障碍设置") {
startActivity(android.content.Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
})
content.addView(text("Token 使用 Android Keystore 加密保存,本页面不会显示明文。清除 App 数据后会生成新的 installId,并按新设备注册。", 13f, MUTED))
return ScrollView(this).apply { addView(content, matchWrap()) }
}
content.addView(navigation, LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
resources.getDimensionPixelSize(R.dimen.agent_bottom_navigation_height),
))
navigation.selectedItemId = selectedTab
return content
}
private fun refreshStatus() {
val state = stateStore.read()
stateText.text = state.code
stateText.setTextColor(if (state.code in setOf("ONLINE", "BUSY")) GREEN else AMBER)
val heartbeat = if (state.lastHeartbeatAt > 0L) {
DateFormat.getDateTimeInstance().format(Date(state.lastHeartbeatAt))
private fun showTab(tabId: Int) {
selectedTab = tabId
val tag = "agent-tab-$tabId"
val transaction = supportFragmentManager.beginTransaction()
supportFragmentManager.fragments.forEach(transaction::hide)
val existing = supportFragmentManager.findFragmentByTag(tag)
if (existing != null) {
transaction.show(existing)
} else {
"尚无成功心跳"
}
detailText.text = "${state.message}\n最近心跳:$heartbeat"
val installId = runCatching { identityStore.installId() }.getOrElse { "生成失败" }
identityText.text = buildString {
append("installId:$installId\n")
append("deviceId:${state.deviceId.takeIf { it > 0 } ?: "未注册"}\n")
append("Device Token:${if (state.tokenStored) "已安全保存" else "未签发"}")
transaction.add(R.id.agent_tab_content, fragmentFor(tabId), tag)
}
transaction.commit()
}
private fun fragmentFor(tabId: Int): Fragment = when (tabId) {
TAB_COLLECTION -> TaskPlaceholderFragment.collection()
TAB_PURCHASE -> TaskPlaceholderFragment.purchase()
TAB_SETTINGS -> AgentSettingsFragment()
else -> AgentStatusFragment()
}
private fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT >= 33 && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
if (Build.VERSION.SDK_INT >= 33 &&
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) {
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 100)
}
}
private fun sectionTitle(value: String) = text(value, 16f, Color.WHITE, true).apply {
setPadding(0, 28, 0, 8)
}
private fun text(value: String, size: Float, color: Int, bold: Boolean = false) = TextView(this).apply {
text = value
textSize = size
setTextColor(color)
if (bold) setTypeface(typeface, android.graphics.Typeface.BOLD)
setPadding(0, 7, 0, 7)
layoutParams = matchWrap()
}
private fun button(label: String, action: () -> Unit) = Button(this).apply {
text = label
isAllCaps = false
setTextColor(Color.WHITE)
setBackgroundColor(GREEN_DARK)
gravity = Gravity.CENTER
minHeight = (48 * resources.displayMetrics.density).toInt()
setOnClickListener { action() }
layoutParams = matchWrap().apply { setMargins(0, 16, 0, 0) }
}
private fun matchWrap() = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
)
private companion object {
val BACKGROUND = Color.rgb(2, 6, 23)
val CARD = Color.rgb(15, 23, 42)
val MUTED = Color.rgb(148, 163, 184)
val GREEN = Color.rgb(74, 222, 128)
val GREEN_DARK = Color.rgb(21, 128, 61)
val AMBER = Color.rgb(251, 191, 36)
const val STATUS_REFRESH_MILLIS = 1_000L
const val TAB_STATUS = 1
const val TAB_COLLECTION = 2
const val TAB_PURCHASE = 3
const val TAB_SETTINGS = 4
const val STATE_SELECTED_TAB = "selected_tab"
}
}
@@ -72,6 +72,10 @@ class AgentApiException(
) : Exception(message)
class AgentApiClient(private val serverUrl: String) {
fun testConnection() {
requireNotNull(request("GET", "/api/v1/health", null, null))
}
fun register(info: DeviceInfo, token: String?): RegistrationResult {
val payload = JSONObject()
.put("requestId", UUID.randomUUID().toString())
@@ -81,7 +81,10 @@ class AgentForegroundService : Service() {
settingsStore = AgentSettingsStore(this)
stateStore = AgentStateStore(this)
purchaseStore = PurchaseTaskStore(this)
runningTaskId.set(purchaseStore.activeTaskId())
val restoredPurchaseTaskId = purchaseStore.activeTaskId()
runningTaskId.set(restoredPurchaseTaskId)
stateStore.clearActiveTask()
restoredPurchaseTaskId?.let { stateStore.setActiveTask(it, "purchase") }
connectivityManager = getSystemService(ConnectivityManager::class.java)
createNotificationChannel()
startForeground(NOTIFICATION_ID, notification("正在启动"))
@@ -195,11 +198,13 @@ class AgentForegroundService : Service() {
val task = api.nextTask(token) ?: return
if (!taskMutex.tryAcquire(task.taskId)) return
if (task.status == "running") runningTaskId.set(task.taskId)
stateStore.setActiveTask(task.taskId, "collection")
taskExecutor.execute {
try {
executeTask(api, task, token)
} finally {
runningTaskId.compareAndSet(task.taskId, null)
stateStore.clearActiveTask(task.taskId)
taskMutex.release(task.taskId)
}
}
@@ -208,11 +213,13 @@ class AgentForegroundService : Service() {
private fun schedulePurchaseTask(api: AgentApiClient, task: PurchaseAgentTask, token: String) {
if (!taskMutex.tryAcquire(task.taskId)) return
runningTaskId.set(task.taskId)
stateStore.setActiveTask(task.taskId, "purchase")
taskExecutor.execute {
try {
executePurchaseTask(api, task, token)
} finally {
taskMutex.release(task.taskId)
stateStore.clearActiveTask(task.taskId)
runningTaskId.set(purchaseStore.activeTaskId())
}
}
@@ -470,7 +477,7 @@ class AgentForegroundService : Service() {
private fun deviceInfo(): DeviceInfo = DeviceInfo(
installId = identityStore.installId(),
name = "${Build.MANUFACTURER}-${Build.MODEL}".take(100),
name = settingsStore.deviceName().ifBlank { "${Build.MANUFACTURER}-${Build.MODEL}" }.take(100),
manufacturer = Build.MANUFACTURER.take(100),
model = Build.MODEL.take(100),
androidVersion = Build.VERSION.RELEASE.take(32),
@@ -10,6 +10,8 @@ data class AgentState(
val deviceId: Long,
val lastHeartbeatAt: Long,
val tokenStored: Boolean,
val currentTaskId: Long?,
val currentTaskType: String?,
)
class AgentSettingsStore(context: Context) {
@@ -18,27 +20,43 @@ class AgentSettingsStore(context: Context) {
fun serverUrl(): String = preferences.getString(SERVER_URL, null)
?: BuildConfig.DEFAULT_SERVER_URL.takeIf { it.isNotBlank() }.orEmpty()
fun deviceName(): String = preferences.getString(DEVICE_NAME, "").orEmpty()
fun saveServerUrl(value: String): String {
val normalized = ServerUrlPolicy.normalize(value, BuildConfig.DEBUG)
check(preferences.edit().putString(SERVER_URL, normalized).commit()) { "无法保存服务端地址" }
return normalized
}
fun saveConnectionSettings(serverUrl: String, deviceName: String): String {
val normalizedUrl = ServerUrlPolicy.normalize(serverUrl, BuildConfig.DEBUG)
val normalizedName = deviceName.trim()
require(normalizedName.isNotEmpty()) { "请输入设备名称" }
require(normalizedName.length <= 100) { "设备名称不能超过 100 个字符" }
check(preferences.edit()
.putString(SERVER_URL, normalizedUrl)
.putString(DEVICE_NAME, normalizedName)
.commit()
) { "无法保存连接设置" }
return normalizedUrl
}
private companion object {
const val PREFERENCES = "goauto_agent_settings"
const val SERVER_URL = "server_url"
const val DEVICE_NAME = "device_name"
}
}
class AgentStateStore(context: Context) {
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
fun update(code: String, message: String, deviceId: Long = 0L, tokenStored: Boolean = false, heartbeat: Boolean = false) {
fun update(code: String, message: String, deviceId: Long? = null, tokenStored: Boolean? = null, heartbeat: Boolean = false) {
val editor = preferences.edit()
.putString(STATE_CODE, code)
.putString(STATE_MESSAGE, message)
.putLong(DEVICE_ID, deviceId)
.putBoolean(TOKEN_STORED, tokenStored)
deviceId?.let { editor.putLong(DEVICE_ID, it) }
tokenStored?.let { editor.putBoolean(TOKEN_STORED, it) }
if (heartbeat) editor.putLong(LAST_HEARTBEAT, System.currentTimeMillis())
editor.apply()
}
@@ -49,8 +67,28 @@ class AgentStateStore(context: Context) {
deviceId = preferences.getLong(DEVICE_ID, 0L),
lastHeartbeatAt = preferences.getLong(LAST_HEARTBEAT, 0L),
tokenStored = preferences.getBoolean(TOKEN_STORED, false),
currentTaskId = preferences.getLong(CURRENT_TASK_ID, 0L).takeIf { it > 0L },
currentTaskType = preferences.getString(CURRENT_TASK_TYPE, null),
)
fun setActiveTask(taskId: Long, taskType: String) {
require(taskId > 0L)
require(taskType == "collection" || taskType == "purchase")
preferences.edit()
.putLong(CURRENT_TASK_ID, taskId)
.putString(CURRENT_TASK_TYPE, taskType)
.apply()
}
fun clearActiveTask(taskId: Long) {
if (preferences.getLong(CURRENT_TASK_ID, 0L) != taskId) return
preferences.edit().remove(CURRENT_TASK_ID).remove(CURRENT_TASK_TYPE).apply()
}
fun clearActiveTask() {
preferences.edit().remove(CURRENT_TASK_ID).remove(CURRENT_TASK_TYPE).apply()
}
private companion object {
const val PREFERENCES = "goauto_agent_runtime"
const val STATE_CODE = "state_code"
@@ -58,5 +96,7 @@ class AgentStateStore(context: Context) {
const val DEVICE_ID = "device_id"
const val LAST_HEARTBEAT = "last_heartbeat"
const val TOKEN_STORED = "token_stored"
const val CURRENT_TASK_ID = "current_task_id"
const val CURRENT_TASK_TYPE = "current_task_type"
}
}
@@ -0,0 +1,53 @@
package cn.ilapage.goauto.agent.ui
import android.content.ComponentName
import android.content.Context
import android.provider.Settings
import cn.ilapage.goauto.agent.automation.GoAutoAccessibilityService
enum class AccessibilityReadiness {
DISABLED,
ENABLED_WAITING,
READY,
}
object AccessibilityReadinessResolver {
fun resolve(systemEnabled: Boolean, serviceBound: Boolean): AccessibilityReadiness = when {
!systemEnabled -> AccessibilityReadiness.DISABLED
serviceBound -> AccessibilityReadiness.READY
else -> AccessibilityReadiness.ENABLED_WAITING
}
}
object AccessibilityReadinessDetector {
fun current(context: Context): AccessibilityReadiness {
val serviceEnabled = runCatching {
Settings.Secure.getInt(
context.contentResolver,
Settings.Secure.ACCESSIBILITY_ENABLED,
0,
) == 1 && enabledServices(context).any { it in serviceComponents(context) }
}.getOrDefault(false)
return AccessibilityReadinessResolver.resolve(
systemEnabled = serviceEnabled,
serviceBound = GoAutoAccessibilityService.instance != null,
)
}
private fun enabledServices(context: Context): Set<String> = Settings.Secure.getString(
context.contentResolver,
Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES,
).orEmpty().split(':').map { it.trim().lowercase() }.filter { it.isNotEmpty() }.toSet()
private fun serviceComponents(context: Context): Set<String> = ComponentName(
context,
GoAutoAccessibilityService::class.java,
).let { component ->
setOf(component.flattenToString().lowercase(), component.flattenToShortString().lowercase())
}
}
object SettingsAvailabilityResolver {
fun editable(stateCode: String, currentTaskId: Long?, testing: Boolean): Boolean =
!testing && stateCode != "BUSY" && currentTaskId == null
}
@@ -0,0 +1,286 @@
package cn.ilapage.goauto.agent.ui
import android.content.Intent
import android.content.res.ColorStateList
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.view.inputmethod.EditorInfo
import android.widget.LinearLayout
import android.widget.TextView
import androidx.fragment.app.Fragment
import cn.ilapage.goauto.agent.BuildConfig
import cn.ilapage.goauto.agent.R
import cn.ilapage.goauto.agent.identity.SecureDeviceStore
import cn.ilapage.goauto.agent.network.AgentApiClient
import cn.ilapage.goauto.agent.network.ServerUrlPolicy
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 com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import java.util.concurrent.Executors
class AgentSettingsFragment : Fragment() {
private val handler = Handler(Looper.getMainLooper())
private val executor = Executors.newSingleThreadExecutor()
private lateinit var settingsStore: AgentSettingsStore
private lateinit var stateStore: AgentStateStore
private lateinit var identityStore: SecureDeviceStore
private lateinit var serverLayout: TextInputLayout
private lateinit var serverInput: TextInputEditText
private lateinit var nameLayout: TextInputLayout
private lateinit var nameInput: TextInputEditText
private lateinit var testButton: MaterialButton
private lateinit var saveButton: MaterialButton
private lateinit var connectionFeedback: TextView
private lateinit var disabledReason: TextView
private lateinit var diagnostics: TextView
private lateinit var accessibilityText: TextView
private var testing = false
private val refresh = object : Runnable {
override fun run() {
refreshDiagnostics()
handler.postDelayed(this, REFRESH_MILLIS)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
settingsStore = AgentSettingsStore(requireContext())
stateStore = AgentStateStore(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.card(context.cardColumn().apply {
addView(context.label("连接设置", 18f, context.getColor(R.color.agent_text), true))
serverLayout = TextInputLayout(context).apply {
hint = "服务器地址"
helperText = "请输入完整的 HTTP 或 HTTPS 地址"
boxBackgroundMode = TextInputLayout.BOX_BACKGROUND_OUTLINE
defaultHintTextColor = ColorStateList.valueOf(context.getColor(R.color.agent_text_muted))
setHelperTextColor(ColorStateList.valueOf(context.getColor(R.color.agent_text_muted)))
boxStrokeColor = context.getColor(R.color.agent_primary_light)
serverInput = TextInputEditText(context).apply {
setText(settingsStore.serverUrl())
setTextColor(context.getColor(R.color.agent_text))
setHintTextColor(context.getColor(R.color.agent_text_muted))
inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_URI
imeOptions = EditorInfo.IME_ACTION_NEXT
minHeight = context.dp(48)
contentDescription = "GoAuto 服务器地址"
}
addView(serverInput, LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
))
}
addView(serverLayout, fullWidth(14))
nameLayout = TextInputLayout(context).apply {
hint = "设备名称"
helperText = "用于管理端识别这台手机"
boxBackgroundMode = TextInputLayout.BOX_BACKGROUND_OUTLINE
defaultHintTextColor = ColorStateList.valueOf(context.getColor(R.color.agent_text_muted))
setHelperTextColor(ColorStateList.valueOf(context.getColor(R.color.agent_text_muted)))
boxStrokeColor = context.getColor(R.color.agent_primary_light)
nameInput = TextInputEditText(context).apply {
setText(settingsStore.deviceName().ifBlank { "${Build.MANUFACTURER}-${Build.MODEL}" })
setTextColor(context.getColor(R.color.agent_text))
setHintTextColor(context.getColor(R.color.agent_text_muted))
inputType = android.text.InputType.TYPE_CLASS_TEXT
imeOptions = EditorInfo.IME_ACTION_DONE
maxLines = 1
minHeight = context.dp(48)
contentDescription = "设备名称"
}
addView(nameInput, LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
))
}
addView(nameLayout, fullWidth(8))
val actions = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
testButton = MaterialButton(context).apply {
text = "测试连接"
minHeight = context.dp(48)
setOnClickListener { testConnection() }
}
saveButton = MaterialButton(context).apply {
text = "保存并重新连接"
minHeight = context.dp(48)
setOnClickListener { confirmSave() }
}
addView(testButton, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply {
marginEnd = context.dp(8)
})
addView(saveButton, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1.5f))
}
addView(actions, fullWidth(12))
connectionFeedback = context.label("尚未测试连接", 14f, context.getColor(R.color.agent_text_muted))
connectionFeedback.setPadding(0, context.dp(10), 0, 0)
addView(connectionFeedback)
disabledReason = context.label("", 14f, context.getColor(R.color.agent_warning), true)
disabledReason.setPadding(0, context.dp(8), 0, 0)
addView(disabledReason)
}))
addView(context.card(context.cardColumn().apply {
addView(context.label("设备与诊断", 18f, context.getColor(R.color.agent_text), true))
diagnostics = context.label("—", 14f, context.getColor(R.color.agent_text))
diagnostics.setPadding(0, context.dp(10), 0, 0)
addView(diagnostics)
}))
addView(context.card(context.cardColumn().apply {
addView(context.label("无障碍服务", 18f, context.getColor(R.color.agent_text), true))
accessibilityText = context.label("正在检查", 14f, context.getColor(R.color.agent_text_muted))
accessibilityText.setPadding(0, context.dp(8), 0, 0)
addView(accessibilityText)
addView(MaterialButton(context).apply {
text = "打开无障碍设置"
minHeight = context.dp(48)
contentDescription = "打开系统无障碍设置"
setOnClickListener { startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) }
}, fullWidth(12))
}))
}
return context.page(content)
}
override fun onResume() {
super.onResume()
handler.removeCallbacks(refresh)
handler.post(refresh)
}
override fun onPause() {
handler.removeCallbacks(refresh)
super.onPause()
}
override fun onDestroyView() {
handler.removeCallbacks(refresh)
super.onDestroyView()
}
override fun onDestroy() {
executor.shutdownNow()
super.onDestroy()
}
private fun testConnection() {
val normalized = validateInputs(requireName = false) ?: return
testing = true
refreshDiagnostics()
connectionFeedback.text = "正在测试连接…"
connectionFeedback.setTextColor(requireContext().getColor(R.color.agent_warning))
executor.execute {
val result = runCatching { AgentApiClient(normalized).testConnection() }
activity?.runOnUiThread {
if (!isAdded || view == null) return@runOnUiThread
testing = false
connectionFeedback.text = result.fold(
onSuccess = { "连接成功,服务端可以访问" },
onFailure = { "连接失败:${friendlyError(it)}" },
)
connectionFeedback.setTextColor(requireContext().getColor(
if (result.isSuccess) R.color.agent_primary_light else R.color.agent_error,
))
refreshDiagnostics()
}
}
}
private fun confirmSave() {
val normalizedUrl = validateInputs(requireName = true) ?: return
val deviceName = nameInput.text?.toString()?.trim().orEmpty()
val oldUrl = settingsStore.serverUrl().ifBlank { "尚未配置" }
MaterialAlertDialogBuilder(requireContext())
.setTitle("保存并重新连接?")
.setMessage("当前地址:$oldUrl\n新地址:$normalizedUrl\n\n保存后 Agent 会使用现有设备身份重新连接。")
.setNegativeButton("取消", null)
.setPositiveButton("保存并连接") { _, _ -> saveAndReconnect(normalizedUrl, deviceName) }
.show()
}
private fun saveAndReconnect(serverUrl: String, deviceName: String) {
val result = runCatching {
settingsStore.saveConnectionSettings(serverUrl, deviceName)
AgentForegroundService.start(requireContext(), reconnect = true)
}
connectionFeedback.text = result.fold(
onSuccess = { "设置已保存,正在重新连接服务端" },
onFailure = { "保存失败:${friendlyError(it)}" },
)
connectionFeedback.setTextColor(requireContext().getColor(
if (result.isSuccess) R.color.agent_primary_light else R.color.agent_error,
))
refreshDiagnostics()
}
private fun validateInputs(requireName: Boolean): String? {
serverLayout.error = null
nameLayout.error = null
val normalizedUrl = runCatching {
ServerUrlPolicy.normalize(serverInput.text?.toString().orEmpty(), BuildConfig.DEBUG)
}.getOrElse {
serverLayout.error = friendlyError(it)
serverInput.requestFocus()
return null
}
if (requireName) {
val name = nameInput.text?.toString()?.trim().orEmpty()
if (name.isEmpty() || name.length > 100) {
nameLayout.error = if (name.isEmpty()) "请输入设备名称" else "设备名称不能超过 100 个字符"
nameInput.requestFocus()
return null
}
}
return normalizedUrl
}
private fun refreshDiagnostics() {
if (!isAdded || view == null) return
val context = requireContext()
val state = stateStore.read()
val busy = state.code == "BUSY" || state.currentTaskId != null
val editable = SettingsAvailabilityResolver.editable(state.code, state.currentTaskId, testing)
serverInput.isEnabled = editable
nameInput.isEnabled = editable
testButton.isEnabled = editable
saveButton.isEnabled = editable
testButton.text = if (testing) "正在测试…" else "测试连接"
disabledReason.text = if (busy) "任务执行中,暂时不能修改服务器或设备名称。" else ""
val installId = runCatching { identityStore.installId() }.getOrElse { "读取失败" }
diagnostics.text = buildString {
append("installId:$installId\n")
append("Device Token:${if (state.tokenStored) "已配置" else "未配置"}\n")
append("注册状态:${if (state.deviceId > 0) "已注册(设备 ${state.deviceId})" else "未注册"}\n")
append("Agent 版本:${BuildConfig.VERSION_NAME}\n")
append("服务端连接:${if (state.code in setOf("ONLINE", "BUSY")) "已连接" else "未连接"}\n")
append("保持屏幕常亮:${if (busy) "任务执行中已开启" else "仅在任务执行时开启"}")
}
accessibilityText.text = when (AccessibilityReadinessDetector.current(context)) {
AccessibilityReadiness.READY -> "已开启并就绪,可以执行任务。"
AccessibilityReadiness.ENABLED_WAITING -> "已开启,正在等待 Agent 服务连接。"
AccessibilityReadiness.DISABLED -> "未开启,请先完成设置。"
}
}
private fun friendlyError(error: Throwable): String = error.message?.takeIf { it.isNotBlank() }
?: "请检查地址和网络后重试"
private companion object {
const val REFRESH_MILLIS = 1_000L
}
}
@@ -0,0 +1,153 @@
package cn.ilapage.goauto.agent.ui
import android.content.Intent
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 cn.ilapage.goauto.agent.R
import cn.ilapage.goauto.agent.identity.SecureDeviceStore
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
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 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 val refresh = object : Runnable {
override fun run() {
refreshStatus()
handler.postDelayed(this, REFRESH_MILLIS)
}
}
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.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)
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)
addView(taskText)
}))
addView(context.card(context.cardColumn().apply {
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)
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)
}
override fun onResume() {
super.onResume()
handler.removeCallbacks(refresh)
handler.post(refresh)
}
override fun onPause() {
handler.removeCallbacks(refresh)
super.onPause()
}
override fun onDestroyView() {
handler.removeCallbacks(refresh)
super.onDestroyView()
}
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" -> "已连接 · 正在执行"
"CONNECTING" -> "正在连接"
"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"
} ?: "设备空闲,等待新任务"
when (AccessibilityReadinessDetector.current(context)) {
AccessibilityReadiness.READY -> {
accessibilityTitle.text = "已开启并就绪"
accessibilityTitle.setTextColor(context.getColor(R.color.agent_primary_light))
accessibilityDetail.text = "服务已绑定,可以执行采集和采购任务。"
}
AccessibilityReadiness.ENABLED_WAITING -> {
accessibilityTitle.text = "已开启,等待服务连接"
accessibilityTitle.setTextColor(context.getColor(R.color.agent_warning))
accessibilityDetail.text = "系统开关已打开,但 Agent 尚未连接服务。请返回本页稍候。"
}
AccessibilityReadiness.DISABLED -> {
accessibilityTitle.text = "未开启"
accessibilityTitle.setTextColor(context.getColor(R.color.agent_error))
accessibilityDetail.text = "请打开 GoAuto 采集服务,否则任务会失败。"
}
}
val installId = runCatching { identityStore.installId() }.getOrElse { "读取失败" }
val displayName = settingsStore.deviceName().ifBlank { "使用手机型号" }
deviceText.text = "设备名称:$displayName\ninstallId:$installId\n设备编号:${state.deviceId.takeIf { it > 0 } ?: "未注册"}"
}
private companion object {
const val REFRESH_MILLIS = 1_000L
}
}
@@ -0,0 +1,82 @@
package cn.ilapage.goauto.agent.ui
import android.content.Context
import android.graphics.Typeface
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import cn.ilapage.goauto.agent.R
import com.google.android.material.card.MaterialCardView
internal fun Context.dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
internal fun Context.page(content: LinearLayout): ScrollView = ScrollView(this).apply {
isFillViewport = true
setBackgroundColor(getColor(R.color.agent_background))
addView(content, ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
))
}
internal fun Context.column(padding: Int = 20): LinearLayout = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(padding), dp(20), dp(padding), dp(24))
}
internal fun Context.label(
value: String,
size: Float = 15f,
color: Int = getColor(R.color.agent_text),
bold: Boolean = false,
): TextView = TextView(this).apply {
text = value
textSize = size
setTextColor(color)
setLineSpacing(0f, 1.2f)
if (bold) setTypeface(typeface, Typeface.BOLD)
}
internal fun Context.screenTitle(title: String, subtitle: String): LinearLayout = column(0).apply {
setPadding(0, 0, 0, dp(20))
addView(label(title, 26f, getColor(R.color.agent_text), true))
addView(label(subtitle, 14f, getColor(R.color.agent_text_muted)).apply {
setPadding(0, dp(4), 0, 0)
})
}
internal fun Context.card(content: LinearLayout): MaterialCardView = MaterialCardView(this).apply {
radius = dp(16).toFloat()
strokeWidth = dp(1)
strokeColor = getColor(R.color.agent_surface_high)
setCardBackgroundColor(getColor(R.color.agent_surface))
addView(content, ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
))
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
).apply { bottomMargin = dp(16) }
}
internal fun Context.cardColumn(): LinearLayout = column(16).apply {
setPadding(dp(16), dp(16), dp(16), dp(16))
}
internal fun View.fullWidth(topMargin: Int = 0): LinearLayout.LayoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
).apply { this.topMargin = context.dp(topMargin) }
internal fun Context.centeredMessage(title: String, description: String): View = card(cardColumn().apply {
gravity = Gravity.CENTER_HORIZONTAL
addView(label(title, 18f, getColor(R.color.agent_text), true))
addView(label(description, 14f, getColor(R.color.agent_text_muted)).apply {
gravity = Gravity.CENTER
setPadding(0, dp(8), 0, 0)
})
})
@@ -0,0 +1,40 @@
package cn.ilapage.goauto.agent.ui
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
class TaskPlaceholderFragment : Fragment() {
override fun onCreateView(inflater: android.view.LayoutInflater, container: ViewGroup?, state: Bundle?): View {
val context = requireContext()
val type = requireArguments().getString(ARG_TYPE).orEmpty()
val collection = type == TYPE_COLLECTION
val content = context.column().apply {
addView(context.screenTitle(
if (collection) "采集记录" else "采购记录",
if (collection) "查看当前设备的采集任务" else "查看当前设备的采购任务",
))
addView(context.centeredMessage(
"记录功能正在准备中",
if (collection) "任务历史、编号搜索和详情将在下一实施工单接入。"
else "采购记录将保持只读,不提供修改订单或支付操作。",
))
}
return context.page(content)
}
companion object {
private const val ARG_TYPE = "type"
private const val TYPE_COLLECTION = "collection"
private const val TYPE_PURCHASE = "purchase"
fun collection() = TaskPlaceholderFragment().apply {
arguments = Bundle().apply { putString(ARG_TYPE, TYPE_COLLECTION) }
}
fun purchase() = TaskPlaceholderFragment().apply {
arguments = Bundle().apply { putString(ARG_TYPE, TYPE_PURCHASE) }
}
}
}
@@ -0,0 +1,4 @@
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/agent_primary_light" android:state_checked="true" />
<item android:color="@color/agent_text_muted" />
</selector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="@android:color/transparent" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M9,5h10M9,12h10M9,19h10M4,5h0.01M4,12h0.01M4,19h0.01" />
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="@android:color/transparent" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M3,3h2l2.4,11.2a2,2 0,0 0,2 1.6h7.7a2,2 0,0 0,2 -1.6L20.5,7H6M10,21a1,1 0,1 0,0 -2a1,1 0,0 0,0 2M18,21a1,1 0,1 0,0 -2a1,1 0,0 0,0 2" />
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="@android:color/transparent" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M12,15.5a3.5,3.5 0,1 0,0 -7a3.5,3.5 0,0 0,0 7M19.4,15a1.7,1.7 0,0 0,0.34 1.88l0.06,0.06a2,2 0,1 1,-2.83 2.83l-0.06,-0.06A1.7,1.7 0,0 0,15 19.4a1.7,1.7 0,0 0,-1 1.55V21a2,2 0,1 1,-4 0v-0.09A1.7,1.7 0,0 0,9 19.4a1.7,1.7 0,0 0,-1.88 0.34l-0.06,0.06a2,2 0,1 1,-2.83 -2.83l0.06,-0.06A1.7,1.7 0,0 0,4.6 15a1.7,1.7 0,0 0,-1.55 -1H3a2,2 0,1 1,0 -4h0.09A1.7,1.7 0,0 0,4.6 9a1.7,1.7 0,0 0,-0.34 -1.88L4.2,7.06a2,2 0,1 1,2.83 -2.83l0.06,0.06A1.7,1.7 0,0 0,9 4.6a1.7,1.7 0,0 0,1 -1.55V3a2,2 0,1 1,4 0v0.09A1.7,1.7 0,0 0,15 4.6a1.7,1.7 0,0 0,1.88 -0.34l0.06,-0.06a2,2 0,1 1,2.83 2.83l-0.06,0.06A1.7,1.7 0,0 0,19.4 9a1.7,1.7 0,0 0,1.55 1H21a2,2 0,1 1,0 4h-0.09A1.7,1.7 0,0 0,19.4 15z" />
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="@android:color/transparent" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M4,13h4l2,-6l4,10l2,-4h4M5,21h14a2,2 0,0 0,2 -2V5a2,2 0,0 0,-2 -2H5a2,2 0,0 0,-2 2v14a2,2 0,0 0,2 2z" />
</vector>
@@ -0,0 +1,11 @@
<resources>
<color name="agent_background">#020617</color>
<color name="agent_surface">#0F172A</color>
<color name="agent_surface_high">#172033</color>
<color name="agent_primary">#059669</color>
<color name="agent_primary_light">#34D399</color>
<color name="agent_text">#F8FAFC</color>
<color name="agent_text_muted">#CBD5E1</color>
<color name="agent_warning">#FBBF24</color>
<color name="agent_error">#F87171</color>
</resources>
@@ -0,0 +1,3 @@
<resources>
<dimen name="agent_bottom_navigation_height">72dp</dimen>
</resources>
+3
View File
@@ -0,0 +1,3 @@
<resources>
<item name="agent_tab_content" type="id" />
</resources>
+6 -2
View File
@@ -1,9 +1,13 @@
<resources>
<style name="Theme.GoAutoAgent" parent="android:style/Theme.Material.Light.NoActionBar">
<style name="Theme.GoAutoAgent" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:colorAccent">#22C55E</item>
<item name="colorPrimary">#059669</item>
<item name="colorPrimaryVariant">#047857</item>
<item name="colorSecondary">#38BDF8</item>
<item name="android:colorAccent">#059669</item>
<item name="android:navigationBarColor">#020617</item>
<item name="android:statusBarColor">#020617</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowBackground">#020617</item>
</style>
</resources>
@@ -0,0 +1,40 @@
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.SettingsAvailabilityResolver
import org.junit.Assert.assertEquals
import org.junit.Test
class AccessibilityReadinessTest {
@Test
fun disabledWinsEvenWhenAStaleServiceReferenceExists() {
assertEquals(
AccessibilityReadiness.DISABLED,
AccessibilityReadinessResolver.resolve(systemEnabled = false, serviceBound = true),
)
}
@Test
fun enabledButUnboundIsWaiting() {
assertEquals(
AccessibilityReadiness.ENABLED_WAITING,
AccessibilityReadinessResolver.resolve(systemEnabled = true, serviceBound = false),
)
}
@Test
fun enabledAndBoundIsReady() {
assertEquals(
AccessibilityReadiness.READY,
AccessibilityReadinessResolver.resolve(systemEnabled = true, serviceBound = true),
)
}
@Test
fun connectionSettingsAreDisabledForTasksAndConnectionTests() {
assertEquals(false, SettingsAvailabilityResolver.editable("BUSY", 35L, testing = false))
assertEquals(false, SettingsAvailabilityResolver.editable("ONLINE", null, testing = true))
assertEquals(true, SettingsAvailabilityResolver.editable("ONLINE", null, testing = false))
}
}
+11 -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: ce37cbe86bad24c38c801955e301ff8e90ec86b5
synchronized_at: 2026-08-24T07:56:32Z
wiki_revision: d076a86d9282a98c8bb3f00deb0edee68041f2ec
synchronized_at: 2026-08-25T08:52:07Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -149,3 +149,12 @@ Android Portal/Agent
导入器从 cmautobuy MySQL 只读一致性快照读取有效 `pdd_products`、`shopee_products` 和 `shopee_skus`,再写入 GoAuto 的 `pdd_product`、`shopee_product`。旧 PDD 组合 SKU 聚合为 GoAuto 通用维度;同一颜色出现多个不同价格时不猜测价格。旧组合 `spec_mappings`、SYB、任务、订单和其他域数据不进入导入范围。
默认模式是 dry-run;显式 `--apply` 才会在目标 MySQL 单事务覆盖同业务键商品。来源与目标配置都来自未跟踪 YAML,连接串和密码不输出。来源蝦皮的字符串 `pdd_goods_id` 必须通过目标 PDD `goods_id` 换算为数字 `pdd_product_id`,提交前重新校验 JSON、唯一键和关联完整性。
## Android Agent 0.3 设备端外壳(#88)
- Android 入口仍为单 Activity,但从动态单页升级为 AndroidX Fragment + Material `BottomNavigationView` 的四 Tab 外壳:状态、采集、采购、设置;默认进入状态页并保存当前 Tab。
- `android/app/src/main/java/cn/ilapage/goauto/agent/ui/` 保存状态页、设置页、任务占位页、无障碍真实就绪检测和共享 UI 组件;采集/采购历史列表与详情由 #90 接入。
- 状态页同时读取系统启用的 AccessibilityService 组件和 `GoAutoAccessibilityService.instance` 绑定事实,区分“未开启”“已开启等待连接”“已开启并就绪”,并显示服务连接、当前任务和最近心跳。
- 设置页维护服务器地址和设备名称,测试连接只访问 `GET /api/v1/health`;保存后沿用当前 installId / Device Token 重新连接。执行任务或测试连接期间表单与按钮禁用。
- 设置页只显示 Token 是否配置,不返回或显示明文;不包含 PDD 账号、采购写操作或支付入口。
- `AgentStateStore` 保存界面所需的当前任务类型/编号摘要;任务结束后由执行服务清除。它不保存控件树、截图、完整规则或凭据。
+22 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Local-Development-and-Verification
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Local-Development-and-Verification.-
wiki_revision: 465390216d69d28a642854fd946d2d818a834f9c
synchronized_at: 2026-08-24T07:56:50Z
wiki_revision: f79711e63e200dc8f7bdc0b04bf5a87f35e2a405
synchronized_at: 2026-08-25T08:52:22Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -179,3 +179,23 @@ D:\supervisor\supervisord.exe /c D:\supervisor\supervisord.conf ctl restart goau
```
Supervisor 托管期间不要再运行 `start-all.bat` 或重复启动对应单端脚本,否则会因 8010/9527 被占用而失败。两个 GoAuto 实例同时重启时,Admin UI 会等待 API HTTP 就绪后再监听 Web 端口;SYB 商品页对一次短暂网络断开做单次有界重试,不对认证、权限或业务错误重试。
### Android Agent 0.3 四 Tab 真机检查
#88 起 Debug APK 版本为 0.3.0。除单元测试和构建外,在设备空闲且没有采集/采购任务时安装:
```powershell
Set-Location android
.\gradlew.bat testDebugUnitTest assembleDebug
adb install -r app\build\outputs\apk\debug\app-debug.apk
adb shell am start -n cn.ilapage.goauto.agent/.MainActivity
```
真机至少检查:
- 底部状态、采集、采购、设置四项都有图标与文字,默认状态页,触控目标不少于 48dp。
- 状态页分别验证无障碍未开启、系统已开启但服务未绑定、服务已绑定就绪;返回系统设置后自动刷新。
- 设置页服务器地址、设备名称、测试连接和保存重连反馈可读;测试连接只访问 `GET /api/v1/health`。
- 任务执行中服务器地址、设备名称、测试和保存均禁用;Token 只显示“已配置/未配置”。
- 采集/采购 Tab 在 #90 前只显示明确占位,不应出现采购写操作、PDD 凭据或支付入口。
- Release 仍只接受 HTTPS;Debug 可使用 HTTP 进行局域网联调。安装前必须确认设备空闲,避免重启 Agent 中断任务。