From 2580cfaead5d7cc342c1ad1c09c5a163bec9270a Mon Sep 17 00:00:00 2001 From: butubb <1422726308@qq.com> Date: Sun, 13 Sep 2026 16:30:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=AE=BE=E5=A4=87=E7=AB=AF=20Agent=20?= =?UTF-8?q?=E9=A6=96=E4=B8=AA=E7=89=88=E6=9C=AC=E2=80=94=E2=80=94=E5=89=AA?= =?UTF-8?q?=E8=B4=B4=E6=9D=BF=E6=B3=A8=E5=85=A5=20/=20=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E5=95=86=E5=BA=97=20/=20=E8=BA=AB=E4=BB=BD=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=20/=20=E9=85=8D=E7=BD=AE=E4=B8=8B=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 配合 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。 --- .gitignore | 22 + INTERFACE.md | 244 +++++++++++ README.md | 71 +++ app/build.gradle | 49 +++ app/proguard-rules.pro | 4 + app/src/main/AndroidManifest.xml | 89 ++++ .../main/java/com/example/deviceagent/Api.kt | 160 +++++++ .../com/example/deviceagent/ClipActivity.kt | 66 +++ .../com/example/deviceagent/ConfigActivity.kt | 47 ++ .../com/example/deviceagent/InfoActivity.kt | 94 ++++ .../com/example/deviceagent/MainActivity.kt | 405 ++++++++++++++++++ .../com/example/deviceagent/PingActivity.kt | 53 +++ .../java/com/example/deviceagent/Prefs.kt | 78 ++++ app/src/main/res/drawable/ic_launcher.xml | 19 + app/src/main/res/values/strings.xml | 4 + app/src/main/res/values/themes.xml | 9 + app/src/main/res/xml/file_paths.xml | 6 + build.gradle | 5 + gradle.properties | 6 + settings.gradle | 24 ++ 20 files changed, 1455 insertions(+) create mode 100644 .gitignore create mode 100644 INTERFACE.md create mode 100644 README.md create mode 100644 app/build.gradle create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/example/deviceagent/Api.kt create mode 100644 app/src/main/java/com/example/deviceagent/ClipActivity.kt create mode 100644 app/src/main/java/com/example/deviceagent/ConfigActivity.kt create mode 100644 app/src/main/java/com/example/deviceagent/InfoActivity.kt create mode 100644 app/src/main/java/com/example/deviceagent/MainActivity.kt create mode 100644 app/src/main/java/com/example/deviceagent/PingActivity.kt create mode 100644 app/src/main/java/com/example/deviceagent/Prefs.kt create mode 100644 app/src/main/res/drawable/ic_launcher.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/file_paths.xml create mode 100644 build.gradle create mode 100644 gradle.properties create mode 100644 settings.gradle diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..76cfd9a --- /dev/null +++ b/.gitignore @@ -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 diff --git a/INTERFACE.md b/INTERFACE.md new file mode 100644 index 0000000..46047db --- /dev/null +++ b/INTERFACE.md @@ -0,0 +1,244 @@ + + +# 设备端 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` + → 视为"设备未安装 Agent" + +### 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.3 一次性配置下发(建议设备端实现) + +```bash +adb -s shell am start -n /.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 账号密码 diff --git a/README.md b/README.md new file mode 100644 index 0000000..c672954 --- /dev/null +++ b/README.md @@ -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`,依赖越少越不容易被构建问题绊住。 diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..158d87d --- /dev/null +++ b/app/build.gradle @@ -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' +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..4cabaa7 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,4 @@ +# 工具类 App,release 也不开混淆(见 app/build.gradle 的 minifyEnabled false)。 +# 这里留空;将来若要开混淆,注意别把 Activity 类名混淆掉—— +# 平台是通过 `am start -n com.example.deviceagent/.ClipActivity` 这种**显式类名**拉起的, +# 类名被混淆就调不起来了。 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..79c76f7 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/example/deviceagent/Api.kt b/app/src/main/java/com/example/deviceagent/Api.kt new file mode 100644 index 0000000..691133c --- /dev/null +++ b/app/src/main/java/com/example/deviceagent/Api.kt @@ -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 { + 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 三次都没成功(不影响安装,平台面板会缺这条记录)") + } +} diff --git a/app/src/main/java/com/example/deviceagent/ClipActivity.kt b/app/src/main/java/com/example/deviceagent/ClipActivity.kt new file mode 100644 index 0000000..0d4918c --- /dev/null +++ b/app/src/main/java/com/example/deviceagent/ClipActivity.kt @@ -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 + * + * 为什么必须是个 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" + } +} diff --git a/app/src/main/java/com/example/deviceagent/ConfigActivity.kt b/app/src/main/java/com/example/deviceagent/ConfigActivity.kt new file mode 100644 index 0000000..abe38ee --- /dev/null +++ b/app/src/main/java/com/example/deviceagent/ConfigActivity.kt @@ -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 + * + * 有了它,一批设备不用一台台手输地址和令牌。 + * 只写传进来的字段;三个都能单独更新。 + * + * ⚠️ 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" + } +} diff --git a/app/src/main/java/com/example/deviceagent/InfoActivity.kt b/app/src/main/java/com/example/deviceagent/InfoActivity.kt new file mode 100644 index 0000000..769c06e --- /dev/null +++ b/app/src/main/java/com/example/deviceagent/InfoActivity.kt @@ -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()) + } +} diff --git a/app/src/main/java/com/example/deviceagent/MainActivity.kt b/app/src/main/java/com/example/deviceagent/MainActivity.kt new file mode 100644 index 0000000..cdd6e4f --- /dev/null +++ b/app/src/main/java/com/example/deviceagent/MainActivity.kt @@ -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" + } +} diff --git a/app/src/main/java/com/example/deviceagent/PingActivity.kt b/app/src/main/java/com/example/deviceagent/PingActivity.kt new file mode 100644 index 0000000..7bb84a2 --- /dev/null +++ b/app/src/main/java/com/example/deviceagent/PingActivity.kt @@ -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" + } +} diff --git a/app/src/main/java/com/example/deviceagent/Prefs.kt b/app/src/main/java/com/example/deviceagent/Prefs.kt new file mode 100644 index 0000000..c5d45eb --- /dev/null +++ b/app/src/main/java/com/example/deviceagent/Prefs.kt @@ -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() + .firstOrNull { !it.isLoopbackAddress }?.hostAddress ?: "" + } catch (e: Exception) { + "" + } +} diff --git a/app/src/main/res/drawable/ic_launcher.xml b/app/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..85016ab --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..d3efd2d --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + 设备 Agent + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..02f69b5 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + + +