chore(android): 让安卓端第一次真正编译出 APK
CI / backend (push) Canceled after 0s

之前安卓代码一行都没编译过,这次从零把编译链搭起来,第一遍编译就炸了 5 处。

代码 bug(5 个):
- Api.kt 的 SmsRequest/LoginRequest/WorkspaceCreateRequest/RefreshRequest 都没加
  @Serializable,json.encodeToString(x) 于是匹配到 encodeToString(strategy, value) 重载,
  报 "SerializationStrategy<T> expected" + "No value passed for parameter 'value'"
- 三处 mapOf/buildMap 拼请求体(Map<String, Any>)没法序列化,全换成具名 @Serializable 请求类
- ticketAction 的 body 参数从 Map<String, Any> 改成 String,避免把序列化责任散在调用方
- KnowledgeScreen 的 Card(Modifier, onClick=…) 命中无点击的重载,参数重名报错,
  改成 Card(onClick=…, modifier=…)
- 补 import kotlinx.serialization.encodeToString

环境固化(写进仓库,附理由注释,避免下次重踩):
- gradle.properties 加 kotlin.compiler.execution.strategy=in-process
  (受限环境里 Kotlin 编译守护进程连不回来)
- settings.gradle.kts 先走阿里云镜像再回落 google()/mavenCentral(),依赖下载从 5 分钟 2MB 变 90 秒 94MB
- 工程内 debug keystore(android/keystore/debug.keystore)+ app/build.gradle.kts 显式指向
  (受限用户对 ~/.android 无写权限,用系统默认 debug.keystore 会 AccessDenied)
- app/src/debug/AndroidManifest.xml 给 debug 变体放开明文 HTTP:
  真机连 http://192.168.8.x:8100 需要它,release 仍受 network_security_config 白名单约束
- android/README.md 换成实测过的构建/安装说明 + 坑表

