feat(android): implement #88 agent four-tab shell
This commit is contained in:
+8
-2
@@ -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 真机验证记录
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<item name="agent_tab_content" type="id" />
|
||||
</resources>
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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` 保存界面所需的当前任务类型/编号摘要;任务结束后由执行服务清除。它不保存控件树、截图、完整规则或凭据。
|
||||
|
||||
@@ -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 中断任务。
|
||||
|
||||
Reference in New Issue
Block a user