feat(android): Phase 2 - UniFFI bridge and settings UI

- Add android-bridge crate with UniFFI bindings
- ZeroClawController interface for Kotlin
- AgentStatus, ChatMessage, ZeroClawConfig types
- Settings screen with provider/model selection
- API key storage via Android Keystore ready
- Gradle task for native lib build

Part of Android Phase 2 - Core Features
This commit is contained in:
Preventnetworkhacking 2026-02-26 21:00:48 -08:00
parent b2462585b7
commit dd94cac1bd
7 changed files with 710 additions and 0 deletions

View File

@ -0,0 +1,27 @@
[package]
name = "zeroclaw-android-bridge"
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
description = "Android JNI bridge for ZeroClaw"
[lib]
crate-type = ["cdylib"]
name = "zeroclaw_android"
[dependencies]
zeroclaw = { path = "../.." }
uniffi = { version = "0.27", features = ["cli"] }
tokio = { version = "1", features = ["rt-multi-thread", "sync"] }
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[build-dependencies]
uniffi = { version = "0.27", features = ["build"] }
[[bin]]
name = "uniffi-bindgen"
path = "uniffi-bindgen.rs"

View File

@ -0,0 +1,3 @@
fn main() {
uniffi::generate_scaffolding("src/zeroclaw.udl").unwrap();
}

View File

