Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .github/workflows/android-client.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions mobile/android/README.md
Original file line number Diff line number Diff line change
@@ -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/**`.
36 changes: 36 additions & 0 deletions mobile/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Debug only: local RoleWeave on 127.0.0.1 still speaks HTTP. -->
<base-config cleartextTrafficPermitted="true" />
</network-security-config>
20 changes: 20 additions & 0 deletions mobile/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:label="RoleWeave"
android:supportsRtl="true"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@android:style/Theme.DeviceDefault.NoActionBar">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -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<PhoneViewModel>()

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<Role?>(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("组织 只读预览")
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading