From 7668799040dd5e7371a903851d1669eeaccc5f2a Mon Sep 17 00:00:00 2001 From: Steven Date: Sat, 31 Oct 2026 03:15:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(app):=20=E5=AE=89=E5=8D=93=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E5=86=85=E5=8D=87=E7=BA=A7=EF=BC=8C=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E5=8C=85=E7=94=B1=E5=90=8E=E7=AB=AF=E5=8F=91=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求是"改完 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 的 同一套图形:墨绿圆角底、三条米白圆头线、陶土点; 同时出 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 --- .gitignore | 9 ++ 2.Design/API.md | 64 +++++++- 2.Design/DEPLOY.md | 56 ++++++- 2.Design/ROADMAP-M0-M1.md | 31 ++++ 2.Design/screens/android-icon-512.png | Bin 0 -> 22077 bytes android/app/build.gradle.kts | 38 ++++- android/app/src/main/AndroidManifest.xml | 15 ++ .../main/java/com/dadao/task/MainActivity.kt | 38 +++++ .../src/main/java/com/dadao/task/data/Api.kt | 26 +++- .../java/com/dadao/task/data/AppUpdater.kt | 140 +++++++++++++++++ .../main/java/com/dadao/task/ui/HomeScreen.kt | 43 +++++- .../java/com/dadao/task/ui/UpdateDialog.kt | 145 ++++++++++++++++++ .../res/drawable/ic_launcher_foreground.xml | 26 ++++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 2650 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 3499 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 1772 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 2380 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 3568 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 4744 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 5788 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 8014 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 6987 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 9710 bytes android/app/src/main/res/values/colors.xml | 5 + android/app/src/main/res/xml/file_paths.xml | 6 + .../main/res/xml/network_security_config.xml | 5 +- backend/app/api/app_release.py | 93 +++++++++++ backend/app/config.py | 5 + backend/app/errors.py | 4 + backend/app/main.py | 3 +- backend/scripts/publish_apk.py | 86 +++++++++++ backend/tests/test_app_release.py | 101 ++++++++++++ 34 files changed, 937 insertions(+), 12 deletions(-) create mode 100644 2.Design/screens/android-icon-512.png create mode 100644 android/app/src/main/java/com/dadao/task/data/AppUpdater.kt create mode 100644 android/app/src/main/java/com/dadao/task/ui/UpdateDialog.kt create mode 100644 android/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 android/app/src/main/res/values/colors.xml create mode 100644 android/app/src/main/res/xml/file_paths.xml create mode 100644 backend/app/api/app_release.py create mode 100644 backend/scripts/publish_apk.py create mode 100644 backend/tests/test_app_release.py 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 0000000000000000000000000000000000000000..78ceb41bbee0d86b1b2b4be44d3a4a86d4bfb769 GIT binary patch literal 22077 zcmd43XH-;8(>A(?C?Fu9*{JgztB)3C8j3^0Dx3Q`KdMl;D8@- zfExthpCj+zXaLZ~Q+fJW$2(v2xx-&0E@3Jt!ri7$(v3N#^%wDby#HTV;ps zKQn17;T(;GFkcQV~A=$D)8>W($RJ_zJa9jHVzG(!$7KF$ih zsj;85pyz*HbMb^ABxG?l{ZFmH^AYH?KV7qDFQ4{)MqNM~Iu-Z>n<#|M?%E;B{!W^; zeHtJ4^!^-z{Oo--oP()e^&qe0h8{gDtnuKtu)iW;nXTEhoA%?9b?XnEGjEf&dn)D! zO}n*EPUpmYtBl;z<#PH-gsjbBek}J${NA`=gt!uDyd8=|D;q)M9RIpp^azH?z&bYGNV6d?)97?Xpn2Fb9I*TH9f`wHLwN^x7A-%gX~n?oI8JRZ7AaYxK{`%O~O zoa5I;Q*Wmny0V=N&0<>gXKTej#UHlyP$oZhdva3zOSWf&-FH9Hn+fShrMQph0}w_r z2LDw!OuNfTtn&Ewhwt*c62);2RP1)`otlUm?1t^=s2y@Yl{W@CI6rU37)PS0=+4^e z`Z$>tVjYuF0;5YbsuUNw&6~=d8yZ8>*2D1ZGEppibv=|H4=pl6(L^N}ZbqJQPbJ8^ zK#lQ3c5vja@9QLYSH>-t#L5n`M5mXUy70T6Jb9wE7^oyPXu$y#WyV(hL>$E%`P!K3 z{7BtwH(qNrGJC2~_2JD1d`_>`Zt|2I@GxNB%6Wdns4A!R>H-nPLRVH}Il9zeHFt)Z zSV|0fMRL*vB@zw+gp!jsEn?nb^jT_4#S4x}zqmM!W;kW7)kdm}hdLj{#!~J<+$|K$ z!rJPMGM;ssd$RU0kGzp@xN`g&R%CD$YuMqXLU2-=MI`(lFXP$EORK`#Gq1LKw%z3j zd&Wj>ZfD~p+j*~9TC0Zf=s$Ztz+hR!oR6lk)*A=LkW^*9K4w8hj7kbq-Dwg7Y}Wsg zDqckho*6;OHmFJwpGxFbfRkMRg?AlIT^p^$G^dNqS}tAyAfZaI;43t5o2Nl{+5PE{ znVRpR%xl<1nXx6lZqaZM)ra@Oa6GemQu66<$D4iqLzul%{CM*bu>nx z(X_`s#?dl3N*12NiEEKU@!6h|&Y07Abn3^a%VwX$2)hWW9|lf?5UY=6u?;c_b*y9fc>TMNe4~(Mk)+R>l2Sy{4l$2E$311@Uc4p@IJ>;*p@Vl znV@G_7h1R6cWo?NDLB1cM4W1R%(VxxK_0#MDFe|&UNWstDPw+n!#?wJJbPX6xyMM? z>6f}73$TSPgnKhgc5;5^BiCsV6t7il@}H{`pOF|1puGSU3jh zs~=XUOkz-OD{4O@tXMgTKTZRXg^O|byQ`Y(+z)QC*~i532&E%;3yzm z*yv`KDMK8R8}*oasi#q-?E|GH`PDO$>qpTfqPGtc}Byg@!-g z&gDzmm(gCu(|rS6AV?3L!@Q42dkR|noI*s+r-a{t@R?qBZ*l<+)j^R8I#R*J;X z*~78c=PG}R_YcesKM|HckEM_UDi_1#%UFw(c5bo542xeOOaIpI%F=|(B3&n5uVC=-k)ivHmun)qfsQ+-SLk znt{qQCpMHEhftw8FuP)3_&leo#rw3OH#aYV!DkJpd5EkDTfe_NKZlgjrbkP^q{j)C z_#g=ln2Mu-C){BP1Cx)^(sj8LPttQ7_yPArm$rpP1wmIEbRi8k3Y@-C%=i=#)A3PO zl`mi#=cRHJZTpba=CeSZQ(XBpPm+nw-=TOTTZT;r7oniL>5pp=y_L`MeQ=weP%(HO zxv=e2{b6b6{^z+w-GW5wkkTr0;NZ#bxvZNB6pq3{5YaPuj^T=s(`~pK%i#|491LO| z{gx?U4igGn%K9(mhOH*8C|Vku?wmp8V_$T zCEZssNsr?`hO$d!(f}FmI`{9o zqfX^1S)c7_*meK_mk~s@(Q}JfT5k=~W|lpLKL5mPoKs*4B-x2s-6Un&A?&Skp+r6? z(h=HsKg0#L*mpf%)`o_*-!ICR6Og)GJ`E3(9&zx17{>r2Fu~l$)y#^;wpp{qv$o2X zuX4bAb3ERH1)HZ0`h|g8eBsZXI8%?8lxFpL6He=IX&PHOhej>bOm$WDcL@PTe`9&d zj)kj)g15VdhsTDF8*vNG{lySkYAI=H`xXqQCq&jZvk0Cjgx<6=E8_S)Qmlh{r%}F(}geum`XjptFO(2(yv0#tR;T- zDrq_%+qYDUCL3`Xp)u!1bt>mvU5>Q+1>U+7T{G6boD?-U8Zi$J_COJeKrtd@=--P_ z+o)}{IlN=KBaoac%IkYN9nM1?gJo(TTBy`6qJu{3!khs3<=*T#sHU!A`+#iii!0K( z%l;e;74Luk^UxmewUIQ%4$4}EY01Hd)R+Mb%jZTl3MN;jHcLG`3| z$*g(5JIcX8RR9ry3FT)o(EGGRgHr5xxkm`BHMBHu z4cT&*7{azYS3K>hf13ycQ`%ZmmyL(1ha^$wbDVcUfh0o(If-${?@O9M6H%47E=5sQ zQ74HIYsP%%u^bXro2peMEj9`M(UT^Wb%-1QNIt)MWbVLVbYWwZP-}J^RTI}rsQcV7 zg=vs)@)Mnb+?%7!?^VOcE`oTl?L8$>4tEZyT45PaTcyFeFT#awtg37NCod)Z*WX_+ zj%wbt9~3Mo>_^xx-@3HfB>~o~Q3+3>NhmRd3;m|ZiVB0Z501biY1$Hlx`)Q~3z^vH z+axR;pxE_-M9N=2klPlHB;=NrEYH2>#_n6j1ds6hO&@&Mqwlg6bx3HAY69Sni_e~V z5w=x5_gQNyfvv9SiW8R?IUc#8ztp7C-TP4TQ^aFI<`)YvhzLVHLU_<0ikwu{Yem}V zw_~60Uk8dOsxE&Wp?%yHA|9xOIDQoX5O~>e%J&7jnF{-!qB}6@JmZxfZ%*`VPpHR$ z!Wl#y@6o$xh1;f1G0N|ej;HL`(k2O_Yz1bC|mC zdYA7jwcnP=0jyTjxCMDAdBg{LD#J42=E}?tZ1f$PMj5`_jw%IJzh4{?0-Y?o&&q2f z+ia?Qhoit zMaO5)tc7*^QF3yIzs7GCnK}^=hCl@;NyW-T9_>nlYKSyM>0J*lDrOt@TTjC9{h_`J zKtQ;eiZOYFX+nmFwvUhHV$==Y6>xxzS0dE5YQHCb2nx9qGp3~a0#bGtSNuQM)V01j z;}c#2z+SX4t~Mfyp4+NGu31e}SdWf5?isn9bO~+jIK5r?kv{4-Xi`7Nai2!*_dDgk z$-c*+@#rtUex0`5f}|^fnX|XRZQ zvw!V3w)@q3bWfZqHBIQQ_13A?3=05%U=7ND-6$6|m}o?tka!Q6K{+o!M;;w~%i@TJ z3E#yv@sh6owf-IeDg_-vOSlNPBh^eASEizJm%7b`sSI(gI9SPNT|!7Zm}yVj@2jjx zu8N?kE+N2#RKY*}zCdKN<@5tmFJEC*`Cu2%T*^wNi-eBJf#yJ7d>xMm8Kp+hb+v8c zgq?c;du!{8=z!A}e}wM1Av8R50~8T=`p$diW zxH**8s<2FdWuR_%Dr$7Z$86C3EtS;!28vTJ&T`baAI&XL{zn3Aakgrs6O!jw!+b<= z4ONlw^8iz$hfRD1qe8w{xPY^>$UhD81$Cj@?!5R3>klub>_*T*m7EM=f+YSq9hc?X z=yr`Liym^ehP571Wv_5lrEx--Y&O* zb&%1$0~yPGrN2w(`;5Nl>TVNFgo-l*75mp38=EWJF;tsrFX1n?nn3?^w3GcxNV*!f`MF)l0x zlT`?+THqq3Y)piV7=4WC$6-|7J3;;AwrAX&!a~GZLGcMSprt4aL>HkDiP=fb=G=AG z!o#sL*WlF^2qOi>GFBKAM~&r+VmFTGXnm|<^|(8SH=9A$V+|U>c#eHT;_}OH$cEs0 zjz#7o)OKoP6NQ5H6a}Z-F{i68xyVi9kcsc*Lr*=^$a}CH;E0ux;$xo%{*V;wO0PF< zmds80V|xM?WZx3MfB#F<6~J-A)j@jgKm;AL zm@0FllfzezP-EWX`Zed$IZ*(3q&zE?+{RfjI#wcsIl5<1GJe@PE4%&nz4v0TISie( z87TgoxP%jQm4k|5W%S9Sigtx$T=-+qQw-b*Y>h1+>2GqYac72EYmYT}nL@+<+ID}g-c(7YK}2<=_tRwVh61Q%zlJlO zFN^mJvhBv0VN;2&%g+%Demr=Uew!C1>9tEWoBd@j4ZlaVuT3*U#wKh`uSn1kT7%lV z)pIHinJ9m&8sOR7b5AVXT?oDa3OJR3vPDeN|MdF2{8GTr2Px-e{R4!)4xbj*CKL}@ zPB5TsO(}@NzTvH!#zoJeRCac%zJ#U=+}xXMeOK?PaCb;S7i%A9i3bBaODBNkmKj28 zo1mI9kQ{$2x9(=Cia)ej#@GZ5C=ZpjCID?f?KSe^svY+WymB5Ro5k__%3J! ztwXs}%zfJD5A?1+F`IyA zKOj;+2bN3+D#iZUYPLaKISC!NONB}T1@XivHX**FVGZB67uSfN0vGGZW-!8Kl>ZAfg z4#<1`hIsEPBFeb_TvSC_B?-=Hm{4^Jc~z?uUlm@jjEo0BKP`&V3_tfgNP?HGh@z58 zEe98zXaPXYlu7>6ehjoJwSm*DEuCA#i?H;cE(ZgFCgw%2&*z9L_#z*&L1aA4u~JNO z>SwPV%P(D64*<-Y`8e6XN$E-ZvvQ&7yESFkV?K6%owyA z^j`5_b7*p75R^YxX$os9eRaJ?%xu+mVdHuwyMq%>O5%~81)7V%ZYqL(Q|NT>0d(bSR8$lX9-8#Sk@CK)`y*_}iz`sP*F!HMn@YGWOcsF^ zN1rGetPnh>e&>pvKQ#t;CxwvvTeRjn!iDs^lB`H z(h`%01^0*42Wl8++jZ;x9gdO;>=C&8GNjfY&0X9&G&RBza19G4gQq}1Gw>QNf)_I% zin2V~#AQ()?mlF&XH1lpnMc&qJo~Re`d&I|F-O0xnv4FTh!z$+h6gUe z4vj_AzLK7xQ2ZIFuHrGsioxQtGf@#_^13^5lCmb|L-O$hEKV-CCXqDelSnf}6uvts zFRR=PAbMg*(TV|gu50@v?pdxPj2Hkk^Bl;QS@invw)g@LaL@=GIotf9D|QsD0MJQC z3C_v&6CS8%#=GDMtRc$agHE>pPaiMn!f@BrI4@1lU=$_TLtacc{Br&{WL+Ky9$*nh zF@58#->)l$D*Y|nmSF)4REQ{nH}UlE@wmT(qE=l1O&7bS%!5er>#<MxOKpMXBu8fbEg^AY!w9Sinrir*CFq--_2|IHU1zzuf%RKDL) z*m&6kB7K2ffl0CYCkyujyL8y${oho5>iu6U6Cc#P_E(EbIm(Y*vuh#Wva1zWX)Z2q2%{N?T2wo!q|QOr9la|?4+@TQCuSV+4a811y@z`{DtQc zj$CKB#%Gc&$+Yn?5wg0)wM#;D{PA95WD0?TxRKC$#Yb8XLW%+nwvitGYMGzLZpn)e z>o?M%JUf#Iq)cZ^k9q}0a@!j_`2#N`a@YZfgRG0oDuYKJ59~1V@u%d2Q)ynN&gPBA z0vBFx2t3w~EUax2wdAEuOnTWt@YAD1!ulj??7DWdL~a^qF00@=r z#~r+Ci09B)+UHGfm0(S|nPAERNaV1=`radLj%y%h)IR%^)5>Fx{;ck}*%U7VqkyHa z8t1XZ&+0BP9tXW+G#sBTHb9qG>MLaCqY&71!u)`P2myBu!(+{L??&p}?_^I-`0o*7eU|kpW|kvEQY5azJ)hwNBn6tPyOTC;c(0zRK?8-FU~4)*uM0-*f8ym*hS+z zz#rp8dL`i<#rrPjCzr%uz%ELUPLDo{eFN(4HcagT#pJMs{yWL9LlTi_f8ZdD4xVDi zhr8yP5pmTz*Xiw}@I@tx16a$PFA0!y|GCSBbd8m+_IasHFmXXn2?UQcqy5ltuGq=D z(qa1*+NyTLh8yM*z}h<-tim_e_V27LS0VhJOs9W%-7W}}_b4I()E_F97$vdM9kv^Q!yOKRzTI=0duo9h)!55%1fzKJW=>sPqt z`Nf_iiwf$x)@)R_5Z$xX=k!Rfl9&nHSA{P2)`x2^!f)Xl@c(IUB=7M-cNj2KA8%|P zo|s+<6`pTaVvR^Kx6dsoDS?LIYJ$=C3aFm95>D%huIug&G({p?ORgLU!-I= zFo>?6tw)tqNtYqs779^F`Uh@S!=k2l&_31|1$LOQ%Q7iSKutx7+!s12D9HSgHZ&ZT zol^5AF~#NJu7u-XE%S$3161S}U+O+K7uq$QRHp7$mudM}^jPXw><110)l9xzLu2Kg zOuaeqVF|$}Bl9Pon9OO|qX9}_(01zA z%Xq*yw7v9XH#PjsSb09Ekqy!;OZ4(n(=4m9618Nv*%EoBCWV`JZGIT;<<>U(zQxh@QqM4pHA z#>`qg_>*xgxET9fnVL5KZw3`m^0D*eN;78Q(%*Gbrlo(`O|=|72aCDMCuJC(0;{DJjJC}CuFu&& znl8x$F{?jE3B5c#zoVpemi3@T*w8fc+Ce!sT!oaQF&@L?BMi|&D~pgV1~3)6TYi1a z)pv5`RHw{$)hgb@7Jw_H%N%c;G?p%c@R;oCyepXM8Q?h@s6x3mBnbP|AOQ2Gt*hB*4Z*NN5zFx8*Kxz0{1c z^0Fu0EHEIE-&6W2fOzvNJ!W8gf|HB!iuio0L+;*cTJ_WFi0vCJxO)f6z%4~>$<%yy zEPucfRk_)$dAWmoNSoGWbe>*D>Y8>epXQzDcbD6DSYqyRH9q7hPa4FTkTSV?{eWpf-ply%m5A_NmW(Ry-KO~# z-=AhDxj24LaUvIoU{$&#Oqj~90X-juyXf<*R-65FKKvB74I9@D7p~5yvDtu`CKxZJ z(c|mb3Yt_-CFW{)<^y-q|I7u-fj#X8Ay+jkrTElx>(#s_dGSHd(Ih+`nYUWj6k8Gk zHxj@Y{$I&JhTYnMZc_I?Nknvz$@a}oI!Lq5b!aBPSm!?OHg{Y7fCsk$+)30eJqYF_ zF+qD=9JOk05PmRjJ@FO(jhCSmVZ=-Qgq_tA7<3qLO8vXRy?;q?5{$<>-m4m6j9X8{ z4l|1YdsIGE+Pt5qUC{kBo8A+z)R^8lYyWc0v)LDg&jbm|yAIW9d*TMn@vGHNB#4)A z$G`N(kQzjI_ZaG%uE7zp*=ul_va>RuXWxyGJ$_oN|FPIcUVP6a`cYIzjXl|_k+Tr1 zfGC(TjYJ|>e$7>)r-7VU8vZiGn{5=KLX9dt1o9G$+YeGHdGCC2J-ymoN-W}6rfmNW z$>7$Lp(~YUlQZ>;xE&p=DBsK+`A^~yPmF(^!5>sqohqAZMm&nILQmt6H6fpsaoCAD zMqV)czVHpbH--(8Vf{BD(Ec|eut_6ZyL`(#195J3kc$*iwE^(9@Vzcm_;Eobu(U(E zxfCbmSinM5XbcG=`#xa&9|=KFJg8c?{~;yFm(NwdvQzI_Sxt`}2sqpjsCbo54wQ0% zY8f{o7~Ixoxd=ff+j=8G>NIpEXGbNenM|YJN-HULTy&Vk!i7G!O9JsTA0ZcQ! z5Vpj2e#jrFYipzKt181on8BWNMe^a@?U;A|M=VXOUVq$bdbb%^gYZuvL;z0eN&3h* zzgMv#`!2SZYk5CcyvH2Zt@ZwwMV+DWtiS-1euf10X(g{tLOHC>IYx8$hA6;e4!J<_ z7?-7d5Zd}bj$|;X&b9qsd{^?EbD(v$q?jO|{-WOK%1}OI*_X*%jxT0ImLFb0i%6$+ zkI%g>N&@M|En)Be79m|BQA{LdO}ym@iQ}cFgU+Yo2@|7vF62R%ufmYS4R6<$Lc_ zRFb%M@{v!@xsRgLQQ)dCKM^G9`o#~A=frUWUx`nBipq2m8hGRCozhJq7K5+#Fl16~ zg_f%krTWWzU64<@8spKR(-8GVkIqZc)0=zXr*d_za$5hw-W-h)M^;lB&TV>?r-yTv zo~$4?0L-XGl@=szOMKa*yc3;-B?R-jGix7qphWe}gx6~vtuj0uPhe6Pc(N=EFVkCQ3T(-a&FDE5pds>NhTDr=i-K_ir1^9E5lh z-lR*(x9QH7`?0S(?nq?4h@;9%>Mi5L+d&_}WF&rURc9h|XBi}yODM4IL;h}_@=qB& zSy;xGzSllnrRiw7`3q`u_jK&Jt7*}FP0My>`Aj)G7r8*ldj zw@i7p?#;O@KK{0sx9Kx4B0)aqrgeCZ=#LY6E~Fu2%p7lc50MM6+C_ZhOdX#o|3tDx zObpRg8Yg|1BF9}T&gH$;eV({<9pB~LSmwzU_H|)N_Fu#D;_MIrPIsePGIN~x@-TGK z^|6gkc!aIFFY=Eb&X1DO8q^XKYuJXdbX}9v>s8WoizZ+*0?+1^%VVoUVju zaks~!+Z*ZIV&xoz>;JrF4QDyz6xlwP$Li`czV_5T-t4IVfO%SfQesHpp}$yL%ZYW{ zy5;kTH<;k*Z+!P+jpS4ucw8ub)@96M{-vB-8VS?o(Y7b|UNT=FJ;4=SmDoHKcc5$X z1JUFP@8j?9uay``0AnIWRtvDP(0R*>9MX~U2K-FNgw-Ro{0IYUvH0ij7xWU2StH#! z1D$}_G zL=S2C@WjUHFHDiPfUgn%FIfIE{I{xdq`Osc8o#`=4eT-;Fe4A*E!<~dIXSnB@GpX| z=13e@4ZjbsRLu~(Ig9}-9TN1TPf%`jK+X%fp@Hrf1L2A>>He=SSd;~ZpdTZ`ZC&-} z#PZfFfhl^>9MoK(_9rLfCr$O^sq@%_Bl`6!bZSd*L)7Nj$+hpOiGrzfq->n-%x;6# z4;ON7Pv3V{**$r| z`sw5vPsKQXGWgXb@*w2BQvzD9wkW12x;*7IBYDpQyyTPCT|9u^C`)F;&`;6J(Wy0P zX!G^4;_R?6r<*$Jq)Y6&11Tk}ZeZ>s<|K^uGb!=T_eRet*p()eE=_FIIO%imWd`hB z62MVw$ni&Y86y46CpI?)-a0LNg;KfJsy}hLry_p=E=8nY-w={{-_Ku%bOw7vXQU=e z;XU@eeLwQ1v+*?OY~X)JhG0Xr0@|bDo_KldS>xKc*v(KXp$G$bwE_qe1GN~S8?ajA ze#v62yXMQ}!R_7Pgdwb2AI@4_cLniVOUTp>5Se}^KzB~qqI66w|}NTWY8HBN|pqDD=RjRbmcsz193ixH0g zVy_&kZNZtb{10>!Hv3@#(#g_sv^!^1okHB$MW{RSo4 z2OI8X;g`sxD9z4yJ|SJzLrj!Pg6tP$xvCQN{xAQ7G~RX)<8tbQg(HRsF9|Cuw-ykL zOxeRuMtZJ~H=SufHaRCqA@?dG1t|}rUij;KO6@<>OLZ znGBnZr@ls3(+LSYz3GiTodQ`uzgw zaa%9%`XGu_3Wt_xS$XlaWE*~b^HYN(?#&K^8gz1}U@>g-zp$=(Bunwo9p~w_ANCH6 z=zU(bk+SJDJ)C!{y(_WUu4>P&{}UuONrfU1@D`05 zPH$S0ovApkQkOTK{A*7nNm`GcQZE}1DAv9@f#+gB!84`-`+&#M$`_ezCAAX7|9@EIUYvEu@I8 zJsm%V-H&_Ihaz>IdqNJ9pI-}(v0X=#n1&JH>p(g(_AbTbMrP^FW@5EX5PC}!4eFMP z&tO^=MIUK~IOcxS@B;ogPm^=>4I7DqC8wArL!+l>P7w9_>PHOG0+nQ5my9D(rlxsH zJF}%4j&+OG=&0tYJGYEgU^P?cyCJ^CAaRwE{gn9Ozzc^6^MG?{Zr=JQQiRGP*sM zSx3>&tX}PY`hgSZMOIP|T>MMRm;T?hd`8bZ)4~ z@PjZ^512M@^AWehjZVkdAo3X_7&kO}x!=QX=_>vFyENv=Qo`QMk@nt-Pik$9(iPe- zSzJJ=I$|f1JP+}pV^7L{%qATAI zb}!^+FI(dDcKw~UE>tC}lz)K8mq~r>+zakteVWQR{dx-tB>t9yVe7_Y!NukBl$s;8 zx}$d_%PAmdDDll!I>t8XBi6S0v0tUfHNs#1^e@8yyZ>JZ9~lzYJlm;6kQU#r^47{q zzV}7Tog6IH`R|j~7HWA<(@N^x=J|W>8+zoQhSnEM3k}(kwN3~gNldKkZ1h`KR9EOy z?FXd~5$iq&=B5(krT3D?n~aLO|F^?|U$9*(oIi%#-O=BBD||ktm-u$*y&j>X;l@RM zc%L^2`l^Iz4R$NZ*gqo-vw?g29dBlykbl(5*STD@w^=+1hm72Q|)J7atmF}aVi6)ulsB;X$o4tp)!9@ ze*AlsP|J7{>7+FEOY0xXU~qU+iX~7yso8uke>lCcGC@9e5fZ-ow=$k}@BC^9hNYfH6rM?QszdU7RSmwMm4dkar| zUfI@@I+|8`9KDV`xju&=dlbe$MC#6AAfz!i_WZzGH!>^J;QmdHU#YVzas%~xJNa=f zRV%YY%=f+VBmL^}oqrpit7LQC&Zo%8i7@G5UR;VFjh0@tP_f30h=KY0l8LbLRpBtZ zW_>V+SyD`agKYFPccVE2YuBrP^I<8!-*y=fPe0xj#TorIlK;(QamiG)tXsKo3Or%~ zL~J1tFpIRj#1+gG!~~+3+jw%S~~OCes~qVL*~fejND^r18l(d2k-A*zf`@$ACBX;u(p{Dlzv^Dwbb(PtB3^caK6t ztSesBKd6?thFq#nz|5UJIVJKaBBz!Io;-wV0Y#M_<$`xjnWUbkc}9JmP@KF*Li2C) zCt3Q559B(?qB=#$$y?qumH91fLfw|ftF-k9%r=l$*9tMQW*wo2|16MUYqrUF$pP{3 z6$P1_xkTE?>4yQoRo!o0kJ-U74||PXVpw#0;*#9$M>$VFzU;RgNS8=^<3o(jr5D+C zqli>UP27bSC&b5?Y%c~xm@~V{i*7C2M3SLnwE%DeZMzs)4$S-9>E~M$@=-i5;ZIo3 z5&hagJ_H;0IY{7O*A5S+4etl*l>tHls=ZcMq`gO!e}19UvB4QOkC=V{MCQW-I-fG5btM~ zy;tEmGLWbQvS`hZIUq79)k(uTTlcif9G%Ez87YOJ`GlI|l}Ajz9?2g9>&Hqw$m++i zjR*2;d$}W+t;1b^(4?Gii74BKG*2l1AgM*_epU6Y=_&ut^GZ zA1;yrpXsmM=3TnPwRr5yby6kt#z0O`x&?CQF{r~44m(EH?>j$^x=IS}fd$01m!|E4 zAf5KWkG)Efc#scYFL1bykp8UZ{Qi;TnR%UNnu`nV@um=A7G~$k_4_|06voW*f0>+i zZ$`C%tQSaT1v8qz?4no2)ep7APkKBjQhmJcj&?LKM9O&hnrmR7%JlxAXL-sMr^Aw!VKC}cV< zf7F@4VTK3srtP!3T-|7d4W;evFQ^atp)03hd{%0_)cbKkazJm&$_*nae6jb1C*)`9 z29dk5sf<+F2l=%ROTKJ&HA@?sdetmE(az|SKU?hUw`erpSw@r;v5c~ZdaOK_d(Bb+ zF&#fne-8Cwr;r_u_+uECm~ICCwd%D>Hg9D zx8dYQq4Z*=!0@=7p%Kq?Drv-LhV&9)%KStlR<4Fql-^>4{ZQh!hr$4ObpA%3p50UZ z2D6$+Y?ExXW>~POUqA}MZgltrL%Ka8xY=~5=%b0#8mImAh(as67arI&w>-zXwrWhf z`5Nl9M7O<_A*^~63(2VJf@{oal8yC6Y}Q{Uh?{TBUKmF(4c8f}-{vei6-Q>KFU-jY zet7lWw-=^p^}br`f*ja}=9@XtcLjy7ow^S=1~LaR`(+QX@K0OpudJh=QUDpL6cd6> z3caT*Hg?a_65~p4fLB@Gfkk$R$?wU}62Kx)R`Lq0iO5NZ;&7iVNn`F*wkTM)oIrs> z^Hiox*{70m*_m2aD}IFgCky!To*9~w~J*RpzX z0-^c)oPr16M-}9&alm?5B z&H(+Y6$I@JQ8N!H=?q(&Ct{y|^O%YLqE(_9{p7OGxC3^Ust&D3f!(p4k&}mjdIboV!+llF1ie1qh4h09lA-#W z1b7mbe*3T$%d3ZEEj;&I-96~HtI0t3gRc(`iGYQ^fw`$Sf4X}8+1~obV7C)J$UyTfispn&$UYb=NQL>hs5f60c$ut{`~dAD z<8T~{02#kBS%3S3MRxpkdRi;YdYbdHojMKV^`Rfom-b&Mc4x4oO1>{T*a_Uy_TWo-`9pvM+kgihrpFFmD`K6kde?J!S5@(Zgj%E;cWTXVR-zggG{LB8Y|4EVCeLGe0v{6%<=NpZC)&t+TXkOO4V|2821&gq22sXe(p zc5|`GPD%GC&y|Ue@*@_|Y^aZ}#%T-7^q}RamB!<%^zNYWd4%{r%)=(90@woGkQuej z)+pHGOx|m0+Y6`Eje0&QInX#A|GoeTm>3p@V@QsNu%hBuXn95KhS}I&B0lIBaFwux zDZrK_$$SF%)l63jyOVMKbHN$nX<&Abk!71TwSp;VBbFb>^$G-ELwA39F3QZ~Wk`^e zo@^NIbPZ35Kj8)bpDxZkoUOEN;D=gXr5m*~YH2%a3AK+3ty)@3r)bo^q_st)mZ1cp zs@0MzUVBjtszhpEDxxK!CDgt~tV1o0v2RJfW9Ita>z()iJSW$4pL6c}exB$2epE1_ zXPgs*YYh8z4~?Q#3Ry9^Y$A5e_f3w9rwdqGWkY1_DVIb$@IuAuBO+y$Fn9%d%9s!8 z%3aU#ZRvEwK~oYfEImTiLkOI!s3_yP$VwH&r<+Qvz*uL#(mOgm#vP8#bLs-RNABNWx5TlKQn3u=4t#Mr_9qN}d8m6ZJ zasU$Q*iz}buw8#_tyo|QyfLDwX)n`5^>cC;)H!{~r%BD=4j&j#?xr=!t%7p< zL((_`wV#uppYQeHLA!+syNc7mmM-xeWXBL1*iy;fY7S0{`%=3Vwp7gFGSQ{@Qw?rF ztB%un=IvW`hLxka44rXZ@MrLt+ja_FQfUMCnQP5ka;;;m`8A!{;NgtMhepCNp*2lY zVuAney$T`!diGzyu_7k(=c_%3CAS%|q!7ul{-vPvObbnFw9a{ssqGNjq_Xm1ptuQH zyzRK&9bN~iM35d7vo0xHM^%aHd<}W@NX5C<=U&tNM!fN?yML&C>4U$o-1X;_r+=kL zQR-I;0#A7OPSpBg7Nv9W=>2AINPz%rifIHZLsk#_6nr|K7dw8HS<)!`E_d8EWKMPuXk(bT1jfWc*VybI*XHs77yC)lGTn-V6L}I z=!;~5>s)fT@em8#X3ETE8%5f-POwa@mKzIT$|dBC$C^y!adO>-THG#bfaUIe9!C%v zm|mM9j2Vb&+p#&+DQY^n?4$It$$Pc(p5RLn&=cS5zP9aRh6(MI#Y0h_o@_@pg)xY4 z#+JV=ng~I5z-(zwGD&afM%I$WoHjFxFd#3qGfNn_|4#KTypU{)O|AazRnKbe(hx4% z(6BEHfeIgNTuof*;CdbgoaSQ(=CNm}%;eF(+1G&Lx+9B+F!a`nG*=VOZB3&-{3EP1 z94apar|zg^)4+0$E3Ui)e@niBeIX`ZviUYI_&wX`0!>0fBH_l}>6Z)oFL}R6{hoTo zC>lI?JI^4IZoGjZ^qtWZim>`iX4P2kITT4gB64WIEa+vMP4$*jMVcNzsj`RTS1=bX z`GAk9>K%CAF*J6RdR#s&;%*ZtjE!LR1)W*PF9)oJ@w|>$052hAqSl(ED&{8A^ zu2+@+#U#n$U@ULSh+kyckL^61B_<~sh~pbcm+cu3a+AmH9Q}MnZm}r!lxmE)Dt)PHd2S(he;X+&0G2PaYx`$M@U@2XZQ$NWLt{@BZ-o0)Y z>ryZF_8kxzQT{|EfGUO>!P!pJy1kA3*jUp@H{YP}Vs38H!jItH^kPlt$t8u16%Ls@ z*E7eHD3sAl$loKtF~-JMon-rXVK^HmA;d56-|b1c8H3BHQ{eVX-+e*%v_R=+-iqQr zZPT<|9)r2=sMj3fo9LA?K^A&ypK{k6tlD0i(^81_-E56uZ$89%My=C2s`p~ltt<?)xlt_)64h}@pPCK4n{iW6uzi;H@;_dQdsi|aSGt=o zG9AH}c8(w{t9(UB*0z+%AdE~Nd4B9DupAhR4!FPifv@Xpssc3`^jC-p7WD3^3oO1- zBL@v`aDjwoC_SfkI7>UZJS)O?tyVTq@~5Bb6dpcw*xu>2W&hQai<{eUhScAs9QNFz zpp2B!Ql1u&d;yrAR$!!P3(-3qmC&>-JT1VwuwNhXTsC4e7vP}ag#RJpu(}9tAxX6R zw{Yt9(erEmP__%9DiN1|M@B+Q5XE1z3G7?Ka=P=s*<~><$P2>z44($AaRE^gGa4Ry z=I}T&KUSV9bo9}O>#cV`jIVl&(8c=YWeRKcDvq~P+Ym(StMnK+tcT zDW(5tM@BZ90Z(Sg-Q?T;V920y!;xb*`||BlFR}rxgUdOLla;+yi=;+D+6H|Wr3($STXgO zO*2?CgECCE9g~A-ZFqXR}ytoz%y z*F#{Bze`|fL;l3n-wmn^%xq9E3_S}3xy2GeEzY>9V;a>hE<=Vmqc0vIL{%z6_esP@r!_f;y=pv!MqOXCP*zrnJ ze%JP%$->udS=rv_cUiL}=$Y6^G)N4ngfcwG4xc$MHiv^r{l&j)^7q#3os39MPj zVmxH2lfq`OGDf@D{*oRjD3ZY=80Z@Y6NbpBmP*j-!}=7*cxq7qulBKMhS!6QaNuvm zK0`F`iw6502gr{BM8!}0E(ev@@Y{4Ap4Qw0F2_czU5-7FtX**{`u$Ptu>p1`(STe2fUuM6K@o7Mp`B~I+(4wFu>S_o9g!jv8KR5CMTpL;9t;?FkJ19 z>@3Ad^Oc<9DHT}&sMenyDP^h)sQ$M%YIFG8q5F_2zi5$-M5}&T&#P2-U(Mc!CjjO zti`fT8;tL~{9C=%;DejRHhy}zv2a*{<7QInZKL{f05_uk@^qDc%bpGW=#bR;(N_Yp zHSXMy_KZ282#O`3=9_3$dtYP?5|!mA1Q`)Ww1oBQPd#SY>^ez^@JEThKL@g@^f!6K z{qa~;y4G;6TX|A>+Ax|YZsf<4?aL~_ypzb9xbV*U<;tncB;64FF0;&^pVn}oKnsIO zk;PbBU~!naX+U%ShY2k*?|ZpHG4hI9G8@o%`amYhLIz%7TC#6qG|%0Wjd4Uzmey;S z>~SzNud}O|O>p1S4*k~AMd-Xp?i|y6VR$|Mj@lyDpRA?^yq!p#!_ijECG3>m1plJ8 zykKiQ&js(#-hSHhN84DBR<$YMQA+s0d>Fv`t_E{F(sif4-rX?KeC1rKzz!aFnsNa_ z-ro|JuhY;a6MO&k!Zp0w*(TPk<$$0^wfE!E)1bT~=bFM_ygS7wBF25vfsoGYdtTDB zeCVb@Z~1A5lcdLnY|>GdSLOwz{2VK>GSo&q;vnn}x?5yF>G2`;XHkdR1W=Kl1%NzH*x-Q9Kg8h z^h$-^!&%dy3v)MO)Eix5TF#*nFpD3Khgej?8vmBC;`-cKUbY(}eVy%byB?2kGNS$vUxQS-$g%kh?P@DO1y_X|*7h38|y8tv&sB)kS{AfU_S(jcFC zrE+l3f*~(rSXp4sWsXENZq|2dmMAQ7e__etW%ZUP+LB*;k)Ud)EhJlWi4Ss2?mxYN z*W$#k>(b9nnYiP4J~?xX4nKVnsmFOQ;hHpg(zAW?;?!iSc1SOE)()}E-RzsbXSCn53msM0+4Mzw?+5_Bwkkb9E*?zSEYAXAhoS!-hC0)j5oRX=U3bjMddO7tP?5yXwh-8kgxoE`Lo;Kzk zh8+7dmnxe%j!w5yng;~fos>rn7gnd&9~~W~hzlEghn2_aj)`Ml#_9Au#v};aEs=V) z+_}upTQSJ11{rk3B4EANkYP&CQBKJ0d{TUcK`-@PMmpC9EGTy+rI_rw+2=*kDouf=M_&wjgzai0 z>8Z7tz5FwRTKBYicWb?(w`*gkzNor)pEF*~x13ZpK8lLVflLXKGC19|Xa3iCD!q&* z8YST38axRZlCV_)=gG}k#sK-NK#yFuMKV&S>cje&6q8E3x6=go{!>UcBu=FW; + + @@ -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 0000000000000000000000000000000000000000..0e6439b6789721975f9689cf2fc07e0b7bb7ae16 GIT binary patch literal 2650 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 IM6N<$f+rXUaR2}S literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..08b43e7551062d14e2bc5452bd6ed62b2750e0d8 GIT binary patch literal 3499 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 Z{{u0`dJ$!{D6Rki002ovPDHLkV1n>=tg`?B literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4d3ee05b63b95a7f127b862971e13b28c4e6c809 GIT binary patch literal 1772 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 VJM3c_^N%{?X}!=_uTis${2vJ$IG6wc literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e793fe6b0f7a771e5305be4f38927fbb174524a0 GIT binary patch literal 8014 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 literal 0 HcmV?d00001 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