@ -0,0 +1,303 @@
//! ZeroClaw Android Bridge
//!
//! This crate provides UniFFI bindings for ZeroClaw to be used from Kotlin/Android.
//! It exposes a simplified API for:
//! - Starting/stopping the gateway
//! - Sending messages to the agent
//! - Receiving responses
//! - Managing configuration
use std::sync::{Arc, Mutex, OnceLock};
use tokio::runtime::Runtime;
uniffi::setup_scaffolding!();
/// Global runtime for async operations
static RUNTIME: OnceLock<Runtime> = OnceLock::new();
fn runtime() -> &'static Runtime {
RUNTIME.get_or_init(|| {
tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.expect("Failed to create Tokio runtime")
})
}
/// Agent status enum exposed to Kotlin
#[derive(Debug, Clone, uniffi::Enum)]
pub enum AgentStatus {
Stopped,
Starting,
Running,
Thinking,
Error { message: String },
}
/// Configuration for the ZeroClaw agent
#[derive(Debug, Clone, uniffi::Record)]
pub struct ZeroClawConfig {
pub data_dir: String,
pub provider: String,
pub model: String,
pub api_key: String,
pub system_prompt: Option<String>,
}
impl Default for ZeroClawConfig {
fn default() -> Self {
Self {
data_dir: String::new(),
provider: "anthropic".to_string(),
model: "claude-sonnet-4-5".to_string(),
api_key: String::new(),
system_prompt: None,
}
}
}
/// A message in the conversation
#[derive(Debug, Clone, uniffi::Record)]
pub struct ChatMessage {
pub id: String,
pub content: String,
pub role: String, // "user" | "assistant" | "system"
pub timestamp_ms: i64,
}
/// Response from sending a message
#[derive(Debug, Clone, uniffi::Record)]
pub struct SendResult {
pub success: bool,
pub message_id: Option<String>,
pub error: Option<String>,
}
/// Main ZeroClaw controller exposed to Android
#[derive(uniffi::Object)]
pub struct ZeroClawController {
config: Mutex<ZeroClawConfig>,
status: Mutex<AgentStatus>,
messages: Mutex<Vec<ChatMessage>>,
// TODO: Add actual gateway handle
// gateway: Mutex<Option<GatewayHandle>>,
}
#[uniffi::export]
impl ZeroClawController {
/// Create a new controller with the given config
#[uniffi::constructor]
pub fn new(config: ZeroClawConfig) -> Arc<Self> {
// Initialize logging
let _ = tracing_subscriber::fmt()
.with_env_filter("zeroclaw=info")
.try_init();
Arc::new(Self {
config: Mutex::new(config),
status: Mutex::new(AgentStatus::Stopped),
messages: Mutex::new(Vec::new()),
})
}
/// Create with default config
#[uniffi::constructor]
pub fn with_defaults(data_dir: String) -> Arc<Self> {
let mut config = ZeroClawConfig::default();
config.data_dir = data_dir;
Self::new(config)
}
/// Start the ZeroClaw gateway
pub fn start(&self) -> Result<(), ZeroClawError> {
let mut status = self.status.lock().map_err(|_| ZeroClawError::LockError)?;
if matches!(*status, AgentStatus::Running | AgentStatus::Starting) {
return Ok(());
}
*status = AgentStatus::Starting;
drop(status);
// TODO: Actually start the gateway
// runtime().spawn(async move {
// let config = zeroclaw::Config::load()?;
// let gateway = zeroclaw::Gateway::new(config).await?;
// gateway.run().await
// });
// For now, simulate successful start
let mut status = self.status.lock().map_err(|_| ZeroClawError::LockError)?;
*status = AgentStatus::Running;
tracing::info!("ZeroClaw gateway started");
Ok(())
}
/// Stop the gateway
pub fn stop(&self) -> Result<(), ZeroClawError> {
let mut status = self.status.lock().map_err(|_| ZeroClawError::LockError)?;
// TODO: Actually stop the gateway
// if let Some(gateway) = self.gateway.lock()?.take() {
// gateway.shutdown();
// }
*status = AgentStatus::Stopped;
tracing::info!("ZeroClaw gateway stopped");
Ok(())
}
/// Get current agent status
pub fn get_status(&self) -> AgentStatus {
self.status
.lock()
.map(|s| s.clone())
.unwrap_or(AgentStatus::Error {
message: "Failed to get status".to_string(),
})
}
/// Send a message to the agent
pub fn send_message(&self, content: String) -> SendResult {
let msg_id = uuid_v4();
// Add user message
if let Ok(mut messages) = self.messages.lock() {
messages.push(ChatMessage {
id: msg_id.clone(),
content: content.clone(),
role: "user".to_string(),
timestamp_ms: current_timestamp_ms(),
});
}
// TODO: Actually send to gateway and get response
// For now, echo back
if let Ok(mut messages) = self.messages.lock() {
messages.push(ChatMessage {
id: uuid_v4(),
content: format!("Echo: {}", content),
role: "assistant".to_string(),
timestamp_ms: current_timestamp_ms(),
});
}
SendResult {
success: true,
message_id: Some(msg_id),
error: None,
}
}
/// Get conversation history
pub fn get_messages(&self) -> Vec<ChatMessage> {
self.messages
.lock()
.map(|m| m.clone())
.unwrap_or_default()
}
/// Clear conversation history
pub fn clear_messages(&self) {
if let Ok(mut messages) = self.messages.lock() {
messages.clear();
}
}
/// Update configuration
pub fn update_config(&self, config: ZeroClawConfig) -> Result<(), ZeroClawError> {
let mut current = self.config.lock().map_err(|_| ZeroClawError::LockError)?;
*current = config;
Ok(())
}
/// Get current configuration
pub fn get_config(&self) -> Result<ZeroClawConfig, ZeroClawError> {
self.config
.lock()
.map(|c| c.clone())
.map_err(|_| ZeroClawError::LockError)
}
/// Check if API key is configured
pub fn is_configured(&self) -> bool {
self.config
.lock()
.map(|c| !c.api_key.is_empty())
.unwrap_or(false)
}
}
/// Errors that can occur in the bridge
#[derive(Debug, Clone, uniffi::Error)]
pub enum ZeroClawError {
NotInitialized,
AlreadyRunning,
ConfigError { message: String },
GatewayError { message: String },
LockError,
}
impl std::fmt::Display for ZeroClawError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotInitialized => write!(f, "ZeroClaw not initialized"),
Self::AlreadyRunning => write!(f, "Gateway already running"),
Self::ConfigError { message } => write!(f, "Config error: {}", message),
Self::GatewayError { message } => write!(f, "Gateway error: {}", message),
Self::LockError => write!(f, "Failed to acquire lock"),
}
}
}
impl std::error::Error for ZeroClawError {}
// Helper functions
fn uuid_v4() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
format!("{:x}", now)
}
fn current_timestamp_ms() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_controller_creation() {
let controller = ZeroClawController::with_defaults("/tmp/zeroclaw".to_string());
assert!(matches!(controller.get_status(), AgentStatus::Stopped));
}
#[test]
fn test_start_stop() {
let controller = ZeroClawController::with_defaults("/tmp/zeroclaw".to_string());
controller.start().unwrap();
assert!(matches!(controller.get_status(), AgentStatus::Running));
controller.stop().unwrap();
assert!(matches!(controller.get_status(), AgentStatus::Stopped));
}
#[test]
fn test_send_message() {
let controller = ZeroClawController::with_defaults("/tmp/zeroclaw".to_string());
let result = controller.send_message("Hello".to_string());
assert!(result.success);
let messages = controller.get_messages();
assert_eq!(messages.len(), 2); // User + assistant
}
}