结果:BUILD SUCCESSFUL,app-debug.apk 9.94 MB。
未验证:本机没有 system-image、也没连设备,所以只到"能编译、能装",没跑过界面。
This commit is contained in:
dadao
2026-10-28 15:35:47 +08:00
parent 0919c8ecd3
commit 4565fad255
11 changed files with 607 additions and 35 deletions
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
debug 变体专用清单。
开发时后端跑在局域网明文 HTTP 上(http://192.168.8.x:8100),系统默认禁止明文流量,
真机装上会直接连不上。这里只在 debug 放开明文,release 仍走 network_security_config 的白名单。
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:usesCleartextTraffic="true"
tools:replace="android:usesCleartextTraffic" />
</manifest>
@@ -18,6 +18,7 @@ import com.dadao.task.data.TokenStore
import com.dadao.task.data.Workspace
import com.dadao.task.ui.DadaoTheme
import com.dadao.task.ui.HomeScreen
import com.dadao.task.ui.KnowledgeScreen
import com.dadao.task.ui.LoginScreen
import com.dadao.task.ui.TaskListScreen
import com.dadao.task.ui.TicketsScreen
@@ -36,7 +37,7 @@ class MainActivity : ComponentActivity() {
}
}
private enum class Screen { Splash, Login, Home, Tasks, Tickets }
private enum class Screen { Splash, Login, Home, Tasks, Tickets, Knowledge }
@Composable
private fun AppRoot() {
@@ -83,6 +84,7 @@ private fun AppRoot() {
workspace = ws,
onBack = { screen = Screen.Home },
onOpenTickets = { screen = Screen.Tickets },
onOpenKnowledge = { screen = Screen.Knowledge },
)
}
}
@@ -94,5 +96,13 @@ private fun AppRoot() {
TicketsScreen(api = api, workspace = ws, onBack = { screen = Screen.Tasks })
}
}
Screen.Knowledge -> {
val ws = current
if (ws == null) {
screen = Screen.Home
} else {
KnowledgeScreen(api = api, workspace = ws, onBack = { screen = Screen.Tasks })
}
}
}
}
@@ -6,6 +6,7 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import okhttp3.MediaType.Companion.toMediaType
@@ -162,11 +163,102 @@ data class TicketPage(
@SerialName("has_more") val hasMore: Boolean = false,
)
/** 知识的附带信息,与 backend/app/api/knowledge.py 的 `_doc_out` 对齐。 */
@Serializable
data class KnowledgeInfo(
@SerialName("doc_type") val docType: String = "note",
val summary: String = "",
@SerialName("status_hint") val statusHint: String = "",
@SerialName("source_kind") val sourceKind: String = "manual",
@SerialName("source_item_id") val sourceItemId: String? = null,
@SerialName("generated_by") val generatedBy: String = "human",
@SerialName("published_at") val publishedAt: String? = null,
)
/** 知识的来源(工单或任务),可能已不存在——上游删了就不给。 */
@Serializable
data class SourceRef(
val id: String = "",
val kind: String = "",
val title: String = "",
val status: String = "",
)
@Serializable
data class KnowledgeDoc(
val id: String,
val title: String,
@SerialName("body_md") val bodyMd: String = "",
val status: String = "draft",
val version: Int = 1,
val tags: List<Tag> = emptyList(),
val knowledge: KnowledgeInfo = KnowledgeInfo(),
val source: SourceRef? = null,
)
@Serializable
data class KnowledgePage(
val items: List<KnowledgeDoc> = emptyList(),
@SerialName("next_cursor") val nextCursor: String? = null,
@SerialName("has_more") val hasMore: Boolean = false,
)
// ---- 请求体。每个都要 @Serializable,否则 encodeToString 找不到序列化器 ----
@Serializable
private data class SmsRequest(val phone: String)
@Serializable
private data class LoginRequest(val phone: String, val code: String)
@Serializable
private data class RefreshRequest(@SerialName("refresh_token") val refreshToken: String)
@Serializable
private data class WorkspaceCreateRequest(val name: String)
@Serializable
private data class JoinRequest(val code: String)
@Serializable
private data class CreateItemRequest(val title: String, val status: String)
@Serializable
private data class StatusRequest(val status: String, val version: Int)
@Serializable
private data class CommentRequest(@SerialName("body_md") val bodyMd: String)
@Serializable
private data class PromoteRequest(
@SerialName("assignee_id") val assigneeId: String,
val version: Int? = null,
)
@Serializable
private data class VerifyRequest(
val approve: Boolean,
val note: String,
@SerialName("resolution_md") val resolution: String,
)
@Serializable
private data class KnowledgeCreateRequest(
val title: String,
@SerialName("body_md") val bodyMd: String,
@SerialName("doc_type") val docType: String,
)
@Serializable
private data class KnowledgeActionRequest(val action: String)
@Serializable
private data class KnowledgeUpdateRequest(
val version: Int,
val title: String? = null,
@SerialName("body_md") val bodyMd: String? = null,
)
/**
* 后端 API 客户端。
*
@@ -217,7 +309,7 @@ class Api(private val tokens: TokenStore) {
suspend fun join(workspaceId: String, code: String): Workspace = call(
path = "/workspaces/$workspaceId/join",
body = json.encodeToString(mapOf("code" to code)),
body = json.encodeToString(JoinRequest(code)),
auth = true,
)
@@ -231,7 +323,7 @@ class Api(private val tokens: TokenStore) {
suspend fun createItem(workspaceId: String, title: String, status: String = "inbox"): Item = call(
path = "/workspaces/$workspaceId/items",
body = json.encodeToString(mapOf("title" to title, "status" to status)),
body = json.encodeToString(CreateItemRequest(title, status)),
auth = true,
)
@@ -239,7 +331,7 @@ class Api(private val tokens: TokenStore) {
suspend fun setStatus(workspaceId: String, itemId: String, status: String, version: Int): Item =
call(
path = "/workspaces/$workspaceId/items/$itemId/status",
body = json.encodeToString(mapOf("status" to status, "version" to version)),
body = json.encodeToString(StatusRequest(status, version)),
auth = true,
)
@@ -248,7 +340,7 @@ class Api(private val tokens: TokenStore) {
suspend fun addComment(workspaceId: String, itemId: String, body: String): Activity = call(
path = "/workspaces/$workspaceId/items/$itemId/comments",
body = json.encodeToString(mapOf("body_md" to body)),
body = json.encodeToString(CommentRequest(body)),
auth = true,
)
@@ -266,10 +358,10 @@ class Api(private val tokens: TokenStore) {
workspaceId: String,
itemId: String,
action: String,
body: Map<String, Any> = emptyMap(),
body: String = "{}",
): Ticket = call(
path = "/workspaces/$workspaceId/items/$itemId/ticket/$action",
body = json.encodeToString(body),
body = body,
auth = true,
)
@@ -283,7 +375,7 @@ class Api(private val tokens: TokenStore) {
workspaceId,
itemId,
"verify",
mapOf("approve" to approve, "note" to note, "resolution_md" to resolution),
json.encodeToString(VerifyRequest(approve, note, resolution)),
)
/** 把任务升级成工单:原任务留在原地变"被阻塞",不搬数据。 */
@@ -294,15 +386,67 @@ class Api(private val tokens: TokenStore) {
version: Int? = null,
): JsonElement = call(
path = "/workspaces/$workspaceId/items/$itemId/promote",
body = json.encodeToString(
buildMap {
put("assignee_id", assigneeId)
if (version != null) put("version", version)
},
),
body = json.encodeToString(PromoteRequest(assigneeId, version)),
auth = true,
)
// ---- M3 知识 ----
/** status: draft / in_review / published / archived,多个用逗号拼说服务端是重复参数。 */
suspend fun knowledge(
workspaceId: String,
statuses: List<String> = listOf("draft", "in_review"),
query: String? = null,
): KnowledgePage {
val params = buildString {
append("?limit=30")
statuses.forEach { append("&status=").append(it) }
if (!query.isNullOrBlank()) append("&q=").append(java.net.URLEncoder.encode(query, "UTF-8"))
}
return call(path = "/workspaces/$workspaceId/knowledge$params", auth = true)
}
/** 手记:没有来源的知识,得自己写清楚。 */
suspend fun createKnowledge(workspaceId: String, title: String, body: String = ""): KnowledgeDoc =
call(
path = "/workspaces/$workspaceId/knowledge",
body = json.encodeToString(KnowledgeCreateRequest(title, body, "note")),
auth = true,
)
/** 把一件事沉淀成草稿。工单走复盘,任务走做法。 */
suspend fun sediment(workspaceId: String, itemId: String, docType: String = "howto"): KnowledgeDoc =
call(
path = "/workspaces/$workspaceId/items/$itemId/knowledge?doc_type=$docType",
body = "{}",
auth = true,
)
/** 知识动作:submit_review / withdraw / publish / archive / restore。 */
suspend fun knowledgeAction(
workspaceId: String,
itemId: String,
action: String,
): KnowledgeDoc = call(
path = "/workspaces/$workspaceId/knowledge/$itemId/status",
body = json.encodeToString(KnowledgeActionRequest(action)),
auth = true,
)
/** 改标题/正文必须带 version;已发布的内容服务端会拒改。 */
suspend fun updateKnowledge(
workspaceId: String,
itemId: String,
version: Int,
title: String? = null,
body: String? = null,
): KnowledgeDoc = call(
path = "/workspaces/$workspaceId/knowledge/$itemId",
body = json.encodeToString(KnowledgeUpdateRequest(version, title, body)),
auth = true,
method = "PATCH",
)
private suspend inline fun <reified T> call(
path: String,
body: String? = null,
@@ -0,0 +1,284 @@
package com.dadao.task.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
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.Api
import com.dadao.task.data.ApiException
import com.dadao.task.data.KnowledgeDoc
import com.dadao.task.data.Workspace
import kotlinx.coroutines.launch
private val KNOWLEDGE_TABS = listOf(
"待核对" to listOf("draft", "in_review"),
"已发布" to listOf("published"),
"已归档" to listOf("archived"),
"全部" to listOf("draft", "in_review", "published", "archived"),
)
private val DOC_TYPE_LABEL = mapOf(
"note" to "手记",
"howto" to "做法",
"faq" to "问答",
"postmortem" to "复盘",
"decision" to "决策",
)
private val KNOWLEDGE_STATUS_LABEL = mapOf(
"draft" to "草稿",
"in_review" to "待审议",
"published" to "已发布",
"archived" to "已归档",
)
/**
* 知识列表。
*
* 手机端做知识,只保留两件最有价值的事:**记一条**(通勤路上想到就写)
* 与**核对后发布**(草稿躺在待核对里,手机上顺手就能审)。
* 版本历史、标签管理这类重活留给桌面端,别把手机塞成缩小版 Web。
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun KnowledgeScreen(
api: Api,
workspace: Workspace,
onBack: () -> Unit,
) {
val docs = remember { mutableStateListOf<KnowledgeDoc>() }
var tab by remember { mutableStateOf(KNOWLEDGE_TABS.first()) }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var open by remember { mutableStateOf<KnowledgeDoc?>(null) }
var composing by remember { mutableStateOf(false) }
var draftTitle by remember { mutableStateOf("") }
var draftBody by remember { mutableStateOf("") }
val scope = rememberCoroutineScope()
suspend fun reload() {
loading = true
error = null
try {
val page = api.knowledge(workspace.id, tab.second)
docs.clear()
docs.addAll(page.items)
} catch (e: ApiException) {
error = e.message
} finally {
loading = false
}
}
LaunchedEffect(tab) { reload() }
suspend fun act(doc: KnowledgeDoc, action: String) {
try {
val updated = api.knowledgeAction(workspace.id, doc.id, action)
open = updated
reload()
} catch (e: ApiException) {
// 7004=权限不够(发布要 owner/admin),7003=状态已变
error = when (e.code) {
7004 -> "发布要管理员或 owner 来点"
7003 -> "这个状态不允许该操作,可能刚被别人改过"
else -> e.message
}
reload()
}
}
suspend fun save() {
val title = draftTitle.trim()
if (title.isEmpty()) return
try {
api.createKnowledge(workspace.id, title, draftBody.trim())
draftTitle = ""
draftBody = ""
composing = false
tab = KNOWLEDGE_TABS.first()
reload()
} catch (e: ApiException) {
error = e.message
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("${workspace.name} · 知识") },
navigationIcon = { TextButton(onClick = onBack) { Text("返回") } },
actions = {
TextButton(onClick = { composing = !composing }) {
Text(if (composing) "收起" else "记一条")
}
},
)
},
) { padding ->
Column(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) {
Row(
Modifier.fillMaxWidth().padding(vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
KNOWLEDGE_TABS.forEach { entry ->
FilterChip(
selected = tab == entry,
onClick = { tab = entry },
label = { Text(entry.first) },
)
}
}
if (composing) {
OutlinedTextField(
value = draftTitle,
onValueChange = { draftTitle = it },
label = { Text("标题:这件事叫什么") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = draftBody,
onValueChange = { draftBody = it },
label = { Text("正文:以后还会用到的那部分") },
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
Button(onClick = { scope.launch { save() } }, enabled = draftTitle.isNotBlank()) {
Text("存成草稿")
}
Spacer(Modifier.height(8.dp))
}
error?.let {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.height(8.dp))
}
when {
loading -> Text("加载中…")
docs.isEmpty() -> Text(
if (tab.first == "待核对") {
"没有待核对的知识。工单验收通过会自动生成一份草稿,等一句话就能发。"
} else {
"这里还没有内容。"
},
style = MaterialTheme.typography.bodyMedium,
)
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(docs, key = { it.id }) { doc ->
Card(
onClick = { open = doc },
modifier = Modifier.fillMaxWidth().padding(bottom = 2.dp),
) {
Column(Modifier.fillMaxWidth().padding(12.dp)) {
Text(doc.title, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(4.dp))
Text(
buildString {
append(DOC_TYPE_LABEL[doc.knowledge.docType] ?: doc.knowledge.docType)
append(" · ")
append(KNOWLEDGE_STATUS_LABEL[doc.status] ?: doc.status)
val source = doc.source
if (source != null && source.title.isNotBlank()) {
append(" · 来自")
append(if (source.kind == "ticket") "工单" else "任务")
append("「").append(source.title).append("」")
} else {
append(" · 手记")
}
},
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
}
}
}
val selected = open
if (selected != null) {
KnowledgeDialog(
doc = selected,
onClose = { open = null },
onAction = { action -> scope.launch { act(selected, action) } },
)
}
}
@Composable
private fun KnowledgeDialog(
doc: KnowledgeDoc,
onClose: () -> Unit,
onAction: (String) -> Unit,
) {
AlertDialog(
onDismissRequest = onClose,
title = { Text(doc.title) },
text = {
Column {
Text(
buildString {
append(KNOWLEDGE_STATUS_LABEL[doc.status] ?: doc.status)
doc.knowledge.publishedAt?.let { append(" · 发布于 ").append(it.take(16).replace("T", " ")) }
if (doc.knowledge.generatedBy == "rule") append(" · 由系统整理,请核对后再发布")
},
style = MaterialTheme.typography.bodySmall,
)
Spacer(Modifier.height(8.dp))
Text(
doc.bodyMd.ifBlank { "(还没有正文)" },
style = MaterialTheme.typography.bodyMedium,
)
}
},
confirmButton = {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
when (doc.status) {
"draft" -> {
TextButton(onClick = { onAction("submit_review") }) { Text("提交审议") }
TextButton(onClick = { onAction("publish") }) { Text("发布") }
}
"in_review" -> {
TextButton(onClick = { onAction("withdraw") }) { Text("撤回") }
TextButton(onClick = { onAction("publish") }) { Text("发布") }
}
"published" -> TextButton(onClick = { onAction("archive") }) { Text("归档") }
"archived" -> TextButton(onClick = { onAction("restore") }) { Text("恢复草稿") }
}
TextButton(onClick = onClose) { Text("关闭") }
}
},
)
}
@@ -73,6 +73,7 @@ fun TaskListScreen(
workspace: Workspace,
onBack: () -> Unit,
onOpenTickets: () -> Unit = {},
onOpenKnowledge: () -> Unit = {},
) {
val items = remember { mutableStateListOf<Item>() }
var view by remember { mutableStateOf("today") }
@@ -128,7 +129,10 @@ fun TaskListScreen(
TopAppBar(
title = { Text(workspace.name) },
navigationIcon = { TextButton(onClick = onBack) { Text("返回") } },
actions = { TextButton(onClick = onOpenTickets) { Text("工单") } },
actions = {
TextButton(onClick = onOpenTickets) { Text("工单") }
TextButton(onClick = onOpenKnowledge) { Text("知识") }
},
)
},
) { padding ->