diff --git a/android/README.md b/android/README.md index 23ac0b6..77c590a 100644 --- a/android/README.md +++ b/android/README.md @@ -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 真机验证记录 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 7857470..ddff439 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "cn.ilapage.goauto.agent" minSdk = 23 targetSdk = 34 - versionCode = 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") } diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/MainActivity.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/MainActivity.kt index 66899e9..3a6c5a0 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/MainActivity.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/MainActivity.kt @@ -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" } } diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt index cc298b9..9af9069 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt @@ -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()) diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt index 59b67d4..f1b90ad 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt @@ -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), diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentStateStore.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentStateStore.kt index a3b622f..f3d6cc7 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentStateStore.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentStateStore.kt @@ -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" } } diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/ui/AccessibilityReadiness.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/AccessibilityReadiness.kt new file mode 100644 index 0000000..ce893c2 --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/AccessibilityReadiness.kt @@ -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 = 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 = 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 +} diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/ui/AgentSettingsFragment.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/AgentSettingsFragment.kt new file mode 100644 index 0000000..b7a080a --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/AgentSettingsFragment.kt @@ -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 + } +} diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/ui/AgentStatusFragment.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/AgentStatusFragment.kt new file mode 100644 index 0000000..490f165 --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/AgentStatusFragment.kt @@ -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 + } +} diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/ui/AgentUiKit.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/AgentUiKit.kt new file mode 100644 index 0000000..763299c --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/AgentUiKit.kt @@ -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) + }) +}) diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskPlaceholderFragment.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskPlaceholderFragment.kt new file mode 100644 index 0000000..f2537ec --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskPlaceholderFragment.kt @@ -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) } + } + } +} diff --git a/android/app/src/main/res/color/agent_navigation_item.xml b/android/app/src/main/res/color/agent_navigation_item.xml new file mode 100644 index 0000000..5d5ea46 --- /dev/null +++ b/android/app/src/main/res/color/agent_navigation_item.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_agent_collection.xml b/android/app/src/main/res/drawable/ic_agent_collection.xml new file mode 100644 index 0000000..58d6e2f --- /dev/null +++ b/android/app/src/main/res/drawable/ic_agent_collection.xml @@ -0,0 +1,3 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_agent_purchase.xml b/android/app/src/main/res/drawable/ic_agent_purchase.xml new file mode 100644 index 0000000..11129e8 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_agent_purchase.xml @@ -0,0 +1,3 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_agent_settings.xml b/android/app/src/main/res/drawable/ic_agent_settings.xml new file mode 100644 index 0000000..ff35bc3 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_agent_settings.xml @@ -0,0 +1,3 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_agent_status.xml b/android/app/src/main/res/drawable/ic_agent_status.xml new file mode 100644 index 0000000..87177c1 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_agent_status.xml @@ -0,0 +1,3 @@ + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..8f66706 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,11 @@ + + #020617 + #0F172A + #172033 + #059669 + #34D399 + #F8FAFC + #CBD5E1 + #FBBF24 + #F87171 + diff --git a/android/app/src/main/res/values/dimens.xml b/android/app/src/main/res/values/dimens.xml new file mode 100644 index 0000000..2b07879 --- /dev/null +++ b/android/app/src/main/res/values/dimens.xml @@ -0,0 +1,3 @@ + + 72dp + diff --git a/android/app/src/main/res/values/ids.xml b/android/app/src/main/res/values/ids.xml new file mode 100644 index 0000000..e03ce0d --- /dev/null +++ b/android/app/src/main/res/values/ids.xml @@ -0,0 +1,3 @@ + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index 177d528..d8a4d24 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -1,9 +1,13 @@ - diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/AccessibilityReadinessTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/AccessibilityReadinessTest.kt new file mode 100644 index 0000000..b59b6a6 --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/AccessibilityReadinessTest.kt @@ -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)) + } +} diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 0410cd2..cac0059 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -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 # 架构与代码地图 @@ -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` 保存界面所需的当前任务类型/编号摘要;任务结束后由执行服务清除。它不保存控件树、截图、完整规则或凭据。 diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index fdcf966..f101eaf 100644 --- a/docs/04-local-development-and-verification.md +++ b/docs/04-local-development-and-verification.md @@ -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 # 本地开发与验证 @@ -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 中断任务。