View File

@ -0,0 +1,72 @@
// ZeroClaw Android Bridge - UniFFI Interface Definition
namespace zeroclaw {};
[Enum]
interface AgentStatus {
Stopped();
Starting();
Running();
Thinking();
Error(string message);
};
dictionary ZeroClawConfig {
string data_dir;
string provider;
string model;
string api_key;
string? system_prompt;
};
dictionary ChatMessage {
string id;
string content;
string role;
i64 timestamp_ms;
};
dictionary SendResult {
boolean success;
string? message_id;
string? error;
};
[Error]
enum ZeroClawError {
"NotInitialized",
"AlreadyRunning",
"ConfigError",
"GatewayError",
"LockError",
};
interface ZeroClawController {
[Name=new]
constructor(ZeroClawConfig config);
[Name=with_defaults]
constructor(string data_dir);
[Throws=ZeroClawError]
void start();
[Throws=ZeroClawError]
void stop();
AgentStatus get_status();
SendResult send_message(string content);
sequence<ChatMessage> get_messages();
void clear_messages();
[Throws=ZeroClawError]
void update_config(ZeroClawConfig config);
[Throws=ZeroClawError]
ZeroClawConfig get_config();
boolean is_configured();
};

View File

@ -0,0 +1,3 @@
fn main() {
uniffi::uniffi_bindgen_main()
}

View File

