Files
DadaoTask/android/app/src/main/java/com/dadao/task/MainActivity.kt
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

261 lines
11 KiB
Kotlin
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.
package com.dadao.task
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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
import com.dadao.task.ui.BoardScreen
import com.dadao.task.ui.DadaoTheme
import com.dadao.task.ui.EditRequest
import com.dadao.task.ui.HomeScreen
import com.dadao.task.ui.KnowledgeScreen
import com.dadao.task.ui.LoginScreen
import com.dadao.task.ui.MembersScreen
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
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
DadaoTheme {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
AppRoot(joinUri = intent?.dataString)
}
}
}
}
}
private enum class Screen { Splash, Login, Home, Workspace }
/** 从 https://dadaotask.com/join/<workspaceId>/<code> 里拆出工作区与邀请码。 */
private fun parseJoin(uri: String?): Pair<String, String>? {
if (uri.isNullOrBlank()) return null
val parts = uri.trimEnd('/').split("/")
val idx = parts.indexOf("join")
if (idx < 0 || parts.size < idx + 3) return null
val ws = parts[idx + 1]
val code = parts[idx + 2]
if (ws.length < 10 || code.length < 4) return null
return ws to code
}
@Composable
private fun AppRoot(joinUri: String?) {
val context = LocalContext.current
val tokens = remember { TokenStore(context) }
val api = remember { Api(tokens) }
var screen by remember { mutableStateOf(if (tokens.isLoggedIn) Screen.Splash else Screen.Login) }
var pendingJoin by remember { mutableStateOf(parseJoin(joinUri)) }
var current by remember { mutableStateOf<Workspace?>(null) }
var tab by remember { mutableStateOf(WorkspaceTab.Tasks) }
var unread by remember { mutableStateOf(0) }
// 通知页标了已读之后要立刻刷新角标:改这个数会触发下面的 LaunchedEffect
var unreadRefresh by remember { mutableStateOf(0) }
// 打开富文本编辑器时非空;关掉就恢复原来的界面
var editing by remember { mutableStateOf<EditRequest?>(null) }
// 编辑器保存成功的那条:回到工作区后重新打开它的详情,然后清掉
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) {
if (screen == Screen.Splash) {
screen = try {
api.me()
Screen.Home
} catch (e: Exception) {
tokens.clear()
Screen.Login
}
}
}
// 邀请链接:登录之后自动加入,加入完直接进那个工作区。
LaunchedEffect(screen, pendingJoin) {
val join = pendingJoin ?: return@LaunchedEffect
if (screen != Screen.Home) return@LaunchedEffect
runCatching { api.join(join.first, join.second) }
.onSuccess {
current = it
tab = WorkspaceTab.Tasks
pendingJoin = null
screen = Screen.Workspace
}
.onFailure { pendingJoin = null }
}
// 未读数:进工作区、切页面、或通知页标了已读(unreadRefresh)时刷新
LaunchedEffect(current?.id, tab, unreadRefresh) {
val ws = current ?: return@LaunchedEffect
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
},
onOpenWorkspace = { ws ->
current = ws
tab = WorkspaceTab.Tasks
screen = Screen.Workspace
},
)
Screen.Workspace -> {
val ws = current
val request = editing
if (ws == null) {
screen = Screen.Home
} else if (request != null) {
// 编辑器**替换**整页,而不是叠在上面:抽屉/对话框都是独立窗口,
// 叠一层会被它们盖住,用户只能看到编辑器的一条边。
RichTextEditorScreen(
api = api,
workspaceId = ws.id,
itemId = request.itemId,
initialTitle = request.title,
initialBody = request.body,
titleEnabled = request.titleEnabled,
save = { title, body ->
if (request.knowledge) {
api.updateKnowledge(
workspaceId = ws.id,
itemId = request.itemId,
version = request.version,
title = title,
body = body,
)
} else {
api.updateItem(
workspaceId = ws.id,
itemId = request.itemId,
version = request.version,
title = title,
body = body,
)
}
// 保存成功才标记"回到哪条":取消退出不该改变任何界面状态。
reopenItem = request.itemId
reopenKnowledge = request.knowledge
},
// saved 参数由编辑器给:true 时上面已经设过 reopenItem,
// 这里只负责把编辑器收起来,回到工作区后由对应页面重开详情。
onClose = { _ -> editing = null },
)
} else {
WorkspaceScaffold(
workspace = ws,
tab = tab,
unread = unread,
onTab = { tab = it },
onBackHome = { screen = Screen.Home },
) {
when (tab) {
WorkspaceTab.Tasks -> TaskListScreen(
api = api,
workspace = ws,
onEdit = { editing = it },
reopenItem = if (reopenItem != null && !reopenKnowledge) reopenItem else null,
onReopened = { reopenItem = null },
)
WorkspaceTab.Tickets -> TicketsScreen(
api = api,
workspace = ws,
focusId = reopenItem,
onFocusHandled = { reopenItem = null },
)
WorkspaceTab.Knowledge -> KnowledgeScreen(
api = api,
workspace = ws,
onEdit = { editing = it },
reopenItem = if (reopenItem != null && reopenKnowledge) reopenItem else null,
onReopened = { reopenItem = null },
)
WorkspaceTab.Members -> MembersScreen(api = api, workspace = ws)
WorkspaceTab.Notifications -> NotificationsScreen(
api = api,
workspaceId = ws.id,
onOpenItem = { itemId, isTicket ->
// 通知点开后跳到对应列表并展开那一条;通知本身不带数据,页面自己回源
reopenItem = itemId
reopenKnowledge = false
tab = if (isTicket) WorkspaceTab.Tickets else WorkspaceTab.Tasks
},
onUnreadChanged = { unreadRefresh += 1 },
)
}
}
}
}
}
// 更新对话框是独立窗口,摆在哪一层都能盖住当前界面
update?.let { release ->
UpdateDialog(
release = release,
currentVersionName = updater.currentVersionName,
updater = updater,
onLater = { update = null },
)
}
}