fix: persist pairing token before agent startup
This commit is contained in:
@@ -1,11 +1,18 @@
|
||||
package com.system.cloudservice.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.view.View
|
||||
import android.widget.*
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
@@ -16,6 +23,8 @@ import com.google.gson.reflect.TypeToken
|
||||
import com.google.zxing.integration.android.IntentIntegrator
|
||||
import com.system.cloudservice.BuildConfig
|
||||
import com.system.cloudservice.R
|
||||
import com.system.cloudservice.engine.ConnectionState
|
||||
import com.system.cloudservice.frida.FridaConfig
|
||||
import com.system.cloudservice.engine.LanDiscovery
|
||||
import com.system.cloudservice.service.AgentForegroundService
|
||||
import com.system.cloudservice.util.DeviceInfo
|
||||
@@ -30,10 +39,17 @@ class SetupActivity : AppCompatActivity() {
|
||||
private enum class PendingAction { NONE, START_QR_SCAN }
|
||||
private var pendingAction: PendingAction = PendingAction.NONE
|
||||
|
||||
private lateinit var tvStatus: TextView
|
||||
private lateinit var viewFlipper: ViewFlipper
|
||||
private lateinit var tvStepIndicator: TextView
|
||||
private lateinit var tvNetworkStatus: TextView
|
||||
private lateinit var btnStep1Next: Button
|
||||
private lateinit var tvSetupStatus: TextView
|
||||
private lateinit var btnScan: Button
|
||||
private lateinit var btnManualInput: Button
|
||||
private lateinit var progressBar: ProgressBar
|
||||
private lateinit var tvSuccessDeviceId: TextView
|
||||
|
||||
private var currentStep = 1
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -44,16 +60,69 @@ class SetupActivity : AppCompatActivity() {
|
||||
if (!hasForcedConfig && isAlreadyBound()) { launchMain(); return }
|
||||
|
||||
setContentView(R.layout.activity_setup)
|
||||
tvStatus = findViewById(R.id.tvSetupStatus)
|
||||
|
||||
viewFlipper = findViewById(R.id.viewFlipper)
|
||||
tvStepIndicator = findViewById(R.id.tvStepIndicator)
|
||||
tvNetworkStatus = findViewById(R.id.tvNetworkStatus)
|
||||
btnStep1Next = findViewById(R.id.btnStep1Next)
|
||||
tvSetupStatus = findViewById(R.id.tvSetupStatus)
|
||||
btnScan = findViewById(R.id.btnScan)
|
||||
btnManualInput = findViewById(R.id.btnManualInput)
|
||||
progressBar = findViewById(R.id.progressSetup)
|
||||
tvSuccessDeviceId = findViewById(R.id.tvSuccessDeviceId)
|
||||
|
||||
// 步骤 1:联网检查
|
||||
btnStep1Next.setOnClickListener { goToStep(2) }
|
||||
|
||||
// 步骤 2:保活 checklist(WP-AGENT-01)
|
||||
findViewById<Button>(R.id.btnSetupAutoStart).setOnClickListener {
|
||||
safeStart(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.parse("package:$packageName")
|
||||
})
|
||||
}
|
||||
findViewById<Button>(R.id.btnSetupBattery).setOnClickListener {
|
||||
safeStart(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
|
||||
}
|
||||
findViewById<Button>(R.id.btnSetupNotify).setOnClickListener {
|
||||
safeStart(Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
|
||||
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
|
||||
})
|
||||
}
|
||||
findViewById<Button>(R.id.btnSetupA11y).setOnClickListener {
|
||||
safeStart(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
|
||||
}
|
||||
findViewById<Button>(R.id.btnStep2Next).setOnClickListener { goToStep(3) }
|
||||
|
||||
// 步骤 3:扫码绑定
|
||||
btnScan.setOnClickListener { ensureCameraThenStartQr() }
|
||||
btnManualInput.setOnClickListener { startLanScan() }
|
||||
|
||||
// 成功页
|
||||
findViewById<Button>(R.id.btnCopySuccess).setOnClickListener { copyDeviceId(tvSuccessDeviceId.text.toString()) }
|
||||
findViewById<Button>(R.id.btnEnterMain).setOnClickListener { launchMain() }
|
||||
|
||||
// adb 注入配置:跳过向导直接绑定
|
||||
if (hasForcedConfig && consumeIntentConfigIfPresent()) return
|
||||
if (consumeIntentConfigIfPresent()) return
|
||||
autoScanLan()
|
||||
if (isAlreadyBound()) {
|
||||
launchMain()
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
goToStep(1)
|
||||
checkNetwork()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (currentStep == 1 && ::tvNetworkStatus.isInitialized) checkNetwork()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
if (::tvSetupStatus.isInitialized) consumeIntentConfigIfPresent()
|
||||
}
|
||||
|
||||
override fun onDestroy() { super.onDestroy(); scope.cancel() }
|
||||
@@ -61,13 +130,43 @@ class SetupActivity : AppCompatActivity() {
|
||||
private fun isAlreadyBound(): Boolean =
|
||||
prefs.getBoolean("bound", false) && prefs.getString("server_url", "").orEmpty().isNotBlank()
|
||||
|
||||
/** 步骤切换:1 联网 / 2 保活 / 3 扫码 / 4 成功 */
|
||||
private fun goToStep(step: Int) {
|
||||
currentStep = step
|
||||
viewFlipper.displayedChild = step - 1
|
||||
tvStepIndicator.text = when (step) {
|
||||
1 -> "1/3 · 联网检查"
|
||||
2 -> "2/3 · 保活权限"
|
||||
3 -> "3/3 · 扫码绑定"
|
||||
else -> "绑定完成"
|
||||
}
|
||||
if (step == 3) autoScanLan()
|
||||
}
|
||||
|
||||
/** 步骤 1 联网检查:WiFi/移动网络可达即放行 */
|
||||
private fun checkNetwork() {
|
||||
scope.launch {
|
||||
val cm = getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val network = cm.activeNetwork
|
||||
val caps = cm.getNetworkCapabilities(network)
|
||||
val hasNetwork = caps != null && (
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) ||
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
|
||||
)
|
||||
if (hasNetwork) {
|
||||
tvNetworkStatus.text = "网络已连接 ✓\n请点击「下一步」继续"
|
||||
btnStep1Next.isEnabled = true
|
||||
} else {
|
||||
tvNetworkStatus.text = "未检测到网络连接\n请连接 WiFi 后返回本页"
|
||||
btnStep1Next.isEnabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureCameraThenStartQr() {
|
||||
val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
|
||||
if (granted) {
|
||||
startQrScan()
|
||||
return
|
||||
}
|
||||
|
||||
if (granted) { startQrScan(); return }
|
||||
pendingAction = PendingAction.START_QR_SCAN
|
||||
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.CAMERA), 2001)
|
||||
}
|
||||
@@ -75,6 +174,7 @@ class SetupActivity : AppCompatActivity() {
|
||||
private fun startQrScan() {
|
||||
@Suppress("DEPRECATION")
|
||||
IntentIntegrator(this)
|
||||
.setCaptureActivity(PortraitCaptureActivity::class.java)
|
||||
.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
|
||||
.setPrompt("对准服务器二维码")
|
||||
.setCameraId(0)
|
||||
@@ -93,14 +193,12 @@ class SetupActivity : AppCompatActivity() {
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if (requestCode != 2001) return
|
||||
|
||||
val granted = grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED
|
||||
if (!granted) {
|
||||
tvStatus.text = "需要相机权限才能扫码绑定"
|
||||
tvSetupStatus.text = "需要相机权限才能扫码绑定"
|
||||
pendingAction = PendingAction.NONE
|
||||
return
|
||||
}
|
||||
|
||||
when (pendingAction) {
|
||||
PendingAction.START_QR_SCAN -> startQrScan()
|
||||
PendingAction.NONE -> {}
|
||||
@@ -109,7 +207,7 @@ class SetupActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
private fun autoScanLan() {
|
||||
tvStatus.text = "正在搜索局域网服务器..."
|
||||
tvSetupStatus.text = "正在搜索局域网服务器..."
|
||||
progressBar.visibility = View.VISIBLE
|
||||
scope.launch {
|
||||
try {
|
||||
@@ -117,29 +215,29 @@ class SetupActivity : AppCompatActivity() {
|
||||
val servers = LanDiscovery.discover(localIp)
|
||||
if (servers.isNotEmpty()) {
|
||||
val best = servers.first()
|
||||
tvStatus.text = "发现服务器: ${best.name}"
|
||||
tvSetupStatus.text = "发现服务器: ${best.name}"
|
||||
handleConfig(mapOf("server" to best.wsUrl, "project" to "cunkebao"))
|
||||
} else {
|
||||
tvStatus.text = "未发现局域网服务器\n请扫码或搜索连接"
|
||||
tvSetupStatus.text = "未发现局域网服务器\n请扫码绑定"
|
||||
progressBar.visibility = View.GONE
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e("LAN auto scan failed", e)
|
||||
tvStatus.text = "请扫码绑定服务器"
|
||||
tvSetupStatus.text = "请扫码绑定服务器"
|
||||
progressBar.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startLanScan() {
|
||||
tvStatus.text = "搜索局域网服务器中..."
|
||||
tvSetupStatus.text = "搜索局域网服务器中..."
|
||||
setLoading(true)
|
||||
scope.launch {
|
||||
try {
|
||||
val localIp = withContext(Dispatchers.IO) { getLocalIp() }
|
||||
val servers = LanDiscovery.discover(localIp)
|
||||
if (servers.isEmpty()) {
|
||||
tvStatus.text = "未找到服务器,请扫码绑定"
|
||||
tvSetupStatus.text = "未找到服务器,请扫码绑定"
|
||||
setLoading(false)
|
||||
return@launch
|
||||
}
|
||||
@@ -150,7 +248,7 @@ class SetupActivity : AppCompatActivity() {
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e("LAN scan failed", e)
|
||||
tvStatus.text = "搜索失败,请扫码绑定"
|
||||
tvSetupStatus.text = "搜索失败,请扫码绑定"
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
@@ -177,9 +275,7 @@ class SetupActivity : AppCompatActivity() {
|
||||
val addrs = ni.inetAddresses
|
||||
while (addrs.hasMoreElements()) {
|
||||
val addr = addrs.nextElement()
|
||||
if (!addr.isLoopbackAddress && addr is java.net.Inet4Address) {
|
||||
return addr.hostAddress
|
||||
}
|
||||
if (!addr.isLoopbackAddress && addr is java.net.Inet4Address) return addr.hostAddress
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
@@ -201,7 +297,7 @@ class SetupActivity : AppCompatActivity() {
|
||||
handleConfig(config)
|
||||
} catch (e: Exception) {
|
||||
Logger.e("QR parse failed", e)
|
||||
tvStatus.text = "二维码格式错误,请重试"
|
||||
tvSetupStatus.text = "二维码格式错误,请重试"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,44 +305,87 @@ class SetupActivity : AppCompatActivity() {
|
||||
* 支持通过 adb am start 直接注入配置,避免手动扫码/局域网发现。
|
||||
* 示例:
|
||||
* adb shell am start -n com.system.cloudservice/.ui.SetupActivity \
|
||||
* --es server_url ws://127.0.0.1:8899 --es project_id cunkebao
|
||||
* --es server_url ws://127.0.0.1:8899 --es project_id cunkebao \
|
||||
* --es pairing_token '<token>' --es device_id xgfe65eimrrofyws --ei frida_port 15715
|
||||
*/
|
||||
private fun consumeIntentConfigIfPresent(): Boolean {
|
||||
val server = intent?.getStringExtra("server_url")?.trim().orEmpty()
|
||||
if (server.isEmpty()) return false
|
||||
val project = intent?.getStringExtra("project_id")?.trim().takeUnless { it.isNullOrEmpty() } ?: "cunkebao"
|
||||
val pwa = intent?.getStringExtra("pwa_url")?.trim().takeUnless { it.isNullOrEmpty() } ?: BuildConfig.PWA_URL
|
||||
tvStatus.text = "收到外部配置,正在绑定..."
|
||||
handleConfig(mapOf("server" to server, "project" to project, "pwa" to pwa))
|
||||
val cfg = mutableMapOf("server" to server, "project" to project, "pwa" to pwa)
|
||||
intent?.getStringExtra("pairing_token")?.trim()?.takeIf { it.isNotEmpty() }?.let { token ->
|
||||
cfg["pairing_token"] = token
|
||||
}
|
||||
intent?.getStringExtra("device_id")?.trim()?.takeIf { it.isNotEmpty() }?.let { deviceId ->
|
||||
cfg["device_id"] = deviceId
|
||||
}
|
||||
if (intent?.hasExtra("frida_port") == true) {
|
||||
cfg["frida_port"] = intent!!.getIntExtra("frida_port", FridaConfig.DEFAULT_PORT).toString()
|
||||
}
|
||||
if (::tvSetupStatus.isInitialized) tvSetupStatus.text = "收到外部配置,正在绑定..."
|
||||
handleConfig(cfg)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun handleConfig(config: Map<String, String>) {
|
||||
setLoading(true)
|
||||
tvStatus.text = "连接中..."
|
||||
tvSetupStatus.text = "连接中..."
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
val serverUrl = config["server"] ?: throw IllegalArgumentException("缺少服务器地址")
|
||||
val projectId = config["project"] ?: "cunkebao"
|
||||
val pwaUrl = config["pwa"] ?: BuildConfig.PWA_URL
|
||||
val deviceId = DeviceInfo.getOrCreateDeviceId(this@SetupActivity)
|
||||
val deviceId = config["device_id"]?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: DeviceInfo.getOrCreateDeviceId(this@SetupActivity)
|
||||
|
||||
prefs.edit()
|
||||
// 绑定字段必须一次性同步落盘:AgentEngine.start() 会立即读取 pairing_token
|
||||
// 组装 WSS URL。使用 apply() 会产生竞态,导致首次扫码握手漏带 token。
|
||||
val editor = prefs.edit()
|
||||
.putString("server_url", serverUrl)
|
||||
.putString("project_id", projectId)
|
||||
.putString("device_id", deviceId)
|
||||
.putString("pwa_url", pwaUrl)
|
||||
.putBoolean("auto_connect", true)
|
||||
.putBoolean("bound", true)
|
||||
.apply()
|
||||
config["ai_api_key"]?.trim()?.takeIf { it.isNotEmpty() }?.let { key ->
|
||||
editor.putString("ai_api_key", key).putBoolean("ai_enabled", true)
|
||||
}
|
||||
config["ai_api_url"]?.trim()?.takeIf { it.isNotEmpty() }?.let { url ->
|
||||
editor.putString("ai_api_url", url)
|
||||
}
|
||||
config["public_servers"]?.trim()?.takeIf { it.isNotEmpty() }?.let { ps ->
|
||||
editor.putString("public_servers", ps)
|
||||
}
|
||||
config["pairing_token"]?.trim()?.takeIf { it.isNotEmpty() }?.let { token ->
|
||||
editor.putString("pairing_token", token)
|
||||
}
|
||||
config["auto_discover"]?.trim()?.takeIf { it.isNotEmpty() }?.let { ad ->
|
||||
editor.putBoolean("auto_discover", ad == "1" || ad.equals("true", true))
|
||||
}
|
||||
if (!editor.commit()) throw IllegalStateException("绑定配置保存失败")
|
||||
config["frida_port"]?.trim()?.toIntOrNull()?.takeIf { it in 1025..65535 }?.let { port ->
|
||||
FridaConfig.savePort(this@SetupActivity, port)
|
||||
}
|
||||
|
||||
startAgentService(serverUrl, projectId, deviceId)
|
||||
delay(300)
|
||||
launchMain()
|
||||
tvSuccessDeviceId.text = DeviceInfo.deviceIdMd5(this@SetupActivity)
|
||||
// BIND-04 连接阶段实时映射(复用 DashboardFragment 人话映射)
|
||||
var elapsed = 0
|
||||
while (isActive && elapsed < 15_000) {
|
||||
delay(500)
|
||||
elapsed += 500
|
||||
val engine = AgentForegroundService.instance?.engine
|
||||
val connState = engine?.wsManager?.state?.value
|
||||
tvSetupStatus.text = "连接中 · ${stageText(engine?.getConnectStage())}..."
|
||||
if (connState == ConnectionState.CONNECTED) { goToStep(4); return@launch }
|
||||
}
|
||||
tvSetupStatus.text = "连接超时,请检查服务器\n可返回首页查看连接状态"
|
||||
setLoading(false)
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Bind failed", e)
|
||||
tvStatus.text = "连接失败,请重试"
|
||||
tvSetupStatus.text = "连接失败,请重试"
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
@@ -258,6 +397,17 @@ class SetupActivity : AppCompatActivity() {
|
||||
progressBar.visibility = if (loading) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
private fun copyDeviceId(deviceId: String) {
|
||||
val cm = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
cm.setPrimaryClip(ClipData.newPlainText("device_id_md5", deviceId))
|
||||
Toast.makeText(this, "已复制设备 MD5", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun safeStart(intent: Intent) {
|
||||
try { startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) }
|
||||
catch (e: Exception) { Toast.makeText(this, "无法打开:${e.message}", Toast.LENGTH_SHORT).show() }
|
||||
}
|
||||
|
||||
private fun startAgentService(serverUrl: String, projectId: String, deviceId: String) {
|
||||
val intent = Intent(this, AgentForegroundService::class.java).apply {
|
||||
action = AgentForegroundService.ACTION_START
|
||||
@@ -270,4 +420,13 @@ class SetupActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
private fun launchMain() { startActivity(Intent(this, MainActivity::class.java)); finish() }
|
||||
|
||||
/** BIND-04 connect_stage 人话映射(与 DashboardFragment 一致) */
|
||||
private fun stageText(raw: String?): String = when (raw) {
|
||||
"primary" -> "主服务器"
|
||||
"lan" -> "局域网服务器"
|
||||
"public" -> "公网备用"
|
||||
null -> "初始化"
|
||||
else -> raw
|
||||
}
|
||||
}
|
||||
|
||||
BIN
sdk/app/static/downloads/workphone-agent-latest.apk
Normal file
BIN
sdk/app/static/downloads/workphone-agent-latest.apk
Normal file
Binary file not shown.
@@ -3799,4 +3799,7 @@ v3.1: Agent 内置 AI Brain → 心跳驱动自主决策 → Frida优先/u2兜
|
||||
- 已把设备页 `扫描` 拆分为 `扫码绑定` 与 `扫描 ADB`;扫码绑定会自动生成当前服务器二维码。
|
||||
- 已新增离线设备删除接口和页面按钮,在线设备有 409 保护,保留命令审计。
|
||||
- 验收:本地定向测试 3/3 通过;宝塔容器 healthy、二维码接口 HTTP 200 且返回 PNG、页面标识回读通过、删除不存在设备返回 404。当前 WSS 在线设备 0 台,等待手机扫码注册。
|
||||
### 2026-08-08 22:00|扫码成功但未进入设备列表修复
|
||||
- 根因确认:扫码配置的 pairing_token 使用异步 apply 写入,与前台 Agent 启动存在首次握手竞态。
|
||||
- 已改为原子 commit 后启动 Agent;修复 APK 已发布到公网静态下载入口,HTTP 200,长度 6,663,970 字节,SHA-256 已核验。
|
||||
|
||||
|
||||
@@ -194,4 +194,10 @@ curl -X POST http://127.0.0.1:8899/api/v3/health 2>/dev/null || true
|
||||
- 页面源码回读确认 `openBindQRCode`、`扫码绑定`、`扫描 ADB`、`删除失效设备` 均已上线。
|
||||
- `DELETE /api/v3/devices/__verification_missing_device__` 返回 HTTP 404,确认删除接口受设备存在性校验保护;在线设备另有 HTTP 409 保护。
|
||||
- 公网健康检查:`https://wpsdk.quwanzhi.com/health` 返回 healthy;当前 `devices_online=0`,等待手机扫描二维码并完成 WSS 注册。
|
||||
### 2026-08-08 22:00 已扫码未注册修复
|
||||
|
||||
- **根因**:手机扫码后 `SetupActivity` 以异步 `SharedPreferences.apply()` 写入 `pairing_token`,随即启动 Agent;首次启动存在令牌尚未落盘的竞态,WSS 握手未带 token。
|
||||
- **修复**:扫码绑定字段改为单个 Editor 原子 `commit()` 成功后再启动 `AgentForegroundService`。
|
||||
- **APK**:`/static/downloads/workphone-agent-latest.apk`,SHA-256:`b50a96b9475e365a377e0ac9d7efbf40fba04ac357fb660001829946cc3ad601`。
|
||||
- **验收口径**:安装修复包后重新扫码;服务端日志应出现 WSS 连接与 `设备注册`,`/health` 的 `devices_online` 应大于 0。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user