feat: 设备端 Agent 首个版本——剪贴板注入 / 应用商店 / 身份显示 / 配置下发

配合 auto_control 平台的设备端补位 App(接口契约见 INTERFACE.md,与平台仓库
doc/DEVICE_AGENT.md 是同一份)。

四个 adb 可拉起的 Activity + 一个商店界面:
- ClipActivity    剪贴板注入(透明 Activity,**必须等窗口获得焦点再写** ——
                  Android 10+ 只有前台应用能写剪贴板,这正是 u2 set_clipboard
                  "成功但内容被丢弃"的原因);写完立即退出,不驻留
- InfoActivity    全屏大字显示设备身份(名称/IP/序列号/平台地址),
                  替代原来"推 HTML 让设备浏览器打开"那套
- ConfigActivity  一次性配置下发(平台用 adb 把 server/token/指纹推进来),
                  一批设备不用一台台手输
- PingActivity    自检(版本/指纹/配置状态),结果写 logcat + 外部私有目录的
                  status.json,平台据此判断"装没装、哪一版"
- MainActivity    应用商店:拉平台清单 → 选应用 → 安装;已装的显示「已安装」,
                  有新版显示「更新到 vX」

实机踩出来的几个坑(都已处理):
- **应用身份读不到 ro.serialno**(shell 能读、应用被 SELinux 挡)→ 指纹改由平台
  通过 ConfigActivity 下发(平台本来就知道),本机 getprop 只作兜底
- **Android 9+ 默认拦明文 HTTP** → 平台是局域网 http,需 usesCleartextTraffic
- **Android 11+ 软件包可见性** → 不加 QUERY_ALL_PACKAGES 会"明明装了却显示未安装"
- **MIUI 安装确认不止一步** → 装完检测窗口给到 3 分钟(人会依次点继续→勾选→继续更新)
- 上报带重试(实测遇到过一次网络抖动导致 timeout)

