diff --git a/.github/workflows/android-client.yml b/.github/workflows/android-client.yml new file mode 100644 index 00000000..3f009efe --- /dev/null +++ b/.github/workflows/android-client.yml @@ -0,0 +1,47 @@ +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" + # 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d6a4857..835f94cb 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`(不安装已下线的 SDK `tools` 包)。 + ## [0.3.0] — 2026-09-18 ### Added diff --git a/mobile/android/README.md b/mobile/android/README.md new file mode 100644 index 00000000..2acc428c --- /dev/null +++ b/mobile/android/README.md @@ -0,0 +1,13 @@ +# 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. + +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. + +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/build.gradle.kts b/mobile/android/app/build.gradle.kts new file mode 100644 index 00000000..1371a072 --- /dev/null +++ b/mobile/android/app/build.gradle.kts @@ -0,0 +1,36 @@ +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") + testImplementation("junit:junit:4.13.2") +} 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/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..c2b66c3f --- /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..47d3645a --- /dev/null +++ b/mobile/android/app/src/main/java/com/bytefolk/roleweave/MainActivity.kt @@ -0,0 +1,145 @@ +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, model, Modifier.padding(padding)) + 1 -> CommandScreen(state, model, Modifier.padding(padding)) + else -> SettingsScreen(Modifier.padding(padding)) + } + } +} + +@Composable +fun OrgScreen(state: PhoneState, model: PhoneViewModel, 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 + if (role.id == state.selectedRoleId) " · 已选" else "") }, + supportingContent = { Text(role.description) }, + modifier = Modifier.clickable { + model.selectRole(role.id) + 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) + val selectedName = state.snapshot?.roles?.firstOrNull { it.id == state.selectedRoleId }?.name + Text(if (selectedName == null) "先在组织里选一个岗位" else "发给 $selectedName") + Button( + onClick = { + model.send(text) + 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/PhoneLinkCodec.kt b/mobile/android/app/src/main/java/com/bytefolk/roleweave/PhoneLinkCodec.kt new file mode 100644 index 00000000..8ab5fad5 --- /dev/null +++ b/mobile/android/app/src/main/java/com/bytefolk/roleweave/PhoneLinkCodec.kt @@ -0,0 +1,62 @@ +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() + } + + /** 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" -> "电脑已接到" + "running" -> "员工正在处理" + "completed" -> "完成" + "failed" -> "失败" + "busy" -> "该员工正在忙" + "needs_approval" -> "请在电脑上确认" + else -> state + } +} 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..fcf1f055 --- /dev/null +++ b/mobile/android/app/src/main/java/com/bytefolk/roleweave/PhoneViewModel.kt @@ -0,0 +1,212 @@ +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 +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 +import java.util.concurrent.TimeUnit + +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 selectedRoleId: String? = null, + val status: String = "尚未连接电脑", + val summary: String = "", + val paired: Boolean = false, + val error: String? = null, +) + +class PhoneViewModel : ViewModel() { + 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 { + val request = Request.Builder() + .url(_state.value.host.trimEnd('/') + "/api/mobile/workspace") + .build() + client.newCall(request).execute().use { response -> + 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 { + val stillValid = it.selectedRoleId?.takeIf { id -> + snapshot.roles.any { role -> role.id == id } + } + it.copy( + snapshot = snapshot, + selectedRoleId = stillValid, + error = null, + ) + } + } + } catch (error: Exception) { + Log.e(TAG, "loadOrg failed", error) + _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()) { + 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 (error: Exception) { + Log.e(TAG, "pair failed", error) + _state.update { it.copy(status = "配对失败") } + } + } + } + + 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 + } + 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) { + reconnectAttempt = 0 + webSocket.send(PhoneLinkCodec.helloPayload(token)) + } + + 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 state = json.optString("state") + _state.update { + it.copy( + status = PhoneLinkCodec.commandStatusLabel(state), + summary = json.optString("summary", it.summary), + ) + } + } + } + } + + 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) { + 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 = PhoneLinkCodec.reconnectDelayMs(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" + } +} 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..fcc7d981 --- /dev/null +++ b/mobile/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +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 new file mode 100644 index 00000000..fcf081b2 --- /dev/null +++ b/mobile/android/app/src/test/java/com/bytefolk/roleweave/PhoneLinkCodecTest.kt @@ -0,0 +1,58 @@ +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 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")) + assertEquals("请在电脑上确认", PhoneLinkCodec.commandStatusLabel("needs_approval")) + assertEquals("unknown", PhoneLinkCodec.commandStatusLabel("unknown")) + } +} 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")