feat(android): provider selection, stop button, adaptive icon, release signing; docs + previews

This commit is contained in:
OpenClaw Dev 2026-10-25 04:52:27 +01:00
parent 49fdc5a189
commit 6ff31589be
38 changed files with 468 additions and 41 deletions

View file

@ -26,7 +26,7 @@ Abhängigkeiten des Backends: `gopkg.in/yaml.v3`, `modernc.org/sqlite`, `github.
- TLS-Zertifikatsprüfung ist überall an (LLM-Provider, `web_fetch`); es gibt keinen Schalter zum Abschalten.
- `web_fetch`: nur http(s), Timeout, Größenlimit, max. 5 Redirects, blockiert Loopback/Private/Link-Local/CGNAT-Adressen beim Verbindungsaufbau (schützt auch gegen DNS-Rebinding und Redirect-Tricks). Abschaltbar per `allow_private`.
- Datei-Tools sind per `os.Root` auf den Workspace beschränkt (kein `..`, keine absoluten Pfade, keine Symlink-Ausbrüche) und größenbegrenzt.
- Kein Shell-Tool.
- Shell-Tool standardmäßig AUS (`tools.shell.enabled`). Wenn an: nur Befehle aus `allowed_commands` (Interpreter/Netzwerk-/Shell-Programme wie `sh`, `bash`, `env`, `xargs`, `python`, `curl`, `git` sind nie erlaubt), jedes Glied von `|`, `&&`, `||`, `;` wird geprüft, `$`, Backticks, `<( )`, Umleitungen, `&`, Klammern werden abgelehnt, keine Pfade außerhalb des Workspace (`/`, `~`, `..`), Ausführung ohne `sh -c` direkt per exec im Workspace, Timeout, Output-Limit, bereinigte Umgebung (nur `PATH`, `HOME`=Workspace, `LANG`, `TERM` – keine API-Keys/Token).
- Docker: distroless, User `65532` (nonroot), Go-Version fixiert.
- Android: Release-Builds erlauben nur HTTPS/WSS; Klartext (http/ws) nur im Debug-Build für lokale Entwicklung. Token/URL verschlüsselt per Android Keystore.
@ -79,10 +79,26 @@ curl -H "Authorization: Bearer $BYTECLAW_AUTH_TOKEN" -d '{"text":"Wie spät ist
| GET | /api/sessions/{id}/messages | Verlauf |
| POST | /api/chat | Nicht-streamend `{"session_id"?, "text"}`|
| GET | /api/ws | WebSocket-Streaming |
| POST | /api/sessions/{id}/cancel | Laufende Antwort abbrechen |
| POST | /api/sessions/{id}/summarize | Zusammenfassung manuell auslösen |
| GET | /api/sessions/{id}/summary | Letzte Zusammenfassung |
| GET | /api/providers | Provider-Liste + aktiv (ohne Keys) |
| PUT | /api/providers/active | Aktiven Provider/Modell setzen `{"provider","model"?}` |
| GET | /api/providers/{name}/models | Modellliste vom Provider (`/v1/models`) |
| PUT | /api/sessions/{id}/provider | Provider nur für diese Session (`{"provider":""}` = Standard) |
Alle `/api/*` brauchen `Authorization: Bearer <token>`.
WebSocket: Client sendet `{"type":"message","session_id":"<optional>","text":"..."}`; Server sendet Events
`session`, `delta`, `tool_call`, `tool_result`, `done` oder `error` (`{"type","text","tool","args","call_id","session_id"}`). `{"type":"ping"}` → `pong`.
`session`, `summarized`, `delta`, `tool_call`, `tool_result`, `done`, `cancelled` oder `error` (`{"type","text","tool","args","call_id","session_id"}`). `{"type":"ping"}` → `pong`.
Abbrechen: `{"type":"cancel","session_id":"…"}` über denselben WebSocket oder `POST /api/sessions/{id}/cancel`. Der Abbruch läuft per Context bis in den HTTP-Request zum Provider; die bis dahin gestreamte Teilantwort wird als Assistant-Nachricht mit `"cancelled":true` gespeichert.
### Provider umschalten
Alle Presets (openai, openrouter, ollama, dahl, gonkarouter) plus der in `llm.*` konfigurierte Provider stehen zur Auswahl. Keys kommen nur aus Env (`DAHL_API_KEY`, `GONKAROUTER_API_KEY`, …) oder `llm.providers.<name>.api_key` und verlassen den Server nie; die App sieht nur `configured: true/false`. Die Auswahl wird in SQLite gespeichert und überlebt Neustarts; pro Session kann ein anderer Provider gesetzt werden.
### Lange Sessions
`agent.summarize`: ab `max_messages` (Standard 40) oder ~`max_tokens` (12000, Zeichen/4) noch nicht zusammengefasster Nachrichten fasst das aktive LLM die älteren Nachrichten zusammen (die neuesten `keep_recent` bleiben wörtlich, Schnitt immer vor einer User-Nachricht). Die Zusammenfassung liegt in der Tabelle `summaries`, die Originalnachrichten bleiben erhalten.
### Build, Tests, Cross-Builds
@ -124,18 +140,37 @@ Build (JDK 17 + Android SDK, `ANDROID_HOME` oder `local.properties` mit `sdk.dir
cd android
./gradlew assembleDebug # app/build/outputs/apk/debug/app-debug.apk
./gradlew testDebugUnitTest
./gradlew assembleRelease # unsigniert; Signing-Config fehlt noch
./gradlew assembleRelease # signiert, wenn Keystore-Daten vorhanden sind (sonst unsigniert)
```
### Release-Signing
Die `signingConfig` liest Keystore-Daten aus Umgebungsvariablen (`BYTECLAW_KEYSTORE_FILE`, `BYTECLAW_KEYSTORE_PASSWORD`, `BYTECLAW_KEY_ALIAS`, `BYTECLAW_KEY_PASSWORD`) oder aus `android/keystore.properties` (Vorlage: `keystore.properties.example`). `keystore.properties`, `*.jks` und `*.keystore` sind gitignored – Keystore niemals ins Repo.
```bash
mkdir -p ~/byteclaw-keys && chmod 700 ~/byteclaw-keys
keytool -genkeypair -keystore ~/byteclaw-keys/byteclaw-release.jks -storetype PKCS12 \
-alias byteclaw -keyalg RSA -keysize 3072 -validity 10000 -dname "CN=ByteClaw, C=DE"
cp android/keystore.properties.example android/keystore.properties # Passwörter eintragen
cd android && ./gradlew assembleRelease
$ANDROID_HOME/build-tools/35.0.0/apksigner verify --print-certs app/build/outputs/apk/release/app-release.apk
```
Keystore und Passwörter sicher sichern: Ohne denselben Schlüssel lassen sich Updates nicht installieren.
### App-Icon
Adaptive Icon als Vektor (`res/drawable/ic_launcher_{background,foreground,monochrome}.xml`, `mipmap-anydpi-v26`): drei Krallen-Kratzer in Claw-Orange plus Byte-Bits in Türkis auf dunklem Navy – dieselben Markenfarben wie im Compose-Theme (`ui/Theme.kt`, `res/values/colors.xml`). Da minSdk 26 ist, deckt der Vektor alle Dichten ab; die Monochrom-Ebene sorgt für Themed Icons ab Android 13. Vorschau: `preview/icon.png`.
In der App unter Einstellungen Server-URL (`https://…`; im Debug-Build auch `http://10.0.2.2:8787` für den Emulator) und Token eintragen, „Verbindung testen", speichern.
## Nächste Schritte
- Release-Signing, App-Icon, CI (GitHub Actions) für Go + Android
- Session-Kontext zusammenfassen statt nur Fenster der letzten N Nachrichten
- Weitere Provider (Anthropic nativ), Abbrechen laufender Antworten über WebSocket
- CI (GitHub Actions) für Go + Android
- Weitere Provider (Anthropic nativ)
- App: Provider pro Session wählen (API existiert schon)
- Optional: Einrichtung per QR-Code (URL + Token)
## Vorschau
Screenshots mit Demo-Daten (Paparazzi, ohne Emulator) liegen in `preview/` (hell/dunkel). Neu erzeugen: `cd android && ./gradlew recordPaparazziDebug`.
Screenshots mit Demo-Daten (Paparazzi, ohne Emulator) liegen in `preview/` (hell/dunkel): Chat mit Stopp-Button, Sessions, Einstellungen, Provider-Auswahl, `icon.png`. Neu erzeugen: `cd android && ./gradlew recordPaparazziDebug`.

3
android/.gitignore vendored
View file

@ -4,3 +4,6 @@ app/build/
local.properties
.idea/
*.iml
keystore.properties
*.jks
*.keystore

View file

@ -1,3 +1,5 @@
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
@ -6,6 +8,21 @@ plugins {
alias(libs.plugins.paparazzi) // screenshot tests only (JVM, test source set)
}
// Release signing: values come from env (BYTECLAW_KEYSTORE_FILE/_PASSWORD, BYTECLAW_KEY_ALIAS/_PASSWORD)
// or from android/keystore.properties (storeFile, storePassword, keyAlias, keyPassword).
// keystore.properties and *.jks/*.keystore are gitignored. Without them the release APK stays unsigned.
val keystoreProps = Properties().apply {
val f = rootProject.file("keystore.properties")
if (f.isFile) f.inputStream().use { load(it) }
}
fun signingValue(env: String, prop: String): String? =
System.getenv(env)?.takeIf { it.isNotBlank() } ?: keystoreProps.getProperty(prop)?.takeIf { it.isNotBlank() }
val releaseStoreFile = signingValue("BYTECLAW_KEYSTORE_FILE", "storeFile")
val releaseStorePassword = signingValue("BYTECLAW_KEYSTORE_PASSWORD", "storePassword")
val releaseKeyAlias = signingValue("BYTECLAW_KEY_ALIAS", "keyAlias")
val releaseKeyPassword = signingValue("BYTECLAW_KEY_PASSWORD", "keyPassword")
val hasReleaseSigning = listOf(releaseStoreFile, releaseStorePassword, releaseKeyAlias, releaseKeyPassword).all { it != null }
android {
namespace = "dev.byteclaw.client"
compileSdk = 35
@ -18,8 +35,22 @@ android {
versionName = "0.1.0"
}
signingConfigs {
if (hasReleaseSigning) {
create("release") {
storeFile = file(releaseStoreFile!!.replaceFirst(Regex("^~"), System.getProperty("user.home")))
storePassword = releaseStorePassword
keyAlias = releaseKeyAlias
keyPassword = releaseKeyPassword
enableV2Signing = true
enableV3Signing = true
}
}
}
buildTypes {
release {
if (hasReleaseSigning) signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}

View file

@ -4,7 +4,8 @@
<application
android:allowBackup="false"
android:label="@string/app_name"
android:icon="@android:drawable/sym_def_app_icon"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.ByteClaw">
<activity

View file

@ -35,7 +35,7 @@ class MainActivity : ComponentActivity() {
composable("chat") {
ChatScreen(
state, onSend = vm::send, onNewChat = vm::newChat, onDismissError = vm::dismissError,
onSessions = { nav.navigate("sessions") }, onSettings = { nav.navigate("settings") },
onSessions = { nav.navigate("sessions") }, onSettings = { nav.navigate("settings") }, onStop = vm::cancel,
)
}
composable("sessions") {
@ -48,7 +48,8 @@ class MainActivity : ComponentActivity() {
composable("settings") {
SettingsScreen(state, onSave = vm::saveSettings, onTest = { s, cb -> vm.testConnection(s, cb) }, onBack = {
if (!nav.popBackStack()) nav.navigate("chat") { popUpTo("settings") { inclusive = true } }
})
}, onLoadProviders = { vm.loadProviders() }, onLoadModels = { vm.loadModels(it) },
onSelectProvider = { p, m -> vm.selectProvider(p, m) })
}
}
}

View file

@ -7,7 +7,9 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
@ -51,6 +53,22 @@ class ByteClawApi(private val settings: ServerSettings) {
call(Request.Builder().url(url("/api/sessions/$sessionId")).delete().build())
}
suspend fun providers(): ProvidersResponse =
json.decodeFromString(call(Request.Builder().url(url("/api/providers")).build()))
suspend fun setActiveProvider(provider: String, model: String): ActiveProvider {
val body = json.encodeToString(ActiveProvider.serializer(), ActiveProvider(provider, model)).toRequestBody(JSON)
return json.decodeFromString(call(Request.Builder().url(url("/api/providers/active")).put(body).build()))
}
/** Model ids from the provider's /v1/models, fetched by the server with its own key. */
suspend fun models(provider: String): List<String> =
json.decodeFromString<ModelsResponse>(call(Request.Builder().url(url("/api/providers/$provider/models")).build())).models
/** Stops the running answer of a session; the partial answer is kept on the server. */
suspend fun cancel(sessionId: String): Boolean =
json.decodeFromString<CancelResponse>(call(Request.Builder().url(url("/api/sessions/$sessionId/cancel")).post(ByteArray(0).toRequestBody(null)).build())).cancelled
/**
* Opens a WebSocket, sends one message and emits streamed events until "done"/"error".
*/
@ -64,7 +82,7 @@ class ByteClawApi(private val settings: ServerSettings) {
override fun onMessage(webSocket: WebSocket, text: String) {
val ev = runCatching { json.decodeFromString(AgentEvent.serializer(), text) }.getOrNull() ?: return
trySend(ev)
if (ev.type == "done" || ev.type == "error") {
if (ev.type == "done" || ev.type == "error" || ev.type == "cancelled") {
webSocket.close(1000, null)
close()
}
@ -82,6 +100,7 @@ class ByteClawApi(private val settings: ServerSettings) {
}
companion object {
private val JSON = "application/json".toMediaType()
private val shared = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.SECONDS) // streaming responses can be long

View file

@ -20,6 +20,7 @@ data class Message(
val content: String = "",
@SerialName("tool_calls") val toolCalls: List<ToolCall> = emptyList(),
val name: String = "",
val cancelled: Boolean = false,
)
/** Event streamed by the server over the WebSocket. */
@ -49,6 +50,28 @@ data class ServerInfo(
@SerialName("base_url") val baseUrl: String = "",
)
/** GET /api/providers entry. API keys never leave the server; only "configured" is reported. */
@Serializable
data class ProviderInfo(
val name: String,
@SerialName("base_url") val baseUrl: String = "",
@SerialName("default_model") val defaultModel: String = "",
val configured: Boolean = false,
val active: Boolean = false,
)
@Serializable
data class ActiveProvider(val provider: String = "", val model: String = "")
@Serializable
data class ProvidersResponse(val active: ActiveProvider = ActiveProvider(), val providers: List<ProviderInfo> = emptyList())
@Serializable
data class ModelsResponse(val models: List<String> = emptyList())
@Serializable
data class CancelResponse(val cancelled: Boolean = false)
/** Provider presets known to the server (configured server-side via llm.provider). */
object ProviderPresets {
val names = mapOf(

View file

@ -3,7 +3,9 @@ package dev.byteclaw.client.ui
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import dev.byteclaw.client.data.ActiveProvider
import dev.byteclaw.client.data.ByteClawApi
import dev.byteclaw.client.data.ProviderInfo
import dev.byteclaw.client.data.ProviderPresets
import dev.byteclaw.client.data.ServerSettings
import dev.byteclaw.client.data.Session
@ -14,7 +16,16 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class ChatLine(val role: String, val text: String, val tool: String = "")
data class ChatLine(val role: String, val text: String, val tool: String = "", val cancelled: Boolean = false)
/** Provider selection shown in the settings (loaded from the server, never contains keys). */
data class ProviderUi(
val providers: List<ProviderInfo> = emptyList(),
val active: ActiveProvider? = null,
val models: List<String> = emptyList(),
val modelsFor: String? = null,
val status: String? = null,
)
data class UiState(
val settings: ServerSettings = ServerSettings(),
@ -23,6 +34,7 @@ data class UiState(
val sessions: List<Session> = emptyList(),
val busy: Boolean = false,
val error: String? = null,
val provider: ProviderUi = ProviderUi(),
)
class ChatViewModel(app: Application) : AndroidViewModel(app) {
@ -67,7 +79,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
when (m.role) {
"user" -> listOf(ChatLine("user", m.content))
"assistant" -> buildList {
if (m.content.isNotBlank()) add(ChatLine("assistant", m.content))
if (m.content.isNotBlank()) add(ChatLine("assistant", m.content, cancelled = m.cancelled))
m.toolCalls.forEach { add(ChatLine("tool", it.arguments, it.name)) }
}
else -> emptyList()
@ -104,6 +116,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
"done" -> _state.update { s ->
s.copy(busy = false, lines = s.lines.filterNot { it.role == "assistant" && it.text.isEmpty() })
}
"cancelled" -> markCancelled()
"error" -> _state.update { s ->
s.copy(busy = false, error = ev.text, lines = s.lines.filterNot { it.role == "assistant" && it.text.isEmpty() })
}
@ -113,6 +126,48 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
}
}
/** Stop button: cancel on the server (partial answer is saved there), fall back to closing the stream. */
fun cancel() {
if (!_state.value.busy) return
val sid = _state.value.sessionId
viewModelScope.launch {
val ok = sid != null && runCatching { api().cancel(sid) }.getOrDefault(false)
if (!ok) {
job?.cancel()
markCancelled()
}
}
}
private fun markCancelled() = _state.update { s ->
val lines = s.lines.filterNot { it.role == "assistant" && it.text.isEmpty() }.toMutableList()
val i = lines.indexOfLast { it.role == "assistant" }
if (i >= 0 && i == lines.lastIndex) lines[i] = lines[i].copy(cancelled = true)
s.copy(busy = false, lines = lines)
}
fun loadProviders() = viewModelScope.launch {
runCatching { api().providers() }
.onSuccess { r -> _state.update { it.copy(provider = it.provider.copy(providers = r.providers, active = r.active, status = null)) } }
.onFailure { e -> _state.update { it.copy(provider = it.provider.copy(status = "Provider laden fehlgeschlagen: ${e.message}")) } }
}
fun loadModels(name: String) = viewModelScope.launch {
_state.update { it.copy(provider = it.provider.copy(models = emptyList(), modelsFor = name, status = "Lade Modelle…")) }
runCatching { api().models(name) }
.onSuccess { ids -> _state.update { it.copy(provider = it.provider.copy(models = ids, modelsFor = name, status = "${ids.size} Modelle verfügbar")) } }
.onFailure { _ -> _state.update { it.copy(provider = it.provider.copy(status = "Keine Modellliste verfügbar – Modell manuell eintragen")) } }
}
fun selectProvider(name: String, model: String) = viewModelScope.launch {
runCatching { api().setActiveProvider(name, model.trim()) }
.onSuccess { a ->
_state.update { it.copy(provider = it.provider.copy(active = a, status = "Aktiv: ${ProviderPresets.label(a.provider)} · ${a.model}")) }
loadProviders()
}
.onFailure { e -> _state.update { it.copy(provider = it.provider.copy(status = "Fehler: ${e.message}")) } }
}
private fun appendToLast(delta: String) = _state.update { s ->
val lines = s.lines.toMutableList()
val last = lines.lastOrNull()

View file

@ -17,6 +17,7 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@ -24,8 +25,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import dev.byteclaw.client.data.ByteClawApi
import dev.byteclaw.client.data.ProviderPresets
import dev.byteclaw.client.data.ServerSettings
@OptIn(ExperimentalMaterial3Api::class)
@ -37,6 +40,7 @@ fun ChatScreen(
onDismissError: () -> Unit,
onSessions: () -> Unit,
onSettings: () -> Unit,
onStop: () -> Unit = {},
initialInput: String = "",
) {
var input by remember { mutableStateOf(initialInput) }
@ -78,13 +82,23 @@ fun ChatScreen(
placeholder = { Text("Nachricht…") }, maxLines = 5,
enabled = state.settings.isConfigured,
)
if (state.busy) {
FilledTonalIconButton(
onClick = onStop,
colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
) { Icon(Icons.Filled.Stop, "Antwort stoppen") }
} else {
IconButton(
onClick = { onSend(input); input = "" },
enabled = !state.busy && input.isNotBlank() && state.settings.isConfigured,
enabled = input.isNotBlank() && state.settings.isConfigured,
) { Icon(Icons.AutoMirrored.Filled.Send, "Senden") }
}
}
}
}
}
@Composable
@ -99,7 +113,7 @@ private fun Bubble(line: ChatLine) {
}
return
}
Box(Modifier.fillMaxWidth().padding(vertical = 4.dp), contentAlignment = if (isUser) Alignment.CenterEnd else Alignment.CenterStart) {
Column(Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalAlignment = if (isUser) Alignment.End else Alignment.Start) {
Text(
line.text.ifEmpty { "…" },
Modifier.widthIn(max = 320.dp)
@ -108,6 +122,10 @@ private fun Bubble(line: ChatLine) {
RoundedCornerShape(14.dp),
).padding(horizontal = 12.dp, vertical = 8.dp),
)
if (line.cancelled) {
Text("⏹ abgebrochen", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline,
modifier = Modifier.padding(start = 8.dp, top = 2.dp))
}
}
}
@ -131,7 +149,7 @@ fun SessionsScreen(
LazyColumn(Modifier.padding(pad).fillMaxSize()) {
items(state.sessions, key = { it.id }) { s ->
ListItem(
headlineContent = { Text(s.title.ifBlank { "(ohne Titel)" }, maxLines = 1) },
headlineContent = { Text(s.title.ifBlank { "(ohne Titel)" }, maxLines = 1, overflow = TextOverflow.Ellipsis) },
supportingContent = { Text(s.updatedAt.take(16).replace('T', ' ') + " UTC") },
trailingContent = { IconButton(onClick = { onDelete(s.id) }) { Icon(Icons.Filled.Delete, "Löschen") } },
modifier = Modifier.clickable { onOpen(s.id) },
@ -149,26 +167,37 @@ fun SettingsScreen(
onSave: (ServerSettings) -> Unit,
onTest: (ServerSettings, (String) -> Unit) -> Unit,
onBack: () -> Unit,
onLoadProviders: () -> Unit = {},
onLoadModels: (String) -> Unit = {},
onSelectProvider: (String, String) -> Unit = { _, _ -> },
initialStatus: String? = null,
) {
var url by remember { mutableStateOf(state.settings.serverUrl) }
var token by remember { mutableStateOf(state.settings.token) }
var status by remember { mutableStateOf(initialStatus) }
val urlError = if (url.isBlank()) null else ByteClawApi.validateUrl(url)
LaunchedEffect(state.settings) { if (state.settings.isConfigured) onLoadProviders() }
Scaffold(topBar = {
TopAppBar(title = { Text("Einstellungen") },
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück") } })
}) { pad ->
Column(Modifier.padding(pad).padding(16.dp).fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
LazyColumn(Modifier.padding(pad).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
item {
OutlinedTextField(url, { url = it }, Modifier.fillMaxWidth(), label = { Text("Server-URL") },
placeholder = { Text("https://claw.example.org") }, singleLine = true,
isError = urlError != null, supportingText = { urlError?.let { Text(it) } },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri))
}
item {
OutlinedTextField(token, { token = it }, Modifier.fillMaxWidth(), label = { Text("Token") }, singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password))
}
item {
Text("Token und URL werden verschlüsselt (Android Keystore) gespeichert. Release-Builds erlauben nur HTTPS.",
style = MaterialTheme.typography.bodySmall)
}
item {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(enabled = urlError == null && url.isNotBlank() && token.isNotBlank(), onClick = {
onSave(ServerSettings(url, token)); onBack()
@ -177,10 +206,69 @@ fun SettingsScreen(
status = "Teste…"; onTest(ServerSettings(url.trim().trimEnd('/'), token.trim())) { status = it }
}) { Text("Verbindung testen") }
}
status?.let { Text(it) }
Text("Der LLM-Provider (z. B. Dahl, GonkaRouter) wird am Server über llm.provider gewählt. " +
"Verbindung testen zeigt den aktiven Provider und das Modell.",
style = MaterialTheme.typography.bodySmall)
}
status?.let { item { Text(it) } }
if (state.settings.isConfigured) {
item { ProviderSection(state.provider, onLoadProviders, onLoadModels, onSelectProvider) }
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ProviderSection(
p: ProviderUi,
onLoadProviders: () -> Unit,
onLoadModels: (String) -> Unit,
onSelectProvider: (String, String) -> Unit,
) {
var selected by remember(p.active) { mutableStateOf(p.active?.provider.orEmpty()) }
var model by remember(p.active) { mutableStateOf(p.active?.model.orEmpty()) }
var expanded by remember { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
HorizontalDivider()
Row(verticalAlignment = Alignment.CenterVertically) {
Text("LLM-Provider", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f))
TextButton(onClick = onLoadProviders) { Text("Aktualisieren") }
}
Text("API-Keys bleiben auf dem Server. Nicht konfigurierte Provider sind ausgegraut.",
style = MaterialTheme.typography.bodySmall)
p.providers.forEach { info ->
Row(
Modifier.fillMaxWidth().clickable(enabled = info.configured) {
selected = info.name; model = info.defaultModel; onLoadModels(info.name)
},
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = selected == info.name, enabled = info.configured, onClick = {
selected = info.name; model = info.defaultModel; onLoadModels(info.name)
})
Column(Modifier.weight(1f)) {
Text(ProviderPresets.label(info.name) + if (info.active) " · aktiv" else "",
color = if (info.configured) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outline)
Text(if (info.configured) info.baseUrl else "kein API-Key am Server", style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
val models = if (p.modelsFor == selected) p.models else emptyList()
ExposedDropdownMenuBox(expanded = expanded && models.isNotEmpty(), onExpandedChange = { expanded = it }) {
OutlinedTextField(
model, { model = it }, Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryEditable),
label = { Text("Modell") }, singleLine = true,
trailingIcon = { if (models.isNotEmpty()) ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
)
ExposedDropdownMenu(expanded = expanded && models.isNotEmpty(), onDismissRequest = { expanded = false }) {
models.take(200).forEach { id ->
DropdownMenuItem(text = { Text(id, maxLines = 1, overflow = TextOverflow.Ellipsis) }, onClick = { model = id; expanded = false })
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(enabled = selected.isNotBlank() && model.isNotBlank(), onClick = { onSelectProvider(selected, model) }) { Text("Übernehmen") }
OutlinedButton(enabled = selected.isNotBlank(), onClick = { onLoadModels(selected) }) { Text("Modelle laden") }
}
p.status?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
}
}

View file

@ -4,8 +4,27 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
/** ByteClaw brand colours (also used by the launcher icon, see res/values/colors.xml). */
object Brand {
val Claw = Color(0xFFFF6A3D) // claw orange
val ClawDark = Color(0xFFB8401C)
val Ink = Color(0xFF14202E) // deep navy background
val Byte = Color(0xFF39D3C3) // byte teal accent
}
private val Light = lightColorScheme(
primary = Brand.ClawDark, onPrimary = Color.White, primaryContainer = Color(0xFFFFDBCF), onPrimaryContainer = Color(0xFF3A0B00),
secondary = Color(0xFF006A62), secondaryContainer = Color(0xFFB3F0E7),
)
private val Dark = darkColorScheme(
primary = Brand.Claw, onPrimary = Color(0xFF3A0B00), primaryContainer = Color(0xFF7A2A10), onPrimaryContainer = Color(0xFFFFDBCF),
secondary = Brand.Byte, secondaryContainer = Color(0xFF00504A),
background = Color(0xFF0F1822), surface = Color(0xFF0F1822), surfaceVariant = Color(0xFF243243),
)
@Composable
fun ByteClawTheme(dark: Boolean, content: @Composable () -> Unit) {
MaterialTheme(colorScheme = if (dark) darkColorScheme() else lightColorScheme(), content = content)
MaterialTheme(colorScheme = if (dark) Dark else Light, content = content)
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108">
<path android:fillColor="@color/brand_ink" android:pathData="M0,0h108v108h-108z" />
<!-- subtle byte grid -->
<path android:fillColor="#1D2C3D" android:pathData="M0,27h108v1h-108zM0,54h108v1h-108zM0,81h108v1h-108zM27,0h1v108h-1zM54,0h1v108h-1zM81,0h1v108h-1z" />
</vector>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108">
<!-- three claw scratches -->
<path android:strokeColor="@color/brand_claw" android:strokeWidth="7" android:strokeLineCap="round" android:fillColor="#00000000"
android:pathData="M45,33 Q38,53 33,74" />
<path android:strokeColor="@color/brand_claw" android:strokeWidth="7" android:strokeLineCap="round" android:fillColor="#00000000"
android:pathData="M57,30 Q50,53 45,78" />
<path android:strokeColor="@color/brand_claw" android:strokeWidth="7" android:strokeLineCap="round" android:fillColor="#00000000"
android:pathData="M69,33 Q62,53 57,74" />
<!-- byte bits -->
<path android:fillColor="@color/brand_byte" android:pathData="M70,52h7v7h-7z M70,64h7v7h-7z" />
<path android:strokeColor="@color/brand_byte" android:strokeWidth="1.6" android:fillColor="#00000000" android:pathData="M70.8,40.8h5.4v5.4h-5.4z" />
</vector>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108">
<!-- three claw scratches -->
<path android:strokeColor="#FFFFFFFF" android:strokeWidth="7" android:strokeLineCap="round" android:fillColor="#00000000"
android:pathData="M45,33 Q38,53 33,74" />
<path android:strokeColor="#FFFFFFFF" android:strokeWidth="7" android:strokeLineCap="round" android:fillColor="#00000000"
android:pathData="M57,30 Q50,53 45,78" />
<path android:strokeColor="#FFFFFFFF" android:strokeWidth="7" android:strokeLineCap="round" android:fillColor="#00000000"
android:pathData="M69,33 Q62,53 57,74" />
<!-- byte bits -->
<path android:fillColor="#FFFFFFFF" android:pathData="M70,52h7v7h-7z M70,64h7v7h-7z" />
<path android:strokeColor="#FFFFFFFF" android:strokeWidth="1.6" android:fillColor="#00000000" android:pathData="M70.8,40.8h5.4v5.4h-5.4z" />
</vector>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Brand colours, kept in sync with ui/Theme.kt (Brand) -->
<color name="brand_claw">#FF6A3D</color>
<color name="brand_ink">#14202E</color>
<color name="brand_byte">#39D3C3</color>
</resources>

View file

@ -3,6 +3,11 @@ package dev.byteclaw.client
import dev.byteclaw.client.data.AgentEvent
import dev.byteclaw.client.data.Message
import dev.byteclaw.client.data.ProviderPresets
import dev.byteclaw.client.data.ProvidersResponse
import dev.byteclaw.client.data.ModelsResponse
import dev.byteclaw.client.data.CancelResponse
import org.junit.Assert.assertTrue
import org.junit.Assert.assertFalse
import dev.byteclaw.client.data.ServerInfo
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
@ -28,4 +33,17 @@ class ModelsTest {
assertEquals("Dahl (dahl.global)", ProviderPresets.label("dahl"))
assertEquals("x", ProviderPresets.label("x"))
}
@Test fun decodesProvidersWithoutSecrets() {
val r = json.decodeFromString(ProvidersResponse.serializer(),
"""{"active":{"provider":"dahl","model":"MiniMaxAI/MiniMax-M2.7"},"providers":[{"name":"dahl","base_url":"https://inference.dahl.global/v1","default_model":"MiniMaxAI/MiniMax-M2.7","configured":true,"active":true},{"name":"openrouter","configured":false}]}""")
assertEquals("dahl", r.active.provider)
assertTrue(r.providers[0].active); assertFalse(r.providers[1].configured)
}
@Test fun decodesModelsCancelAndCancelledMessage() {
assertEquals(listOf("a", "b"), json.decodeFromString(ModelsResponse.serializer(), """{"models":["a","b"]}""").models)
assertTrue(json.decodeFromString(CancelResponse.serializer(), """{"cancelled":true}""").cancelled)
assertTrue(json.decodeFromString(Message.serializer(), """{"role":"assistant","content":"halb","cancelled":true}""").cancelled)
}
}

View file

@ -0,0 +1,51 @@
package dev.byteclaw.client.preview
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.Paparazzi
import dev.byteclaw.client.R
import org.junit.Rule
import org.junit.Test
/** Renders the adaptive launcher icon layers: round mask, squircle mask and Android 13 monochrome (themed). */
class IconScreenshot {
@get:Rule
val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_6, maxPercentDifference = 0.1)
@androidx.compose.runtime.Composable
private fun Layered(shape: Shape, mono: Boolean = false) {
Box(Modifier.padding(12.dp).size(112.dp).clip(shape).background(if (mono) Color(0xFFE3E8EF) else Color.Unspecified)) {
if (!mono) Image(painterResource(R.drawable.ic_launcher_background), null, Modifier.fillMaxSize())
Image(
painterResource(if (mono) R.drawable.ic_launcher_monochrome else R.drawable.ic_launcher_foreground), null, Modifier.fillMaxSize(),
colorFilter = if (mono) ColorFilter.tint(Color(0xFF2C3E50)) else null,
)
}
}
@Test fun icon() = paparazzi.snapshot("icon") {
Box(Modifier.fillMaxSize().background(Color.White), contentAlignment = Alignment.Center) {
Row {
Layered(CircleShape)
Layered(RoundedCornerShape(30))
Layered(CircleShape, mono = true)
}
}
}
}

View file

@ -11,6 +11,9 @@ import dev.byteclaw.client.ui.ChatScreen
import dev.byteclaw.client.ui.SessionsScreen
import dev.byteclaw.client.ui.SettingsScreen
import dev.byteclaw.client.ui.UiState
import dev.byteclaw.client.ui.ProviderUi
import dev.byteclaw.client.data.ProviderInfo
import dev.byteclaw.client.data.ActiveProvider
import androidx.compose.runtime.Composable
import org.junit.Rule
import org.junit.Test
@ -62,6 +65,7 @@ class PreviewScreenshots(private val dark: Boolean) {
Session("2", "[DEMO] Einkaufsliste für das Wochenende", "2026-10-24T18:05:00Z"),
Session("3", "[DEMO] Zusammenfassung README.md", "2026-10-23T09:41:00Z"),
Session("4", "[DEMO] Ideen für den Android-Client", "2026-10-21T20:15:00Z"),
Session("5", "[DEMO] Sehr langer Session-Titel, der in der Liste sauber mit Auslassungspunkten abgeschnitten wird", "2026-10-20T08:00:00Z"),
),
),
onRefresh = {}, onOpen = {}, onDelete = {}, onBack = {},
@ -74,4 +78,24 @@ class PreviewScreenshots(private val dark: Boolean) {
initialStatus = "Verbindung OK · Provider: Dahl (dahl.global) · Modell: MiniMaxAI/MiniMax-M2.7 [DEMO]",
)
}
@Test fun providers() = snap("providers") {
SettingsScreen(
state = UiState(
settings = settings,
provider = ProviderUi(
active = ActiveProvider("dahl", "MiniMaxAI/MiniMax-M2.7"),
providers = listOf(
ProviderInfo("dahl", "https://inference.dahl.global/v1", "MiniMaxAI/MiniMax-M2.7", configured = true, active = true),
ProviderInfo("gonkarouter", "https://api.gonkarouter.io/v1", "MiniMaxAI/MiniMax-M2.7", configured = true),
ProviderInfo("ollama", "http://127.0.0.1:11434/v1", "llama3.1", configured = true),
ProviderInfo("openai", "https://api.openai.com/v1", "gpt-4o-mini", configured = false),
ProviderInfo("openrouter", "https://openrouter.ai/api/v1", "openai/gpt-4o-mini", configured = false),
),
status = "[DEMO] Aktiv: Dahl (dahl.global) · MiniMaxAI/MiniMax-M2.7",
),
),
onSave = {}, onTest = { _, _ -> }, onBack = {},
)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Before After
Before After

View file

@ -0,0 +1,5 @@
# Copy to keystore.properties (gitignored) – never commit real values.
storeFile=~/byteclaw-keys/byteclaw-release.jks
storePassword=CHANGE_ME
keyAlias=byteclaw
keyPassword=CHANGE_ME

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Before After
Before After

BIN
preview/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
preview/providers_dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

BIN
preview/providers_light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Before After
Before After