diff --git a/.gitignore b/.gitignore index aa3d224..f7a0ce1 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,9 @@ android/.gradle/ android/build/ android/app/build/ android/local.properties +# 发布签名证书与口令(只在发布者本机;仓库里没有它就用调试签名) +android/keystore.properties +*.jks # 编辑器 / 系统 .DS_Store @@ -47,6 +50,12 @@ android/.kotlin/ # 本地上传文件(附件落盘目录,绝不入库) backend/var/ +# 发布的安卓安装包由 publish_apk.py 放在这里,APK 不进版本库 +backend/static/apk/ + +# pytest 的临时目录(在 backend/ 下跑测试时生成) +backend/pytest-of-*/ + # Vditor 运行时资源(lute/i18n/icons,约 3.8 MB) # 由 web/scripts/vendor-vditor.mjs 从 node_modules 生成,不进版本库 web/public/vditor/ diff --git a/2.Design/API.md b/2.Design/API.md index 6f91573..486b36d 100644 --- a/2.Design/API.md +++ b/2.Design/API.md @@ -544,8 +544,6 @@ Query: ### 6.3 附件错误码 -### 6.3 附件错误码 - | 码 | HTTP | 含义 | |---|---|---| | 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. 健康与版本 | 方法 | 路径 | 说明 | |---|---|---| diff --git a/2.Design/DEPLOY.md b/2.Design/DEPLOY.md index cbc5374..925ba91 100644 --- a/2.Design/DEPLOY.md +++ b/2.Design/DEPLOY.md @@ -100,7 +100,59 @@ curl -sI http://192.168.8.188:8200/ | head -1 # 200,text/html(前端产 浏览器验收:打开 `http://192.168.8.188:8200` → 手机号任意 + 验证码 `123456` 登录 → 任务/工单/知识各点一遍。 -## 5. 运维 +## 5. 安卓安装包发布(OTA) + +APK 不进版本库,也不走 `deploy.sh`:代码发布和"发一个安装包"是两件事, +前者每次上线都做,后者只在安卓端有改动时做。 + +### 5.1 出包(本机) + +```bash +cd android +export JAVA_HOME= # 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 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`;**数据库迁移不回滚**,回滚代码前确认旧代码兼容新表结构 - 端口:`8200`(systemd 单元与 `deploy.sh` 的 `PORT` 默认值,两处要一致) -## 6. 已知限制(当前阶段) +## 7. 已知限制(当前阶段) | 限制 | 影响 | 解除方式 | |---|---|---| diff --git a/2.Design/ROADMAP-M0-M1.md b/2.Design/ROADMAP-M0-M1.md index ec2ad61..1ee503b 100644 --- a/2.Design/ROADMAP-M0-M1.md +++ b/2.Design/ROADMAP-M0-M1.md @@ -417,3 +417,34 @@ - [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] 安卓 `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//files/updates/`(先写 `.part` 再改名)→ 校验 sha256 → 经 `FileProvider` 交给系统安装器 +- [x] `ui/UpdateDialog.kt`:当前/新版本号、更新说明、下载进度、失败重试;「允许安装未知应用」只跳系统设置,不代点 +- [x] 启动时自动查一次;首页底部有「检查更新」手动入口,结果用一句话回给用户 +- [x] 图标换成与 Web 的 `` 同一套图形(墨绿圆角底 + 三条米白圆头线 + 陶土点),生成 `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`)同上,安装包已随部署上传 diff --git a/2.Design/screens/android-icon-512.png b/2.Design/screens/android-icon-512.png new file mode 100644 index 0000000..78ceb41 Binary files /dev/null and b/2.Design/screens/android-icon-512.png differ diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index a2228d9..c79b56c 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -26,8 +26,9 @@ android { applicationId = "com.dadao.task" minSdk = 26 targetSdk = 35 - versionCode = 1 - versionName = "0.1.0" + // 版本号是 OTA 的唯一判据:后端只比对 version_code,改这里才算"有新版本" + versionCode = 2 + versionName = "0.2.0" buildConfigField("String", "API_BASE", "\"$apiBase\"") } @@ -51,6 +52,37 @@ android { keyAlias = "androiddebugkey" 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 { @@ -69,6 +101,8 @@ dependencies { implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.material3:material3") 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("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4f9e1bf..172f92b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,11 +2,15 @@ + + @@ -26,5 +30,16 @@ + + + + + diff --git a/android/app/src/main/java/com/dadao/task/MainActivity.kt b/android/app/src/main/java/com/dadao/task/MainActivity.kt index e70e46b..26a0242 100644 --- a/android/app/src/main/java/com/dadao/task/MainActivity.kt +++ b/android/app/src/main/java/com/dadao/task/MainActivity.kt @@ -17,6 +17,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext 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.Workspace 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.TaskListScreen import com.dadao.task.ui.TicketsScreen +import com.dadao.task.ui.UpdateDialog import com.dadao.task.ui.WorkspaceScaffold import com.dadao.task.ui.WorkspaceTab @@ -80,6 +83,9 @@ private fun AppRoot(joinUri: String?) { var reopenItem by remember { mutableStateOf(null) } // 上面那条是任务还是知识:决定回哪个页面去重开 var reopenKnowledge by remember { mutableStateOf(false) } + // 应用内升级:启动后查一次,查到了就把对话框摆出来 + val updater = remember { AppUpdater(context, api) } + var update by remember { mutableStateOf(null) } // 有本地令牌就先静默校验一次:令牌过期/被吊销要退回登录页,而不是进了主页再崩。 LaunchedEffect(screen) { @@ -114,11 +120,33 @@ private fun AppRoot(joinUri: String?) { 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) { Screen.Splash -> Unit Screen.Login -> LoginScreen(api = api, onLoggedIn = { screen = Screen.Home }) Screen.Home -> HomeScreen( api = api, + versionName = updater.currentVersionName, + onCheckUpdate = { checkUpdate() }, onLogout = { tokens.clear() 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 }, + ) + } } diff --git a/android/app/src/main/java/com/dadao/task/data/Api.kt b/android/app/src/main/java/com/dadao/task/data/Api.kt index ee2c9c4..c88df31 100644 --- a/android/app/src/main/java/com/dadao/task/data/Api.kt +++ b/android/app/src/main/java/com/dadao/task/data/Api.kt @@ -236,10 +236,25 @@ data class SourceRef( 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 形如「研发 / 部署 / 回滚」。 */ @Serializable -data class KnowledgeCategory( - val id: String, +data class KnowledgeCategory( val id: String, val name: String = "", @SerialName("parent_id") val parentId: String? = null, val depth: Int = 1, @@ -472,6 +487,13 @@ class Api(private val tokens: TokenStore) { 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 = call(path = "/workspaces/$workspaceId/members", auth = true).items diff --git a/android/app/src/main/java/com/dadao/task/data/AppUpdater.kt b/android/app/src/main/java/com/dadao/task/data/AppUpdater.kt new file mode 100644 index 0000000..fa03043 --- /dev/null +++ b/android/app/src/main/java/com/dadao/task/data/AppUpdater.kt @@ -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//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) } + } +} diff --git a/android/app/src/main/java/com/dadao/task/ui/HomeScreen.kt b/android/app/src/main/java/com/dadao/task/ui/HomeScreen.kt index b4ea4f7..be3db49 100644 --- a/android/app/src/main/java/com/dadao/task/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/dadao/task/ui/HomeScreen.kt @@ -63,7 +63,13 @@ private fun roleLabel(role: String?): String = when (role) { } @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() var user by remember { mutableStateOf(null) } var workspaces by remember { mutableStateOf>(emptyList()) } @@ -71,6 +77,9 @@ fun HomeScreen(api: Api, onLogout: () -> Unit, onOpenWorkspace: (Workspace) -> U var name by remember { mutableStateOf("") } var busy by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } + // 手动检查更新的回话:查到/没查到/失败都用一句话说清楚 + var updateNotice by remember { mutableStateOf(null) } + var checking by remember { mutableStateOf(false) } suspend fun reload() { runCatching { api.me() } @@ -234,6 +243,38 @@ fun HomeScreen(api: Api, onLogout: () -> Unit, onOpenWorkspace: (Workspace) -> U 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)) } } diff --git a/android/app/src/main/java/com/dadao/task/ui/UpdateDialog.kt b/android/app/src/main/java/com/dadao/task/ui/UpdateDialog.kt new file mode 100644 index 0000000..4867de4 --- /dev/null +++ b/android/app/src/main/java/com/dadao/task/ui/UpdateDialog.kt @@ -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(null) } + var file by remember { mutableStateOf(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) +} diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..e146031 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..0e6439b Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..08b43e7 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..4d3ee05 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..d191f48 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..4a30c3c Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..25fdd63 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..e35cfa5 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..e793fe6 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..9a2e91a Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..9975c26 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..cef75a5 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + + #2F6054 + diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..d3d83f1 --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml index bc97459..1e8a5d7 100644 --- a/android/app/src/main/res/xml/network_security_config.xml +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -1,11 +1,14 @@ 10.0.2.2 127.0.0.1 localhost + 192.168.8.188 diff --git a/backend/app/api/app_release.py b/backend/app/api/app_release.py new file mode 100644 index 0000000..2d67655 --- /dev/null +++ b/backend/app/api/app_release.py @@ -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, + ) diff --git a/backend/app/config.py b/backend/app/config.py index 78465ea..8bf24f3 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -108,6 +108,11 @@ class Settings(BaseSettings): # 指向 web/dist 的绝对路径;留空则只提供 API。 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_time: str = "unknown" diff --git a/backend/app/errors.py b/backend/app/errors.py index 0f9b2c3..8b349c9 100644 --- a/backend/app/errors.py +++ b/backend/app/errors.py @@ -66,6 +66,9 @@ class ErrorCode(IntEnum): ATTACHMENT_FORBIDDEN = 8003 CONFIRM_REQUIRED = 8004 + # 9xxx 客户端发布(OTA) + APP_RELEASE_NOT_FOUND = 9001 + # 默认 HTTP 状态:业务码自己带语义,HTTP 只是传输层 _HTTP_STATUS: dict[ErrorCode, int] = { @@ -111,6 +114,7 @@ _HTTP_STATUS: dict[ErrorCode, int] = { ErrorCode.ATTACHMENT_TOO_LARGE: 413, ErrorCode.ATTACHMENT_FORBIDDEN: 403, ErrorCode.CONFIRM_REQUIRED: 409, + ErrorCode.APP_RELEASE_NOT_FOUND: 404, } diff --git a/backend/app/main.py b/backend/app/main.py index b621093..8a75606 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -16,7 +16,7 @@ from starlette.responses import Response from starlette.staticfiles import StaticFiles 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 .db import SessionFactory, current_app_role, engine 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(knowledge.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="存活探针") diff --git a/backend/scripts/publish_apk.py b/backend/scripts/publish_apk.py new file mode 100644 index 0000000..9e89de2 --- /dev/null +++ b/backend/scripts/publish_apk.py @@ -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("提示:服务器上把这两个文件放到 /backend/static/apk/ 即可,无需重启服务。") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/tests/test_app_release.py b/backend/tests/test_app_release.py new file mode 100644 index 0000000..9008a6e --- /dev/null +++ b/backend/tests/test_app_release.py @@ -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