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
+22
View File
@@ -0,0 +1,22 @@
# 构建产物
.gradle/
build/
app/build/
captures/
*.apk
*.aab
# 本机路径配置(含 SDK 路径,不入 git)
local.properties
# 工具链(JDK / Android SDK / Gradle,本机下载的,约 1GB)
.toolchain/
# IDE
.idea/
*.iml
.vscode/
# OS
.DS_Store
Thumbs.db
+244
View File
@@ -0,0 +1,244 @@
<!--
这是 auto_control 平台仓库 doc/DEVICE_AGENT.md 的副本(两个仓库共享的接口契约)。
以平台仓库那份为准;改协议时两边一起改。
同步时间:2026-09-13
-->
# 设备端 Agent 接口契约(DEVICE_AGENT)
> 适用读者:写**设备端 Agent APK**(另一个仓库)的人,以及改平台侧这组接口的人。
> 相关:[API.md](API.md)(管理端接口)、[DEPLOY.md](DEPLOY.md)(部署)、[ARCHITECTURE.md](ARCHITECTURE.md)。
**这份文档是两个仓库之间的契约。** 平台侧实现见 `web/device_agent_api.py`;
设备端实现见另一个仓库。**改协议必须同时改两边**(见 §6 版本约定)。
---
## 1. 为什么有这组接口
平台通过 **adb** 驱动设备(点击/输入/装应用……),但有三件事 adb 干不好,需要设备上装一个 App:
| 需求 | adb 的困境 | Agent 的做法 |
|------|-----------|-------------|
| 写系统剪贴板 | Android 10+ 禁止后台/shell 写剪贴板;u2 的 `set_clipboard` 会"成功但内容被丢弃" | 启动一个**透明 Activity**(有焦点才能写),写完立即退出 |
| 安装 APK | MIUI 会拦 `adb install`(`USER_RESTRICTED`),要人工点「继续安装」 | **App 自己调系统安装器** → 走普通应用安装流程,**不触发那道 USB 拦截** |
| 显示设备身份 | 现在要把一个 HTML 大字页推给设备浏览器打开,依赖浏览器 | 全屏大字显示:设备名/IP/序列号/平台地址 |
其中「装应用」是**设备主动来取**(拉清单 → 选 → 下载 → 装),所以平台开了一组
**设备专用接口**;「写剪贴板」「显示身份」是**平台推指令**,仍走 adb(§5)。
---
## 2. 接入流程(设备端视角)
```
① 一次性:平台在「工具 → 应用管理 → 📱 设备端应用商店」启用,拿到
接入地址 base_url(如 http://192.168.20.220:18050)和设备令牌 token
—— 这两个值可以通过 adb 一次性推给设备(见 §5.3),不必在每台手机上手输
② 开机/定时:GET {base_url}/api/device/agent/bootstrap
带上 X-Device-Token / X-Device-Fingerprint
→ 拿到「我是谁」(平台分配的设备名)+「有哪些应用可以装」
③ 用户在手机上选一个应用:
GET {base_url}/api/device/agent/apk/{id} → 下载 APK 到应用私有目录
④ 调系统安装器安装(PackageInstaller / ACTION_VIEW + FileProvider)
—— 会弹系统安装确认框,用户在屏幕上点一下即可
⑤ 回报结果:POST {base_url}/api/device/agent/report
(download / install_ok / install_fail + 失败原因)
平台在「设备端应用商店」面板能看到记录
```
> Agent 需要 `INTERNET` 与 `REQUEST_INSTALL_PACKAGES` 权限;还需要用户在系统设置里
> 给"安装未知应用"授权(这是 App 自装的**唯一**前置条件,比 MIUI 的 USB 拦截好过得多)。
---
## 3. 接口规格
所有接口:
- **不需要登录会话**,靠 `X-Device-Token` 鉴权
- 请求/响应均为 UTF-8 JSON(下载接口除外)
- **功能默认关闭**:平台侧未启用时,全部返回 `404 {"ok":false,"error":"not found"}`
(不暴露接口是否存在)
### 3.1 通用请求头
| 头 | 必填 | 说明 |
|----|------|------|
| `X-Device-Token` | ✅ | 平台生成的设备令牌 |
| `X-Device-Fingerprint` | 建议 | `ro.serialno`。**平台靠它认出"你是哪台设备"**(换 IP 也能认回原名) |
| `X-Device-Serial` | 可选 | 当前 adb 地址(`IP:5555`),指纹认不出时的兜底 |
错误码:
| 状态 | 含义 | 设备端该怎么办 |
|------|------|---------------|
| 404 | 平台未启用设备端商店(或接口不存在) | 提示管理员去平台开启;不要重试轰炸 |
| 401 | 令牌不对/缺失 | 提示重新配置令牌(管理员重置后要在设备上更新) |
| 500 | 平台内部错误 | 退避重试 |
### 3.2 `GET /api/device/agent/bootstrap`
拉设备身份 + 可安装应用清单。建议开机后拉一次 + 每次打开"商店"界面时拉一次。
**响应**
```json
{
"ok": true,
"agent_api_version": 1,
"server_time": "2026-09-13 14:38:11",
"platform": { "url": "http://192.168.20.220:18050" },
"device": {
"fingerprint": "woijo7v4sgnrhqb6",
"serial": "192.168.20.100:5555",
"name": "A08",
"known": true
},
"apks": [
{
"id": "56b114aa",
"display_name": "ClipInject",
"package_name": "com.example.clipinject",
"version_name": "1.0",
"version_code": 1,
"size": 11615,
"upload_time": "2026-09-09 13:49:06",
"download_path": "/api/device/agent/apk/56b114aa"
}
]
}
```
- `device.name`:**平台给这台设备起的名字**(如 `A08`)—— 身份显示界面直接用它
- `device.known=false`:这台设备还没登记进平台设备池(指纹没匹配上),`name` 为空;
设备端应显示"未登记",不要假装自己有名字
- `platform.url`:按**设备实际请求到的地址**回填,可直接用于拼下载地址
- `apks[].download_path` 是相对路径,拼 `platform.url` 即完整 URL
### 3.3 `GET /api/device/agent/apk/{apk_id}`
下载 APK 文件本体(`application/vnd.android.package-archive`)。
- 成功:`200` + 文件流
- `404`:id 不存在或平台侧文件已删(**注意与"未启用"的 404 区分:看响应体**,
未启用时是 JSON `{"ok":false,...}`,文件缺失时也是 JSON —— 设备端拿到非 `application/...` 的响应就视为失败)
### 3.4 `POST /api/device/agent/install`(可选:让平台帮你静默装)
**背景**:设备上的 App 自己调系统安装器,MIUI 上要过「继续 → 勾选未经安全检测 →
继续更新」甚至 ICP 备案检查好几步;而**平台用 adb 装是静默的**(实测升级 2.7s、
全新安装 7.9s、336MB 大包都没弹过一次框)。
所以"安装/更新"按钮**建议走这个接口**:用户在手机上选(商店体验),平台用 adb 落地(静默)。
**请求体**:`{"apk_id": "56b114aa"}`
**响应**:
```json
{"ok": true, "msg": "开始安装 X 到 1 台设备", "serial": "192.168.20.100:5555"}
```
- 受理后平台**异步**安装,设备端应轮询本机该包的 versionCode 变化来判断结果
(装上后按 §3.5 上报 `install_ok`)
- 平台装不了(比如 MIUI 拦了 adb 安装、设备不在池里)→ `ok=false` + `error`,
**设备端应退回本机安装**(`ACTION_VIEW` + FileProvider,会弹系统确认框,但至少能装)
- `409` = 这台设备还没登记进平台设备池(平台不认识它,不能替它装)
### 3.5 `POST /api/device/agent/report`
回报下载/安装结果,平台侧留档(面板可见)。
**请求体**
```json
{
"apk_id": "56b114aa",
"action": "download | install_ok | install_fail",
"package_name": "com.example.clipinject",
"version_name": "1.0",
"message": "失败原因(install_fail 时必填,越具体越好)"
}
```
**响应**:`{"ok": true}`;`action` 非法 → `400`。
> **失败原因请写人话**:平台面板直接显示 `message`。
> 例如「MIUI 拦截:用户未确认」「安装包解析失败」「存储空间不足」。
---
## 4. 平台侧配置(管理端)
「工具 → 应用管理 → 📱 设备端应用商店」:
| 操作 | 接口 |
|------|------|
| 读配置(含令牌) | `GET /api/agent-store/config` |
| 启用/停用 | `POST /api/agent-store/config` `{"enabled": true}` |
| 重置令牌 | `POST /api/agent-store/config` `{"regenerate_token": true}` |
| 安装记录 | `GET /api/agent-store/logs?limit=50` |
- 三个接口都需要**登录 + 应用管理权限**
- **停用**不影响已装的 App,只是设备端拿不到清单(返回 404)
- **重置令牌**后旧令牌立即失效,所有设备要在 Agent 里更新
---
## 5. 平台 → 设备的指令(走 adb,不走网络)
这些**没有 HTTP 接口**,由平台用 `adb shell am start` 拉起设备上的 Activity。
### 5.1 剪贴板注入(已在用,务必保持兼容)
```bash
adb -s <serial> shell am start -n <pkg>/.ClipActivity --es text_b64 <base64(UTF-8)>
```
- **必须 base64**:adb shell 直传中文/特殊字符会变形
- 必须用**透明 Activity**(有焦点才能写剪贴板),写完**立即退出**
- 平台判定失败的方式:`am start` 输出里有 `unable to resolve Intent` / `does not exist`
→ 视为"设备未安装 Agent"
### 5.2 设备身份显示(待设备端实现)
```bash
adb -s <serial> shell am start -n <pkg>/.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
```
要求:全屏大字、再次调用可覆盖内容、`close` 可关闭、不驻留前台。
### 5.3 一次性配置下发(建议设备端实现)
```bash
adb -s <serial> shell am start -n <pkg>/.ConfigActivity \
--es server "http://192.168.20.220:18050" --es token "<设备令牌>"
```
有了它,8 台设备不用一台台手输地址和令牌。
---
## 6. 版本与兼容
- `agent_api_version`(当前 **1**)在每次 `bootstrap` 响应里返回
- **设备端**:启动时对比自己实现的版本,不一致要在日志里明确记下来(不要静默)
- **平台端**:
- 加字段 / 加接口 = 小版本,设备端忽略未知字段即可
- 改已有字段语义 / 删字段 / 改鉴权方式 = **大版本**,两边必须同时改
- 改了协议,**必须同时改这份文档的两个仓库版本**(本文件是平台侧那一份)
---
## 7. 安全须知
- **令牌是凭据**:等于"能读平台的应用清单和 APK 文件"。别写进日志、别外传、
别提交到 git(平台侧存在 `app_meta.agent_device_token`,会随备份一起走)
- 平台侧这组接口**只读**:只能读清单和下载 APK,**不能**触发任务、不能改设备池、
不能读配置或密钥
- 生产环境建议:令牌泄露时立即在面板点「重置令牌」
- 设备端**不需要也不应该**拿 admin 账号密码
+71
View File
@@ -0,0 +1,71 @@
# 设备 Agent(DeviceAgent)
装在每台 Android 手机上的**设备端补位 App**,配合
[auto_control](https://gitea.butubb.cn/butubb/auto_control) 平台使用。
平台通过 **adb** 驱动设备;这个 App 负责三件 adb 干不好/干不了的事:
| 能力 | 为什么 adb 不行 | 入口 |
|------|----------------|------|
| **剪贴板注入** | Android 10+ 只有**有焦点**的应用能写剪贴板,shell/后台写会被系统静默丢弃 | `ClipActivity`(透明 Activity,adb 拉起) |
| **应用分发(商店)** | `adb install` 会被 MIUI 的「USB 安装」拦成 `USER_RESTRICTED`;**由设备上的 App 自己调系统安装器**走的是普通安装流程,不触发那道拦截 | `MainActivity`(商店界面,自己联网拉清单) |
| **设备身份显示** | 以前要把 HTML 大字页推给设备浏览器打开,依赖浏览器 | `InfoActivity`(全屏大字,adb 拉起) |
## 接口契约
**平台的接口协议见 `../auto_control/doc/DEVICE_AGENT.md`**(那是两个仓库共享的契约;
改协议必须同时改两边)。本仓库里也留了一份副本:[INTERFACE.md](INTERFACE.md)。
## 快速开始
```bash
# 1. 构建(需要 JDK 17 + Android SDK;也可以用 Android Studio 直接打开)
./gradlew assembleDebug
# 2. 装到设备(平台用网络 adb 管理设备,serial 形如 IP:5555)
adb -s 192.168.20.100:5555 install -r app/build/outputs/apk/debug/app-debug.apk
# 3. 一次性配置平台地址与令牌(不用在手机上手输)
adb -s 192.168.20.100:5555 shell am start -n com.example.deviceagent/.ConfigActivity \
--es server "http://192.168.20.220:18050" --es token "<平台面板里的设备令牌>"
# 4. 自检:确认装上了、看是哪一版
adb -s 192.168.20.100:5555 shell am start -n com.example.deviceagent/.PingActivity
adb -s 192.168.20.100:5555 shell cat /sdcard/Android/data/com.example.deviceagent/files/status.json
# 5. 手动试试剪贴板注入
adb -s 192.168.20.100:5555 shell am start -n com.example.deviceagent/.ClipActivity \
--es text_b64 "$(printf '测试中文' | base64)"
# 6. 让设备显示自己的身份大字页
adb -s 192.168.20.100:5555 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"
```
然后用手机打开 App 图标 → 就是应用商店(前提是平台侧已启用设备端商店并给了令牌)。
## 权限说明
只申请三个,都是必需的:
- `INTERNET` —— 从平台拉应用清单、下载 APK
- `REQUEST_INSTALL_PACKAGES` —— 调系统安装器装应用(**首次会引导去系统设置里授权**)
- `ACCESS_NETWORK_STATE` —— 判断有没有网
**不需要 root、不需要设备管理员、不常驻后台、不申请任何读取隐私的权限。**
## 代码结构
```
app/src/main/java/com/example/deviceagent/
MainActivity.kt 应用商店主界面(清单/下载/调安装器/回报)
ClipActivity.kt 剪贴板注入(透明,等窗口获得焦点后再写)
InfoActivity.kt 设备身份全屏大字页
ConfigActivity.kt 一次性配置下发(平台用 adb 推地址+令牌)
PingActivity.kt 自检(版本/指纹/配置状态,写 logcat + status.json)
Api.kt 平台接口客户端(HttpURLConnection,无第三方库)
Prefs.kt 配置存储 + 设备指纹(getprop ro.serialno)
```
刻意**只依赖 androidx.core**(就为了 FileProvider):HTTP 用 `HttpURLConnection`、
JSON 用系统内置的 `org.json`,依赖越少越不容易被构建问题绊住。
+49
View File
@@ -0,0 +1,49 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'com.example.deviceagent'
compileSdk 34
defaultConfig {
applicationId "com.example.deviceagent"
minSdk 29 // Android 10;剪贴板限制就是从这一版开始的
targetSdk 34
versionCode 4
versionName "1.3"
}
buildTypes {
release {
minifyEnabled false // 工具类 App,不混淆(方便排查)
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
debug {
applicationIdSuffix ""
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
// 设备都是 arm64,只出一个架构的包,构建快、体积小
splits {
abi {
enable false
}
}
}
dependencies {
// 刻意只引一个 androidx.core:只为 FileProvider(把下载的 APK 授权给系统安装器读)。
// HTTP 用 HttpURLConnection、JSON 用 org.json(都是系统内置),不引网络/序列化库——
// 依赖越少,构建越快、出问题越好排查。
implementation 'androidx.core:core-ktx:1.13.1'
}
+4
View File
@@ -0,0 +1,4 @@
# 工具类 App,release 也不开混淆(见 app/build.gradle 的 minifyEnabled false)。
# 这里留空;将来若要开混淆,注意别把 Activity 类名混淆掉——
# 平台是通过 `am start -n com.example.deviceagent/.ClipActivity` 这种**显式类名**拉起的,
# 类名被混淆就调不起来了。
+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>
+5
View File
@@ -0,0 +1,5 @@
// 顶层构建文件:只声明插件版本,具体配置在 app/build.gradle
plugins {
id 'com.android.application' version '8.5.2' apply false
id 'org.jetbrains.kotlin.android' version '1.9.24' apply false
}
+6
View File
@@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
# 只做 arm64(设备都是 arm64 手机),构建更快、包更小
# 需要模拟器/其它架构时改回默认即可
+24
View File
@@ -0,0 +1,24 @@
pluginManagement {
repositories {
// 阿里云镜像在前:国内直连 google/mavenCentral 常常很慢甚至超时。
// 阿里云没有的构件会自动回落到下面的官方仓。
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
maven { url 'https://maven.aliyun.com/repository/google' }
maven { url 'https://maven.aliyun.com/repository/public' }
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
maven { url 'https://maven.aliyun.com/repository/google' }
maven { url 'https://maven.aliyun.com/repository/public' }
google()
mavenCentral()
}
}
rootProject.name = "DeviceAgent"
include ':app'