From 1d97e7eb4ad8cec9db30a60add82710aca0b2eb2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8B=92=E5=B8=83=E6=9C=97-=E8=A9=B9=E5=A7=86=E6=96=AF?=
<2986253039@qq.com>
Date: Fri, 18 Sep 2026 11:01:23 +0800
Subject: [PATCH 01/12] feat(android): add Kotlin Compose RoleWeave client
(#339)
---
mobile/android/README.md | 9 +
mobile/android/app/build.gradle.kts | 35 ++++
.../android/app/src/main/AndroidManifest.xml | 20 ++
.../com/bytefolk/roleweave/MainActivity.kt | 140 ++++++++++++++
.../com/bytefolk/roleweave/PhoneViewModel.kt | 171 ++++++++++++++++++
mobile/android/build.gradle.kts | 5 +
mobile/android/gradle.properties | 3 +
mobile/android/settings.gradle.kts | 16 ++
8 files changed, 399 insertions(+)
create mode 100644 mobile/android/README.md
create mode 100644 mobile/android/app/build.gradle.kts
create mode 100644 mobile/android/app/src/main/AndroidManifest.xml
create mode 100644 mobile/android/app/src/main/java/com/bytefolk/roleweave/MainActivity.kt
create mode 100644 mobile/android/app/src/main/java/com/bytefolk/roleweave/PhoneViewModel.kt
create mode 100644 mobile/android/build.gradle.kts
create mode 100644 mobile/android/gradle.properties
create mode 100644 mobile/android/settings.gradle.kts
diff --git a/mobile/android/README.md b/mobile/android/README.md
new file mode 100644
index 00000000..28239417
--- /dev/null
+++ b/mobile/android/README.md
@@ -0,0 +1,9 @@
+# RoleWeave Android
+
+Native Kotlin + Compose client. Open the `android/` folder in Android Studio (Koala or newer).
+
+1. Sync Gradle.
+2. Run on a device or emulator (API 26+).
+3. In 指令, set the RoleWeave host, pair with the desktop code, send a command.
+
+This project does not compile in the Linux sandbox (no JDK / Android SDK).
diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts
new file mode 100644
index 00000000..1ff1be38
--- /dev/null
+++ b/mobile/android/app/build.gradle.kts
@@ -0,0 +1,35 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("org.jetbrains.kotlin.plugin.compose")
+}
+
+android {
+ namespace = "com.bytefolk.roleweave"
+ compileSdk = 35
+ defaultConfig {
+ applicationId = "com.bytefolk.roleweave"
+ minSdk = 26
+ targetSdk = 35
+ versionCode = 1
+ versionName = "0.1.0"
+ }
+ buildFeatures { compose = true }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ kotlinOptions { jvmTarget = "17" }
+}
+
+dependencies {
+ implementation(platform("androidx.compose:compose-bom:2024.10.01"))
+ implementation("androidx.activity:activity-compose:1.9.3")
+ implementation("androidx.compose.material3:material3")
+ implementation("androidx.compose.ui:ui")
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
+ implementation("org.json:json:20240303")
+}
diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..63da50bd
--- /dev/null
+++ b/mobile/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile/android/app/src/main/java/com/bytefolk/roleweave/MainActivity.kt b/mobile/android/app/src/main/java/com/bytefolk/roleweave/MainActivity.kt
new file mode 100644
index 00000000..8d10bfde
--- /dev/null
+++ b/mobile/android/app/src/main/java/com/bytefolk/roleweave/MainActivity.kt
@@ -0,0 +1,140 @@
+package com.bytefolk.roleweave
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.viewModels
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+class MainActivity : ComponentActivity() {
+ private val model by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ MaterialTheme(colorScheme = darkColorScheme()) {
+ RoleWeaveApp(model)
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun RoleWeaveApp(model: PhoneViewModel) {
+ var tab by remember { mutableIntStateOf(0) }
+ val state by model.state.collectAsState()
+ LaunchedEffect(Unit) { model.loadOrg() }
+ Scaffold(
+ topBar = {
+ TopAppBar(title = {
+ Text(
+ when (tab) {
+ 0 -> state.snapshot?.name ?: "组织"
+ 1 -> "指令"
+ else -> "设置"
+ }
+ )
+ })
+ },
+ bottomBar = {
+ NavigationBar {
+ NavigationBarItem(selected = tab == 0, onClick = { tab = 0 }, icon = { Text("组") }, label = { Text("组织") })
+ NavigationBarItem(selected = tab == 1, onClick = { tab = 1 }, icon = { Text("令") }, label = { Text("指令") })
+ NavigationBarItem(selected = tab == 2, onClick = { tab = 2 }, icon = { Text("设") }, label = { Text("设置") })
+ }
+ }
+ ) { padding ->
+ when (tab) {
+ 0 -> OrgScreen(state, Modifier.padding(padding))
+ 1 -> CommandScreen(state, model, Modifier.padding(padding))
+ else -> SettingsScreen(Modifier.padding(padding))
+ }
+ }
+}
+
+@Composable
+fun OrgScreen(state: PhoneState, modifier: Modifier = Modifier) {
+ var selected by remember { mutableStateOf(null) }
+ if (selected != null) {
+ Column(modifier.padding(16.dp).fillMaxSize()) {
+ Text(selected!!.name, style = MaterialTheme.typography.headlineSmall)
+ Text(selected!!.description, modifier = Modifier.padding(top = 8.dp))
+ Text(selected!!.skillExcerpt.ifEmpty { "没有摘录" }, modifier = Modifier.padding(top = 12.dp), style = MaterialTheme.typography.bodySmall)
+ Button(onClick = { selected = null }, modifier = Modifier.padding(top = 16.dp)) { Text("返回") }
+ }
+ return
+ }
+ LazyColumn(modifier.fillMaxSize()) {
+ state.error?.let { item { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(16.dp)) } }
+ items(state.snapshot?.roles.orEmpty()) { role ->
+ ListItem(
+ headlineContent = { Text(role.name) },
+ supportingContent = { Text(role.description) },
+ modifier = Modifier.clickable { selected = role }
+ )
+ }
+ }
+}
+
+@Composable
+fun CommandScreen(state: PhoneState, model: PhoneViewModel, modifier: Modifier = Modifier) {
+ var code by remember { mutableStateOf("") }
+ var text by remember { mutableStateOf("") }
+ Column(modifier.padding(16.dp).fillMaxWidth()) {
+ OutlinedTextField(state.host, onValueChange = model::setHost, label = { Text("RoleWeave 地址") }, modifier = Modifier.fillMaxWidth())
+ Text(state.status, modifier = Modifier.padding(vertical = 12.dp))
+ if (!state.paired) {
+ OutlinedTextField(code, onValueChange = { code = it }, label = { Text("6 位配对码") }, modifier = Modifier.fillMaxWidth())
+ Button(onClick = { model.pair(code) }, modifier = Modifier.padding(top = 12.dp).fillMaxWidth()) { Text("配对这台电脑") }
+ } else {
+ OutlinedTextField(text, onValueChange = { text = it }, label = { Text("指令") }, modifier = Modifier.fillMaxWidth(), minLines = 4)
+ Button(
+ onClick = {
+ model.send(text, state.snapshot?.roles?.firstOrNull()?.id)
+ text = ""
+ },
+ modifier = Modifier.padding(top = 12.dp).fillMaxWidth()
+ ) { Text("发给电脑") }
+ }
+ if (state.summary.isNotEmpty()) {
+ Text(state.summary, modifier = Modifier.padding(top = 16.dp))
+ }
+ }
+}
+
+@Composable
+fun SettingsScreen(modifier: Modifier = Modifier) {
+ Column(modifier.padding(16.dp)) {
+ Text("平台 Android")
+ Text("回合 在电脑上执行")
+ Text("组织 只读预览")
+ }
+}
diff --git a/mobile/android/app/src/main/java/com/bytefolk/roleweave/PhoneViewModel.kt b/mobile/android/app/src/main/java/com/bytefolk/roleweave/PhoneViewModel.kt
new file mode 100644
index 00000000..3ae00d5e
--- /dev/null
+++ b/mobile/android/app/src/main/java/com/bytefolk/roleweave/PhoneViewModel.kt
@@ -0,0 +1,171 @@
+package com.bytefolk.roleweave
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.WebSocket
+import okhttp3.WebSocketListener
+import org.json.JSONObject
+import java.util.UUID
+
+data class Role(
+ val id: String,
+ val name: String,
+ val description: String,
+ val skillExcerpt: String,
+)
+
+data class Snapshot(
+ val name: String,
+ val description: String,
+ val roles: List,
+)
+
+data class PhoneState(
+ val host: String = "http://127.0.0.1:8800",
+ val snapshot: Snapshot? = null,
+ val status: String = "尚未连接电脑",
+ val summary: String = "",
+ val paired: Boolean = false,
+ val error: String? = null,
+)
+
+class PhoneViewModel : ViewModel() {
+ private val client = OkHttpClient()
+ private val _state = MutableStateFlow(PhoneState())
+ val state: StateFlow = _state
+ private var socket: WebSocket? = null
+ private var deviceToken: String? = null
+
+ fun setHost(value: String) {
+ _state.update { it.copy(host = value) }
+ }
+
+ fun loadOrg() {
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ val request = Request.Builder()
+ .url(_state.value.host.trimEnd('/') + "/api/mobile/workspace")
+ .build()
+ client.newCall(request).execute().use { response ->
+ val json = JSONObject(response.body?.string().orEmpty())
+ val roles = json.getJSONArray("roles")
+ val list = buildList {
+ for (i in 0 until roles.length()) {
+ val item = roles.getJSONObject(i)
+ add(
+ Role(
+ id = item.getString("id"),
+ name = item.getString("name"),
+ description = item.optString("description"),
+ skillExcerpt = item.optString("skillExcerpt"),
+ )
+ )
+ }
+ }
+ _state.update {
+ it.copy(
+ snapshot = Snapshot(json.getString("name"), json.optString("description"), list),
+ error = null,
+ )
+ }
+ }
+ } catch (_: Exception) {
+ _state.update { it.copy(error = "组织预览读不到") }
+ }
+ }
+ }
+
+ fun pair(code: String) {
+ viewModelScope.launch(Dispatchers.IO) {
+ _state.update { it.copy(status = "正在配对…") }
+ try {
+ val body = JSONObject().put("code", code).toString()
+ .toRequestBody("application/json; charset=utf-8".toMediaType())
+ val request = Request.Builder()
+ .url(_state.value.host.trimEnd('/') + "/phone-link/v1/pair")
+ .post(body)
+ .build()
+ client.newCall(request).execute().use { response ->
+ val json = JSONObject(response.body?.string().orEmpty())
+ val token = json.optString("deviceToken")
+ if (!response.isSuccessful || token.isEmpty()) {
+ _state.update { it.copy(status = json.optString("message", "配对失败")) }
+ return@use
+ }
+ deviceToken = token
+ connectSocket()
+ }
+ } catch (_: Exception) {
+ _state.update { it.copy(status = "配对失败") }
+ }
+ }
+ }
+
+ fun send(text: String, positionId: String?) {
+ val socket = socket
+ if (socket == null || !_state.value.paired) {
+ _state.update { it.copy(status = "还没连上电脑") }
+ return
+ }
+ val payload = JSONObject()
+ .put("v", 1)
+ .put("type", "command.submit")
+ .put("commandId", UUID.randomUUID().toString())
+ .put("text", text)
+ if (!positionId.isNullOrEmpty()) payload.put("positionId", positionId)
+ socket.send(payload.toString())
+ _state.update { it.copy(status = "已发出,等电脑受理…") }
+ }
+
+ private fun connectSocket() {
+ val http = _state.value.host.trimEnd('/')
+ val ws = http.replace("https://", "wss://").replace("http://", "ws://") + "/phone-link/phone"
+ val request = Request.Builder().url(ws).build()
+ socket = client.newWebSocket(request, object : WebSocketListener() {
+ override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
+ val hello = JSONObject()
+ .put("v", 1)
+ .put("type", "phone.hello")
+ .put("deviceToken", deviceToken)
+ webSocket.send(hello.toString())
+ }
+
+ override fun onMessage(webSocket: WebSocket, text: String) {
+ val json = JSONObject(text)
+ when (json.optString("type")) {
+ "phone.accepted" -> _state.update { it.copy(paired = true, status = "已连上电脑,可以发指令。") }
+ "command.status" -> {
+ val labels = mapOf(
+ "accepted" to "电脑已接到",
+ "running" to "员工正在处理",
+ "completed" to "完成",
+ "failed" to "失败",
+ "busy" to "该员工正在忙",
+ "needs_approval" to "请在电脑上确认",
+ )
+ val state = json.optString("state")
+ _state.update {
+ it.copy(
+ status = labels[state] ?: state,
+ summary = json.optString("summary", it.summary),
+ )
+ }
+ }
+ }
+ }
+
+ override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
+ _state.update { it.copy(paired = false, status = "连接断开") }
+ }
+ })
+ }
+}
diff --git a/mobile/android/build.gradle.kts b/mobile/android/build.gradle.kts
new file mode 100644
index 00000000..3d6fd2b4
--- /dev/null
+++ b/mobile/android/build.gradle.kts
@@ -0,0 +1,5 @@
+plugins {
+ id("com.android.application") version "8.7.2" apply false
+ id("org.jetbrains.kotlin.android") version "2.0.21" apply false
+ id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
+}
diff --git a/mobile/android/gradle.properties b/mobile/android/gradle.properties
new file mode 100644
index 00000000..9b2fce43
--- /dev/null
+++ b/mobile/android/gradle.properties
@@ -0,0 +1,3 @@
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+org.gradle.jvmargs=-Xmx2g
diff --git a/mobile/android/settings.gradle.kts b/mobile/android/settings.gradle.kts
new file mode 100644
index 00000000..47df7d13
--- /dev/null
+++ b/mobile/android/settings.gradle.kts
@@ -0,0 +1,16 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+rootProject.name = "RoleWeave"
+include(":app")
From 1a0b3f179bb54c20334776dec6481a5abf4e644d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8B=92=E5=B8=83=E6=9C=97-=E8=A9=B9=E5=A7=86=E6=96=AF?=
<2986253039@qq.com>
Date: Fri, 18 Sep 2026 05:38:43 +0000
Subject: [PATCH 02/12] fix(android):
mobile/android/app/src/main/AndroidManifest.xml (#342 review)
---
mobile/android/app/src/main/AndroidManifest.xml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml
index 63da50bd..c2b66c3f 100644
--- a/mobile/android/app/src/main/AndroidManifest.xml
+++ b/mobile/android/app/src/main/AndroidManifest.xml
@@ -5,7 +5,7 @@
android:allowBackup="true"
android:label="RoleWeave"
android:supportsRtl="true"
- android:usesCleartextTraffic="true"
+ android:networkSecurityConfig="@xml/network_security_config"
android:theme="@android:style/Theme.DeviceDefault.NoActionBar">
Date: Fri, 18 Sep 2026 05:38:45 +0000
Subject: [PATCH 03/12] fix(android):
mobile/android/app/src/main/res/xml/network_security_config.xml (#342 review)
---
.../app/src/main/res/xml/network_security_config.xml | 9 +++++++++
1 file changed, 9 insertions(+)
create mode 100644 mobile/android/app/src/main/res/xml/network_security_config.xml
diff --git a/mobile/android/app/src/main/res/xml/network_security_config.xml b/mobile/android/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 00000000..6ae19f8d
--- /dev/null
+++ b/mobile/android/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
From 1e8fafc22a9f93447578a643ed92425ad7005322 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8B=92=E5=B8=83=E6=9C=97-=E8=A9=B9=E5=A7=86=E6=96=AF?=
<2986253039@qq.com>
Date: Fri, 18 Sep 2026 05:38:48 +0000
Subject: [PATCH 04/12] fix(android):
mobile/android/app/src/main/java/com/bytefolk/roleweave/PhoneLinkCodec.kt
(#342 review)
---
.../com/bytefolk/roleweave/PhoneLinkCodec.kt | 56 +++++++++++++++++++
1 file changed, 56 insertions(+)
create mode 100644 mobile/android/app/src/main/java/com/bytefolk/roleweave/PhoneLinkCodec.kt
diff --git a/mobile/android/app/src/main/java/com/bytefolk/roleweave/PhoneLinkCodec.kt b/mobile/android/app/src/main/java/com/bytefolk/roleweave/PhoneLinkCodec.kt
new file mode 100644
index 00000000..a6f8340a
--- /dev/null
+++ b/mobile/android/app/src/main/java/com/bytefolk/roleweave/PhoneLinkCodec.kt
@@ -0,0 +1,56 @@
+package com.bytefolk.roleweave
+
+import org.json.JSONObject
+
+object PhoneLinkCodec {
+ fun parseSnapshot(raw: String): Snapshot {
+ val json = JSONObject(raw)
+ val roles = json.getJSONArray("roles")
+ val list = buildList {
+ for (i in 0 until roles.length()) {
+ val item = roles.getJSONObject(i)
+ add(
+ Role(
+ id = item.getString("id"),
+ name = item.getString("name"),
+ description = item.optString("description"),
+ skillExcerpt = item.optString("skillExcerpt"),
+ )
+ )
+ }
+ }
+ return Snapshot(
+ name = json.getString("name"),
+ description = json.optString("description"),
+ roles = list,
+ )
+ }
+
+ fun helloPayload(deviceToken: String): String =
+ JSONObject()
+ .put("v", 1)
+ .put("type", "phone.hello")
+ .put("deviceToken", deviceToken)
+ .toString()
+
+ fun commandPayload(commandId: String, text: String, positionId: String?): String {
+ val payload = JSONObject()
+ .put("v", 1)
+ .put("type", "command.submit")
+ .put("commandId", commandId)
+ .put("text", text)
+ if (!positionId.isNullOrEmpty()) payload.put("positionId", positionId)
+ return payload.toString()
+ }
+
+ fun commandStatusLabel(state: String): String =
+ when (state) {
+ "accepted" -> "电脑已接到"
+ "running" -> "员工正在处理"
+ "completed" -> "完成"
+ "failed" -> "失败"
+ "busy" -> "该员工正在忙"
+ "needs_approval" -> "请在电脑上确认"
+ else -> state
+ }
+}
From 201749e3925b641ad194139a5377a9a083c1ac48 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8B=92=E5=B8=83=E6=9C=97-=E8=A9=B9=E5=A7=86=E6=96=AF?=
<2986253039@qq.com>
Date: Fri, 18 Sep 2026 05:38:50 +0000
Subject: [PATCH 05/12] fix(android):
mobile/android/app/src/main/java/com/bytefolk/roleweave/PhoneViewModel.kt
(#342 review)
---
.../com/bytefolk/roleweave/PhoneViewModel.kt | 120 ++++++++++++------
1 file changed, 79 insertions(+), 41 deletions(-)
diff --git a/mobile/android/app/src/main/java/com/bytefolk/roleweave/PhoneViewModel.kt b/mobile/android/app/src/main/java/com/bytefolk/roleweave/PhoneViewModel.kt
index 3ae00d5e..821a79e0 100644
--- a/mobile/android/app/src/main/java/com/bytefolk/roleweave/PhoneViewModel.kt
+++ b/mobile/android/app/src/main/java/com/bytefolk/roleweave/PhoneViewModel.kt
@@ -1,8 +1,11 @@
package com.bytefolk.roleweave
+import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
@@ -15,6 +18,7 @@ import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.json.JSONObject
import java.util.UUID
+import java.util.concurrent.TimeUnit
data class Role(
val id: String,
@@ -32,6 +36,7 @@ data class Snapshot(
data class PhoneState(
val host: String = "http://127.0.0.1:8800",
val snapshot: Snapshot? = null,
+ val selectedRoleId: String? = null,
val status: String = "尚未连接电脑",
val summary: String = "",
val paired: Boolean = false,
@@ -39,16 +44,25 @@ data class PhoneState(
)
class PhoneViewModel : ViewModel() {
- private val client = OkHttpClient()
+ private val client = OkHttpClient.Builder()
+ .readTimeout(0, TimeUnit.MILLISECONDS)
+ .build()
private val _state = MutableStateFlow(PhoneState())
val state: StateFlow = _state
private var socket: WebSocket? = null
private var deviceToken: String? = null
+ private var reconnectJob: Job? = null
+ private var reconnectAttempt = 0
+ private var stopReconnect = false
fun setHost(value: String) {
_state.update { it.copy(host = value) }
}
+ fun selectRole(id: String) {
+ _state.update { it.copy(selectedRoleId = id) }
+ }
+
fun loadOrg() {
viewModelScope.launch(Dispatchers.IO) {
try {
@@ -56,29 +70,23 @@ class PhoneViewModel : ViewModel() {
.url(_state.value.host.trimEnd('/') + "/api/mobile/workspace")
.build()
client.newCall(request).execute().use { response ->
- val json = JSONObject(response.body?.string().orEmpty())
- val roles = json.getJSONArray("roles")
- val list = buildList {
- for (i in 0 until roles.length()) {
- val item = roles.getJSONObject(i)
- add(
- Role(
- id = item.getString("id"),
- name = item.getString("name"),
- description = item.optString("description"),
- skillExcerpt = item.optString("skillExcerpt"),
- )
- )
- }
+ val body = response.body?.string().orEmpty()
+ if (!response.isSuccessful) {
+ Log.e(TAG, "loadOrg HTTP ${response.code}")
+ _state.update { it.copy(error = "组织预览读不到") }
+ return@use
}
+ val snapshot = PhoneLinkCodec.parseSnapshot(body)
_state.update {
it.copy(
- snapshot = Snapshot(json.getString("name"), json.optString("description"), list),
+ snapshot = snapshot,
+ selectedRoleId = it.selectedRoleId ?: snapshot.roles.firstOrNull()?.id,
error = null,
)
}
}
- } catch (_: Exception) {
+ } catch (error: Exception) {
+ Log.e(TAG, "loadOrg failed", error)
_state.update { it.copy(error = "组织预览读不到") }
}
}
@@ -98,45 +106,47 @@ class PhoneViewModel : ViewModel() {
val json = JSONObject(response.body?.string().orEmpty())
val token = json.optString("deviceToken")
if (!response.isSuccessful || token.isEmpty()) {
+ Log.e(TAG, "pair HTTP ${response.code}")
_state.update { it.copy(status = json.optString("message", "配对失败")) }
return@use
}
deviceToken = token
+ stopReconnect = false
+ reconnectAttempt = 0
connectSocket()
}
- } catch (_: Exception) {
+ } catch (error: Exception) {
+ Log.e(TAG, "pair failed", error)
_state.update { it.copy(status = "配对失败") }
}
}
}
- fun send(text: String, positionId: String?) {
+ fun send(text: String) {
+ val roleId = _state.value.selectedRoleId
+ if (roleId.isNullOrEmpty()) {
+ _state.update { it.copy(status = "先在组织里选一个岗位") }
+ return
+ }
val socket = socket
if (socket == null || !_state.value.paired) {
_state.update { it.copy(status = "还没连上电脑") }
return
}
- val payload = JSONObject()
- .put("v", 1)
- .put("type", "command.submit")
- .put("commandId", UUID.randomUUID().toString())
- .put("text", text)
- if (!positionId.isNullOrEmpty()) payload.put("positionId", positionId)
- socket.send(payload.toString())
+ socket.send(PhoneLinkCodec.commandPayload(UUID.randomUUID().toString(), text, roleId))
_state.update { it.copy(status = "已发出,等电脑受理…") }
}
private fun connectSocket() {
+ val token = deviceToken ?: return
val http = _state.value.host.trimEnd('/')
val ws = http.replace("https://", "wss://").replace("http://", "ws://") + "/phone-link/phone"
val request = Request.Builder().url(ws).build()
+ socket?.cancel()
socket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
- val hello = JSONObject()
- .put("v", 1)
- .put("type", "phone.hello")
- .put("deviceToken", deviceToken)
- webSocket.send(hello.toString())
+ reconnectAttempt = 0
+ webSocket.send(PhoneLinkCodec.helloPayload(token))
}
override fun onMessage(webSocket: WebSocket, text: String) {
@@ -144,18 +154,10 @@ class PhoneViewModel : ViewModel() {
when (json.optString("type")) {
"phone.accepted" -> _state.update { it.copy(paired = true, status = "已连上电脑,可以发指令。") }
"command.status" -> {
- val labels = mapOf(
- "accepted" to "电脑已接到",
- "running" to "员工正在处理",
- "completed" to "完成",
- "failed" to "失败",
- "busy" to "该员工正在忙",
- "needs_approval" to "请在电脑上确认",
- )
val state = json.optString("state")
_state.update {
it.copy(
- status = labels[state] ?: state,
+ status = PhoneLinkCodec.commandStatusLabel(state),
summary = json.optString("summary", it.summary),
)
}
@@ -163,9 +165,45 @@ class PhoneViewModel : ViewModel() {
}
}
+ override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
+ Log.e(TAG, "websocket failed", t)
+ scheduleReconnect()
+ }
+
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
- _state.update { it.copy(paired = false, status = "连接断开") }
+ Log.e(TAG, "websocket closed $code")
+ scheduleReconnect()
}
})
}
+
+ private fun scheduleReconnect() {
+ if (stopReconnect || deviceToken == null) {
+ _state.update { it.copy(paired = false, status = "连接断开") }
+ return
+ }
+ if (reconnectAttempt >= 5) {
+ _state.update { it.copy(paired = false, status = "连接断开,请重新配对") }
+ return
+ }
+ reconnectAttempt += 1
+ val waitMs = 1000L * reconnectAttempt
+ _state.update { it.copy(paired = false, status = "连接断开,正在重连…") }
+ reconnectJob?.cancel()
+ reconnectJob = viewModelScope.launch {
+ delay(waitMs)
+ connectSocket()
+ }
+ }
+
+ override fun onCleared() {
+ stopReconnect = true
+ reconnectJob?.cancel()
+ socket?.cancel()
+ super.onCleared()
+ }
+
+ private companion object {
+ const val TAG = "RoleWeave"
+ }
}
From 5d0f6715c2236d2c56457e3af8613f453c1b4bbe Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8B=92=E5=B8=83=E6=9C=97-=E8=A9=B9=E5=A7=86=E6=96=AF?=
<2986253039@qq.com>
Date: Fri, 18 Sep 2026 05:38:52 +0000
Subject: [PATCH 06/12] fix(android):
mobile/android/app/src/main/java/com/bytefolk/roleweave/MainActivity.kt (#342
review)
---
.../java/com/bytefolk/roleweave/MainActivity.kt | 15 ++++++++++-----
1 file changed, 10 insertions(+), 5 deletions(-)
diff --git a/mobile/android/app/src/main/java/com/bytefolk/roleweave/MainActivity.kt b/mobile/android/app/src/main/java/com/bytefolk/roleweave/MainActivity.kt
index 8d10bfde..47d3645a 100644
--- a/mobile/android/app/src/main/java/com/bytefolk/roleweave/MainActivity.kt
+++ b/mobile/android/app/src/main/java/com/bytefolk/roleweave/MainActivity.kt
@@ -73,7 +73,7 @@ fun RoleWeaveApp(model: PhoneViewModel) {
}
) { padding ->
when (tab) {
- 0 -> OrgScreen(state, Modifier.padding(padding))
+ 0 -> OrgScreen(state, model, Modifier.padding(padding))
1 -> CommandScreen(state, model, Modifier.padding(padding))
else -> SettingsScreen(Modifier.padding(padding))
}
@@ -81,7 +81,7 @@ fun RoleWeaveApp(model: PhoneViewModel) {
}
@Composable
-fun OrgScreen(state: PhoneState, modifier: Modifier = Modifier) {
+fun OrgScreen(state: PhoneState, model: PhoneViewModel, modifier: Modifier = Modifier) {
var selected by remember { mutableStateOf(null) }
if (selected != null) {
Column(modifier.padding(16.dp).fillMaxSize()) {
@@ -96,9 +96,12 @@ fun OrgScreen(state: PhoneState, modifier: Modifier = Modifier) {
state.error?.let { item { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(16.dp)) } }
items(state.snapshot?.roles.orEmpty()) { role ->
ListItem(
- headlineContent = { Text(role.name) },
+ headlineContent = { Text(role.name + if (role.id == state.selectedRoleId) " · 已选" else "") },
supportingContent = { Text(role.description) },
- modifier = Modifier.clickable { selected = role }
+ modifier = Modifier.clickable {
+ model.selectRole(role.id)
+ selected = role
+ }
)
}
}
@@ -116,9 +119,11 @@ fun CommandScreen(state: PhoneState, model: PhoneViewModel, modifier: Modifier =
Button(onClick = { model.pair(code) }, modifier = Modifier.padding(top = 12.dp).fillMaxWidth()) { Text("配对这台电脑") }
} else {
OutlinedTextField(text, onValueChange = { text = it }, label = { Text("指令") }, modifier = Modifier.fillMaxWidth(), minLines = 4)
+ val selectedName = state.snapshot?.roles?.firstOrNull { it.id == state.selectedRoleId }?.name
+ Text(if (selectedName == null) "先在组织里选一个岗位" else "发给 $selectedName")
Button(
onClick = {
- model.send(text, state.snapshot?.roles?.firstOrNull()?.id)
+ model.send(text)
text = ""
},
modifier = Modifier.padding(top = 12.dp).fillMaxWidth()
From b6cd5c938278762016e21ad72770ccd1649b736e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8B=92=E5=B8=83=E6=9C=97-=E8=A9=B9=E5=A7=86=E6=96=AF?=
<2986253039@qq.com>
Date: Fri, 18 Sep 2026 05:38:54 +0000
Subject: [PATCH 07/12] fix(android):
mobile/android/app/src/test/java/com/bytefolk/roleweave/PhoneLinkCodecTest.kt
(#342 review)
---
.../bytefolk/roleweave/PhoneLinkCodecTest.kt | 49 +++++++++++++++++++
1 file changed, 49 insertions(+)
create mode 100644 mobile/android/app/src/test/java/com/bytefolk/roleweave/PhoneLinkCodecTest.kt
diff --git a/mobile/android/app/src/test/java/com/bytefolk/roleweave/PhoneLinkCodecTest.kt b/mobile/android/app/src/test/java/com/bytefolk/roleweave/PhoneLinkCodecTest.kt
new file mode 100644
index 00000000..b65ec20c
--- /dev/null
+++ b/mobile/android/app/src/test/java/com/bytefolk/roleweave/PhoneLinkCodecTest.kt
@@ -0,0 +1,49 @@
+package com.bytefolk.roleweave
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class PhoneLinkCodecTest {
+ @Test
+ fun parseSnapshotReadsRolesWithoutHostPaths() {
+ val snapshot = PhoneLinkCodec.parseSnapshot(
+ """
+ {
+ "name": "oss-maintainer",
+ "description": "开源维护组织",
+ "roles": [
+ {"id": "repo-owner", "name": "仓库负责人", "description": "路线图", "skillExcerpt": "只读"}
+ ]
+ }
+ """.trimIndent()
+ )
+ assertEquals("oss-maintainer", snapshot.name)
+ assertEquals("repo-owner", snapshot.roles.single().id)
+ assertFalse(snapshot.roles.single().skillExcerpt.contains("/workspace"))
+ }
+
+ @Test
+ fun helloPayloadNeverEmbedsCommandText() {
+ val hello = PhoneLinkCodec.helloPayload("abc")
+ assertTrue(hello.contains("phone.hello"))
+ assertTrue(hello.contains("abc"))
+ assertFalse(hello.contains("command.submit"))
+ }
+
+ @Test
+ fun commandPayloadRequiresExplicitPosition() {
+ val withRole = PhoneLinkCodec.commandPayload("cmd-1", "看一下 PR", "issue-researcher")
+ assertTrue(withRole.contains("issue-researcher"))
+ val withoutRole = PhoneLinkCodec.commandPayload("cmd-1", "看一下 PR", null)
+ assertFalse(withoutRole.contains("positionId"))
+ }
+
+ @Test
+ fun commandStatusLabelsAreStable() {
+ assertEquals("电脑已接到", PhoneLinkCodec.commandStatusLabel("accepted"))
+ assertEquals("请在电脑上确认", PhoneLinkCodec.commandStatusLabel("needs_approval"))
+ assertEquals("unknown", PhoneLinkCodec.commandStatusLabel("unknown"))
+ }
+}
From 14c69c7d4e27d632cd1f4e8b46196020d696d1e2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8B=92=E5=B8=83=E6=9C=97-=E8=A9=B9=E5=A7=86=E6=96=AF?=
<2986253039@qq.com>
Date: Fri, 18 Sep 2026 05:38:56 +0000
Subject: [PATCH 08/12] fix(android): mobile/android/app/build.gradle.kts (#342
review)
---
mobile/android/app/build.gradle.kts | 1 +
1 file changed, 1 insertion(+)
diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts
index 1ff1be38..1371a072 100644
--- a/mobile/android/app/build.gradle.kts
+++ b/mobile/android/app/build.gradle.kts
@@ -32,4 +32,5 @@ dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.json:json:20240303")
+ testImplementation("junit:junit:4.13.2")
}
From 0260835176c4e5318cc02275585b19f27f99467e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8B=92=E5=B8=83=E6=9C=97-=E8=A9=B9=E5=A7=86=E6=96=AF?=
<2986253039@qq.com>
Date: Fri, 18 Sep 2026 05:38:58 +0000
Subject: [PATCH 09/12] fix(android): mobile/android/README.md (#342 review)
---
mobile/android/README.md | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/mobile/android/README.md b/mobile/android/README.md
index 28239417..f54dfc15 100644
--- a/mobile/android/README.md
+++ b/mobile/android/README.md
@@ -6,4 +6,6 @@ Native Kotlin + Compose client. Open the `android/` folder in Android Studio (Ko
2. Run on a device or emulator (API 26+).
3. In 指令, set the RoleWeave host, pair with the desktop code, send a command.
-This project does not compile in the Linux sandbox (no JDK / Android SDK).
+Release builds require HTTPS. Debug builds allow HTTP so a local RoleWeave (`127.0.0.1`) can pair.
+
+CI runs `gradle test` on `mobile/android` via `.github/workflows/android-client.yml`.
From 40fab92e184bcefd0dddd91d12a1589f1836f96c Mon Sep 17 00:00:00 2001
From: asteam-worker
Date: Fri, 18 Sep 2026 14:37:05 +0000
Subject: [PATCH 10/12] fix(android): exponential reconnect, explicit role,
debug-only cleartext (#339)
Do not auto-select roles.first() on loadOrg. Reconnect waits 1/2/4/8/16s.
Release HTTPS only; debug source set permits HTTP for 127.0.0.1.
Add android-client.yml so gradle test actually runs.
---
.github/workflows/android-client.yml | 34 +++++++++++++++++++
CHANGELOG.md | 6 ++++
mobile/android/README.md | 6 ++--
.../debug/res/xml/network_security_config.xml | 5 +++
.../com/bytefolk/roleweave/PhoneLinkCodec.kt | 6 ++++
.../com/bytefolk/roleweave/PhoneViewModel.kt | 7 ++--
.../main/res/xml/network_security_config.xml | 6 +---
.../bytefolk/roleweave/PhoneLinkCodecTest.kt | 9 +++++
8 files changed, 70 insertions(+), 9 deletions(-)
create mode 100644 .github/workflows/android-client.yml
create mode 100644 mobile/android/app/src/debug/res/xml/network_security_config.xml
diff --git a/.github/workflows/android-client.yml b/.github/workflows/android-client.yml
new file mode 100644
index 00000000..965b0dc3
--- /dev/null
+++ b/.github/workflows/android-client.yml
@@ -0,0 +1,34 @@
+name: Android client
+
+on:
+ pull_request:
+ paths:
+ - "mobile/android/**"
+ - ".github/workflows/android-client.yml"
+ push:
+ branches: [main]
+ paths:
+ - "mobile/android/**"
+ - ".github/workflows/android-client.yml"
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ name: gradle test
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@v6
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "17"
+ - uses: android-actions/setup-android@v3
+ - uses: gradle/actions/setup-gradle@v4
+ with:
+ gradle-version: "8.9"
+ - name: Unit tests
+ working-directory: mobile/android
+ run: gradle test --no-daemon
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1d6a4857..89f5a378 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,12 @@
本仓库采用 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 格式。
早期开发记录以里程碑(D0/D1/D2…)标注,安装包发布使用语义化版本。
+## [Unreleased]
+
+### Fixed
+
+- #339 Android 原生客户端:release 强制 HTTPS;debug 才允许明文以便连本机 RoleWeave。WebSocket 断开按 1/2/4/8/16s 指数退避重连最多 5 次。发指令必须在组织页显式点选岗位,不再默认第一角色。`android-client.yml` 对 `mobile/android` 跑 `gradle test`。
+
## [0.3.0] — 2026-09-18
### Added
diff --git a/mobile/android/README.md b/mobile/android/README.md
index f54dfc15..2acc428c 100644
--- a/mobile/android/README.md
+++ b/mobile/android/README.md
@@ -6,6 +6,8 @@ Native Kotlin + Compose client. Open the `android/` folder in Android Studio (Ko
2. Run on a device or emulator (API 26+).
3. In 指令, set the RoleWeave host, pair with the desktop code, send a command.
-Release builds require HTTPS. Debug builds allow HTTP so a local RoleWeave (`127.0.0.1`) can pair.
+Release builds require HTTPS (`src/main` network security config). Debug builds allow HTTP (`src/debug`) so a local RoleWeave (`127.0.0.1`) can pair.
-CI runs `gradle test` on `mobile/android` via `.github/workflows/android-client.yml`.
+Commands require an explicit org-tab role tap. Load does not pick `roles.first()`.
+
+CI: `.github/workflows/android-client.yml` runs `gradle test` (JDK 17 + Android SDK) on `mobile/android/**`.
diff --git a/mobile/android/app/src/debug/res/xml/network_security_config.xml b/mobile/android/app/src/debug/res/xml/network_security_config.xml
new file mode 100644
index 00000000..f5bb5061
--- /dev/null
+++ b/mobile/android/app/src/debug/res/xml/network_security_config.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/mobile/android/app/src/main/java/com/bytefolk/roleweave/PhoneLinkCodec.kt b/mobile/android/app/src/main/java/com/bytefolk/roleweave/PhoneLinkCodec.kt
index a6f8340a..8ab5fad5 100644
--- a/mobile/android/app/src/main/java/com/bytefolk/roleweave/PhoneLinkCodec.kt
+++ b/mobile/android/app/src/main/java/com/bytefolk/roleweave/PhoneLinkCodec.kt
@@ -43,6 +43,12 @@ object PhoneLinkCodec {
return payload.toString()
}
+ /** Exponential backoff: 1s, 2s, 4s, 8s, 16s for attempts 1..5. */
+ fun reconnectDelayMs(attempt: Int): Long {
+ val n = attempt.coerceAtLeast(1).coerceAtMost(16)
+ return 1000L shl (n - 1)
+ }
+
fun commandStatusLabel(state: String): String =
when (state) {
"accepted" -> "电脑已接到"
diff --git a/mobile/android/app/src/main/java/com/bytefolk/roleweave/PhoneViewModel.kt b/mobile/android/app/src/main/java/com/bytefolk/roleweave/PhoneViewModel.kt
index 821a79e0..fcf1f055 100644
--- a/mobile/android/app/src/main/java/com/bytefolk/roleweave/PhoneViewModel.kt
+++ b/mobile/android/app/src/main/java/com/bytefolk/roleweave/PhoneViewModel.kt
@@ -78,9 +78,12 @@ class PhoneViewModel : ViewModel() {
}
val snapshot = PhoneLinkCodec.parseSnapshot(body)
_state.update {
+ val stillValid = it.selectedRoleId?.takeIf { id ->
+ snapshot.roles.any { role -> role.id == id }
+ }
it.copy(
snapshot = snapshot,
- selectedRoleId = it.selectedRoleId ?: snapshot.roles.firstOrNull()?.id,
+ selectedRoleId = stillValid,
error = null,
)
}
@@ -187,7 +190,7 @@ class PhoneViewModel : ViewModel() {
return
}
reconnectAttempt += 1
- val waitMs = 1000L * reconnectAttempt
+ val waitMs = PhoneLinkCodec.reconnectDelayMs(reconnectAttempt)
_state.update { it.copy(paired = false, status = "连接断开,正在重连…") }
reconnectJob?.cancel()
reconnectJob = viewModelScope.launch {
diff --git a/mobile/android/app/src/main/res/xml/network_security_config.xml b/mobile/android/app/src/main/res/xml/network_security_config.xml
index 6ae19f8d..fcc7d981 100644
--- a/mobile/android/app/src/main/res/xml/network_security_config.xml
+++ b/mobile/android/app/src/main/res/xml/network_security_config.xml
@@ -1,9 +1,5 @@
+
-
-
-
-
-
diff --git a/mobile/android/app/src/test/java/com/bytefolk/roleweave/PhoneLinkCodecTest.kt b/mobile/android/app/src/test/java/com/bytefolk/roleweave/PhoneLinkCodecTest.kt
index b65ec20c..fcf081b2 100644
--- a/mobile/android/app/src/test/java/com/bytefolk/roleweave/PhoneLinkCodecTest.kt
+++ b/mobile/android/app/src/test/java/com/bytefolk/roleweave/PhoneLinkCodecTest.kt
@@ -40,6 +40,15 @@ class PhoneLinkCodecTest {
assertFalse(withoutRole.contains("positionId"))
}
+ @Test
+ fun reconnectDelayIsExponential() {
+ assertEquals(1000L, PhoneLinkCodec.reconnectDelayMs(1))
+ assertEquals(2000L, PhoneLinkCodec.reconnectDelayMs(2))
+ assertEquals(4000L, PhoneLinkCodec.reconnectDelayMs(3))
+ assertEquals(8000L, PhoneLinkCodec.reconnectDelayMs(4))
+ assertEquals(16000L, PhoneLinkCodec.reconnectDelayMs(5))
+ }
+
@Test
fun commandStatusLabelsAreStable() {
assertEquals("电脑已接到", PhoneLinkCodec.commandStatusLabel("accepted"))
From e5f6c116e70ef2dded5d8f5453e336ca61869ac1 Mon Sep 17 00:00:00 2001
From: asteam-worker
Date: Fri, 18 Sep 2026 15:08:13 +0000
Subject: [PATCH 11/12] fix(ci): stop installing retired Android SDK tools
package (#339)
android-actions/setup-android@v3 defaulted to sdkmanager tools,
which Google removed. Install platform-tools, android-35, and
build-tools only so gradle test can run.
---
.github/workflows/android-client.yml | 7 +++++++
CHANGELOG.md | 2 +-
2 files changed, 8 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/android-client.yml b/.github/workflows/android-client.yml
index 965b0dc3..3acc4cbd 100644
--- a/.github/workflows/android-client.yml
+++ b/.github/workflows/android-client.yml
@@ -25,7 +25,14 @@ jobs:
with:
distribution: temurin
java-version: "17"
+ # Do not install the retired `tools` package. setup-android@v3 defaults
+ # to `sdkmanager tools`, which now fails: Failed to find package 'tools'.
- uses: android-actions/setup-android@v3
+ with:
+ packages: |
+ platform-tools
+ platforms;android-35
+ build-tools;35.0.0
- uses: gradle/actions/setup-gradle@v4
with:
gradle-version: "8.9"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 89f5a378..835f94cb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,7 +7,7 @@
### Fixed
-- #339 Android 原生客户端:release 强制 HTTPS;debug 才允许明文以便连本机 RoleWeave。WebSocket 断开按 1/2/4/8/16s 指数退避重连最多 5 次。发指令必须在组织页显式点选岗位,不再默认第一角色。`android-client.yml` 对 `mobile/android` 跑 `gradle test`。
+- #339 Android 原生客户端:release 强制 HTTPS;debug 才允许明文以便连本机 RoleWeave。WebSocket 断开按 1/2/4/8/16s 指数退避重连最多 5 次。发指令必须在组织页显式点选岗位,不再默认第一角色。`android-client.yml` 对 `mobile/android` 跑 `gradle test`(不安装已下线的 SDK `tools` 包)。
## [0.3.0] — 2026-09-18
From 5d65d0c822ccb073624091c57f7a6b1e31036041 Mon Sep 17 00:00:00 2001
From: asteam-worker
Date: Fri, 18 Sep 2026 15:10:15 +0000
Subject: [PATCH 12/12] fix(ci): install Android SDK via sdkmanager, skip
retired tools (#339)
Drop android-actions/setup-android. It still invokes sdkmanager tools
(removed by Google) and multiline packages were parsed as one name.
Use the runner cmdline-tools to install platform-tools, android-35,
and build-tools only.
---
.github/workflows/android-client.yml | 22 ++++++++++++++--------
1 file changed, 14 insertions(+), 8 deletions(-)
diff --git a/.github/workflows/android-client.yml b/.github/workflows/android-client.yml
index 3acc4cbd..3f009efe 100644
--- a/.github/workflows/android-client.yml
+++ b/.github/workflows/android-client.yml
@@ -25,17 +25,23 @@ jobs:
with:
distribution: temurin
java-version: "17"
- # Do not install the retired `tools` package. setup-android@v3 defaults
- # to `sdkmanager tools`, which now fails: Failed to find package 'tools'.
- - uses: android-actions/setup-android@v3
- with:
- packages: |
- platform-tools
- platforms;android-35
- build-tools;35.0.0
+ # Do not use android-actions/setup-android: it still runs
+ # `sdkmanager tools`, and Google removed that package.
+ # GitHub-hosted ubuntu-latest already has cmdline-tools.
+ - name: Install Android SDK packages
+ run: |
+ set -euo pipefail
+ SDKROOT="${ANDROID_HOME:-/usr/local/lib/android/sdk}"
+ SDKMANAGER="$(find "$SDKROOT/cmdline-tools" -name sdkmanager -type f | sort | tail -1)"
+ test -n "$SDKMANAGER"
+ yes | "$SDKMANAGER" --licenses >/dev/null || true
+ "$SDKMANAGER" "platform-tools" "platforms;android-35" "build-tools;35.0.0"
- uses: gradle/actions/setup-gradle@v4
with:
gradle-version: "8.9"
- name: Unit tests
working-directory: mobile/android
+ env:
+ ANDROID_HOME: /usr/local/lib/android/sdk
+ ANDROID_SDK_ROOT: /usr/local/lib/android/sdk
run: gradle test --no-daemon