# 设备端 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 shell am start -n /.ClipActivity --es text_b64 ``` - **必须 base64**:adb shell 直传中文/特殊字符会变形 - 必须用**透明 Activity**(有焦点才能写剪贴板),写完**立即退出** - 平台判定失败的方式:`am start` 输出里有 `unable to resolve Intent` / `does not exist` / `Error type 3` → 视为"这台设备没有这个通道" **平台按顺序试两个包**(`core/clipboard_helper.py` 的 `_CLIP_TARGETS`): | 顺序 | 包 | 说明 | |---|---|---| | 1 | `com.example.deviceagent`(**本 Agent**) | 现在的标准通道;装了就只调它 | | 2 | `com.example.clipinject`(早期独立 APK) | 老设备上可能还残留 → 兜底;走这条会在日志里提示"建议装设备端 Agent" | 两个都没有时报"设备未安装剪贴板注入通道:需要设备端 Agent"(不再只说 ClipInject, 否则用户会去装那个已经不再分发的旧 APK)。**设备端无需为这条改动做任何事**。 ### 5.2 设备身份显示(设备端已实现) ```bash adb -s shell am start -n /.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.2.1 本机账号台账(`accounts_b64`,可选 extras — **需要设备端配合实现**) 平台在**打开**身份页时会顺带把这台设备在「账号」页登记的抖音账号推过去 (`--es close 1` 时**不带**)。**中文必须 base64**(同 §5.1:adb shell 直传中文会变形): ```bash adb -s shell am start -n /.InfoActivity \ --es name "A03" --es serial "192.168.20.63:5555" --es server "http://192.168.20.220:18050" \ --es ip "192.168.20.63" \ --es accounts_b64 "" ``` 解码:**标准 base64**(不是 URL-safe)+ UTF-8。JSON 结构(字段名的设备端解析按名取,多余字段忽略): ```json {"v":1,"total":4,"shown":4,"truncated":false, "note":"设备『A03』登记 4 个号", "accounts":[ {"name":"AA建材王总","douyin_id":"66057500463","device_name":"A03","device_no":"A03", "sim_in_device":true,"can_post_video":true}]} ``` | 字段 | 说明 | |------|------| | `v` | 本块格式版本(当前 **1**)。解不出来时用它区分"格式变了"与"平台没给" | | `total` / `shown` / `truncated` | 该设备在台账里的总数 / 本次带了几条 / 是否被截断 | | `accounts[].name` | 账号名称(台账的「账号名称」列) | | `accounts[].douyin_id` | **抖音号(纯号)**,如 `35377983067` | | `accounts[].sim_in_device` / `can_post_video` | 卡在机内 / 可发视频(布尔) | | `accounts[].phone` | **默认不推**(大字页是机器旁的公开屏幕,手机号不宜默认上屏)。需要时平台请求体传 `{"include_phone":true}` | **设备端要做的**(当前版本忽略该 extra,行为与旧版逐字一致): 1. 读 `accounts_b64` → base64 解码 → `JSONObject` 2. 在身份页上按 `name` / `douyin_id` 渲染一个列表(建议 1~3 行简短列表,最多显示 `shown` 条) 3. `truncated=true` 时提示"还有更多,见平台账号页" **约定与边界**(平台侧实现见 `web/device_agent_api.py` 的 `_encode_accounts`): - **没有这个 extra = 这台设备在台账里没有账号**(不是"平台忘了给")——设备端保持原样显示即可, 不要显示空列表 - 超预算时平台**按整条丢**(`shown` 变小 + `truncated=true`),**绝不按字节切**(切了就是坏 JSON); 台账过大时可能**整个 extra 不发**(平台响应体里 `warn` 会说明原因) - 台账读取失败时同样**不发**这个 extra —— 台账故障不影响"显示身份"这个动作 ### 5.3 扫码配置(设备端已实现) 平台「设备池」里点某台设备的「**二维码**」→ 生成一张二维码 → 手机上的 Agent 点 「扫码配置」扫一下,就把 **平台地址 + 设备令牌 + 这台设备的指纹** 一次写入。 **二维码内容**(JSON 文本,设备端按字段名解析,多余的字段忽略): ```json {"v":1,"server":"http://192.168.20.250:18050","token":"<设备令牌>", "fingerprint":"s8o7nrt8pbzhfadq","name":"A07"} ``` - `v` = 格式版本(当前 1);`fingerprint` 可能为空串(平台还没采到指纹) - 设备端扫到**不是本平台的二维码**时应提示并继续扫,不要静默失败 - 平台侧生成接口:`GET /api/devices/qrcode?serial=<设备地址>`(需登录 + 设备权限), 返回 `{png( base64 data-url), payload, warn}`;`warn` 非空时要显示出来 (最常见的是"你现在用 localhost 打开平台,二维码里的地址手机会连不上") > 与 §5.4 的 adb 下发**等价、互为补位**:设备已经连上平台时用 adb 批量下发(零操作), > 人在机器旁或设备还没接进来时用扫码(不用线、不用打字)。 ### 5.4 一次性配置下发(adb,建议设备端实现) ```bash adb -s shell am start -n /.ConfigActivity \ --es server "http://192.168.20.220:18050" --es token "<设备令牌>" ``` 有了它,8 台设备不用一台台手输地址和令牌。 --- ## 6. 版本与兼容 - **§5.2.1 的 `accounts_b64` 是"加字段"= 小版本**:`agent_api_version` **仍是 1**, 设备端忽略它时行为必须与旧版逐字一致(只是大字页少一块内容)。也**不**走 `bootstrap`:身份页由平台用 adb 推,加进 bootstrap 没人用还会让契约多一处要同步。 - `agent_api_version`(当前 **1**)在每次 `bootstrap` 响应里返回 - **设备端**:启动时对比自己实现的版本,不一致要在日志里明确记下来(不要静默) - **平台端**: - 加字段 / 加接口 = 小版本,设备端忽略未知字段即可 - 改已有字段语义 / 删字段 / 改鉴权方式 = **大版本**,两边必须同时改 - 改了协议,**必须同时改这份文档的两个仓库版本**(本文件是平台侧那一份) --- ## 7. 安全须知 - **令牌是凭据**:等于"能读平台的应用清单和 APK 文件"。别写进日志、别外传、 别提交到 git(平台侧存在 `app_meta.agent_device_token`,会随备份一起走) - 平台侧这组接口**只读**:只能读清单和下载 APK,**不能**触发任务、不能改设备池、 不能读配置或密钥 - 生产环境建议:令牌泄露时立即在面板点「重置令牌」 - 设备端**不需要也不应该**拿 admin 账号密码