需求是"改完 Android 端怎么送到手机上"。不发应用商店,也不想每次手动传包,
于是做了最小闭环:后端存一份安装包清单,App 启动时查、下载、交给系统安装器。
后端
- 新增 /app/release/latest 与 /app/release/download/{file},都免登录:
还没装上新包的客户端未必带着有效令牌,检查更新不该被登录挡住
- download_url 用相对路径,客户端拿自己配置的 API 地址拼——同一份清单要在
内网、公网、模拟器之间共用,写死域名只会让其中一个坏掉
- has_update 只比对 version_code,不比版本名字符串(0.10.0 与 0.9.0 的字典序是坑)
- 下载只认清单里登记的文件名,含 ..%2F 一律 9001
- scripts/publish_apk.py 负责复制 APK 并写 latest.json(sha256/size/published_at),
backend/static/apk/ 不进版本库
安卓
- AppUpdater:查清单 → 下到应用自己的 updates 目录(先写 .part 再改名)→
校验 sha256 → 经 FileProvider 交给系统安装器
- UpdateDialog:版本号、更新说明、下载进度、失败重试;「允许安装未知应用」
是系统开关,只跳设置页,不代点
- 启动时自动查一次,首页底部另有「检查更新」入口
- 图标换成和 Web 的 <Logo/> 同一套图形:墨绿圆角底、三条米白圆头线、陶土点;
同时出 mipmap 各密度 PNG、自适应图标与 512px 预览
- versionCode=2 / versionName=0.2.0;发布签名从 android/keystore.properties 读,
没有该文件时回落调试签名,保证别人 clone 下来也能 assembleRelease
文档
- API.md 第 7 节:两个接口、清单字段、签名与 sha256 的约定、9001
- DEPLOY.md 第 5 节:出包命令(JDK 17 + Gradle 8.9 + 干净的 GRADLE_USER_HOME)、
发布清单、传到服务器、验证
- ROADMAP 第 6 节记录本次范围与本机踩过的构建环境坑
验证
- 后端 pytest 103 passed;ruff check / format --check 全绿
- assembleRelease 成功,app-release.apk 7.4 MB,apksigner 验签显示 CN=Dadao Task
- 本地与生产 GET /app/release/latest?version_code=1 → has_update=true,
version_code=2 → false;下载返回 200 / application/vnd.android.package-archive
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
"""把构建好的 APK 发布成 OTA 版本:算 sha256、写 latest.json。
|
||||
|
||||
用法(在仓库根目录):
|
||||
|
||||
python backend/scripts/publish_apk.py android/app/build/outputs/apk/release/app-release.apk \
|
||||
--version-code 2 --version-name 0.2.0 --notes "第一版安装包:知识库支持分类与附件"
|
||||
|
||||
发布目录默认 `backend/static/apk/`,可用 `--dir` 或环境变量 `APK_DIR` 改。
|
||||
这个目录是产物、不入库;部署到服务器时把 `latest.json` 和 apk 一起 scp 过去即可。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
DEFAULT_DIR = REPO / "backend" / "static" / "apk"
|
||||
|
||||
|
||||
def sha256_of(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="发布安卓安装包(OTA)")
|
||||
parser.add_argument("apk", type=Path, help="构建产物路径,如 app-release.apk")
|
||||
parser.add_argument(
|
||||
"--version-code", type=int, required=True, help="与 build.gradle 的 versionCode 一致"
|
||||
)
|
||||
parser.add_argument("--version-name", required=True, help="展示用版本号,如 0.2.0")
|
||||
parser.add_argument("--notes", default="", help="更新说明,App 里会原样显示")
|
||||
parser.add_argument("--min-sdk", type=int, default=26)
|
||||
parser.add_argument(
|
||||
"--dir",
|
||||
type=Path,
|
||||
default=Path(os.environ.get("APK_DIR") or DEFAULT_DIR),
|
||||
help="发布目录(默认 backend/static/apk)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if not args.apk.is_file():
|
||||
print(f"APK 不存在:{args.apk}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
target_dir = args.dir
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
name = f"dadaotask-{args.version_name}.apk"
|
||||
target = target_dir / name
|
||||
shutil.copyfile(args.apk, target)
|
||||
|
||||
manifest = {
|
||||
"platform": "android",
|
||||
"version_code": args.version_code,
|
||||
"version_name": args.version_name,
|
||||
"notes": args.notes.strip(),
|
||||
"file": name,
|
||||
"size": target.stat().st_size,
|
||||
"sha256": sha256_of(target),
|
||||
"min_sdk": args.min_sdk,
|
||||
"published_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
}
|
||||
(target_dir / "latest.json").write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
print(f"已发布 {name}")
|
||||
print(f" versionCode={manifest['version_code']} versionName={manifest['version_name']}")
|
||||
print(f" size={manifest['size'] / 1024 / 1024:.1f}MB sha256={manifest['sha256'][:16]}…")
|
||||
print(f" 清单:{target_dir / 'latest.json'}")
|
||||
print("提示:服务器上把这两个文件放到 <repo>/backend/static/apk/ 即可,无需重启服务。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user