构建:JDK 17 + Android SDK(minSdk 29 / targetSdk 34),只依赖 androidx.core,
HTTP 用 HttpURLConnection、JSON 用系统内置 org.json。
This commit is contained in:
2026-09-13 16:30:16 +08:00
commit 2580cfaead
20 changed files with 1455 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!--
usesCleartextTraffic=true 的原因:平台跑在局域网里、只有 HTTP(没有 TLS),
Android 9+ 默认会拦掉明文请求(实测报 "Cleartext HTTP traffic ... not permitted")。
本 App 只连你在「配置」里填的那个平台地址,不传输用户隐私数据。
将来平台上了 HTTPS,把 application 上那一行删掉即可。
-->
<!-- 从平台拉应用清单 / 下载 APK -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- 自己调系统安装器安装 APK(这正是绕开 MIUI「USB 安装」拦截的关键) -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!--
查询任意已安装应用的版本号 —— 用来在商店列表里显示「已安装 / 可更新 / 安装」。
Android 11+ 默认限制了软件包可见性(不给的话 getPackageInfo 一律抛 NameNotFound,
实测表现为"明明装了却全显示未安装");本 App 不是上架 Google Play 的应用,
用这个权限没有商店审核问题。
-->
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<application
android:allowBackup="false"
android:usesCleartextTraffic="true"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.DeviceAgent">
<!-- 主界面:应用商店(看清单、选应用、下载安装) -->
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- 剪贴板注入:透明 Activity,由平台用 adb am start 拉起
(完整命令见 README「快速开始」,参数 text_b64 传 base64 文本) -->
<activity
android:name=".ClipActivity"
android:exported="true"
android:excludeFromRecents="true"
android:noHistory="true"
android:theme="@style/Theme.DeviceAgent.Transparent" />
<!-- 身份显示:全屏大字,人在机架前一眼看出这台是平台的哪一台
(参数 name / ip / serial / server,close=1 关闭;命令见 README) -->
<activity
android:name=".InfoActivity"
android:exported="true"
android:launchMode="singleTop"
android:excludeFromRecents="true" />
<!-- 一次性配置下发(平台用 adb 推地址与令牌,省得每台手输) -->
<activity
android:name=".ConfigActivity"
android:exported="true"
android:excludeFromRecents="true"
android:noHistory="true"
android:theme="@style/Theme.DeviceAgent.Transparent" />
<!-- 自检:平台用来判断"装没装、哪一版" -->
<activity
android:name=".PingActivity"
android:exported="true"
android:excludeFromRecents="true"
android:noHistory="true"
android:theme="@style/Theme.DeviceAgent.Transparent" />
<!-- 把下载到私有目录的 APK 授权给系统安装器读 -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,160 @@
package com.example.deviceagent
import android.content.Context
import android.util.Log
import org.json.JSONObject
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
/**
* 平台侧「设备端应用商店」接口的客户端。
*
* 协议见平台仓库的 doc/DEVICE_AGENT.md —— 三个接口:
* GET /api/device/agent/bootstrap 我是谁 + 有哪些应用可装
* GET /api/device/agent/apk/{id} 下载 APK
* POST /api/device/agent/report 回报下载/安装结果
*
* 只用 HttpURLConnection + org.json(系统内置),不引第三方库。
*/
object Api {
private const val TAG = "DeviceAgent"
private const val TIMEOUT = 15000
class ApiException(val code: Int, message: String) : Exception(message)
private fun open(ctx: Context, path: String, method: String): HttpURLConnection {
val base = Prefs.server(ctx)
if (base.isEmpty()) throw ApiException(-1, "还没配置平台地址")
val conn = URL(base + path).openConnection() as HttpURLConnection
conn.requestMethod = method
conn.connectTimeout = TIMEOUT
conn.readTimeout = TIMEOUT
conn.setRequestProperty("X-Device-Token", Prefs.token(ctx))
conn.setRequestProperty("X-Device-Fingerprint", Prefs.fingerprint(ctx))
Prefs.localIp().takeIf { it.isNotEmpty() }?.let {
conn.setRequestProperty("X-Device-Serial", "$it:5555")
}
return conn
}
private fun errText(conn: HttpURLConnection): String = try {
conn.errorStream?.bufferedReader()?.use { it.readText() }?.take(200) ?: ""
} catch (e: Exception) {
""
}
/** 拉身份 + 应用清单。失败抛 ApiException(带 HTTP 码与人类可读原因)。 */
fun bootstrap(ctx: Context): JSONObject {
val conn = open(ctx, "/api/device/agent/bootstrap", "GET")
try {
val code = conn.responseCode
if (code != 200) {
throw ApiException(code, when (code) {
404 -> "平台没启用设备端商店(或地址不对)"
401 -> "设备令牌不对(可能被管理员重置了)"
else -> "平台返回 $code ${errText(conn)}"
})
}
val body = conn.inputStream.bufferedReader().use { it.readText() }
return JSONObject(body)
} finally {
conn.disconnect()
}
}
/** 下载 APK 到私有目录,返回文件。失败抛 ApiException。 */
fun downloadApk(ctx: Context, downloadPath: String, apkId: String): File {
val dir = File(ctx.filesDir, "apks").apply { mkdirs() }
val out = File(dir, "$apkId.apk")
val conn = open(ctx, downloadPath, "GET")
try {
val code = conn.responseCode
if (code != 200) throw ApiException(code, "下载失败(HTTP $code ${errText(conn)})")
conn.inputStream.use { input ->
FileOutputStream(out).use { fos -> input.copyTo(fos, 64 * 1024) }
}
if (out.length() <= 0) throw ApiException(-2, "下载到的文件是空的")
return out
} finally {
conn.disconnect()
}
}
/**
* 请**平台**帮我装这个应用(平台走 adb → 静默,没有系统安装框)。
*
* 为什么不让本机自己装:普通 App 调系统安装器,MIUI 上要过「继续 → 勾选未经检测
* → 继续更新」甚至 ICP 备案检查;而 adb 装在同一台机器上实测是静默的。
* 返回 (ok, 说明);ok=false 时调用方应退回本机安装。
*/
fun requestPlatformInstall(ctx: Context, apkId: String): Pair<Boolean, String> {
return try {
val conn = open(ctx, "/api/device/agent/install", "POST")
try {
conn.doOutput = true
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8")
conn.outputStream.use {
it.write(JSONObject().put("apk_id", apkId)
.toString().toByteArray(Charsets.UTF_8))
}
val code = conn.responseCode
val body = (if (code == 200) conn.inputStream else conn.errorStream)
?.bufferedReader()?.use { r -> r.readText() } ?: ""
val j = try {
JSONObject(body)
} catch (e: Exception) {
JSONObject()
}
if (code == 200 && j.optBoolean("ok")) {
true to j.optString("msg", "平台已开始安装")
} else {
false to j.optString("error", "平台返回 $code")
}
} finally {
conn.disconnect()
}
} catch (e: Exception) {
false to (e.message ?: e.toString())
}
}
/**
* 上报结果。**带重试**:实机实测遇到过一次 timeout(手机在后台、网络抖了一下),
* 而这条记录是平台「设备端商店」面板唯一的可见性来源,丢一条就少一条。
* 三次都失败也只记日志,绝不阻塞安装流程。
*/
fun report(ctx: Context, apkId: String, action: String,
packageName: String = "", versionName: String = "", message: String = "") {
val body = JSONObject().apply {
put("apk_id", apkId)
put("action", action)
put("package_name", packageName)
put("version_name", versionName)
put("message", message.take(400))
}.toString()
for (attempt in 1..3) {
try {
val conn = open(ctx, "/api/device/agent/report", "POST")
try {
conn.doOutput = true
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8")
conn.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
val code = conn.responseCode
Log.i(TAG, "report $action -> HTTP $code(第 $attempt 次)")
if (code == 200) return
} finally {
conn.disconnect()
}
} catch (e: Exception) {
Log.w(TAG, "上报失败(第 $attempt 次): ${e.message}")
try {
Thread.sleep(1500L * attempt)
} catch (_: InterruptedException) {
}
}
}
Log.w(TAG, "上报 $action 三次都没成功(不影响安装,平台面板会缺这条记录)")
}
}
@@ -0,0 +1,66 @@
package com.example.deviceagent
import android.app.Activity
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Base64
import android.util.Log
/**
* 剪贴板注入(透明 Activity,由平台用 adb 拉起):
*
* adb shell am start -n com.example.deviceagent/.ClipActivity --es text_b64 <base64(UTF-8)>
*
* 为什么必须是个 Activity:**Android 10 起,只有当前有焦点的应用才能写剪贴板**——
* shell / 后台进程写会被系统静默丢弃(这正是 u2 的 set_clipboard "成功但内容丢失"的原因)。
*
* 时序要点:onCreate 时窗口还没拿到焦点,**必须等 onWindowFocusChanged(true) 再写**,
* 否则写入会被拒。这里再加一个 800ms 兜底,避免个别机型不发焦点回调时卡住。
*/
class ClipActivity : Activity() {
private var done = false
private val handler = Handler(Looper.getMainLooper())
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.i(TAG, "ClipActivity 启动")
handler.postDelayed({ writeClip() }, 800)
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) writeClip()
}
private fun writeClip() {
if (done) return
done = true
val b64 = intent?.getStringExtra("text_b64").orEmpty()
if (b64.isEmpty()) {
Log.w(TAG, "ClipActivity 没收到 text_b64")
finish()
return
}
try {
val text = String(Base64.decode(b64, Base64.DEFAULT), Charsets.UTF_8)
val cm = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
cm.setPrimaryClip(ClipData.newPlainText("deviceagent", text))
// 读回自证(平台侧也会再验一次)
val got = cm.primaryClip?.getItemAt(0)?.coerceToText(this)?.toString() ?: ""
if (got == text) Log.i(TAG, "clip ok len=${text.length}")
else Log.w(TAG, "clip 读回不一致(写入可能被拒)got=${got.length} want=${text.length}")
} catch (e: Exception) {
Log.e(TAG, "clip 写入异常: ${e.message}")
}
finish()
}
companion object {
const val TAG = "DeviceAgent"
}
}
@@ -0,0 +1,47 @@
package com.example.deviceagent
import android.app.Activity
import android.os.Bundle
import android.util.Log
import android.widget.Toast
/**
* 一次性配置下发(透明 Activity,由平台用 adb 拉起):
*
* adb shell am start -n com.example.deviceagent/.ConfigActivity \
* --es server http://192.168.20.220:18050 --es token <设备令牌> \
* --es fingerprint <ro.serialno>
*
* 有了它,一批设备不用一台台手输地址和令牌。
* 只写传进来的字段;三个都能单独更新。
*
* ⚠️ fingerprint 必须由平台推下来:实测应用身份读不到 ro.serialno
*(shell 能读、应用被 SELinux 挡住),而平台通过 adb 本来就知道这个值。
*/
class ConfigActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val server = intent?.getStringExtra("server")
val token = intent?.getStringExtra("token")
val fp = intent?.getStringExtra("fingerprint")
if (server.isNullOrEmpty() && token.isNullOrEmpty() && fp.isNullOrEmpty()) {
Log.w(TAG, "ConfigActivity 没收到 server/token/fingerprint")
finish()
return
}
try {
Prefs.save(this, server, token, fp)
val msg = "配置已更新:${Prefs.server(this)} / 令牌${if (Prefs.token(this).isEmpty()) "空" else "已设置"} / 指纹${if (Prefs.fingerprint(this).isEmpty()) "空" else "已设置"}"
Log.i(TAG, msg)
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Log.e(TAG, "配置写入失败: ${e.message}")
}
finish()
}
companion object {
const val TAG = "DeviceAgent"
}
}
@@ -0,0 +1,94 @@
package com.example.deviceagent
import android.app.Activity
import android.content.Intent
import android.graphics.Color
import android.os.Bundle
import android.util.TypedValue
import android.view.Gravity
import android.view.WindowManager
import android.widget.LinearLayout
import android.widget.TextView
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* 设备身份大字页(由平台用 adb 拉起)——**替代以前"推一个 HTML 页面让设备浏览器打开"那套**:
*
* adb shell am start -n com.example.deviceagent/.InfoActivity \
* --es name A08 --es ip 192.168.20.100 --es serial 192.168.20.100:5555 \
* --es server http://192.168.20.220:18050
* # 关闭:--es close 1
*
* 用途:人站在机架前,一眼看出"手头这台是平台上的哪一台";排查断联时不用连电脑。
* 再次调用会覆盖内容(launchMode=singleTop + onNewIntent)。
*/
class InfoActivity : Activity() {
private lateinit var tvName: TextView
private lateinit var tvLines: TextView
private lateinit var tvTime: TextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
buildUi()
render(intent)
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
setIntent(intent)
if (intent?.getStringExtra("close") == "1") {
finish()
return
}
render(intent)
}
private fun buildUi() {
val root = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setBackgroundColor(Color.BLACK)
gravity = Gravity.CENTER
setPadding(48, 64, 48, 64)
}
tvName = TextView(this).apply {
setTextColor(Color.WHITE)
setTextSize(TypedValue.COMPLEX_UNIT_SP, 72f)
gravity = Gravity.CENTER
}
tvLines = TextView(this).apply {
setTextColor(Color.parseColor("#FFD54F"))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 26f)
gravity = Gravity.CENTER
setPadding(0, 40, 0, 40)
}
tvTime = TextView(this).apply {
setTextColor(Color.parseColor("#9E9E9E"))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
gravity = Gravity.CENTER
}
root.addView(tvName)
root.addView(tvLines)
root.addView(tvTime)
setContentView(root)
}
private fun render(intent: Intent?) {
val name = intent?.getStringExtra("name").orEmpty()
val ip = intent?.getStringExtra("ip").orEmpty()
val serial = intent?.getStringExtra("serial").orEmpty()
val server = intent?.getStringExtra("server").orEmpty()
tvName.text = if (name.isNotEmpty()) name else "未登记"
tvLines.text = listOf(
"IP $ip",
"序列号 $serial",
"平台 $server",
"指纹 ${Prefs.fingerprint(this)}",
"Agent v${Prefs.AGENT_VERSION}"
).filter { it.substringAfter(" ").trim().isNotEmpty() }.joinToString("\n")
tvTime.text = "更新于 " + SimpleDateFormat("HH:mm:ss", Locale.CHINA).format(Date())
}
}
@@ -0,0 +1,405 @@
package com.example.deviceagent
import android.app.Activity
import android.app.AlertDialog
import android.content.Intent
import android.graphics.Color
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.text.InputType
import android.util.Log
import android.util.TypedValue
import android.view.Gravity
import android.view.View
import android.widget.Button
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import androidx.core.content.FileProvider
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
/**
* 应用商店主界面:从平台拉可安装的应用清单 → 选一个 → 下载 → 调系统安装器安装。
*
* **为什么这条路能绕开 MIUI 的「USB 安装」拦截**:装应用的是设备上的 App(走普通
* 应用安装流程),而不是 adb(`adb install` 会被 MIUI 拦成 USER_RESTRICTED)。
*/
class MainActivity : Activity() {
private val ui = Handler(Looper.getMainLooper())
private lateinit var tvDevice: TextView
private lateinit var tvServer: TextView
private lateinit var tvStatus: TextView
private lateinit var listBox: LinearLayout
private var deviceName = ""
private var deviceSerial = ""
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
buildUi()
}
override fun onResume() {
super.onResume()
refreshDeviceInfo()
if (Prefs.configured(this)) refreshList() else status("还没配置平台地址和令牌,点上面「配置」")
}
// ================== 界面 ==================
private fun buildUi() {
val root = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(32, 40, 32, 32)
}
tvDevice = TextView(this).apply {
setTextSize(TypedValue.COMPLEX_UNIT_SP, 22f)
setTextColor(Color.parseColor("#111827"))
}
tvServer = TextView(this).apply {
setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
setTextColor(Color.parseColor("#6B7280"))
setPadding(0, 8, 0, 16)
}
val toolbar = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL }
toolbar.addView(btn("配置") { showConfigDialog() })
toolbar.addView(btn("刷新清单") { refreshList() })
tvStatus = TextView(this).apply {
setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
setTextColor(Color.parseColor("#374151"))
setPadding(0, 12, 0, 12)
}
listBox = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
root.addView(tvDevice)
root.addView(tvServer)
root.addView(toolbar)
root.addView(tvStatus)
root.addView(listBox)
setContentView(ScrollView(this).apply { addView(root) })
}
private fun btn(text: String, onClick: () -> Unit): Button =
Button(this).apply {
this.text = text
setOnClickListener { onClick() }
}
private fun status(s: String) {
tvStatus.text = s
Log.i(TAG, s)
}
private fun refreshDeviceInfo() {
val fp = Prefs.fingerprint(this)
val ip = Prefs.localIp()
tvDevice.text = (if (deviceName.isNotEmpty()) deviceName else "未登记") +
(if (ip.isNotEmpty()) " $ip" else "")
tvServer.text = "平台 ${Prefs.server(this).ifEmpty { "(未配置)" }} " +
"令牌 ${if (Prefs.token(this).isEmpty()) "(未设置)" else "已设置"} " +
"指纹 $fp"
}
// ================== 拉清单 ==================
private fun refreshList() {
if (!Prefs.configured(this)) {
status("还没配置平台地址和令牌")
return
}
status("正在拉取应用清单…")
listBox.removeAllViews()
Thread {
try {
val j = Api.bootstrap(this)
val device = j.optJSONObject("device") ?: JSONObject()
deviceName = device.optString("name")
deviceSerial = device.optString("serial")
val apks = j.optJSONArray("apks") ?: JSONArray()
ui.post {
refreshDeviceInfo()
status("平台上有 ${apks.length()} 个应用" +
if (deviceName.isNotEmpty()) "(平台识别为 $deviceName)" else "(平台还不认识这台设备)")
renderApks(apks)
}
} catch (e: Exception) {
ui.post { status("拉取失败:${e.message}") }
}
}.start()
}
private fun renderApks(apks: JSONArray) {
listBox.removeAllViews()
for (i in 0 until apks.length()) {
val a = apks.optJSONObject(i) ?: continue
listBox.addView(apkRow(a))
}
if (apks.length() == 0) {
listBox.addView(TextView(this).apply {
text = "平台的应用管理里还没有上传任何 APK"
setTextColor(Color.parseColor("#9CA3AF"))
setPadding(0, 24, 0, 0)
})
}
}
private fun apkRow(a: JSONObject): View {
val wrap = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(0, 20, 0, 20)
}
val name = a.optString("display_name").ifEmpty { a.optString("package_name") }
val ver = a.optString("version_name")
val size = a.optLong("size")
val pkg = a.optString("package_name")
val offeredCode = a.optLong("version_code")
// 本机装没装、装的是哪一版 —— 按钮据此变成 安装 / 更新 / 已安装
val installedCode = installedVersionCode(pkg)
val installedVer = installedVersionName(pkg)
wrap.addView(TextView(this).apply {
text = name + (if (ver.isNotEmpty()) " v$ver" else "")
setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f)
setTextColor(Color.parseColor("#111827"))
})
wrap.addView(TextView(this).apply {
text = "$pkg ${fmtSize(size)}"
setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f)
setTextColor(Color.parseColor("#6B7280"))
})
val row = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
}
val note = TextView(this).apply {
setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
setPadding(16, 0, 0, 0)
}
when {
installedCode == 0L -> {
row.addView(btn("安装") { confirmInstall(a) })
note.text = "未安装"
note.setTextColor(Color.parseColor("#9CA3AF"))
}
offeredCode > installedCode -> {
row.addView(btn("更新到 v$ver") { confirmInstall(a) })
note.text = "已安装 v$installedVer"
note.setTextColor(Color.parseColor("#B45309")) // 提示可更新
}
else -> {
val b = btn("已安装") {}
b.isEnabled = false
row.addView(b)
note.text = "本机 v$installedVer(已是最新)"
note.setTextColor(Color.parseColor("#059669"))
}
}
row.addView(note)
wrap.addView(row)
return wrap
}
/** 本机已装应用的 versionCode(未安装返回 0) */
private fun installedVersionCode(pkg: String): Long {
if (pkg.isEmpty()) return 0L
return try {
val info = packageManager.getPackageInfo(pkg, 0)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) info.longVersionCode
else @Suppress("DEPRECATION") info.versionCode.toLong()
} catch (e: Exception) {
0L
}
}
private fun installedVersionName(pkg: String): String = try {
packageManager.getPackageInfo(pkg, 0).versionName ?: ""
} catch (e: Exception) {
""
}
private fun fmtSize(n: Long): String = when {
n <= 0 -> ""
n < 1024 -> "$n B"
n < 1024 * 1024 -> String.format("%.1f KB", n / 1024.0)
else -> String.format("%.1f MB", n / 1024.0 / 1024.0)
}
// ================== 安装 ==================
private fun confirmInstall(a: JSONObject) {
val name = a.optString("display_name").ifEmpty { a.optString("package_name") }
AlertDialog.Builder(this)
.setTitle("安装 $name")
.setMessage("会请平台用 adb 帮你静默安装(几秒钟,不用在手机上点确认)。\n\n" +
"平台装不了时会自动退回本机安装——那条路 MIUI 会要求点几下确认。")
.setPositiveButton("安装") { _, _ -> doInstall(a) }
.setNegativeButton("取消", null)
.show()
}
private fun doInstall(a: JSONObject) {
val apkId = a.optString("id")
val pkg = a.optString("package_name")
val ver = a.optString("version_name")
val name = a.optString("display_name").ifEmpty { pkg }
val wantCode = a.optLong("version_code")
// 先请平台装:平台走 adb,在 MIUI 上是**静默**的(实测升级 2.7s、全新安装 7.9s,
// 零弹框)。本机自己装要过系统安装器的几道确认,所以只作为兜底。
status("已请平台安装 $name …")
Thread {
val (okPlatform, why) = Api.requestPlatformInstall(this, apkId)
if (okPlatform) {
ui.post { status("平台正在静默安装 $name(走 adb,不用点确认)…") }
val ok = waitVersion(pkg, wantCode, 40) // 最长 2 分钟
Api.report(this, apkId, if (ok) "install_ok" else "install_fail", pkg, ver,
if (ok) "" else "平台安装后没检测到新版本(adb 可能被 MIUI 拦了)")
ui.post {
status(if (ok) "✅ $name 安装成功(已回报平台)"
else "⚠️ $name 平台没能装上(已回报平台),可再点一次试试本机安装")
if (ok) refreshList() // 按钮从「安装/更新」变成「已安装」
}
return@Thread
}
// 平台这条路不通(没登记进设备池 / adb 被拦)→ 退回本机安装
ui.post {
status("平台安装不可用($why),改用本机安装(会有系统确认框)…")
localInstall(a)
}
}.start()
}
/** 本机安装兜底:自己下载 + 调系统安装器(MIUI 上用户要点几步确认) */
private fun localInstall(a: JSONObject) {
if (!canInstallPackages()) {
AlertDialog.Builder(this)
.setTitle("缺少安装权限")
.setMessage("需要在系统设置里允许「${getString(R.string.app_name)}」安装应用。" +
"\n\n提示:平台也可以用 adb 预先授权,见 README。")
.setPositiveButton("去设置") { _, _ ->
startActivity(Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:$packageName")))
}
.setNegativeButton("取消", null)
.show()
return
}
val apkId = a.optString("id")
val path = a.optString("download_path")
val pkg = a.optString("package_name")
val ver = a.optString("version_name")
val name = a.optString("display_name").ifEmpty { pkg }
status("正在下载 $name …")
Thread {
try {
val f = Api.downloadApk(this, path, apkId)
Api.report(this, apkId, "download", pkg, ver)
ui.post {
status("下载完成(${fmtSize(f.length())}),请在系统弹框里点「继续安装」")
startInstaller(f)
watchInstall(apkId, pkg, ver, name)
}
} catch (e: Exception) {
status("下载失败:${e.message}")
Api.report(this, apkId, "install_fail", pkg, ver, "下载失败: ${e.message}")
}
}.start()
}
private fun startInstaller(f: File) {
val uri = FileProvider.getUriForFile(this, "$packageName.fileprovider", f)
val i = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
startActivity(i)
}
/** 等本机版本升到期望的 versionCode(平台 adb 安装是异步的) */
private fun waitVersion(pkg: String, wantCode: Long, tries: Int): Boolean {
for (i in 0 until tries) {
Thread.sleep(3000)
val now = installedVersionCode(pkg)
if (wantCode > 0 && now >= wantCode) return true
if (wantCode <= 0 && now > 0) return true
}
return false
}
/**
* 本机安装路径专用:装完没有?——系统安装器不给我们回调,用"lastUpdateTime 变没变"判断。
* 窗口给到 **3 分钟**:MIUI 上人要依次点「继续」→ 勾「已了解未经安全检测」→「继续更新」,
* 60 秒根本不够(第一次实测就是这么误报失败的)。
*/
private fun watchInstall(apkId: String, pkg: String, ver: String, name: String) {
val before = lastUpdateTime(pkg)
Thread {
var ok = false
for (i in 0 until 60) { // 60 × 3s = 最长 3 分钟
Thread.sleep(3000)
val now = lastUpdateTime(pkg)
if (now > 0 && now != before) { ok = true; break }
}
val msg = if (ok) "" else "3 分钟内没检测到安装完成(可能没点系统安装框的确认)"
Api.report(this, apkId, if (ok) "install_ok" else "install_fail", pkg, ver, msg)
ui.post {
status(if (ok) "✅ $name 安装成功(已回报平台)" else "⚠️ $name $msg(已回报平台)")
if (ok) refreshList()
}
}.start()
}
private fun lastUpdateTime(pkg: String): Long = try {
packageManager.getPackageInfo(pkg, 0).lastUpdateTime
} catch (e: Exception) {
0L
}
private fun canInstallPackages(): Boolean =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
packageManager.canRequestPackageInstalls()
} else true
// ================== 配置 ==================
private fun showConfigDialog() {
val box = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(32, 16, 32, 0)
}
val etServer = EditText(this).apply {
hint = "平台地址,如 http://192.168.20.220:18050"
inputType = InputType.TYPE_TEXT_VARIATION_URI
setText(Prefs.server(this@MainActivity))
}
val etToken = EditText(this).apply {
hint = "设备令牌"
setText(Prefs.token(this@MainActivity))
}
box.addView(etServer)
box.addView(etToken)
AlertDialog.Builder(this)
.setTitle("平台配置")
.setView(box)
.setMessage("一般不用在这里手输:平台可以用 adb 把地址和令牌推到设备上。")
.setPositiveButton("保存") { _, _ ->
Prefs.save(this, etServer.text.toString(), etToken.text.toString())
refreshDeviceInfo()
refreshList()
}
.setNegativeButton("取消", null)
.show()
}
companion object {
const val TAG = "DeviceAgent"
}
}
@@ -0,0 +1,53 @@
package com.example.deviceagent
import android.app.Activity
import android.os.Build
import android.os.Bundle
import android.util.Log
import org.json.JSONObject
import java.io.File
/**
* 自检(透明 Activity,由平台用 adb 拉起):
*
* adb shell am start -n com.example.deviceagent/.PingActivity
*
* 平台靠它判断"这台设备装没装 Agent、装的哪一版"。`am start` 拿不到返回值,
* 所以结果同时写两处(都用 adb 读得到,不需要任何权限):
* 1) logcat: adb logcat -d -s DeviceAgent:I | grep AGENT_INFO
* 2) 应用外部私有目录:
* adb shell cat /sdcard/Android/data/com.example.deviceagent/files/status.json
*/
class PingActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val info = JSONObject().apply {
put("package", packageName)
put("agent_version", Prefs.AGENT_VERSION)
put("agent_api_version", Prefs.API_VERSION)
put("fingerprint", Prefs.fingerprint(this@PingActivity))
put("local_ip", Prefs.localIp())
put("server", Prefs.server(this@PingActivity))
put("has_token", Prefs.token(this@PingActivity).isNotEmpty())
put("android", Build.VERSION.RELEASE)
put("model", "${Build.MANUFACTURER} ${Build.MODEL}")
}
val text = info.toString()
Log.i(TAG, "AGENT_INFO $text")
try {
val dir = getExternalFilesDir(null)
if (dir != null) {
dir.mkdirs()
File(dir, "status.json").writeText(text, Charsets.UTF_8)
}
} catch (e: Exception) {
Log.w(TAG, "写 status.json 失败: ${e.message}")
}
finish()
}
companion object {
const val TAG = "DeviceAgent"
}
}
@@ -0,0 +1,78 @@
package com.example.deviceagent
import android.content.Context
import java.io.BufferedReader
/**
* 本机配置(平台地址 + 设备令牌)与设备标识。
*
* 这两个值一般不用手输:平台可以用 adb 一次性推下来——
* adb shell am start -n com.example.deviceagent/.ConfigActivity \
* --es server http://192.168.20.220:18050 --es token <令牌>
*/
object Prefs {
/** Agent 自身版本,与平台侧 doc/DEVICE_AGENT.md 的契约版本对齐 */
const val AGENT_VERSION = "1.0"
const val API_VERSION = 1
private const val FILE = "device_agent"
private const val K_SERVER = "server"
private const val K_TOKEN = "token"
private const val K_FP = "fingerprint"
private fun sp(ctx: Context) =
ctx.getSharedPreferences(FILE, Context.MODE_PRIVATE)
fun server(ctx: Context): String = sp(ctx).getString(K_SERVER, "") ?: ""
fun token(ctx: Context): String = sp(ctx).getString(K_TOKEN, "") ?: ""
/** 只写传进来的字段(传 null 表示不动) */
fun save(ctx: Context, server: String?, token: String?, fingerprint: String? = null) {
val e = sp(ctx).edit()
if (server != null) e.putString(K_SERVER, server.trim().trimEnd('/'))
if (token != null) e.putString(K_TOKEN, token.trim())
if (fingerprint != null) e.putString(K_FP, fingerprint.trim())
e.apply()
}
fun configured(ctx: Context): Boolean =
server(ctx).isNotEmpty() && token(ctx).isNotEmpty()
/**
* 设备指纹(`ro.serialno`,与平台用 adb 读到的是同一个值)。
*
* ⚠️ 实测(Android 12 / MIUI):**应用身份读 `getprop ro.serialno` 会返回空**——
* shell 能读、应用被 SELinux 挡住了。所以:
* 1) 优先用平台配置时推下来的值(平台通过 adb 本来就知道,见 ConfigActivity)
* 2) 没有再自己试 getprop(老系统/其它 ROM 可能能读)
* 不用 Build.getSerial()(要 READ_PHONE_STATE 敏感权限)、也不用 ANDROID_ID
* (那是另一个值,和平台记录的对不上)。
*/
fun fingerprint(ctx: Context): String {
val saved = sp(ctx).getString(K_FP, "") ?: ""
if (saved.isNotEmpty()) return saved
val v = getProp("ro.serialno")
if (v.isNotEmpty()) return v
return getProp("ro.boot.serialno")
}
private fun getProp(key: String): String = try {
val p = Runtime.getRuntime().exec(arrayOf("getprop", key))
val out = p.inputStream.bufferedReader().use(BufferedReader::readText).trim()
p.waitFor()
out
} catch (e: Exception) {
""
}
/** 本机 Wi-Fi/以太网 IPv4 地址(只作为给平台的提示,认设备仍以指纹为准) */
fun localIp(): String = try {
java.net.NetworkInterface.getNetworkInterfaces().toList()
.filter { it.isUp && !it.isLoopback }
.flatMap { it.inetAddresses.toList() }
.filterIsInstance<java.net.Inet4Address>()
.firstOrNull { !it.isLoopbackAddress }?.hostAddress ?: ""
} catch (e: Exception) {
""
}
}
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 矢量图标:省掉各分辨率 PNG,包更小 -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="#2F6FED"
android:pathData="M0,0h48v48h-48z" />
<!-- 手机外形 -->
<path
android:fillColor="#FFFFFF"
android:pathData="M16,8h16a3,3 0 0 1 3,3v26a3,3 0 0 1 -3,3h-16a3,3 0 0 1 -3,-3v-26a3,3 0 0 1 3,-3zM16,12v20h16v-20z" />
<!-- 屏幕里的勾 -->
<path
android:fillColor="#2F6FED"
android:pathData="M19,22l3.5,3.5 7,-7 1.5,1.5 -8.5,8.5 -5,-5z" />
</vector>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">设备 Agent</string>
</resources>
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 用平台自带 Material 主题,不依赖 AppCompat(少一个依赖,构建更快) -->
<style name="Theme.DeviceAgent" parent="@android:style/Theme.Material.Light.NoActionBar" />
<!-- 透明主题:剪贴板注入 / 配置下发 / 自检 都用它——
这些 Activity 需要短暂获得焦点(写剪贴板必须有焦点),但绝不能挡住用户界面 -->
<style name="Theme.DeviceAgent.Transparent" parent="@android:style/Theme.Translucent.NoTitleBar" />
</resources>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- FileProvider 可对外授权的路径:只放"下载下来的 APK"这一处私有目录,
交给系统安装器读。范围越小越安全。 -->
<paths>
<files-path name="apks" path="apks/" />
</paths>