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