CI / backend (push) Canceled after 0s
需求是"改完 Android 端怎么送到手机上"。不发应用商店,也不想每次手动传包,
于是做了最小闭环:后端存一份安装包清单,App 启动时查、下载、交给系统安装器。
后端
- 新增 /app/release/latest 与 /app/release/download/{file},都免登录:
还没装上新包的客户端未必带着有效令牌,检查更新不该被登录挡住
- download_url 用相对路径,客户端拿自己配置的 API 地址拼——同一份清单要在
内网、公网、模拟器之间共用,写死域名只会让其中一个坏掉
- has_update 只比对 version_code,不比版本名字符串(0.10.0 与 0.9.0 的字典序是坑)
- 下载只认清单里登记的文件名,含 ..%2F 一律 9001
- scripts/publish_apk.py 负责复制 APK 并写 latest.json(sha256/size/published_at),
backend/static/apk/ 不进版本库
安卓
- AppUpdater:查清单 → 下到应用自己的 updates 目录(先写 .part 再改名)→
校验 sha256 → 经 FileProvider 交给系统安装器
- UpdateDialog:版本号、更新说明、下载进度、失败重试;「允许安装未知应用」
是系统开关,只跳设置页,不代点
- 启动时自动查一次,首页底部另有「检查更新」入口
- 图标换成和 Web 的 <Logo/> 同一套图形:墨绿圆角底、三条米白圆头线、陶土点;
同时出 mipmap 各密度 PNG、自适应图标与 512px 预览
- versionCode=2 / versionName=0.2.0;发布签名从 android/keystore.properties 读,
没有该文件时回落调试签名,保证别人 clone 下来也能 assembleRelease
文档
- API.md 第 7 节:两个接口、清单字段、签名与 sha256 的约定、9001
- DEPLOY.md 第 5 节:出包命令(JDK 17 + Gradle 8.9 + 干净的 GRADLE_USER_HOME)、
发布清单、传到服务器、验证
- ROADMAP 第 6 节记录本次范围与本机踩过的构建环境坑
验证
- 后端 pytest 103 passed;ruff check / format --check 全绿
- assembleRelease 成功,app-release.apk 7.4 MB,apksigner 验签显示 CN=Dadao Task
- 本地与生产 GET /app/release/latest?version_code=1 → has_update=true,
version_code=2 → false;下载返回 200 / application/vnd.android.package-archive
115 lines
4.2 KiB
Kotlin
115 lines
4.2 KiB
Kotlin
import java.util.Properties
|
||
|
||
plugins {
|
||
id("com.android.application")
|
||
id("org.jetbrains.kotlin.android")
|
||
id("org.jetbrains.kotlin.plugin.compose")
|
||
id("org.jetbrains.kotlin.plugin.serialization")
|
||
}
|
||
|
||
/**
|
||
* 后端地址不走硬编码:优先读 local.properties 的 dadao.apiBase,
|
||
* 缺省指向模拟器访问宿主机的 10.0.2.2:8100。
|
||
*/
|
||
val apiBase: String = run {
|
||
val props = Properties()
|
||
val file = rootProject.file("local.properties")
|
||
if (file.exists()) file.inputStream().use { props.load(it) }
|
||
props.getProperty("dadao.apiBase") ?: "http://10.0.2.2:8100"
|
||
}
|
||
|
||
android {
|
||
namespace = "com.dadao.task"
|
||
compileSdk = 35
|
||
|
||
defaultConfig {
|
||
applicationId = "com.dadao.task"
|
||
minSdk = 26
|
||
targetSdk = 35
|
||
// 版本号是 OTA 的唯一判据:后端只比对 version_code,改这里才算"有新版本"
|
||
versionCode = 2
|
||
versionName = "0.2.0"
|
||
buildConfigField("String", "API_BASE", "\"$apiBase\"")
|
||
}
|
||
|
||
buildFeatures {
|
||
compose = true
|
||
buildConfig = true
|
||
}
|
||
|
||
/**
|
||
* 调试签名用仓库内的 keystore,而不是 ~/.android/debug.keystore。
|
||
*
|
||
* 原因:CI 与受限沙箱里 `~/.android` 常常不可写,AGP 会去建
|
||
* `debug.keystore.lock` 然后 AccessDenied,构建在 validateSigningDebug 这一步失败。
|
||
* 放在工程里就能在任何机器上直接 assembleDebug。
|
||
* 注意:这只是调试签名,release 必须换成真正的证书并走 secret 注入。
|
||
*/
|
||
signingConfigs {
|
||
getByName("debug") {
|
||
storeFile = rootProject.file("keystore/debug.keystore")
|
||
storePassword = "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 {
|
||
sourceCompatibility = JavaVersion.VERSION_17
|
||
targetCompatibility = JavaVersion.VERSION_17
|
||
}
|
||
|
||
kotlinOptions {
|
||
jvmTarget = "17"
|
||
}
|
||
}
|
||
|
||
dependencies {
|
||
implementation(platform("androidx.compose:compose-bom:2024.12.01"))
|
||
implementation("androidx.compose.ui:ui")
|
||
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")
|
||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||
// Markdown 预览用成熟库渲染,不自己写解析器
|
||
implementation("io.noties.markwon:core:4.6.2")
|
||
implementation("io.coil-kt:coil-compose:2.7.0")
|
||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||
}
|