需求是"改完 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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user