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