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:
+22
@@ -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
@@ -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 账号密码
|
||||||
@@ -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`,依赖越少越不容易被构建问题绊住。
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
# 工具类 App,release 也不开混淆(见 app/build.gradle 的 minifyEnabled false)。
|
||||||
|
# 这里留空;将来若要开混淆,注意别把 Activity 类名混淆掉——
|
||||||
|
# 平台是通过 `am start -n com.example.deviceagent/.ClipActivity` 这种**显式类名**拉起的,
|
||||||
|
# 类名被混淆就调不起来了。
|
||||||
@@ -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) {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">设备 Agent</string>
|
||||||
|
</resources>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- FileProvider 可对外授权的路径:只放"下载下来的 APK"这一处私有目录,
|
||||||
|
交给系统安装器读。范围越小越安全。 -->
|
||||||
|
<paths>
|
||||||
|
<files-path name="apks" path="apks/" />
|
||||||
|
</paths>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
|
# 只做 arm64(设备都是 arm64 手机),构建更快、包更小
|
||||||
|
# 需要模拟器/其它架构时改回默认即可
|
||||||
@@ -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'
|
||||||
Reference in New Issue
Block a user