feat(app): 安卓应用内升级,安装包由后端发布
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
This commit is contained in:
Steven
2026-10-31 03:15:00 +08:00
parent 35fe4d92cb
commit 7668799040
34 changed files with 937 additions and 12 deletions
+15
View File
@@ -2,11 +2,15 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!-- 应用内升级:下好的 APK 交给系统安装器 -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="达道Task"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.DadaoTask">
@@ -26,5 +30,16 @@
<data android:scheme="https" android:host="dadaotask.com" android:pathPrefix="/join/" />
</intent-filter>
</activity>
<!-- 安装包只从应用自己的 updates 目录出,不外扩到其它路径 -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -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<String?>(null) }
// 上面那条是任务还是知识:决定回哪个页面去重开
var reopenKnowledge by remember { mutableStateOf(false) }
// 应用内升级:启动后查一次,查到了就把对话框摆出来
val updater = remember { AppUpdater(context, api) }
var update by remember { mutableStateOf<AppRelease?>(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 },
)
}
}
@@ -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<Member> =
call<MemberPage>(path = "/workspaces/$workspaceId/members", auth = true).items
@@ -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/<pkg>/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) }
}
}
@@ -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<User?>(null) }
var workspaces by remember { mutableStateOf<List<Workspace>>(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<String?>(null) }
// 手动检查更新的回话:查到/没查到/失败都用一句话说清楚
var updateNotice by remember { mutableStateOf<String?>(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)) }
}
@@ -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<String?>(null) }
var file by remember { mutableStateOf<File?>(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)
}
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 自适应图标前景:与 Web Logo 同一套比例(108 画布,安全区 66 居中) -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:pathData="M36.3,42.6 L71.7,42.6"
android:strokeColor="#EFF5F3"
android:strokeWidth="5.06"
android:strokeLineCap="round" />
<path
android:pathData="M36.3,54 L59.6,54"
android:strokeColor="#EFF5F3"
android:strokeWidth="5.06"
android:strokeLineCap="round" />
<path
android:pathData="M36.3,65.4 L50.8,65.4"
android:strokeColor="#EFF5F3"
android:strokeWidth="5.06"
android:strokeLineCap="round" />
<path
android:fillColor="#C2703D"
android:pathData="M71.7,65.4 m-7.6,0 a7.6,7.6 0 1,0 15.2,0 a7.6,7.6 0 1,0 -15.2,0" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 与 Web 端 Logo(web/src/components/icons.tsx 的 <Logo/>)同一个墨绿 -->
<color name="ic_launcher_background">#2F6054</color>
</resources>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- FileProvider 的可见范围:只暴露应用自己的 files/updates,安装包就在那儿 -->
<paths>
<external-files-path name="updates" path="updates/" />
<files-path name="internal_updates" path="updates/" />
</paths>
@@ -1,11 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
只对本机开发地址放行明文 HTTP。生产域名必须走 HTTPS,不放行任何 cleartext。
只给内网/本机地址放行明文 HTTP:10.0.2.2 是模拟器里的宿主机,
192.168.8.188 是当前的内网生产入口(还没上 HTTPS)。
正式域名必须走 HTTPS——上线时把 192.168.8.188 这条删掉。
-->
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">10.0.2.2</domain>
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">localhost</domain>
<domain includeSubdomains="false">192.168.8.188</domain>
</domain-config>
</network-security-config>