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")