diff --git a/clients/android-bridge/Cargo.toml b/clients/android-bridge/Cargo.toml new file mode 100644 index 000000000..1be2f45b2 --- /dev/null +++ b/clients/android-bridge/Cargo.toml @@ -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" diff --git a/clients/android-bridge/build.rs b/clients/android-bridge/build.rs new file mode 100644 index 000000000..ff8e7b998 --- /dev/null +++ b/clients/android-bridge/build.rs @@ -0,0 +1,3 @@ +fn main() { + uniffi::generate_scaffolding("src/zeroclaw.udl").unwrap(); +} diff --git a/clients/android-bridge/src/lib.rs b/clients/android-bridge/src/lib.rs new file mode 100644 index 000000000..85c30982f --- /dev/null +++ b/clients/android-bridge/src/lib.rs @@ -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 = 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, +} + +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, + pub error: Option, +} + +/// Main ZeroClaw controller exposed to Android +#[derive(uniffi::Object)] +pub struct ZeroClawController { + config: Mutex, + status: Mutex, + messages: Mutex>, + // TODO: Add actual gateway handle + // gateway: Mutex>, +} + +#[uniffi::export] +impl ZeroClawController { + /// Create a new controller with the given config + #[uniffi::constructor] + pub fn new(config: ZeroClawConfig) -> Arc { + // 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 { + 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 { + 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 { + 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 + } +} diff --git a/clients/android-bridge/src/zeroclaw.udl b/clients/android-bridge/src/zeroclaw.udl new file mode 100644 index 000000000..1b875af7a --- /dev/null +++ b/clients/android-bridge/src/zeroclaw.udl @@ -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 get_messages(); + + void clear_messages(); + + [Throws=ZeroClawError] + void update_config(ZeroClawConfig config); + + [Throws=ZeroClawError] + ZeroClawConfig get_config(); + + boolean is_configured(); +}; diff --git a/clients/android-bridge/uniffi-bindgen.rs b/clients/android-bridge/uniffi-bindgen.rs new file mode 100644 index 000000000..f6cff6cf1 --- /dev/null +++ b/clients/android-bridge/uniffi-bindgen.rs @@ -0,0 +1,3 @@ +fn main() { + uniffi::uniffi_bindgen_main() +} diff --git a/clients/android/app/build.gradle.kts b/clients/android/app/build.gradle.kts index 3a984e17c..1eeb2c684 100644 --- a/clients/android/app/build.gradle.kts +++ b/clients/android/app/build.gradle.kts @@ -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") diff --git a/clients/android/app/src/main/java/ai/zeroclaw/android/ui/SettingsScreen.kt b/clients/android/app/src/main/java/ai/zeroclaw/android/ui/SettingsScreen.kt new file mode 100644 index 000000000..e0f45626f --- /dev/null +++ b/clients/android/app/src/main/java/ai/zeroclaw/android/ui/SettingsScreen.kt @@ -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 + ) + } +}