Files
DadaoTask/backend/scripts/publish_apk.py
T
Steven 7668799040
CI / backend (push) Canceled after 0s
feat(app): 安卓应用内升级,安装包由后端发布
需求是"改完 Android 端怎么送到手机上"。不发应用商店,也不想每次手动传包,
于是做了最小闭环:后端存一份安装包清单,App 启动时查、下载、交给系统安装器。

后端
- 新增 /app/release/latest 与 /app/release/download/{file},都免登录:
  还没装上新包的客户端未必带着有效令牌,检查更新不该被登录挡住
- download_url 用相对路径,客户端拿自己配置的 API 地址拼——同一份清单要在
  内网、公网、模拟器之间共用,写死域名只会让其中一个坏掉
- has_update 只比对 version_code,不比版本名字符串(0.10.0 与 0.9.0 的字典序是坑)
- 下载只认清单里登记的文件名,含 ..%2F 一律 9001
- scripts/publish_apk.py 负责复制 APK 并写 latest.json(sha256/size/published_at),
  backend/static/apk/ 不进版本库

安卓
- AppUpdater:查清单 → 下到应用自己的 updates 目录(先写 .part 再改名)→
  校验 sha256 → 经 FileProvider 交给系统安装器
- UpdateDialog:版本号、更新说明、下载进度、失败重试;「允许安装未知应用」
  是系统开关,只跳设置页,不代点
- 启动时自动查一次,首页底部另有「检查更新」入口
- 图标换成和 Web 的 <Logo/> 同一套图形:墨绿圆角底、三条米白圆头线、陶土点;
  同时出 mipmap 各密度 PNG、自适应图标与 512px 预览
- versionCode=2 / versionName=0.2.0;发布签名从 android/keystore.properties 读,
  没有该文件时回落调试签名,保证别人 clone 下来也能 assembleRelease

文档
- API.md 第 7 节:两个接口、清单字段、签名与 sha256 的约定、9001
- DEPLOY.md 第 5 节:出包命令(JDK 17 + Gradle 8.9 + 干净的 GRADLE_USER_HOME)、
  发布清单、传到服务器、验证
- ROADMAP 第 6 节记录本次范围与本机踩过的构建环境坑

验证
- 后端 pytest 103 passed;ruff check / format --check 全绿
- assembleRelease 成功,app-release.apk 7.4 MB,apksigner 验签显示 CN=Dadao Task
- 本地与生产 GET /app/release/latest?version_code=1 → has_update=true,
  version_code=2 → false;下载返回 200 / application/vnd.android.package-archive
2026-10-31 03:15:00 +08:00

87 lines
3.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""把构建好的 APK 发布成 OTA 版本:算 sha256、写 latest.json。
用法(在仓库根目录):
python backend/scripts/publish_apk.py android/app/build/outputs/apk/release/app-release.apk \
--version-code 2 --version-name 0.2.0 --notes "第一版安装包:知识库支持分类与附件"
发布目录默认 `backend/static/apk/`,可用 `--dir` 或环境变量 `APK_DIR` 改。
这个目录是产物、不入库;部署到服务器时把 `latest.json` 和 apk 一起 scp 过去即可。
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import shutil
import sys
from datetime import datetime, timezone
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
DEFAULT_DIR = REPO / "backend" / "static" / "apk"
def sha256_of(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="发布安卓安装包(OTA)")
parser.add_argument("apk", type=Path, help="构建产物路径,如 app-release.apk")
parser.add_argument(
"--version-code", type=int, required=True, help="与 build.gradle 的 versionCode 一致"
)
parser.add_argument("--version-name", required=True, help="展示用版本号,如 0.2.0")
parser.add_argument("--notes", default="", help="更新说明,App 里会原样显示")
parser.add_argument("--min-sdk", type=int, default=26)
parser.add_argument(
"--dir",
type=Path,
default=Path(os.environ.get("APK_DIR") or DEFAULT_DIR),
help="发布目录(默认 backend/static/apk)",
)
args = parser.parse_args(argv)
if not args.apk.is_file():
print(f"APK 不存在:{args.apk}", file=sys.stderr)
return 1
target_dir = args.dir
target_dir.mkdir(parents=True, exist_ok=True)
name = f"dadaotask-{args.version_name}.apk"
target = target_dir / name
shutil.copyfile(args.apk, target)
manifest = {
"platform": "android",
"version_code": args.version_code,
"version_name": args.version_name,
"notes": args.notes.strip(),
"file": name,
"size": target.stat().st_size,
"sha256": sha256_of(target),
"min_sdk": args.min_sdk,
"published_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
}
(target_dir / "latest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
print(f"已发布 {name}")
print(f" versionCode={manifest['version_code']} versionName={manifest['version_name']}")
print(f" size={manifest['size'] / 1024 / 1024:.1f}MB sha256={manifest['sha256'][:16]}…")
print(f" 清单:{target_dir / 'latest.json'}")
print("提示:服务器上把这两个文件放到 <repo>/backend/static/apk/ 即可,无需重启服务。")
return 0
if __name__ == "__main__":
raise SystemExit(main())