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
261 lines
11 KiB
Kotlin
261 lines
11 KiB
Kotlin
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 },
|
||
)
|
||
}
|
||
}
|