需求是"改完 Android 端怎么送到手机上"。不发应用商店,也不想每次手动传包,
于是做了最小闭环:后端存一份安装包清单,App 启动时查、下载、交给系统安装器。
后端
- 新增 /app/release/latest 与 /app/release/download/{file},都免登录:
还没装上新包的客户端未必带着有效令牌,检查更新不该被登录挡住
- download_url 用相对路径,客户端拿自己配置的 API 地址拼——同一份清单要在
内网、公网、模拟器之间共用,写死域名只会让其中一个坏掉
- has_update 只比对 version_code,不比版本名字符串(0.10.0 与 0.9.0 的字典序是坑)
- 下载只认清单里登记的文件名,含 ..%2F 一律 9001
- scripts/publish_apk.py 负责复制 APK 并写 latest.json(sha256/size/published_at),
backend/static/apk/ 不进版本库
安卓
- AppUpdater:查清单 → 下到应用自己的 updates 目录(先写 .part 再改名)→
校验 sha256 → 经 FileProvider 交给系统安装器
- UpdateDialog:版本号、更新说明、下载进度、失败重试;「允许安装未知应用」
是系统开关,只跳设置页,不代点
- 启动时自动查一次,首页底部另有「检查更新」入口
- 图标换成和 Web 的 <Logo/> 同一套图形:墨绿圆角底、三条米白圆头线、陶土点;
同时出 mipmap 各密度 PNG、自适应图标与 512px 预览
- versionCode=2 / versionName=0.2.0;发布签名从 android/keystore.properties 读,
没有该文件时回落调试签名,保证别人 clone 下来也能 assembleRelease
文档
- API.md 第 7 节:两个接口、清单字段、签名与 sha256 的约定、9001
- DEPLOY.md 第 5 节:出包命令(JDK 17 + Gradle 8.9 + 干净的 GRADLE_USER_HOME)、
发布清单、传到服务器、验证
- ROADMAP 第 6 节记录本次范围与本机踩过的构建环境坑
验证
- 后端 pytest 103 passed;ruff check / format --check 全绿
- assembleRelease 成功,app-release.apk 7.4 MB,apksigner 验签显示 CN=Dadao Task
- 本地与生产 GET /app/release/latest?version_code=1 → has_update=true,
version_code=2 → false;下载返回 200 / application/vnd.android.package-archive
@@ -27,6 +27,9 @@ android/.gradle/
|
|||||||
android/build/
|
android/build/
|
||||||
android/app/build/
|
android/app/build/
|
||||||
android/local.properties
|
android/local.properties
|
||||||
|
# 发布签名证书与口令(只在发布者本机;仓库里没有它就用调试签名)
|
||||||
|
android/keystore.properties
|
||||||
|
*.jks
|
||||||
|
|
||||||
# 编辑器 / 系统
|
# 编辑器 / 系统
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -47,6 +50,12 @@ android/.kotlin/
|
|||||||
# 本地上传文件(附件落盘目录,绝不入库)
|
# 本地上传文件(附件落盘目录,绝不入库)
|
||||||
backend/var/
|
backend/var/
|
||||||
|
|
||||||
|
# 发布的安卓安装包由 publish_apk.py 放在这里,APK 不进版本库
|
||||||
|
backend/static/apk/
|
||||||
|
|
||||||
|
# pytest 的临时目录(在 backend/ 下跑测试时生成)
|
||||||
|
backend/pytest-of-*/
|
||||||
|
|
||||||
# Vditor 运行时资源(lute/i18n/icons,约 3.8 MB)
|
# Vditor 运行时资源(lute/i18n/icons,约 3.8 MB)
|
||||||
# 由 web/scripts/vendor-vditor.mjs 从 node_modules 生成,不进版本库
|
# 由 web/scripts/vendor-vditor.mjs 从 node_modules 生成,不进版本库
|
||||||
web/public/vditor/
|
web/public/vditor/
|
||||||
|
|||||||
@@ -544,8 +544,6 @@ Query:
|
|||||||
|
|
||||||
### 6.3 附件错误码
|
### 6.3 附件错误码
|
||||||
|
|
||||||
### 6.3 附件错误码
|
|
||||||
|
|
||||||
| 码 | HTTP | 含义 |
|
| 码 | HTTP | 含义 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 8001 | 404 | 附件不存在,或不属于这个工作区 |
|
| 8001 | 404 | 附件不存在,或不属于这个工作区 |
|
||||||
@@ -562,7 +560,67 @@ Query:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. 健康与版本
|
## 7. 安卓发布与升级 `/app`
|
||||||
|
|
||||||
|
安装包不进版本库:`backend/static/apk/` 由 `publish_apk.py` 写入,里面有 APK 与一份
|
||||||
|
`latest.json` 清单。接口只读这份清单,**没有清单就等于没发布过**(9001)。
|
||||||
|
|
||||||
|
### 7.1 `GET /app/release/latest`
|
||||||
|
|
||||||
|
| 参数 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `version_code` | 可选,客户端当前版本号;给了才会算 `has_update` |
|
||||||
|
|
||||||
|
免登录:老版本客户端可能连"登录"这一步都还没走到,检查更新不该需要令牌。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"platform": "android",
|
||||||
|
"version_code": 2,
|
||||||
|
"version_name": "0.2.0",
|
||||||
|
"notes": "新增应用内升级;图标换成和 Web 一样",
|
||||||
|
"file": "dadaotask-0.2.0.apk",
|
||||||
|
"size": 8123456,
|
||||||
|
"sha256": "…",
|
||||||
|
"min_sdk": 26,
|
||||||
|
"published_at": "2026-10-31T03:20:00Z",
|
||||||
|
"download_url": "/api/v1/app/release/download/dadaotask-0.2.0.apk",
|
||||||
|
"has_update": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`download_url` 是**相对路径**:同一份清单要被内网、公网、模拟器三个入口共用,
|
||||||
|
写死域名只会让其中一个坏掉;客户端用自己配置的 API 地址拼。
|
||||||
|
|
||||||
|
`has_update` 的判据只有 `version_code`,不看版本名字符串——字符串比较会把
|
||||||
|
`0.10.0` 判成比 `0.9.0` 旧。
|
||||||
|
|
||||||
|
### 7.2 `GET /app/release/download/{file}`
|
||||||
|
|
||||||
|
只认清单里登记过的文件名,其它一律 9001(含 `..%2F` 这类路径穿越尝试)。
|
||||||
|
返回 `application/vnd.android.package-archive`。同样免登录:下载地址本身就是
|
||||||
|
清单里给出的,能拿到清单就说明已经过了一道。
|
||||||
|
|
||||||
|
### 7.3 发布包的约定
|
||||||
|
|
||||||
|
- APK 必须用**同一把证书**签名,否则新包装不上去(系统只会报"应用未安装")。
|
||||||
|
证书放在发布者本机(`android/keystore.properties` 指向它),不进仓库。
|
||||||
|
- 上传清单带 `sha256`,客户端下完先校验再交给系统安装器:内网代理缓存旧包时,
|
||||||
|
这是唯一能挡住"装了个旧版本"的地方。
|
||||||
|
- 「允许安装未知应用」是系统开关,App 只能把用户送到设置页,不能代点。
|
||||||
|
|
||||||
|
### 7.4 错误码
|
||||||
|
|
||||||
|
| 码 | HTTP | 含义 |
|
||||||
|
|---|---|---|
|
||||||
|
| 9001 | 404 | 还没发布过安装包,或下载的文件不在清单里 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 健康与版本
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|||||||
@@ -100,7 +100,59 @@ curl -sI http://192.168.8.188:8200/ | head -1 # 200,text/html(前端产
|
|||||||
|
|
||||||
浏览器验收:打开 `http://192.168.8.188:8200` → 手机号任意 + 验证码 `123456` 登录 → 任务/工单/知识各点一遍。
|
浏览器验收:打开 `http://192.168.8.188:8200` → 手机号任意 + 验证码 `123456` 登录 → 任务/工单/知识各点一遍。
|
||||||
|
|
||||||
## 5. 运维
|
## 5. 安卓安装包发布(OTA)
|
||||||
|
|
||||||
|
APK 不进版本库,也不走 `deploy.sh`:代码发布和"发一个安装包"是两件事,
|
||||||
|
前者每次上线都做,后者只在安卓端有改动时做。
|
||||||
|
|
||||||
|
### 5.1 出包(本机)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd android
|
||||||
|
export JAVA_HOME=<JDK 17> # AGP 8.7 不认 JDK 25
|
||||||
|
export GRADLE_USER_HOME=<可写的 gradle 目录>
|
||||||
|
gradle -Dorg.gradle.jvmargs="-Djava.io.tmpdir=<可写 tmp>" assembleRelease
|
||||||
|
# 产物:android/app/build/outputs/apk/release/app-release.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
- 版本号在 `android/app/build.gradle.kts`:`versionCode` 是 OTA 的唯一判据,**每次发版必须 +1**,
|
||||||
|
否则客户端查不到更新;`versionName` 只是给人看的。
|
||||||
|
- 签名证书在 `android/keystore.properties`(本机文件,`.gitignore` 已排除)指向的 `.jks`。
|
||||||
|
**换证书等于换应用**:装了旧包的手机再也装不上新包,只能卸载重装。
|
||||||
|
- `dadao.apiBase`(`android/local.properties`)决定包里写死的后端地址。正式包指向
|
||||||
|
`http://192.168.8.188:8200`,模拟器调试包指向 `http://10.0.2.2:8100`。
|
||||||
|
|
||||||
|
### 5.2 发布清单
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python backend/scripts/publish_apk.py android/app/build/outputs/apk/release/app-release.apk \
|
||||||
|
--version-code 2 --version-name 0.2.0 --notes "新增应用内升级;图标与 Web 一致"
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本把 APK 复制成 `backend/static/apk/dadaotask-<版本>.apk`,并写 `latest.json`
|
||||||
|
(含 `sha256`、`size`、`published_at`)。接口读的就是这份清单。
|
||||||
|
|
||||||
|
### 5.3 传到服务器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp backend/static/apk/* openclaw-01:~/app/dadaotask/backend/static/apk/
|
||||||
|
```
|
||||||
|
|
||||||
|
`deploy.sh` 的 `git reset --hard` 不会删未跟踪文件,所以已发布的 APK 在服务器上能留住;
|
||||||
|
但换了机器或清了目录就要重新传一次。**不需要重启服务**:接口每次请求都现读清单与文件。
|
||||||
|
|
||||||
|
### 5.4 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "http://192.168.8.188:8200/api/v1/app/release/latest?version_code=1"
|
||||||
|
# data.version_code 为新版号、has_update=true、download_url 可下载
|
||||||
|
curl -sI http://192.168.8.188:8200/api/v1/app/release/download/dadaotask-0.2.0.apk | head -1 # 200
|
||||||
|
```
|
||||||
|
|
||||||
|
手机端:装上新包 → 下次启动自动查一次;也可以在首页底部点「检查更新」。
|
||||||
|
装完再启动,`version_code` 已是最新,接口返回 `has_update=false`,不会再弹。
|
||||||
|
|
||||||
|
## 6. 运维
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
systemctl --user status dadaotask-api # 状态
|
systemctl --user status dadaotask-api # 状态
|
||||||
@@ -112,7 +164,7 @@ journalctl --user -u dadaotask-api -f # 日志(gunicorn 输出到 stdout
|
|||||||
- 回滚:`cd ~/app/dadaotask && git reset --hard <旧 commit> && bash deploy/prod/deploy.sh`;**数据库迁移不回滚**,回滚代码前确认旧代码兼容新表结构
|
- 回滚:`cd ~/app/dadaotask && git reset --hard <旧 commit> && bash deploy/prod/deploy.sh`;**数据库迁移不回滚**,回滚代码前确认旧代码兼容新表结构
|
||||||
- 端口:`8200`(systemd 单元与 `deploy.sh` 的 `PORT` 默认值,两处要一致)
|
- 端口:`8200`(systemd 单元与 `deploy.sh` 的 `PORT` 默认值,两处要一致)
|
||||||
|
|
||||||
## 6. 已知限制(当前阶段)
|
## 7. 已知限制(当前阶段)
|
||||||
|
|
||||||
| 限制 | 影响 | 解除方式 |
|
| 限制 | 影响 | 解除方式 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|||||||
@@ -417,3 +417,34 @@
|
|||||||
- [x] 既有三套 e2e 回归通过(smoke 7 / tickets 8 / knowledge 7)
|
- [x] 既有三套 e2e 回归通过(smoke 7 / tickets 8 / knowledge 7)
|
||||||
- [x] `tsc -b` 0 错;`vite build` 主包 299.42 kB │ gzip 74.96 kB,Vditor 独立 chunk 385.24 kB │ gzip 136.05 kB(按需加载,只进详情抽屉)
|
- [x] `tsc -b` 0 错;`vite build` 主包 299.42 kB │ gzip 74.96 kB,Vditor 独立 chunk 385.24 kB │ gzip 136.05 kB(按需加载,只进详情抽屉)
|
||||||
- [x] 安卓 `assembleDebug` 成功(APK 10.1 MB),模拟器上走通「详情 → 编辑正文 → 预览 → 保存」,截图 `2.Design/screens/android-editor{,-preview}.png`
|
- [x] 安卓 `assembleDebug` 成功(APK 10.1 MB),模拟器上走通「详情 → 编辑正文 → 预览 → 保存」,截图 `2.Design/screens/android-editor{,-preview}.png`
|
||||||
|
|
||||||
|
## 6. 安卓分发:应用内升级(OTA,2026-10-31)
|
||||||
|
|
||||||
|
要解决的是"改完安卓端怎么送到手机上":不发商店,也不能每次让人手动传 APK。
|
||||||
|
|
||||||
|
### 6.1 后端
|
||||||
|
- [x] `backend/app/api/app_release.py`:`GET /app/release/latest?version_code=` 读 `latest.json` 清单,`GET /app/release/download/{file}` 只认清单里登记的文件名(含 `..%2F` 一律 9001)
|
||||||
|
- [x] 两个接口都免登录:还没装上新包的客户端未必带着有效令牌,检查更新不该被登录挡住
|
||||||
|
- [x] `download_url` 是相对路径,客户端用自己配置的 API 地址拼接——同一份清单要在内网/公网/模拟器之间共用
|
||||||
|
- [x] `has_update` 只比对 `version_code`,不看版本名字符串(`0.10.0` 与 `0.9.0` 的字典序是坑)
|
||||||
|
- [x] `backend/scripts/publish_apk.py`:复制成 `dadaotask-<版本>.apk` 并写 `latest.json`(sha256/size/published_at);`backend/static/apk/` 已在 `.gitignore`
|
||||||
|
- [x] `backend/tests/test_app_release.py` 5 个用例:未发布 9001、清单字段与 download_url、`has_update` 判据、下载字节与 content-type、路径穿越
|
||||||
|
|
||||||
|
### 6.2 安卓
|
||||||
|
- [x] `data/AppUpdater.kt`:查清单 → 下到 `Android/data/<pkg>/files/updates/`(先写 `.part` 再改名)→ 校验 sha256 → 经 `FileProvider` 交给系统安装器
|
||||||
|
- [x] `ui/UpdateDialog.kt`:当前/新版本号、更新说明、下载进度、失败重试;「允许安装未知应用」只跳系统设置,不代点
|
||||||
|
- [x] 启动时自动查一次;首页底部有「检查更新」手动入口,结果用一句话回给用户
|
||||||
|
- [x] 图标换成与 Web 的 `<Logo/>` 同一套图形(墨绿圆角底 + 三条米白圆头线 + 陶土点),生成 `mipmap-*/ic_launcher{,_round}.png` 与自适应图标 `mipmap-anydpi-v26/ic_launcher{,_round}.xml`,512px 预览存 `2.Design/screens/android-icon-512.png`
|
||||||
|
- [x] 版本 `versionCode = 2` / `versionName = "0.2.0"`;发布签名从 `android/keystore.properties` 读,无该文件时回落调试签名(保证别人 clone 下来也能 `assembleRelease`)
|
||||||
|
|
||||||
|
### 6.3 构建环境(本机踩过的坑)
|
||||||
|
- Gradle 8.7 被 AGP 8.7.3 拒绝(`Minimum supported Gradle version is 8.9`),用 **Gradle 8.9**(腾讯镜像下载)
|
||||||
|
- 系统 `~/.gradle` 的 native 库坏了(`Failed to load native library 'native-platform.dll'`),必须 `GRADLE_USER_HOME` 指到一个干净目录
|
||||||
|
- JDK 25 编译不过,用 **Temurin JDK 17**;Kotlin 编译还要把 `java.io.tmpdir` 指到工程内可写目录,否则报 `AccessDeniedException: kotlin-compiler-*.alive`
|
||||||
|
- 完整命令见 `2.Design/DEPLOY.md` 第 5.1 节
|
||||||
|
|
||||||
|
### 6.4 验证
|
||||||
|
- [x] 后端 `pytest` → **103 passed**;`ruff check` / `ruff format --check` 全绿
|
||||||
|
- [x] `assembleRelease` 成功:`app-release.apk` 7.4 MB,`apksigner verify --print-certs` 显示证书 `CN=Dadao Task`
|
||||||
|
- [x] 本地 `GET /api/v1/app/release/latest?version_code=1` → `has_update=true`、`download_url` 可下(200,7 762 912 字节),`version_code=2` → `false`
|
||||||
|
- [x] 生产(`http://192.168.8.188:8200`)同上,安装包已随部署上传
|
||||||
|
|||||||
|
After Width: | Height: | Size: 22 KiB |
@@ -26,8 +26,9 @@ android {
|
|||||||
applicationId = "com.dadao.task"
|
applicationId = "com.dadao.task"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 1
|
// 版本号是 OTA 的唯一判据:后端只比对 version_code,改这里才算"有新版本"
|
||||||
versionName = "0.1.0"
|
versionCode = 2
|
||||||
|
versionName = "0.2.0"
|
||||||
buildConfigField("String", "API_BASE", "\"$apiBase\"")
|
buildConfigField("String", "API_BASE", "\"$apiBase\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +52,37 @@ android {
|
|||||||
keyAlias = "androiddebugkey"
|
keyAlias = "androiddebugkey"
|
||||||
keyPassword = "android"
|
keyPassword = "android"
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 发布签名从 android/keystore.properties 读,证书本身不进仓库。
|
||||||
|
*
|
||||||
|
* 没有这个文件(别人 clone 下来、CI 没注入)时回落到调试签名:
|
||||||
|
* assembleRelease 依然能跑,只是装不上已经装了正式版的机器——
|
||||||
|
* 而这正是应该发生的:发布包的签名只能由发布者本人持有。
|
||||||
|
*/
|
||||||
|
create("release") {
|
||||||
|
val file = rootProject.file("keystore.properties")
|
||||||
|
if (file.exists()) {
|
||||||
|
val props = Properties()
|
||||||
|
file.inputStream().use { props.load(it) }
|
||||||
|
storeFile = rootProject.file(props.getProperty("storeFile"))
|
||||||
|
storePassword = props.getProperty("storePassword")
|
||||||
|
keyAlias = props.getProperty("keyAlias")
|
||||||
|
keyPassword = props.getProperty("keyPassword")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
// 没有正式证书时回落调试签名,保证任何机器都能 assembleRelease
|
||||||
|
signingConfig = if (rootProject.file("keystore.properties").exists()) {
|
||||||
|
signingConfigs.getByName("release")
|
||||||
|
} else {
|
||||||
|
signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
|
isMinifyEnabled = false
|
||||||
|
isShrinkResources = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
@@ -69,6 +101,8 @@ dependencies {
|
|||||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
implementation("androidx.compose.material3:material3")
|
implementation("androidx.compose.material3:material3")
|
||||||
implementation("androidx.activity:activity-compose:1.9.3")
|
implementation("androidx.activity:activity-compose:1.9.3")
|
||||||
|
// FileProvider(应用内升级把安装包交给系统安装器)就在 core 里,显式声明不靠传递依赖
|
||||||
|
implementation("androidx.core:core-ktx:1.13.1")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||||
|
|||||||
@@ -2,11 +2,15 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<!-- 应用内升级:下好的 APK 交给系统安装器 -->
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="达道Task"
|
android:label="达道Task"
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.DadaoTask">
|
android:theme="@style/Theme.DadaoTask">
|
||||||
|
|
||||||
@@ -26,5 +30,16 @@
|
|||||||
<data android:scheme="https" android:host="dadaotask.com" android:pathPrefix="/join/" />
|
<data android:scheme="https" android:host="dadaotask.com" android:pathPrefix="/join/" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!-- 安装包只从应用自己的 updates 目录出,不外扩到其它路径 -->
|
||||||
|
<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>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import com.dadao.task.data.Api
|
import com.dadao.task.data.Api
|
||||||
|
import com.dadao.task.data.AppRelease
|
||||||
|
import com.dadao.task.data.AppUpdater
|
||||||
import com.dadao.task.data.TokenStore
|
import com.dadao.task.data.TokenStore
|
||||||
import com.dadao.task.data.Workspace
|
import com.dadao.task.data.Workspace
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -31,6 +33,7 @@ import com.dadao.task.ui.NotificationsScreen
|
|||||||
import com.dadao.task.ui.RichTextEditorScreen
|
import com.dadao.task.ui.RichTextEditorScreen
|
||||||
import com.dadao.task.ui.TaskListScreen
|
import com.dadao.task.ui.TaskListScreen
|
||||||
import com.dadao.task.ui.TicketsScreen
|
import com.dadao.task.ui.TicketsScreen
|
||||||
|
import com.dadao.task.ui.UpdateDialog
|
||||||
import com.dadao.task.ui.WorkspaceScaffold
|
import com.dadao.task.ui.WorkspaceScaffold
|
||||||
import com.dadao.task.ui.WorkspaceTab
|
import com.dadao.task.ui.WorkspaceTab
|
||||||
|
|
||||||
@@ -80,6 +83,9 @@ private fun AppRoot(joinUri: String?) {
|
|||||||
var reopenItem by remember { mutableStateOf<String?>(null) }
|
var reopenItem by remember { mutableStateOf<String?>(null) }
|
||||||
// 上面那条是任务还是知识:决定回哪个页面去重开
|
// 上面那条是任务还是知识:决定回哪个页面去重开
|
||||||
var reopenKnowledge by remember { mutableStateOf(false) }
|
var reopenKnowledge by remember { mutableStateOf(false) }
|
||||||
|
// 应用内升级:启动后查一次,查到了就把对话框摆出来
|
||||||
|
val updater = remember { AppUpdater(context, api) }
|
||||||
|
var update by remember { mutableStateOf<AppRelease?>(null) }
|
||||||
|
|
||||||
// 有本地令牌就先静默校验一次:令牌过期/被吊销要退回登录页,而不是进了主页再崩。
|
// 有本地令牌就先静默校验一次:令牌过期/被吊销要退回登录页,而不是进了主页再崩。
|
||||||
LaunchedEffect(screen) {
|
LaunchedEffect(screen) {
|
||||||
@@ -114,11 +120,33 @@ private fun AppRoot(joinUri: String?) {
|
|||||||
unread = api.unreadCount(ws.id)
|
unread = api.unreadCount(ws.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 版本检查:登录成功后查一次就够,查不到(网络、没发布过包)就当没有新版
|
||||||
|
LaunchedEffect(screen) {
|
||||||
|
if (screen != Screen.Home && screen != Screen.Workspace) return@LaunchedEffect
|
||||||
|
if (update != null) return@LaunchedEffect
|
||||||
|
update = runCatching { updater.check() }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 手动检查:结果用一句话回给首页显示,查到新版顺便把对话框摆出来。 */
|
||||||
|
suspend fun checkUpdate(): String = runCatching { updater.checkStrict() }.fold(
|
||||||
|
onSuccess = { release ->
|
||||||
|
if (release == null) {
|
||||||
|
"已经是最新版 ${updater.currentVersionName}"
|
||||||
|
} else {
|
||||||
|
update = release
|
||||||
|
"发现新版本 ${release.versionName}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFailure = { "检查更新失败:${it.message ?: "网络不通"}" },
|
||||||
|
)
|
||||||
|
|
||||||
when (screen) {
|
when (screen) {
|
||||||
Screen.Splash -> Unit
|
Screen.Splash -> Unit
|
||||||
Screen.Login -> LoginScreen(api = api, onLoggedIn = { screen = Screen.Home })
|
Screen.Login -> LoginScreen(api = api, onLoggedIn = { screen = Screen.Home })
|
||||||
Screen.Home -> HomeScreen(
|
Screen.Home -> HomeScreen(
|
||||||
api = api,
|
api = api,
|
||||||
|
versionName = updater.currentVersionName,
|
||||||
|
onCheckUpdate = { checkUpdate() },
|
||||||
onLogout = {
|
onLogout = {
|
||||||
tokens.clear()
|
tokens.clear()
|
||||||
screen = Screen.Login
|
screen = Screen.Login
|
||||||
@@ -219,4 +247,14 @@ private fun AppRoot(joinUri: String?) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新对话框是独立窗口,摆在哪一层都能盖住当前界面
|
||||||
|
update?.let { release ->
|
||||||
|
UpdateDialog(
|
||||||
|
release = release,
|
||||||
|
currentVersionName = updater.currentVersionName,
|
||||||
|
updater = updater,
|
||||||
|
onLater = { update = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -236,10 +236,25 @@ data class SourceRef(
|
|||||||
val status: String = "",
|
val status: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** 服务端发布的安卓安装包(OTA)。`hasUpdate` 由服务端按 version_code 比较后给。 */
|
||||||
|
@Serializable
|
||||||
|
data class AppRelease(
|
||||||
|
val platform: String = "android",
|
||||||
|
@SerialName("version_code") val versionCode: Int = 0,
|
||||||
|
@SerialName("version_name") val versionName: String = "",
|
||||||
|
val notes: String = "",
|
||||||
|
val file: String = "",
|
||||||
|
val size: Long = 0,
|
||||||
|
val sha256: String = "",
|
||||||
|
@SerialName("min_sdk") val minSdk: Int = 26,
|
||||||
|
@SerialName("published_at") val publishedAt: String? = null,
|
||||||
|
@SerialName("download_url") val downloadUrl: String = "",
|
||||||
|
@SerialName("has_update") val hasUpdate: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
/** 知识分类:工作区级的三级小树,fullPath 形如「研发 / 部署 / 回滚」。 */
|
/** 知识分类:工作区级的三级小树,fullPath 形如「研发 / 部署 / 回滚」。 */
|
||||||
@Serializable
|
@Serializable
|
||||||
data class KnowledgeCategory(
|
data class KnowledgeCategory( val id: String,
|
||||||
val id: String,
|
|
||||||
val name: String = "",
|
val name: String = "",
|
||||||
@SerialName("parent_id") val parentId: String? = null,
|
@SerialName("parent_id") val parentId: String? = null,
|
||||||
val depth: Int = 1,
|
val depth: Int = 1,
|
||||||
@@ -472,6 +487,13 @@ class Api(private val tokens: TokenStore) {
|
|||||||
auth = true,
|
auth = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查最新安装包。不带 auth:还没登录就可能在检查更新,
|
||||||
|
* 传当前 versionCode 让服务端直接算好 `hasUpdate`。
|
||||||
|
*/
|
||||||
|
suspend fun latestAppRelease(versionCode: Int): AppRelease =
|
||||||
|
call(path = "/app/release/latest?version_code=$versionCode")
|
||||||
|
|
||||||
suspend fun members(workspaceId: String): List<Member> =
|
suspend fun members(workspaceId: String): List<Member> =
|
||||||
call<MemberPage>(path = "/workspaces/$workspaceId/members", auth = true).items
|
call<MemberPage>(path = "/workspaces/$workspaceId/members", auth = true).items
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package com.dadao.task.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import com.dadao.task.BuildConfig
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用内升级(OTA)。
|
||||||
|
*
|
||||||
|
* 流程刻意做得很短:查清单 → 下到应用自己的目录 → 交给系统安装器。
|
||||||
|
* 不申请存储权限、不自己实现安装界面:APK 走 [FileProvider] 授权给系统,
|
||||||
|
* 「允许安装未知应用」这类系统开关也必须由用户在系统设置里点,不能被代劳。
|
||||||
|
*/
|
||||||
|
class AppUpdater(private val context: Context, private val api: Api) {
|
||||||
|
|
||||||
|
private val http = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(60, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val currentVersionName: String = BuildConfig.VERSION_NAME
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 有新版就返回它,否则 null。
|
||||||
|
*
|
||||||
|
* 网络失败、后端还没发布过安装包都当成"没有更新":启动时不打扰用户,
|
||||||
|
* 手动检查那次再把异常抛给界面去提示。
|
||||||
|
*/
|
||||||
|
suspend fun check(): AppRelease? = runCatching {
|
||||||
|
api.latestAppRelease(BuildConfig.VERSION_CODE)
|
||||||
|
}.getOrNull()?.takeIf { it.hasUpdate && it.downloadUrl.isNotBlank() }
|
||||||
|
|
||||||
|
/** 同 [check],但失败会抛异常——给「检查更新」按钮用。 */
|
||||||
|
suspend fun checkStrict(): AppRelease? {
|
||||||
|
val release = api.latestAppRelease(BuildConfig.VERSION_CODE)
|
||||||
|
return release.takeIf { it.hasUpdate && it.downloadUrl.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把安装包下到 `Android/data/<pkg>/files/updates/` 下。
|
||||||
|
*
|
||||||
|
* 先写 .part 再改名:下到一半断网不会留下一个"看起来能用"的坏 APK。
|
||||||
|
* 清单带了 sha256 就校验,防止内网代理/缓存给出旧包。
|
||||||
|
*/
|
||||||
|
suspend fun download(
|
||||||
|
release: AppRelease,
|
||||||
|
onProgress: (downloaded: Long, total: Long) -> Unit = { _, _ -> },
|
||||||
|
): File = withContext(Dispatchers.IO) {
|
||||||
|
val dir = File(context.getExternalFilesDir(null) ?: context.filesDir, "updates")
|
||||||
|
dir.mkdirs()
|
||||||
|
val safeName = release.file.ifBlank { "dadaotask-${release.versionName}.apk" }
|
||||||
|
val target = File(dir, safeName)
|
||||||
|
val partial = File(dir, "$safeName.part")
|
||||||
|
|
||||||
|
val url = api.currentOrigin() + release.downloadUrl
|
||||||
|
val response = try {
|
||||||
|
http.newCall(Request.Builder().url(url).build()).execute()
|
||||||
|
} catch (e: IOException) {
|
||||||
|
throw ApiException(1, "下载失败:连不上 $url")
|
||||||
|
}
|
||||||
|
response.use { res ->
|
||||||
|
if (!res.isSuccessful) throw ApiException(1, "下载失败:HTTP ${res.code}")
|
||||||
|
val body = res.body ?: throw ApiException(1, "下载失败:响应为空")
|
||||||
|
val total = if (release.size > 0) release.size else body.contentLength()
|
||||||
|
partial.outputStream().use { out ->
|
||||||
|
body.byteStream().use { input ->
|
||||||
|
val buffer = ByteArray(64 * 1024)
|
||||||
|
var downloaded = 0L
|
||||||
|
while (true) {
|
||||||
|
val read = input.read(buffer)
|
||||||
|
if (read <= 0) break
|
||||||
|
out.write(buffer, 0, read)
|
||||||
|
downloaded += read
|
||||||
|
onProgress(downloaded, total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (release.sha256.isNotBlank() && sha256Of(partial) != release.sha256.lowercase()) {
|
||||||
|
partial.delete()
|
||||||
|
throw ApiException(1, "安装包校验不通过(sha256 不一致),请稍后重试")
|
||||||
|
}
|
||||||
|
if (target.exists()) target.delete()
|
||||||
|
if (!partial.renameTo(target)) throw ApiException(1, "安装包保存失败,检查存储空间")
|
||||||
|
target
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 应用没有「安装未知应用」权限时,先把用户送到系统设置页。 */
|
||||||
|
fun canInstall(): Boolean =
|
||||||
|
Build.VERSION.SDK_INT < Build.VERSION_CODES.O || context.packageManager.canRequestPackageInstalls()
|
||||||
|
|
||||||
|
fun openInstallPermission(): Boolean {
|
||||||
|
if (canInstall()) return false
|
||||||
|
val intent = Intent(
|
||||||
|
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||||
|
Uri.parse("package:${context.packageName}"),
|
||||||
|
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
runCatching { context.startActivity(intent) }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 交给系统安装器。返回 false 表示还没给"安装未知应用"权限。 */
|
||||||
|
fun install(file: File): Boolean {
|
||||||
|
if (!canInstall()) return false
|
||||||
|
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||||
|
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sha256Of(file: File): String {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
file.inputStream().use { input ->
|
||||||
|
val buffer = ByteArray(64 * 1024)
|
||||||
|
while (true) {
|
||||||
|
val read = input.read(buffer)
|
||||||
|
if (read <= 0) break
|
||||||
|
digest.update(buffer, 0, read)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return digest.digest().joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,7 +63,13 @@ private fun roleLabel(role: String?): String = when (role) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(api: Api, onLogout: () -> Unit, onOpenWorkspace: (Workspace) -> Unit) {
|
fun HomeScreen(
|
||||||
|
api: Api,
|
||||||
|
versionName: String,
|
||||||
|
onCheckUpdate: suspend () -> String,
|
||||||
|
onLogout: () -> Unit,
|
||||||
|
onOpenWorkspace: (Workspace) -> Unit,
|
||||||
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var user by remember { mutableStateOf<User?>(null) }
|
var user by remember { mutableStateOf<User?>(null) }
|
||||||
var workspaces by remember { mutableStateOf<List<Workspace>>(emptyList()) }
|
var workspaces by remember { mutableStateOf<List<Workspace>>(emptyList()) }
|
||||||
@@ -71,6 +77,9 @@ fun HomeScreen(api: Api, onLogout: () -> Unit, onOpenWorkspace: (Workspace) -> U
|
|||||||
var name by remember { mutableStateOf("") }
|
var name by remember { mutableStateOf("") }
|
||||||
var busy by remember { mutableStateOf(false) }
|
var busy by remember { mutableStateOf(false) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
// 手动检查更新的回话:查到/没查到/失败都用一句话说清楚
|
||||||
|
var updateNotice by remember { mutableStateOf<String?>(null) }
|
||||||
|
var checking by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
suspend fun reload() {
|
suspend fun reload() {
|
||||||
runCatching { api.me() }
|
runCatching { api.me() }
|
||||||
@@ -234,6 +243,38 @@ fun HomeScreen(api: Api, onLogout: () -> Unit, onOpenWorkspace: (Workspace) -> U
|
|||||||
item { SoftPill(notice, Rust, RustSoft, Modifier.fillMaxWidth()) }
|
item { SoftPill(notice, Rust, RustSoft, Modifier.fillMaxWidth()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Column(Modifier.padding(top = 6.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
"达道Task v$versionName",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = InkMuted,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Text(
|
||||||
|
if (checking) "检查中…" else "检查更新",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = Primary,
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable(enabled = !checking) {
|
||||||
|
checking = true
|
||||||
|
scope.launch {
|
||||||
|
updateNotice = onCheckUpdate()
|
||||||
|
checking = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val note = updateNotice
|
||||||
|
if (note != null) {
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Text(note, style = MaterialTheme.typography.labelSmall, color = InkMuted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
item { Spacer(Modifier.height(40.dp)) }
|
item { Spacer(Modifier.height(40.dp)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package com.dadao.task.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.dadao.task.data.AppRelease
|
||||||
|
import com.dadao.task.data.AppUpdater
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/** 更新对话框当前停在哪一步。 */
|
||||||
|
private enum class UpdatePhase { Offer, Downloading, Downloaded, Denied, Failed }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用内升级的对话窗口。
|
||||||
|
*
|
||||||
|
* 只有一条主按钮,因为这一步没得选:点了就下载、下完就交给系统安装器。
|
||||||
|
* 「允许安装未知应用」是系统开关,界面只能把用户送到设置页,不能替他点。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun UpdateDialog(
|
||||||
|
release: AppRelease,
|
||||||
|
currentVersionName: String,
|
||||||
|
updater: AppUpdater,
|
||||||
|
onLater: () -> Unit,
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var phase by remember { mutableStateOf(UpdatePhase.Offer) }
|
||||||
|
var progress by remember { mutableStateOf(0f) }
|
||||||
|
var detail by remember { mutableStateOf("") }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
var file by remember { mutableStateOf<File?>(null) }
|
||||||
|
|
||||||
|
fun handToInstaller(target: File) {
|
||||||
|
if (updater.install(target)) onLater() else phase = UpdatePhase.Denied
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
// 下载过程中不给关:半截的包留在磁盘上比重新下一个更麻烦
|
||||||
|
onDismissRequest = { if (phase != UpdatePhase.Downloading) onLater() },
|
||||||
|
title = { Text("有新版 ${release.versionName}", style = MaterialTheme.typography.titleMedium) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
Text(
|
||||||
|
"当前 ${currentVersionName} · 新包 ${formatSize(release.size)}",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = InkMuted,
|
||||||
|
)
|
||||||
|
if (release.notes.isNotBlank()) {
|
||||||
|
Text(release.notes, style = MaterialTheme.typography.bodySmall, color = InkSoft)
|
||||||
|
}
|
||||||
|
when (phase) {
|
||||||
|
UpdatePhase.Downloading -> {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { progress },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"下载中 ${(progress * 100).toInt()}% $detail",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = InkMuted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdatePhase.Denied -> Text(
|
||||||
|
"安装包已经下好了,但系统还关着「允许安装未知应用」。去打开这个开关再回来点安装。",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = Clay,
|
||||||
|
)
|
||||||
|
|
||||||
|
UpdatePhase.Failed -> Text(
|
||||||
|
error ?: "更新失败了,稍后再试",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = Clay,
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
when (phase) {
|
||||||
|
UpdatePhase.Denied -> PrimaryButton(
|
||||||
|
text = "去开启安装权限",
|
||||||
|
onClick = { updater.openInstallPermission() },
|
||||||
|
)
|
||||||
|
|
||||||
|
UpdatePhase.Downloaded -> PrimaryButton(
|
||||||
|
text = "安装",
|
||||||
|
onClick = { file?.let { handToInstaller(it) } },
|
||||||
|
)
|
||||||
|
|
||||||
|
UpdatePhase.Downloading -> PrimaryButton("下载中…", onClick = {}, enabled = false)
|
||||||
|
|
||||||
|
else -> PrimaryButton(
|
||||||
|
text = if (phase == UpdatePhase.Failed) "重新下载" else "下载并安装",
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
phase = UpdatePhase.Downloading
|
||||||
|
progress = 0f
|
||||||
|
runCatching {
|
||||||
|
val downloaded = updater.download(release) { done, total ->
|
||||||
|
progress = if (total > 0) (done.toFloat() / total).coerceIn(0f, 1f) else 0f
|
||||||
|
detail = "${formatSize(done)} / ${formatSize(total)}"
|
||||||
|
}
|
||||||
|
file = downloaded
|
||||||
|
phase = UpdatePhase.Downloaded
|
||||||
|
handToInstaller(downloaded)
|
||||||
|
}.onFailure {
|
||||||
|
error = it.message ?: "更新失败了,稍后再试"
|
||||||
|
phase = UpdatePhase.Failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { if (phase != UpdatePhase.Downloading) onLater() }) {
|
||||||
|
Text("以后再说", color = InkMuted)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把字节数说成人话:文件小就 KB,大就 MB。 */
|
||||||
|
private fun formatSize(bytes: Long): String {
|
||||||
|
if (bytes <= 0) return "未知大小"
|
||||||
|
if (bytes < 1024 * 1024) return "${(bytes / 1024)} KB"
|
||||||
|
return String.format(Locale.US, "%.1f MB", bytes / 1024.0 / 1024.0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- 自适应图标前景:与 Web Logo 同一套比例(108 画布,安全区 66 居中) -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:pathData="M36.3,42.6 L71.7,42.6"
|
||||||
|
android:strokeColor="#EFF5F3"
|
||||||
|
android:strokeWidth="5.06"
|
||||||
|
android:strokeLineCap="round" />
|
||||||
|
<path
|
||||||
|
android:pathData="M36.3,54 L59.6,54"
|
||||||
|
android:strokeColor="#EFF5F3"
|
||||||
|
android:strokeWidth="5.06"
|
||||||
|
android:strokeLineCap="round" />
|
||||||
|
<path
|
||||||
|
android:pathData="M36.3,65.4 L50.8,65.4"
|
||||||
|
android:strokeColor="#EFF5F3"
|
||||||
|
android:strokeWidth="5.06"
|
||||||
|
android:strokeLineCap="round" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#C2703D"
|
||||||
|
android:pathData="M71.7,65.4 m-7.6,0 a7.6,7.6 0 1,0 15.2,0 a7.6,7.6 0 1,0 -15.2,0" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- 与 Web 端 Logo(web/src/components/icons.tsx 的 <Logo/>)同一个墨绿 -->
|
||||||
|
<color name="ic_launcher_background">#2F6054</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- FileProvider 的可见范围:只暴露应用自己的 files/updates,安装包就在那儿 -->
|
||||||
|
<paths>
|
||||||
|
<external-files-path name="updates" path="updates/" />
|
||||||
|
<files-path name="internal_updates" path="updates/" />
|
||||||
|
</paths>
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!--
|
<!--
|
||||||
只对本机开发地址放行明文 HTTP。生产域名必须走 HTTPS,不放行任何 cleartext。
|
只给内网/本机地址放行明文 HTTP:10.0.2.2 是模拟器里的宿主机,
|
||||||
|
192.168.8.188 是当前的内网生产入口(还没上 HTTPS)。
|
||||||
|
正式域名必须走 HTTPS——上线时把 192.168.8.188 这条删掉。
|
||||||
-->
|
-->
|
||||||
<network-security-config>
|
<network-security-config>
|
||||||
<domain-config cleartextTrafficPermitted="true">
|
<domain-config cleartextTrafficPermitted="true">
|
||||||
<domain includeSubdomains="false">10.0.2.2</domain>
|
<domain includeSubdomains="false">10.0.2.2</domain>
|
||||||
<domain includeSubdomains="false">127.0.0.1</domain>
|
<domain includeSubdomains="false">127.0.0.1</domain>
|
||||||
<domain includeSubdomains="false">localhost</domain>
|
<domain includeSubdomains="false">localhost</domain>
|
||||||
|
<domain includeSubdomains="false">192.168.8.188</domain>
|
||||||
</domain-config>
|
</domain-config>
|
||||||
</network-security-config>
|
</network-security-config>
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""安卓 OTA:最新版本清单 + APK 下载。
|
||||||
|
|
||||||
|
为什么不用一张表:一个安装包就是一条"发布记录",而 APK 是构建产物、不能进仓库。
|
||||||
|
所以发布目录里放「latest.json + 同名 apk」,换版本只换文件,运维不需要动数据库。
|
||||||
|
清单由 `backend/scripts/publish_apk.py` 生成(算好大小与 sha256),本模块只读。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
from ..config import get_settings
|
||||||
|
from ..errors import AppError, ErrorCode
|
||||||
|
from ..logging_setup import get_logger
|
||||||
|
from ..schemas import ok
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/app", tags=["版本升级"])
|
||||||
|
settings = get_settings()
|
||||||
|
logger = get_logger("app.release")
|
||||||
|
|
||||||
|
MANIFEST_NAME = "latest.json"
|
||||||
|
|
||||||
|
|
||||||
|
def apk_dir() -> Path:
|
||||||
|
"""发布目录:优先用配置里的 APK_DIR,默认在 backend/static/apk(部署时保留、不入库)。"""
|
||||||
|
if settings.apk_dir:
|
||||||
|
return Path(settings.apk_dir)
|
||||||
|
return Path(__file__).resolve().parents[2] / "static" / "apk"
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest() -> dict[str, Any]:
|
||||||
|
path = apk_dir() / MANIFEST_NAME
|
||||||
|
if not path.is_file():
|
||||||
|
raise AppError(ErrorCode.APP_RELEASE_NOT_FOUND, "还没有发布过安装包")
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError) as exc: # noqa: BLE001 - 清单坏了也要说人话
|
||||||
|
logger.exception("读取 OTA 清单失败")
|
||||||
|
raise AppError(ErrorCode.APP_RELEASE_NOT_FOUND, "安装包清单损坏,请重新发布") from exc
|
||||||
|
if not isinstance(data, dict) or not data.get("file"):
|
||||||
|
raise AppError(ErrorCode.APP_RELEASE_NOT_FOUND, "安装包清单缺少文件名")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _payload(data: dict[str, Any], base: str, current: int | None) -> dict[str, Any]:
|
||||||
|
version_code = int(data.get("version_code") or 0)
|
||||||
|
return {
|
||||||
|
"platform": data.get("platform", "android"),
|
||||||
|
"version_code": version_code,
|
||||||
|
"version_name": data.get("version_name", ""),
|
||||||
|
"notes": data.get("notes", ""),
|
||||||
|
"file": data["file"],
|
||||||
|
"size": int(data.get("size") or 0),
|
||||||
|
"sha256": data.get("sha256", ""),
|
||||||
|
"min_sdk": int(data.get("min_sdk") or 26),
|
||||||
|
"published_at": data.get("published_at"),
|
||||||
|
# 客户端拿相对路径拼自己的 API 前缀,换域名不用改包
|
||||||
|
"download_url": f"{base}/app/release/download/{data['file']}",
|
||||||
|
# 带了当前版本号就顺手算好要不要升级,客户端不用再比一次
|
||||||
|
"has_update": current is not None and version_code > current,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/release/latest", summary="安卓最新版本")
|
||||||
|
async def latest_release(version_code: int | None = None) -> dict[str, Any]:
|
||||||
|
"""公开接口:装完 App 之前就要能查,不该要登录。
|
||||||
|
|
||||||
|
`version_code` 传客户端当前版本(`BuildConfig.VERSION_CODE`),返回里的
|
||||||
|
`has_update` 直接告诉它要不要提示升级。
|
||||||
|
"""
|
||||||
|
data = _manifest()
|
||||||
|
return ok(_payload(data, settings.api_prefix, version_code))
|
||||||
|
|
||||||
|
|
||||||
|
@router.api_route("/release/download/{name}", methods=["GET", "HEAD"], summary="下载安装包")
|
||||||
|
async def download_release(name: str) -> FileResponse:
|
||||||
|
# 只认清单里那一个文件:不做目录列举,也不接受 ../ 之类
|
||||||
|
data = _manifest()
|
||||||
|
if name != data["file"]:
|
||||||
|
raise AppError(ErrorCode.APP_RELEASE_NOT_FOUND, "没有这个安装包")
|
||||||
|
path = apk_dir() / name
|
||||||
|
if not path.is_file():
|
||||||
|
raise AppError(ErrorCode.APP_RELEASE_NOT_FOUND, "安装包文件不见了,请重新发布")
|
||||||
|
return FileResponse(
|
||||||
|
path,
|
||||||
|
media_type="application/vnd.android.package-archive",
|
||||||
|
filename=name,
|
||||||
|
)
|
||||||
@@ -108,6 +108,11 @@ class Settings(BaseSettings):
|
|||||||
# 指向 web/dist 的绝对路径;留空则只提供 API。
|
# 指向 web/dist 的绝对路径;留空则只提供 API。
|
||||||
web_dist_dir: str | None = None
|
web_dist_dir: str | None = None
|
||||||
|
|
||||||
|
# ── 安卓安装包(OTA)──────────────────────────────────
|
||||||
|
# 放 latest.json + 对应 apk 的目录;留空则用 backend/static/apk。
|
||||||
|
# 发布目录是构建产物,不入库,部署脚本也不清理(git reset 不动未跟踪文件)。
|
||||||
|
apk_dir: str | None = None
|
||||||
|
|
||||||
# ── 构建标记(部署校验用)──────────────────────────────
|
# ── 构建标记(部署校验用)──────────────────────────────
|
||||||
build_commit: str = "unknown"
|
build_commit: str = "unknown"
|
||||||
build_time: str = "unknown"
|
build_time: str = "unknown"
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ class ErrorCode(IntEnum):
|
|||||||
ATTACHMENT_FORBIDDEN = 8003
|
ATTACHMENT_FORBIDDEN = 8003
|
||||||
CONFIRM_REQUIRED = 8004
|
CONFIRM_REQUIRED = 8004
|
||||||
|
|
||||||
|
# 9xxx 客户端发布(OTA)
|
||||||
|
APP_RELEASE_NOT_FOUND = 9001
|
||||||
|
|
||||||
|
|
||||||
# 默认 HTTP 状态:业务码自己带语义,HTTP 只是传输层
|
# 默认 HTTP 状态:业务码自己带语义,HTTP 只是传输层
|
||||||
_HTTP_STATUS: dict[ErrorCode, int] = {
|
_HTTP_STATUS: dict[ErrorCode, int] = {
|
||||||
@@ -111,6 +114,7 @@ _HTTP_STATUS: dict[ErrorCode, int] = {
|
|||||||
ErrorCode.ATTACHMENT_TOO_LARGE: 413,
|
ErrorCode.ATTACHMENT_TOO_LARGE: 413,
|
||||||
ErrorCode.ATTACHMENT_FORBIDDEN: 403,
|
ErrorCode.ATTACHMENT_FORBIDDEN: 403,
|
||||||
ErrorCode.CONFIRM_REQUIRED: 409,
|
ErrorCode.CONFIRM_REQUIRED: 409,
|
||||||
|
ErrorCode.APP_RELEASE_NOT_FOUND: 404,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from starlette.responses import Response
|
|||||||
from starlette.staticfiles import StaticFiles
|
from starlette.staticfiles import StaticFiles
|
||||||
from starlette.types import Scope
|
from starlette.types import Scope
|
||||||
|
|
||||||
from .api import attachments, auth, items, knowledge, tickets, workspaces
|
from .api import app_release, attachments, auth, items, knowledge, tickets, workspaces
|
||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
from .db import SessionFactory, current_app_role, engine
|
from .db import SessionFactory, current_app_role, engine
|
||||||
from .errors import AppError, ErrorCode, http_status_for
|
from .errors import AppError, ErrorCode, http_status_for
|
||||||
@@ -115,6 +115,7 @@ app.include_router(tickets.router, prefix=settings.api_prefix)
|
|||||||
app.include_router(tickets.notify_router, prefix=settings.api_prefix)
|
app.include_router(tickets.notify_router, prefix=settings.api_prefix)
|
||||||
app.include_router(knowledge.router, prefix=settings.api_prefix)
|
app.include_router(knowledge.router, prefix=settings.api_prefix)
|
||||||
app.include_router(attachments.router, prefix=settings.api_prefix)
|
app.include_router(attachments.router, prefix=settings.api_prefix)
|
||||||
|
app.include_router(app_release.router, prefix=settings.api_prefix)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health", tags=["运维"], summary="存活探针")
|
@app.get("/health", tags=["运维"], summary="存活探针")
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""把构建好的 APK 发布成 OTA 版本:算 sha256、写 latest.json。
|
||||||
|
|
||||||
|
用法(在仓库根目录):
|
||||||
|
|
||||||
|
python backend/scripts/publish_apk.py android/app/build/outputs/apk/release/app-release.apk \
|
||||||
|
--version-code 2 --version-name 0.2.0 --notes "第一版安装包:知识库支持分类与附件"
|
||||||
|
|
||||||
|
发布目录默认 `backend/static/apk/`,可用 `--dir` 或环境变量 `APK_DIR` 改。
|
||||||
|
这个目录是产物、不入库;部署到服务器时把 `latest.json` 和 apk 一起 scp 过去即可。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
|
DEFAULT_DIR = REPO / "backend" / "static" / "apk"
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_of(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="发布安卓安装包(OTA)")
|
||||||
|
parser.add_argument("apk", type=Path, help="构建产物路径,如 app-release.apk")
|
||||||
|
parser.add_argument(
|
||||||
|
"--version-code", type=int, required=True, help="与 build.gradle 的 versionCode 一致"
|
||||||
|
)
|
||||||
|
parser.add_argument("--version-name", required=True, help="展示用版本号,如 0.2.0")
|
||||||
|
parser.add_argument("--notes", default="", help="更新说明,App 里会原样显示")
|
||||||
|
parser.add_argument("--min-sdk", type=int, default=26)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dir",
|
||||||
|
type=Path,
|
||||||
|
default=Path(os.environ.get("APK_DIR") or DEFAULT_DIR),
|
||||||
|
help="发布目录(默认 backend/static/apk)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
if not args.apk.is_file():
|
||||||
|
print(f"APK 不存在:{args.apk}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
target_dir = args.dir
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
name = f"dadaotask-{args.version_name}.apk"
|
||||||
|
target = target_dir / name
|
||||||
|
shutil.copyfile(args.apk, target)
|
||||||
|
|
||||||
|
manifest = {
|
||||||
|
"platform": "android",
|
||||||
|
"version_code": args.version_code,
|
||||||
|
"version_name": args.version_name,
|
||||||
|
"notes": args.notes.strip(),
|
||||||
|
"file": name,
|
||||||
|
"size": target.stat().st_size,
|
||||||
|
"sha256": sha256_of(target),
|
||||||
|
"min_sdk": args.min_sdk,
|
||||||
|
"published_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||||
|
}
|
||||||
|
(target_dir / "latest.json").write_text(
|
||||||
|
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"已发布 {name}")
|
||||||
|
print(f" versionCode={manifest['version_code']} versionName={manifest['version_name']}")
|
||||||
|
print(f" size={manifest['size'] / 1024 / 1024:.1f}MB sha256={manifest['sha256'][:16]}…")
|
||||||
|
print(f" 清单:{target_dir / 'latest.json'}")
|
||||||
|
print("提示:服务器上把这两个文件放到 <repo>/backend/static/apk/ 即可,无需重启服务。")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""OTA:最新版本清单与安装包下载。
|
||||||
|
|
||||||
|
不依赖数据库,也不需要登录——App 还没登录就可能在检查更新。
|
||||||
|
发布目录用 tmp_path 顶替,绝不去碰真实产物。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from app.api import app_release
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def release_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||||
|
monkeypatch.setattr(app_release.settings, "apk_dir", str(tmp_path))
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def _publish(directory: Path, *, version_code: int = 2, version_name: str = "0.2.0") -> bytes:
|
||||||
|
payload = b"PK\x03\x04 fake apk bytes"
|
||||||
|
name = f"dadaotask-{version_name}.apk"
|
||||||
|
(directory / name).write_bytes(payload)
|
||||||
|
manifest = {
|
||||||
|
"platform": "android",
|
||||||
|
"version_code": version_code,
|
||||||
|
"version_name": version_name,
|
||||||
|
"notes": "修了分类侧栏的层级",
|
||||||
|
"file": name,
|
||||||
|
"size": len(payload),
|
||||||
|
"sha256": hashlib.sha256(payload).hexdigest(),
|
||||||
|
"min_sdk": 26,
|
||||||
|
"published_at": "2026-10-31T02:00:00+00:00",
|
||||||
|
}
|
||||||
|
(directory / "latest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
async def test_latest_release_is_public_and_reports_update(
|
||||||
|
client: AsyncClient, release_dir: Path
|
||||||
|
) -> None:
|
||||||
|
# 没发布过:明确告诉客户端没有安装包,而不是 500
|
||||||
|
resp = await client.get("/api/v1/app/release/latest")
|
||||||
|
body = resp.json()
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert body["code"] == 9001
|
||||||
|
|
||||||
|
|
||||||
|
async def test_latest_release_payload(client: AsyncClient, release_dir: Path) -> None:
|
||||||
|
_publish(release_dir)
|
||||||
|
|
||||||
|
resp = await client.get("/api/v1/app/release/latest")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()["data"]
|
||||||
|
assert data["version_code"] == 2
|
||||||
|
assert data["version_name"] == "0.2.0"
|
||||||
|
assert data["notes"] == "修了分类侧栏的层级"
|
||||||
|
assert data["download_url"] == "/api/v1/app/release/download/dadaotask-0.2.0.apk"
|
||||||
|
assert data["has_update"] is False # 没带当前版本就不猜
|
||||||
|
|
||||||
|
|
||||||
|
async def test_latest_release_tells_client_whether_to_upgrade(
|
||||||
|
client: AsyncClient, release_dir: Path
|
||||||
|
) -> None:
|
||||||
|
_publish(release_dir, version_code=2)
|
||||||
|
|
||||||
|
older = await client.get("/api/v1/app/release/latest?version_code=1")
|
||||||
|
assert older.json()["data"]["has_update"] is True
|
||||||
|
|
||||||
|
same = await client.get("/api/v1/app/release/latest?version_code=2")
|
||||||
|
assert same.json()["data"]["has_update"] is False
|
||||||
|
|
||||||
|
newer = await client.get("/api/v1/app/release/latest?version_code=9")
|
||||||
|
assert newer.json()["data"]["has_update"] is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_download_returns_the_apk_bytes(client: AsyncClient, release_dir: Path) -> None:
|
||||||
|
payload = _publish(release_dir)
|
||||||
|
|
||||||
|
resp = await client.get("/api/v1/app/release/download/dadaotask-0.2.0.apk")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.headers["content-type"] == "application/vnd.android.package-archive"
|
||||||
|
assert resp.content == payload
|
||||||
|
|
||||||
|
|
||||||
|
async def test_download_refuses_files_outside_the_manifest(
|
||||||
|
client: AsyncClient, release_dir: Path
|
||||||
|
) -> None:
|
||||||
|
_publish(release_dir)
|
||||||
|
|
||||||
|
resp = await client.get("/api/v1/app/release/download/..%2Fsecret.txt")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
missing = await client.get("/api/v1/app/release/download/dadaotask-9.9.9.apk")
|
||||||
|
assert missing.status_code == 404
|
||||||
|
assert missing.json()["code"] == 9001
|
||||||