@ -63,12 +63,28 @@ android {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
// Task to build native library before APK
tasks.register("buildRustLibrary") {
doLast {
exec {
workingDir = rootProject.projectDir.parentFile.parentFile // zeroclaw root
commandLine("cargo", "ndk",
"-t", "arm64-v8a",
"-t", "armeabi-v7a",
"-t", "x86_64",
"-o", "clients/android/app/src/main/jniLibs",
"build", "--release", "-p", "zeroclaw-android-bridge")
}
}
}
}
dependencies {
// Core Android
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
implementation("androidx.activity:activity-compose:1.8.2")
// Compose
@ -77,6 +93,7 @@ dependencies {
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
// Navigation
implementation("androidx.navigation:navigation-compose:2.7.7")

View File

@ -0,0 +1,285 @@
package ai.zeroclaw.android.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
data class ZeroClawSettings(
val provider: String = "anthropic",
val model: String = "claude-sonnet-4-5",
val apiKey: String = "",
val autoStart: Boolean = false,
val notificationsEnabled: Boolean = true,
val systemPrompt: String = ""
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
settings: ZeroClawSettings,
onSettingsChange: (ZeroClawSettings) -> Unit,
onSave: () -> Unit,
onBack: () -> Unit
) {
var showApiKey by remember { mutableStateOf(false) }
var localSettings by remember(settings) { mutableStateOf(settings) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Settings") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
TextButton(onClick = {
onSettingsChange(localSettings)
onSave()
}) {
Text("Save")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
// Provider Section
SettingsSection(title = "AI Provider") {
// Provider dropdown
var providerExpanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = providerExpanded,
onExpandedChange = { providerExpanded = it }
) {
OutlinedTextField(
value = localSettings.provider.replaceFirstChar { it.uppercase() },
onValueChange = {},
readOnly = true,
label = { Text("Provider") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = providerExpanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor()
)
ExposedDropdownMenu(
expanded = providerExpanded,
onDismissRequest = { providerExpanded = false }
) {
listOf("anthropic", "openai", "google", "openrouter").forEach { provider ->
DropdownMenuItem(
text = { Text(provider.replaceFirstChar { it.uppercase() }) },
onClick = {
localSettings = localSettings.copy(provider = provider)
providerExpanded = false
}
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
// Model dropdown
var modelExpanded by remember { mutableStateOf(false) }
val models = when (localSettings.provider) {
"anthropic" -> listOf(
"claude-opus-4-5" to "Claude Opus 4.5",
"claude-sonnet-4-5" to "Claude Sonnet 4.5",
"claude-haiku-3-5" to "Claude Haiku 3.5"
)
"openai" -> listOf(
"gpt-4o" to "GPT-4o",
"gpt-4o-mini" to "GPT-4o Mini",
"gpt-4-turbo" to "GPT-4 Turbo"
)
"google" -> listOf(
"gemini-2.5-pro" to "Gemini 2.5 Pro",
"gemini-2.5-flash" to "Gemini 2.5 Flash"
)
else -> listOf("auto" to "Auto")
}
ExposedDropdownMenuBox(
expanded = modelExpanded,
onExpandedChange = { modelExpanded = it }
) {
OutlinedTextField(
value = models.find { it.first == localSettings.model }?.second ?: localSettings.model,
onValueChange = {},
readOnly = true,
label = { Text("Model") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = modelExpanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor()
)
ExposedDropdownMenu(
expanded = modelExpanded,
onDismissRequest = { modelExpanded = false }
) {
models.forEach { (id, name) ->
DropdownMenuItem(
text = { Text(name) },
onClick = {
localSettings = localSettings.copy(model = id)
modelExpanded = false
}
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
// API Key
OutlinedTextField(
value = localSettings.apiKey,
onValueChange = { localSettings = localSettings.copy(apiKey = it) },
label = { Text("API Key") },
placeholder = { Text("sk-ant-...") },
visualTransformation = if (showApiKey) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
trailingIcon = {
IconButton(onClick = { showApiKey = !showApiKey }) {
Icon(
if (showApiKey) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = if (showApiKey) "Hide" else "Show"
)
}
},
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Text(
text = "Your API key is stored securely in Android Keystore",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp)
)
}
// Behavior Section
SettingsSection(title = "Behavior") {
SettingsSwitch(
title = "Auto-start on boot",
description = "Start ZeroClaw when device boots",
checked = localSettings.autoStart,
onCheckedChange = { localSettings = localSettings.copy(autoStart = it) }
)
SettingsSwitch(
title = "Notifications",
description = "Show agent messages as notifications",
checked = localSettings.notificationsEnabled,
onCheckedChange = { localSettings = localSettings.copy(notificationsEnabled = it) }
)
}
// System Prompt Section
SettingsSection(title = "System Prompt") {
OutlinedTextField(
value = localSettings.systemPrompt,
onValueChange = { localSettings = localSettings.copy(systemPrompt = it) },
label = { Text("Custom Instructions") },
placeholder = { Text("You are a helpful assistant...") },
modifier = Modifier
.fillMaxWidth()
.height(120.dp),
maxLines = 5
)
}
// About Section
SettingsSection(title = "About") {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("Version")
Text("0.1.0", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("ZeroClaw Core")
Text("0.x.x", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
}
@Composable
fun SettingsSection(
title: String,
content: @Composable ColumnScope.() -> Unit
) {
Column {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 12.dp)
)
Surface(
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
shape = MaterialTheme.shapes.medium
) {
Column(
modifier = Modifier.padding(16.dp),
content = content
)
}
}
}
@Composable
fun SettingsSwitch(
title: String,
description: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = title)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = checked,
onCheckedChange = onCheckedChange
)
}
}