feat(app): 安卓应用内升级,安装包由后端发布
CI / backend (push) Canceled after 0s

需求是"改完 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
This commit is contained in:
Steven
2026-10-31 03:15:00 +08:00
parent 35fe4d92cb
commit 7668799040
34 changed files with 937 additions and 12 deletions
+9
View File
@@ -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/
+61 -3
View File
@@ -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. 健康与版本
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
|---|---|---| |---|---|---|
+54 -2
View File
@@ -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. 已知限制(当前阶段)
| 限制 | 影响 | 解除方式 | | 限制 | 影响 | 解除方式 |
|---|---|---| |---|---|---|
+31
View File
@@ -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`)同上,安装包已随部署上传
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+36 -2
View File
@@ -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")
+15
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

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>
+93
View File
@@ -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,
)
+5
View File
@@ -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"
+4
View File
@@ -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,
} }
+2 -1
View File
@@ -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="存活探针")
+86
View File
@@ -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())
+101
View File
@@ -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