Initial implementation of CellClaw autonomous AI phone assistant

Kotlin/Android app with full project scaffold:
- Agent loop with configurable autonomy (auto/ask/deny per tool)
- Anthropic + OpenAI provider with streaming SSE and tool use
- 24 native phone tools (SMS, calls, contacts, calendar, camera,
  location, files, scripts, sensors, app automation, etc.)
- Approval queue with biometric gate and notification actions
- Room/SQLite memory (conversation history + semantic facts)
- Foreground service with persistent notification
- AccessibilityService for app automation
- Broadcast receivers (SMS, phone state, boot, battery)
- Skills system with SKILL.md parser and 2 bundled skills
- Jetpack Compose Material 3 UI (chat, setup, settings, approvals, skills)
- Hilt DI, Android Keystore for API key encryption
- Min SDK 26, target SDK 35

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
JordanTheJet
2026-02-18 13:05:06 -05:00
commit e57292c25d
72 changed files with 5351 additions and 0 deletions

16
.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
/app/build
/app/release
*.apk
*.aab
*.jks
*.keystore

95
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,95 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
}
android {
namespace = "com.cellclaw"
compileSdk = 35
defaultConfig {
applicationId = "com.cellclaw"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
}
dependencies {
// Kotlin
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization.json)
// Compose
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
implementation(libs.compose.material.icons)
implementation(libs.activity.compose)
debugImplementation(libs.compose.ui.tooling)
// Lifecycle
implementation(libs.lifecycle.runtime)
implementation(libs.lifecycle.viewmodel)
// Navigation
implementation(libs.navigation.compose)
// Room
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
// Hilt
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.hilt.navigation.compose)
// Network
implementation(libs.okhttp)
implementation(libs.okhttp.sse)
// Biometric
implementation(libs.biometric)
// Camera
implementation(libs.camera.core)
implementation(libs.camera.camera2)
implementation(libs.camera.lifecycle)
// Location
implementation(libs.location)
// AndroidX Core
implementation(libs.core.ktx)
}

12
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,12 @@
# kotlinx.serialization
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-keepclassmembers class kotlinx.serialization.json.** { *** Companion; }
-keepclasseswithmembers class kotlinx.serialization.json.** { kotlinx.serialization.KSerializer serializer(...); }
-keep,includedescriptorclasses class com.cellclaw.**$$serializer { *; }
-keepclassmembers class com.cellclaw.** { *** Companion; }
-keepclasseswithmembers class com.cellclaw.** { kotlinx.serialization.KSerializer serializer(...); }
# Room
-keep class * extends androidx.room.RoomDatabase
-keep @androidx.room.Entity class *

View File

@@ -0,0 +1,145 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Network -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- SMS -->
<uses-permission android:name="android.permission.READ_SMS" />
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.RECEIVE_SMS" />
<!-- Phone -->
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.CALL_PHONE" />
<uses-permission android:name="android.permission.READ_CALL_LOG" />
<!-- Contacts -->
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
<!-- Calendar -->
<uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
<!-- Location -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Camera -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- Storage -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<!-- Foreground service -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Boot -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Biometric -->
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<!-- Sensors -->
<uses-permission android:name="android.permission.BODY_SENSORS" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.telephony" android:required="false" />
<application
android:name=".CellClawApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.CellClaw"
tools:targetApi="35">
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:theme="@style/Theme.CellClaw">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Foreground Service -->
<service
android:name=".service.CellClawService"
android:exported="false"
android:foregroundServiceType="specialUse" />
<!-- Accessibility Service -->
<service
android:name=".service.CellClawAccessibility"
android:exported="true"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_config" />
</service>
<!-- Broadcast Receivers -->
<receiver
android:name=".service.receivers.SmsReceiver"
android:exported="true"
android:permission="android.permission.BROADCAST_SMS">
<intent-filter android:priority="999">
<action android:name="android.provider.Telephony.SMS_RECEIVED" />
</intent-filter>
</receiver>
<receiver
android:name=".service.receivers.PhoneStateReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.PHONE_STATE" />
</intent-filter>
</receiver>
<receiver
android:name=".service.receivers.BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<receiver
android:name=".service.receivers.BatteryReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BATTERY_CHANGED" />
<action android:name="android.intent.action.BATTERY_LOW" />
</intent-filter>
</receiver>
<!-- File Provider for camera photos -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@@ -0,0 +1,18 @@
# Daily Briefing
Get a morning summary of your day including weather, calendar events, and unread messages.
## Trigger
daily briefing
## Steps
1. Check the current time and location
2. Read today's calendar events
3. Read any unread SMS messages
4. Check battery level and network status
5. Compile a concise morning briefing summary
## Tools
- calendar.query
- sms.read
- location.get
- settings.get

View File

@@ -0,0 +1,15 @@
# Quick Reply
Draft and send a reply to the most recent SMS message.
## Trigger
quick reply
## Steps
1. Read the most recent unread SMS message
2. Analyze the message content and suggest a reply
3. Show the draft reply to the user for approval
4. Send the approved reply
## Tools
- sms.read
- sms.send

View File

@@ -0,0 +1,52 @@
package com.cellclaw
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class CellClawApp : Application() {
override fun onCreate() {
super.onCreate()
createNotificationChannels()
}
private fun createNotificationChannels() {
val manager = getSystemService(NotificationManager::class.java)
val serviceChannel = NotificationChannel(
CHANNEL_SERVICE,
getString(R.string.notification_channel_service),
NotificationManager.IMPORTANCE_LOW
).apply {
description = "CellClaw background service status"
setShowBadge(false)
}
val approvalChannel = NotificationChannel(
CHANNEL_APPROVALS,
getString(R.string.notification_channel_approvals),
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Tool execution approval requests"
}
val alertChannel = NotificationChannel(
CHANNEL_ALERTS,
getString(R.string.notification_channel_alerts),
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "General alerts and notifications"
}
manager.createNotificationChannels(listOf(serviceChannel, approvalChannel, alertChannel))
}
companion object {
const val CHANNEL_SERVICE = "cellclaw_service"
const val CHANNEL_APPROVALS = "cellclaw_approvals"
const val CHANNEL_ALERTS = "cellclaw_alerts"
}
}

View File

@@ -0,0 +1,220 @@
package com.cellclaw.agent
import android.util.Log
import com.cellclaw.approval.ApprovalQueue
import com.cellclaw.approval.ApprovalRequest
import com.cellclaw.approval.ApprovalResult
import com.cellclaw.config.Identity
import com.cellclaw.memory.ConversationStore
import com.cellclaw.provider.*
import com.cellclaw.tools.ToolRegistry
import com.cellclaw.tools.ToolResult
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AgentLoop @Inject constructor(
private val provider: AnthropicProvider,
private val toolRegistry: ToolRegistry,
private val approvalQueue: ApprovalQueue,
private val conversationStore: ConversationStore,
private val identity: Identity,
private val autonomyPolicy: AutonomyPolicy
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val json = Json { ignoreUnknownKeys = true }
private val _state = MutableStateFlow(AgentState.IDLE)
val state: StateFlow<AgentState> = _state.asStateFlow()
private val _events = MutableSharedFlow<AgentEvent>(replay = 0, extraBufferCapacity = 64)
val events: SharedFlow<AgentEvent> = _events.asSharedFlow()
private val conversationHistory = mutableListOf<Message>()
private var currentJob: Job? = null
fun submitMessage(text: String) {
currentJob?.cancel()
currentJob = scope.launch {
try {
_state.value = AgentState.THINKING
conversationHistory.add(Message.user(text))
conversationStore.addMessage("user", text)
_events.emit(AgentEvent.UserMessage(text))
runAgentLoop()
} catch (e: CancellationException) {
_state.value = AgentState.IDLE
} catch (e: Exception) {
Log.e(TAG, "Agent loop error", e)
_state.value = AgentState.ERROR
_events.emit(AgentEvent.Error(e.message ?: "Unknown error"))
}
}
}
fun stop() {
currentJob?.cancel()
_state.value = AgentState.IDLE
}
fun pause() {
_state.value = AgentState.PAUSED
}
fun resume() {
if (_state.value == AgentState.PAUSED) {
_state.value = AgentState.THINKING
currentJob = scope.launch { runAgentLoop() }
}
}
private suspend fun runAgentLoop() {
var iterations = 0
val maxIterations = 20
while (iterations < maxIterations && _state.value == AgentState.THINKING) {
iterations++
val request = CompletionRequest(
systemPrompt = identity.buildSystemPrompt(toolRegistry),
messages = conversationHistory.toList(),
tools = toolRegistry.toApiSchema(),
maxTokens = 4096
)
val response = provider.complete(request)
// Process response content
val textParts = mutableListOf<String>()
val toolCalls = mutableListOf<ContentBlock.ToolUse>()
for (block in response.content) {
when (block) {
is ContentBlock.Text -> {
textParts.add(block.text)
_events.emit(AgentEvent.AssistantText(block.text))
}
is ContentBlock.ToolUse -> toolCalls.add(block)
else -> {}
}
}
// Add assistant message to history
conversationHistory.add(Message(Role.ASSISTANT, response.content))
if (textParts.isNotEmpty()) {
conversationStore.addMessage("assistant", textParts.joinToString(""))
}
// If no tool calls, we're done
if (response.stopReason != StopReason.TOOL_USE || toolCalls.isEmpty()) {
_state.value = AgentState.IDLE
return
}
// Execute tool calls
_state.value = AgentState.EXECUTING_TOOLS
val toolResults = mutableListOf<ContentBlock>()
for (call in toolCalls) {
_events.emit(AgentEvent.ToolCallStart(call.name, call.input))
val tool = toolRegistry.get(call.name)
if (tool == null) {
toolResults.add(ContentBlock.ToolResult(
call.id, "Error: Unknown tool '${call.name}'", isError = true
))
continue
}
// Check approval
val approved = checkApproval(call.name, call.input)
if (!approved) {
toolResults.add(ContentBlock.ToolResult(
call.id, "Tool execution denied by user", isError = true
))
_events.emit(AgentEvent.ToolCallDenied(call.name))
continue
}
// Execute
val result = try {
tool.execute(call.input)
} catch (e: Exception) {
ToolResult.error("Execution failed: ${e.message}")
}
val resultStr = if (result.success) {
result.data?.toString() ?: "Success"
} else {
result.error ?: "Unknown error"
}
toolResults.add(ContentBlock.ToolResult(
call.id, resultStr, isError = !result.success
))
_events.emit(AgentEvent.ToolCallResult(call.name, result))
}
// Add tool results to history
conversationHistory.add(Message(Role.USER, toolResults))
_state.value = AgentState.THINKING
}
if (iterations >= maxIterations) {
_events.emit(AgentEvent.Error("Max iterations ($maxIterations) reached"))
_state.value = AgentState.IDLE
}
}
private suspend fun checkApproval(toolName: String, params: JsonObject): Boolean {
val policy = autonomyPolicy.getPolicy(toolName)
return when (policy) {
ToolApprovalPolicy.AUTO -> true
ToolApprovalPolicy.DENY -> false
ToolApprovalPolicy.ASK -> {
_state.value = AgentState.WAITING_APPROVAL
val request = ApprovalRequest(
toolName = toolName,
parameters = params,
description = "Allow $toolName?"
)
val result = approvalQueue.request(request)
_state.value = AgentState.EXECUTING_TOOLS
result == ApprovalResult.APPROVED
}
}
}
fun loadHistory() {
scope.launch {
conversationHistory.clear()
val stored = conversationStore.getRecentMessages(50)
for (msg in stored) {
val role = if (msg.role == "user") Role.USER else Role.ASSISTANT
conversationHistory.add(Message(role, listOf(ContentBlock.Text(msg.content))))
}
}
}
companion object {
private const val TAG = "AgentLoop"
}
}
enum class AgentState {
IDLE, THINKING, EXECUTING_TOOLS, WAITING_APPROVAL, PAUSED, ERROR
}
sealed class AgentEvent {
data class UserMessage(val text: String) : AgentEvent()
data class AssistantText(val text: String) : AgentEvent()
data class ToolCallStart(val name: String, val params: JsonObject) : AgentEvent()
data class ToolCallResult(val name: String, val result: ToolResult) : AgentEvent()
data class ToolCallDenied(val name: String) : AgentEvent()
data class Error(val message: String) : AgentEvent()
}

View File

@@ -0,0 +1,61 @@
package com.cellclaw.agent
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AutonomyPolicy @Inject constructor() {
private val policies = mutableMapOf<String, ToolApprovalPolicy>()
init {
// Defaults: read ops are auto, write/send ops require approval
setDefaults()
}
private fun setDefaults() {
// Safe read operations
setPolicy("sms.read", ToolApprovalPolicy.AUTO)
setPolicy("contacts.search", ToolApprovalPolicy.AUTO)
setPolicy("calendar.query", ToolApprovalPolicy.AUTO)
setPolicy("location.get", ToolApprovalPolicy.AUTO)
setPolicy("clipboard.read", ToolApprovalPolicy.AUTO)
setPolicy("file.read", ToolApprovalPolicy.AUTO)
setPolicy("file.list", ToolApprovalPolicy.AUTO)
setPolicy("settings.get", ToolApprovalPolicy.AUTO)
setPolicy("sensor.read", ToolApprovalPolicy.AUTO)
setPolicy("phone.log", ToolApprovalPolicy.AUTO)
setPolicy("notification.send", ToolApprovalPolicy.AUTO)
setPolicy("browser.search", ToolApprovalPolicy.AUTO)
setPolicy("browser.open", ToolApprovalPolicy.AUTO)
// Write/send operations need approval
setPolicy("sms.send", ToolApprovalPolicy.ASK)
setPolicy("phone.call", ToolApprovalPolicy.ASK)
setPolicy("contacts.add", ToolApprovalPolicy.ASK)
setPolicy("calendar.create", ToolApprovalPolicy.ASK)
setPolicy("camera.snap", ToolApprovalPolicy.ASK)
setPolicy("camera.record", ToolApprovalPolicy.ASK)
setPolicy("clipboard.write", ToolApprovalPolicy.ASK)
setPolicy("file.write", ToolApprovalPolicy.ASK)
setPolicy("app.launch", ToolApprovalPolicy.ASK)
setPolicy("app.automate", ToolApprovalPolicy.ASK)
setPolicy("script.exec", ToolApprovalPolicy.ASK)
}
fun getPolicy(toolName: String): ToolApprovalPolicy {
return policies[toolName] ?: ToolApprovalPolicy.ASK
}
fun setPolicy(toolName: String, policy: ToolApprovalPolicy) {
policies[toolName] = policy
}
fun allPolicies(): Map<String, ToolApprovalPolicy> = policies.toMap()
}
enum class ToolApprovalPolicy {
AUTO, // Always allow
ASK, // Prompt user
DENY // Never allow
}

View File

@@ -0,0 +1,60 @@
package com.cellclaw.agent
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class TaskQueue @Inject constructor() {
private val _tasks = MutableStateFlow<List<AgentTask>>(emptyList())
val tasks: StateFlow<List<AgentTask>> = _tasks.asStateFlow()
fun enqueue(description: String, priority: TaskPriority = TaskPriority.NORMAL): AgentTask {
val task = AgentTask(
id = UUID.randomUUID().toString(),
description = description,
priority = priority,
status = TaskStatus.PENDING
)
_tasks.value = _tasks.value + task
return task
}
fun dequeue(): AgentTask? {
val sorted = _tasks.value
.filter { it.status == TaskStatus.PENDING }
.sortedBy { it.priority.ordinal }
val next = sorted.firstOrNull() ?: return null
updateStatus(next.id, TaskStatus.IN_PROGRESS)
return next
}
fun updateStatus(taskId: String, status: TaskStatus) {
_tasks.value = _tasks.value.map {
if (it.id == taskId) it.copy(status = status) else it
}
}
fun remove(taskId: String) {
_tasks.value = _tasks.value.filter { it.id != taskId }
}
fun clear() {
_tasks.value = emptyList()
}
}
data class AgentTask(
val id: String,
val description: String,
val priority: TaskPriority,
val status: TaskStatus
)
enum class TaskPriority { HIGH, NORMAL, LOW }
enum class TaskStatus { PENDING, IN_PROGRESS, COMPLETED, FAILED }

View File

@@ -0,0 +1,21 @@
package com.cellclaw.approval
import com.cellclaw.agent.AutonomyPolicy
import com.cellclaw.agent.ToolApprovalPolicy
import javax.inject.Inject
import javax.inject.Singleton
/**
* Manages the mapping between approval results and autonomy policy updates.
* When a user selects "Always Allow", this updates the autonomy policy.
*/
@Singleton
class ApprovalPolicyManager @Inject constructor(
private val autonomyPolicy: AutonomyPolicy
) {
fun handleResult(toolName: String, result: ApprovalResult) {
if (result == ApprovalResult.ALWAYS_ALLOW) {
autonomyPolicy.setPolicy(toolName, ToolApprovalPolicy.AUTO)
}
}
}

View File

@@ -0,0 +1,60 @@
package com.cellclaw.approval
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.json.JsonObject
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ApprovalQueue @Inject constructor() {
private val pendingApprovals = ConcurrentHashMap<String, CompletableDeferred<ApprovalResult>>()
private val _requests = MutableStateFlow<List<ApprovalRequest>>(emptyList())
val requests: StateFlow<List<ApprovalRequest>> = _requests.asStateFlow()
/**
* Submit an approval request and suspend until the user responds.
*/
suspend fun request(request: ApprovalRequest): ApprovalResult {
val id = request.id
val deferred = CompletableDeferred<ApprovalResult>()
pendingApprovals[id] = deferred
_requests.value = _requests.value + request
return try {
deferred.await()
} finally {
pendingApprovals.remove(id)
_requests.value = _requests.value.filter { it.id != id }
}
}
/**
* Respond to an approval request (called from UI or notification action).
*/
fun respond(requestId: String, result: ApprovalResult) {
pendingApprovals[requestId]?.complete(result)
}
fun respondAll(result: ApprovalResult) {
pendingApprovals.forEach { (_, deferred) -> deferred.complete(result) }
}
}
data class ApprovalRequest(
val id: String = UUID.randomUUID().toString(),
val toolName: String,
val parameters: JsonObject,
val description: String,
val timestamp: Long = System.currentTimeMillis()
)
enum class ApprovalResult {
APPROVED, DENIED, ALWAYS_ALLOW
}

View File

@@ -0,0 +1,48 @@
package com.cellclaw.approval
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import kotlinx.coroutines.CompletableDeferred
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class BiometricGate @Inject constructor() {
fun isAvailable(activity: FragmentActivity): Boolean {
val manager = BiometricManager.from(activity)
return manager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) ==
BiometricManager.BIOMETRIC_SUCCESS
}
suspend fun authenticate(activity: FragmentActivity, reason: String): Boolean {
val deferred = CompletableDeferred<Boolean>()
val executor = ContextCompat.getMainExecutor(activity)
val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
deferred.complete(true)
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
deferred.complete(false)
}
override fun onAuthenticationFailed() {
// Don't complete yet — user can retry
}
}
val prompt = BiometricPrompt(activity, executor, callback)
val info = BiometricPrompt.PromptInfo.Builder()
.setTitle("CellClaw Authentication")
.setSubtitle(reason)
.setNegativeButtonText("Cancel")
.build()
activity.runOnUiThread { prompt.authenticate(info) }
return deferred.await()
}
}

View File

@@ -0,0 +1,43 @@
package com.cellclaw.config
import android.content.Context
import android.content.SharedPreferences
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AppConfig @Inject constructor(
@ApplicationContext private val context: Context
) {
private val prefs: SharedPreferences =
context.getSharedPreferences("cellclaw_config", Context.MODE_PRIVATE)
var providerType: String
get() = prefs.getString("provider_type", "anthropic") ?: "anthropic"
set(value) = prefs.edit().putString("provider_type", value).apply()
var model: String
get() = prefs.getString("model", "claude-sonnet-4-20250514") ?: "claude-sonnet-4-20250514"
set(value) = prefs.edit().putString("model", value).apply()
var maxTokens: Int
get() = prefs.getInt("max_tokens", 4096)
set(value) = prefs.edit().putInt("max_tokens", value).apply()
var autoStartOnBoot: Boolean
get() = prefs.getBoolean("auto_start_boot", false)
set(value) = prefs.edit().putBoolean("auto_start_boot", value).apply()
var isSetupComplete: Boolean
get() = prefs.getBoolean("setup_complete", false)
set(value) = prefs.edit().putBoolean("setup_complete", value).apply()
var personalityPrompt: String
get() = prefs.getString("personality_prompt", "") ?: ""
set(value) = prefs.edit().putString("personality_prompt", value).apply()
var userName: String
get() = prefs.getString("user_name", "") ?: ""
set(value) = prefs.edit().putString("user_name", value).apply()
}

View File

@@ -0,0 +1,57 @@
package com.cellclaw.config
import com.cellclaw.memory.SemanticMemory
import com.cellclaw.tools.ToolRegistry
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class Identity @Inject constructor(
private val appConfig: AppConfig,
private val semanticMemory: SemanticMemory
) {
fun buildSystemPrompt(toolRegistry: ToolRegistry): String {
return buildString {
appendLine(DEFAULT_IDENTITY)
val userName = appConfig.userName
if (userName.isNotBlank()) {
appendLine("\nThe user's name is $userName.")
}
val personality = appConfig.personalityPrompt
if (personality.isNotBlank()) {
appendLine("\n## Custom Instructions")
appendLine(personality)
}
appendLine("\n## Available Tools")
for (tool in toolRegistry.all()) {
appendLine("- **${tool.name}**: ${tool.description}")
}
appendLine("\n## Tool Use Guidelines")
appendLine("- Use tools proactively to help the user accomplish their goals.")
appendLine("- For tools that require approval, the user will be prompted before execution.")
appendLine("- Always explain what you're about to do before using a tool.")
appendLine("- If a tool fails, explain the error and suggest alternatives.")
}
}
companion object {
private const val DEFAULT_IDENTITY = """You are CellClaw, an autonomous AI assistant running directly on the user's Android phone. You have deep access to the phone's capabilities including SMS, phone calls, contacts, calendar, camera, location, file system, app control, and more.
You are helpful, proactive, and safety-conscious. You can:
- Read and send SMS messages
- Make and track phone calls
- Manage contacts and calendar
- Take photos and access the camera
- Get GPS location
- Read and write files
- Launch and automate other apps
- Run shell scripts
- Access sensors and system settings
Always prioritize the user's safety and privacy. For sensitive actions (sending messages, making calls, executing scripts), you will ask for approval unless the user has set those tools to auto-approve."""
}
}

View File

@@ -0,0 +1,96 @@
package com.cellclaw.config
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import dagger.hilt.android.qualifiers.ApplicationContext
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import javax.inject.Inject
import javax.inject.Singleton
/**
* Encrypts/decrypts API keys using the Android Keystore.
*/
@Singleton
class SecureKeyStore @Inject constructor(
@ApplicationContext private val context: Context
) {
private val prefs = context.getSharedPreferences("cellclaw_secure", Context.MODE_PRIVATE)
private val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) }
fun storeApiKey(provider: String, apiKey: String) {
val secretKey = getOrCreateKey(provider)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
val encrypted = cipher.doFinal(apiKey.toByteArray(Charsets.UTF_8))
val iv = cipher.iv
prefs.edit()
.putString("${provider}_key", Base64.encodeToString(encrypted, Base64.DEFAULT))
.putString("${provider}_iv", Base64.encodeToString(iv, Base64.DEFAULT))
.apply()
}
fun getApiKey(provider: String): String? {
val encryptedB64 = prefs.getString("${provider}_key", null) ?: return null
val ivB64 = prefs.getString("${provider}_iv", null) ?: return null
val encrypted = Base64.decode(encryptedB64, Base64.DEFAULT)
val iv = Base64.decode(ivB64, Base64.DEFAULT)
val secretKey = getOrCreateKey(provider)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(GCM_TAG_LENGTH, iv))
val decrypted = cipher.doFinal(encrypted)
return String(decrypted, Charsets.UTF_8)
}
fun hasApiKey(provider: String): Boolean {
return prefs.contains("${provider}_key")
}
fun deleteApiKey(provider: String) {
prefs.edit()
.remove("${provider}_key")
.remove("${provider}_iv")
.apply()
if (keyStore.containsAlias(keyAlias(provider))) {
keyStore.deleteEntry(keyAlias(provider))
}
}
private fun getOrCreateKey(provider: String): SecretKey {
val alias = keyAlias(provider)
if (keyStore.containsAlias(alias)) {
return (keyStore.getEntry(alias, null) as KeyStore.SecretKeyEntry).secretKey
}
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER
)
keyGenerator.init(
KeyGenParameterSpec.Builder(alias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build()
)
return keyGenerator.generateKey()
}
private fun keyAlias(provider: String) = "cellclaw_${provider}_key"
companion object {
private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val GCM_TAG_LENGTH = 128
}
}

View File

@@ -0,0 +1,88 @@
package com.cellclaw.di
import android.content.Context
import androidx.room.Room
import com.cellclaw.memory.MemoryDb
import com.cellclaw.memory.MemoryFactDao
import com.cellclaw.memory.MessageDao
import com.cellclaw.provider.AnthropicProvider
import com.cellclaw.tools.*
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideMemoryDb(@ApplicationContext context: Context): MemoryDb {
return Room.databaseBuilder(
context,
MemoryDb::class.java,
"cellclaw_memory"
).fallbackToDestructiveMigration().build()
}
@Provides
fun provideMessageDao(db: MemoryDb): MessageDao = db.messageDao()
@Provides
fun provideMemoryFactDao(db: MemoryDb): MemoryFactDao = db.memoryFactDao()
@Provides
@Singleton
fun provideAnthropicProvider(): AnthropicProvider = AnthropicProvider()
@Provides
@Singleton
fun provideToolRegistry(
smsRead: SmsReadTool,
smsSend: SmsSendTool,
phoneCall: PhoneCallTool,
phoneLog: PhoneLogTool,
contactsSearch: ContactsSearchTool,
contactsAdd: ContactsAddTool,
calendarQuery: CalendarQueryTool,
calendarCreate: CalendarCreateTool,
location: LocationTool,
cameraSnap: CameraSnapTool,
cameraRecord: CameraRecordTool,
notification: NotificationTool,
clipboardRead: ClipboardReadTool,
clipboardWrite: ClipboardWriteTool,
fileRead: FileReadTool,
fileWrite: FileWriteTool,
fileList: FileListTool,
scriptExec: ScriptExecTool,
sensor: SensorTool,
settings: SettingsTool,
browserOpen: BrowserOpenTool,
browserSearch: BrowserSearchTool,
appLaunch: AppLaunchTool,
appAutomate: AppAutomateTool
): ToolRegistry {
return ToolRegistry().apply {
register(
smsRead, smsSend,
phoneCall, phoneLog,
contactsSearch, contactsAdd,
calendarQuery, calendarCreate,
location,
cameraSnap, cameraRecord,
notification,
clipboardRead, clipboardWrite,
fileRead, fileWrite, fileList,
scriptExec,
sensor,
settings,
browserOpen, browserSearch,
appLaunch, appAutomate
)
}
}
}

View File

@@ -0,0 +1,43 @@
package com.cellclaw.memory
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ConversationStore @Inject constructor(
private val messageDao: MessageDao
) {
private var currentConversationId = "default"
suspend fun addMessage(role: String, content: String) {
messageDao.insert(
MessageEntity(
role = role,
content = content,
conversationId = currentConversationId
)
)
}
suspend fun getRecentMessages(limit: Int = 50): List<MessageEntity> {
return messageDao.getRecent(currentConversationId, limit).reversed()
}
suspend fun getAllMessages(): List<MessageEntity> {
return messageDao.getAll(currentConversationId)
}
suspend fun clearCurrentConversation() {
messageDao.clearConversation(currentConversationId)
}
suspend fun messageCount(): Int {
return messageDao.count(currentConversationId)
}
fun newConversation(id: String) {
currentConversationId = id
}
fun currentId(): String = currentConversationId
}

View File

@@ -0,0 +1,69 @@
package com.cellclaw.memory
import androidx.room.*
@Database(
entities = [MessageEntity::class, MemoryFactEntity::class],
version = 1,
exportSchema = false
)
abstract class MemoryDb : RoomDatabase() {
abstract fun messageDao(): MessageDao
abstract fun memoryFactDao(): MemoryFactDao
}
@Entity(tableName = "messages")
data class MessageEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
@ColumnInfo(name = "role") val role: String,
@ColumnInfo(name = "content") val content: String,
@ColumnInfo(name = "timestamp") val timestamp: Long = System.currentTimeMillis(),
@ColumnInfo(name = "conversation_id") val conversationId: String = "default"
)
@Entity(tableName = "memory_facts")
data class MemoryFactEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
@ColumnInfo(name = "key") val key: String,
@ColumnInfo(name = "value") val value: String,
@ColumnInfo(name = "category") val category: String = "general",
@ColumnInfo(name = "confidence") val confidence: Float = 1.0f,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
@ColumnInfo(name = "updated_at") val updatedAt: Long = System.currentTimeMillis()
)
@Dao
interface MessageDao {
@Insert
suspend fun insert(message: MessageEntity): Long
@Query("SELECT * FROM messages WHERE conversation_id = :conversationId ORDER BY timestamp DESC LIMIT :limit")
suspend fun getRecent(conversationId: String = "default", limit: Int = 50): List<MessageEntity>
@Query("SELECT * FROM messages WHERE conversation_id = :conversationId ORDER BY timestamp ASC")
suspend fun getAll(conversationId: String = "default"): List<MessageEntity>
@Query("DELETE FROM messages WHERE conversation_id = :conversationId")
suspend fun clearConversation(conversationId: String = "default")
@Query("SELECT COUNT(*) FROM messages WHERE conversation_id = :conversationId")
suspend fun count(conversationId: String = "default"): Int
}
@Dao
interface MemoryFactDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(fact: MemoryFactEntity): Long
@Query("SELECT * FROM memory_facts WHERE category = :category ORDER BY updated_at DESC")
suspend fun getByCategory(category: String): List<MemoryFactEntity>
@Query("SELECT * FROM memory_facts ORDER BY updated_at DESC")
suspend fun getAll(): List<MemoryFactEntity>
@Query("SELECT * FROM memory_facts WHERE `key` LIKE '%' || :query || '%' OR `value` LIKE '%' || :query || '%'")
suspend fun search(query: String): List<MemoryFactEntity>
@Query("DELETE FROM memory_facts WHERE id = :id")
suspend fun delete(id: Long)
}

View File

@@ -0,0 +1,51 @@
package com.cellclaw.memory
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SemanticMemory @Inject constructor(
private val factDao: MemoryFactDao
) {
suspend fun remember(key: String, value: String, category: String = "general") {
factDao.upsert(
MemoryFactEntity(
key = key,
value = value,
category = category
)
)
}
suspend fun recall(category: String): List<MemoryFactEntity> {
return factDao.getByCategory(category)
}
suspend fun recallAll(): List<MemoryFactEntity> {
return factDao.getAll()
}
suspend fun search(query: String): List<MemoryFactEntity> {
return factDao.search(query)
}
suspend fun forget(id: Long) {
factDao.delete(id)
}
/** Build a context string for inclusion in system prompts */
suspend fun buildContext(): String {
val facts = factDao.getAll()
if (facts.isEmpty()) return ""
return buildString {
appendLine("\n## Known Facts")
facts.groupBy { it.category }.forEach { (category, categoryFacts) ->
appendLine("### $category")
categoryFacts.forEach { fact ->
appendLine("- ${fact.key}: ${fact.value}")
}
}
}
}
}

View File

@@ -0,0 +1,311 @@
package com.cellclaw.provider
import com.cellclaw.tools.ToolApiDefinition
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.BufferedReader
import java.util.concurrent.TimeUnit
import javax.inject.Inject
class AnthropicProvider @Inject constructor() : Provider {
override val name = "anthropic"
private var apiKey: String = ""
private var model: String = DEFAULT_MODEL
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = false
explicitNulls = false
}
fun configure(apiKey: String, model: String = DEFAULT_MODEL) {
this.apiKey = apiKey
this.model = model
}
override suspend fun complete(request: CompletionRequest): CompletionResponse =
withContext(Dispatchers.IO) {
val body = buildRequestBody(request, stream = false)
val httpRequest = buildHttpRequest(body)
val response = client.newCall(httpRequest).execute()
val responseBody = response.body?.string()
?: throw ProviderException("Empty response body")
if (!response.isSuccessful) {
throw ProviderException("API error ${response.code}: $responseBody")
}
parseCompletionResponse(json.parseToJsonElement(responseBody).jsonObject)
}
override fun stream(request: CompletionRequest): Flow<StreamEvent> = flow {
val body = buildRequestBody(request, stream = true)
val httpRequest = buildHttpRequest(body)
val response = client.newCall(httpRequest).execute()
if (!response.isSuccessful) {
val errorBody = response.body?.string() ?: "Unknown error"
emit(StreamEvent.Error("API error ${response.code}: $errorBody"))
return@flow
}
val reader = response.body?.byteStream()?.bufferedReader()
?: throw ProviderException("Empty response stream")
val contentBlocks = mutableListOf<ContentBlock>()
var currentToolId = ""
var currentToolName = ""
var currentToolInput = StringBuilder()
var currentText = StringBuilder()
var stopReason = StopReason.END_TURN
try {
reader.processSSE { event, data ->
when (event) {
"content_block_start" -> {
val block = json.parseToJsonElement(data).jsonObject
val contentBlock = block["content_block"]?.jsonObject ?: return@processSSE
when (contentBlock["type"]?.jsonPrimitive?.content) {
"tool_use" -> {
// Flush any accumulated text
if (currentText.isNotEmpty()) {
contentBlocks.add(ContentBlock.Text(currentText.toString()))
currentText = StringBuilder()
}
currentToolId = contentBlock["id"]?.jsonPrimitive?.content ?: ""
currentToolName = contentBlock["name"]?.jsonPrimitive?.content ?: ""
currentToolInput = StringBuilder()
emit(StreamEvent.ToolUseStart(currentToolId, currentToolName))
}
}
}
"content_block_delta" -> {
val block = json.parseToJsonElement(data).jsonObject
val delta = block["delta"]?.jsonObject ?: return@processSSE
when (delta["type"]?.jsonPrimitive?.content) {
"text_delta" -> {
val text = delta["text"]?.jsonPrimitive?.content ?: ""
currentText.append(text)
emit(StreamEvent.TextDelta(text))
}
"input_json_delta" -> {
val partial = delta["partial_json"]?.jsonPrimitive?.content ?: ""
currentToolInput.append(partial)
emit(StreamEvent.ToolUseInputDelta(partial))
}
}
}
"content_block_stop" -> {
if (currentToolName.isNotEmpty()) {
val inputJson = try {
json.parseToJsonElement(currentToolInput.toString()).jsonObject
} catch (_: Exception) {
JsonObject(emptyMap())
}
contentBlocks.add(
ContentBlock.ToolUse(currentToolId, currentToolName, inputJson)
)
currentToolId = ""
currentToolName = ""
currentToolInput = StringBuilder()
}
}
"message_delta" -> {
val block = json.parseToJsonElement(data).jsonObject
val delta = block["delta"]?.jsonObject
val reason = delta?.get("stop_reason")?.jsonPrimitive?.content
stopReason = when (reason) {
"tool_use" -> StopReason.TOOL_USE
"max_tokens" -> StopReason.MAX_TOKENS
else -> StopReason.END_TURN
}
}
"message_stop" -> {
if (currentText.isNotEmpty()) {
contentBlocks.add(ContentBlock.Text(currentText.toString()))
}
emit(StreamEvent.Complete(
CompletionResponse(contentBlocks, stopReason)
))
}
"error" -> {
val block = json.parseToJsonElement(data).jsonObject
val error = block["error"]?.jsonObject
val message = error?.get("message")?.jsonPrimitive?.content ?: data
emit(StreamEvent.Error(message))
}
}
}
} finally {
reader.close()
response.close()
}
}.flowOn(Dispatchers.IO)
private fun buildRequestBody(request: CompletionRequest, stream: Boolean): String {
val messagesArray = buildJsonArray {
for (msg in request.messages) {
add(buildJsonObject {
put("role", msg.role.name.lowercase())
putJsonArray("content") {
for (block in msg.content) {
add(contentBlockToJson(block))
}
}
})
}
}
val body = buildJsonObject {
put("model", model)
put("max_tokens", request.maxTokens)
put("system", request.systemPrompt)
put("messages", messagesArray)
if (stream) put("stream", true)
if (request.tools.isNotEmpty()) {
putJsonArray("tools") {
for (tool in request.tools) {
add(toolToJson(tool))
}
}
}
}
return json.encodeToString(JsonObject.serializer(), body)
}
private fun contentBlockToJson(block: ContentBlock): JsonObject = when (block) {
is ContentBlock.Text -> buildJsonObject {
put("type", "text")
put("text", block.text)
}
is ContentBlock.ToolUse -> buildJsonObject {
put("type", "tool_use")
put("id", block.id)
put("name", block.name)
put("input", block.input)
}
is ContentBlock.ToolResult -> buildJsonObject {
put("type", "tool_result")
put("tool_use_id", block.toolUseId)
put("content", block.content)
if (block.isError) put("is_error", true)
}
}
private fun toolToJson(tool: ToolApiDefinition): JsonObject = buildJsonObject {
put("name", tool.name)
put("description", tool.description)
put("input_schema", buildJsonObject {
put("type", tool.inputSchema.type)
putJsonObject("properties") {
for ((key, prop) in tool.inputSchema.properties) {
putJsonObject(key) {
put("type", prop.type)
put("description", prop.description)
prop.enum?.let { enumValues ->
putJsonArray("enum") {
enumValues.forEach { add(it) }
}
}
}
}
}
if (tool.inputSchema.required.isNotEmpty()) {
putJsonArray("required") {
tool.inputSchema.required.forEach { add(it) }
}
}
})
}
private fun buildHttpRequest(body: String): Request = Request.Builder()
.url(API_URL)
.addHeader("x-api-key", apiKey)
.addHeader("anthropic-version", API_VERSION)
.addHeader("content-type", "application/json")
.post(body.toRequestBody("application/json".toMediaType()))
.build()
private fun parseCompletionResponse(obj: JsonObject): CompletionResponse {
val contentArray = obj["content"]?.jsonArray ?: JsonArray(emptyList())
val blocks = contentArray.map { element ->
val block = element.jsonObject
when (block["type"]?.jsonPrimitive?.content) {
"text" -> ContentBlock.Text(
block["text"]?.jsonPrimitive?.content ?: ""
)
"tool_use" -> ContentBlock.ToolUse(
id = block["id"]?.jsonPrimitive?.content ?: "",
name = block["name"]?.jsonPrimitive?.content ?: "",
input = block["input"]?.jsonObject ?: JsonObject(emptyMap())
)
else -> ContentBlock.Text("")
}
}
val stopReason = when (obj["stop_reason"]?.jsonPrimitive?.content) {
"tool_use" -> StopReason.TOOL_USE
"max_tokens" -> StopReason.MAX_TOKENS
else -> StopReason.END_TURN
}
val usageObj = obj["usage"]?.jsonObject
val usage = usageObj?.let {
Usage(
inputTokens = it["input_tokens"]?.jsonPrimitive?.int ?: 0,
outputTokens = it["output_tokens"]?.jsonPrimitive?.int ?: 0
)
}
return CompletionResponse(blocks, stopReason, usage)
}
private inline fun BufferedReader.processSSE(handler: (event: String, data: String) -> Unit) {
var currentEvent = ""
var currentData = StringBuilder()
forEachLine { line ->
when {
line.startsWith("event: ") -> {
currentEvent = line.removePrefix("event: ").trim()
}
line.startsWith("data: ") -> {
currentData.append(line.removePrefix("data: "))
}
line.isBlank() -> {
if (currentEvent.isNotEmpty() && currentData.isNotEmpty()) {
handler(currentEvent, currentData.toString())
}
currentEvent = ""
currentData = StringBuilder()
}
}
}
}
companion object {
const val API_URL = "https://api.anthropic.com/v1/messages"
const val API_VERSION = "2023-06-01"
const val DEFAULT_MODEL = "claude-sonnet-4-20250514"
}
}
class ProviderException(message: String) : Exception(message)

View File

@@ -0,0 +1,178 @@
package com.cellclaw.provider
import com.cellclaw.tools.ToolApiDefinition
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.concurrent.TimeUnit
import javax.inject.Inject
class OpenAIProvider @Inject constructor() : Provider {
override val name = "openai"
private var apiKey: String = ""
private var model: String = DEFAULT_MODEL
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.build()
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = false
explicitNulls = false
}
fun configure(apiKey: String, model: String = DEFAULT_MODEL) {
this.apiKey = apiKey
this.model = model
}
override suspend fun complete(request: CompletionRequest): CompletionResponse =
withContext(Dispatchers.IO) {
val body = buildRequestBody(request)
val httpRequest = Request.Builder()
.url(API_URL)
.addHeader("Authorization", "Bearer $apiKey")
.addHeader("Content-Type", "application/json")
.post(body.toRequestBody("application/json".toMediaType()))
.build()
val response = client.newCall(httpRequest).execute()
val responseBody = response.body?.string()
?: throw ProviderException("Empty response body")
if (!response.isSuccessful) {
throw ProviderException("API error ${response.code}: $responseBody")
}
parseResponse(json.parseToJsonElement(responseBody).jsonObject)
}
override fun stream(request: CompletionRequest): Flow<StreamEvent> = flow {
// Simplified non-streaming fallback
try {
val response = complete(request)
for (block in response.content) {
when (block) {
is ContentBlock.Text -> emit(StreamEvent.TextDelta(block.text))
is ContentBlock.ToolUse -> {
emit(StreamEvent.ToolUseStart(block.id, block.name))
}
else -> {}
}
}
emit(StreamEvent.Complete(response))
} catch (e: Exception) {
emit(StreamEvent.Error(e.message ?: "Unknown error"))
}
}.flowOn(Dispatchers.IO)
private fun buildRequestBody(request: CompletionRequest): String {
val messages = buildJsonArray {
// System message
add(buildJsonObject {
put("role", "system")
put("content", request.systemPrompt)
})
// Conversation messages
for (msg in request.messages) {
add(buildJsonObject {
put("role", msg.role.name.lowercase())
// Simplified: just use text content
val textContent = msg.content.filterIsInstance<ContentBlock.Text>()
.joinToString("") { it.text }
put("content", textContent)
})
}
}
return json.encodeToString(JsonObject.serializer(), buildJsonObject {
put("model", model)
put("messages", messages)
put("max_tokens", request.maxTokens)
if (request.tools.isNotEmpty()) {
putJsonArray("tools") {
for (tool in request.tools) {
add(buildJsonObject {
put("type", "function")
putJsonObject("function") {
put("name", tool.name)
put("description", tool.description)
putJsonObject("parameters") {
put("type", tool.inputSchema.type)
putJsonObject("properties") {
for ((key, prop) in tool.inputSchema.properties) {
putJsonObject(key) {
put("type", prop.type)
put("description", prop.description)
}
}
}
if (tool.inputSchema.required.isNotEmpty()) {
putJsonArray("required") {
tool.inputSchema.required.forEach { add(it) }
}
}
}
}
})
}
}
}
})
}
private fun parseResponse(obj: JsonObject): CompletionResponse {
val choices = obj["choices"]?.jsonArray ?: return CompletionResponse(emptyList(), StopReason.ERROR)
val choice = choices.firstOrNull()?.jsonObject ?: return CompletionResponse(emptyList(), StopReason.ERROR)
val message = choice["message"]?.jsonObject ?: return CompletionResponse(emptyList(), StopReason.ERROR)
val blocks = mutableListOf<ContentBlock>()
// Text content
message["content"]?.jsonPrimitive?.contentOrNull?.let {
if (it.isNotBlank()) blocks.add(ContentBlock.Text(it))
}
// Tool calls
message["tool_calls"]?.jsonArray?.forEach { toolCallElement ->
val toolCall = toolCallElement.jsonObject
val function = toolCall["function"]?.jsonObject ?: return@forEach
val input = try {
json.parseToJsonElement(function["arguments"]?.jsonPrimitive?.content ?: "{}").jsonObject
} catch (_: Exception) {
JsonObject(emptyMap())
}
blocks.add(ContentBlock.ToolUse(
id = toolCall["id"]?.jsonPrimitive?.content ?: "",
name = function["name"]?.jsonPrimitive?.content ?: "",
input = input
))
}
val finishReason = choice["finish_reason"]?.jsonPrimitive?.content
val stopReason = when (finishReason) {
"tool_calls" -> StopReason.TOOL_USE
"length" -> StopReason.MAX_TOKENS
else -> StopReason.END_TURN
}
return CompletionResponse(blocks, stopReason)
}
companion object {
const val API_URL = "https://api.openai.com/v1/chat/completions"
const val DEFAULT_MODEL = "gpt-4o"
}
}

View File

@@ -0,0 +1,93 @@
package com.cellclaw.provider
import com.cellclaw.tools.ToolApiDefinition
import kotlinx.coroutines.flow.Flow
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
/**
* Abstraction over cloud AI providers (Anthropic, OpenAI, Google, etc.)
*/
interface Provider {
val name: String
/** Send a message and get a complete response (non-streaming) */
suspend fun complete(request: CompletionRequest): CompletionResponse
/** Send a message and get a streaming response */
fun stream(request: CompletionRequest): Flow<StreamEvent>
}
@Serializable
data class CompletionRequest(
val systemPrompt: String,
val messages: List<Message>,
val tools: List<ToolApiDefinition> = emptyList(),
val maxTokens: Int = 4096
)
@Serializable
data class Message(
val role: Role,
val content: List<ContentBlock>
) {
companion object {
fun user(text: String) = Message(Role.USER, listOf(ContentBlock.Text(text)))
fun assistant(text: String) = Message(Role.ASSISTANT, listOf(ContentBlock.Text(text)))
fun toolResult(toolUseId: String, result: String) = Message(
Role.USER,
listOf(ContentBlock.ToolResult(toolUseId, result))
)
}
}
@Serializable
enum class Role {
USER, ASSISTANT
}
@Serializable
sealed class ContentBlock {
@Serializable
data class Text(val text: String) : ContentBlock()
@Serializable
data class ToolUse(
val id: String,
val name: String,
val input: JsonObject
) : ContentBlock()
@Serializable
data class ToolResult(
val toolUseId: String,
val content: String,
val isError: Boolean = false
) : ContentBlock()
}
@Serializable
data class CompletionResponse(
val content: List<ContentBlock>,
val stopReason: StopReason,
val usage: Usage? = null
)
@Serializable
enum class StopReason {
END_TURN, TOOL_USE, MAX_TOKENS, ERROR
}
@Serializable
data class Usage(
val inputTokens: Int = 0,
val outputTokens: Int = 0
)
sealed class StreamEvent {
data class TextDelta(val text: String) : StreamEvent()
data class ToolUseStart(val id: String, val name: String) : StreamEvent()
data class ToolUseInputDelta(val delta: String) : StreamEvent()
data class Complete(val response: CompletionResponse) : StreamEvent()
data class Error(val message: String) : StreamEvent()
}

View File

@@ -0,0 +1,68 @@
package com.cellclaw.provider
import com.cellclaw.tools.ToolApiDefinition
import com.cellclaw.tools.ToolRegistry
import kotlinx.serialization.json.*
/**
* Converts tool definitions between different provider formats.
*/
object ToolSchema {
/** Convert tools to Anthropic's tool format */
fun toAnthropicFormat(tools: List<ToolApiDefinition>): JsonArray = buildJsonArray {
for (tool in tools) {
add(buildJsonObject {
put("name", tool.name)
put("description", tool.description)
putJsonObject("input_schema") {
put("type", tool.inputSchema.type)
putJsonObject("properties") {
for ((key, prop) in tool.inputSchema.properties) {
putJsonObject(key) {
put("type", prop.type)
put("description", prop.description)
prop.enum?.let { enumValues ->
putJsonArray("enum") { enumValues.forEach { add(it) } }
}
}
}
}
if (tool.inputSchema.required.isNotEmpty()) {
putJsonArray("required") { tool.inputSchema.required.forEach { add(it) } }
}
}
})
}
}
/** Convert tools to OpenAI's function calling format */
fun toOpenAIFormat(tools: List<ToolApiDefinition>): JsonArray = buildJsonArray {
for (tool in tools) {
add(buildJsonObject {
put("type", "function")
putJsonObject("function") {
put("name", tool.name)
put("description", tool.description)
putJsonObject("parameters") {
put("type", tool.inputSchema.type)
putJsonObject("properties") {
for ((key, prop) in tool.inputSchema.properties) {
putJsonObject(key) {
put("type", prop.type)
put("description", prop.description)
prop.enum?.let { enumValues ->
putJsonArray("enum") { enumValues.forEach { add(it) } }
}
}
}
}
if (tool.inputSchema.required.isNotEmpty()) {
putJsonArray("required") { tool.inputSchema.required.forEach { add(it) } }
}
}
}
})
}
}
}

View File

@@ -0,0 +1,139 @@
package com.cellclaw.service
import android.accessibilityservice.AccessibilityService
import android.accessibilityservice.GestureDescription
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.Path
import android.os.Build
import android.os.Bundle
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import kotlinx.serialization.json.*
class CellClawAccessibility : AccessibilityService() {
private val actionReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != "com.cellclaw.ACCESSIBILITY_ACTION") return
when (intent.getStringExtra("action")) {
"tap" -> handleTap(
intent.getIntExtra("x", 0),
intent.getIntExtra("y", 0),
intent.getStringExtra("text")
)
"type" -> handleType(intent.getStringExtra("text") ?: "")
"scroll" -> handleScroll(intent.getStringExtra("direction") ?: "down")
"back" -> performGlobalAction(GLOBAL_ACTION_BACK)
"home" -> performGlobalAction(GLOBAL_ACTION_HOME)
"recents" -> performGlobalAction(GLOBAL_ACTION_RECENTS)
"read_screen" -> readScreen()
}
}
}
override fun onServiceConnected() {
super.onServiceConnected()
val filter = IntentFilter("com.cellclaw.ACCESSIBILITY_ACTION")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(actionReceiver, filter, RECEIVER_NOT_EXPORTED)
} else {
registerReceiver(actionReceiver, filter)
}
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
// Events are monitored but not actively processed unless needed
}
override fun onInterrupt() {}
override fun onDestroy() {
unregisterReceiver(actionReceiver)
super.onDestroy()
}
private fun handleTap(x: Int, y: Int, text: String?) {
if (text != null) {
// Find and click node by text
val rootNode = rootInActiveWindow ?: return
val nodes = rootNode.findAccessibilityNodeInfosByText(text)
nodes.firstOrNull()?.performAction(AccessibilityNodeInfo.ACTION_CLICK)
return
}
// Tap at coordinates
val path = Path().apply { moveTo(x.toFloat(), y.toFloat()) }
val gesture = GestureDescription.Builder()
.addStroke(GestureDescription.StrokeDescription(path, 0, 100))
.build()
dispatchGesture(gesture, null, null)
}
private fun handleType(text: String) {
val focusedNode = findFocus(AccessibilityNodeInfo.FOCUS_INPUT) ?: return
val arguments = Bundle().apply {
putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, text)
}
focusedNode.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, arguments)
}
private fun handleScroll(direction: String) {
val rootNode = rootInActiveWindow ?: return
val scrollableNode = findScrollableNode(rootNode) ?: return
when (direction) {
"down" -> scrollableNode.performAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD)
"up" -> scrollableNode.performAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD)
}
}
private fun readScreen(): JsonObject {
val rootNode = rootInActiveWindow ?: return buildJsonObject {
put("error", "No active window")
}
return buildJsonObject {
put("package", rootNode.packageName?.toString() ?: "")
putJsonArray("elements") {
traverseNode(rootNode, this)
}
}
}
private fun traverseNode(node: AccessibilityNodeInfo, array: JsonArrayBuilder, depth: Int = 0) {
if (depth > 10) return // Limit depth
val text = node.text?.toString()
val contentDesc = node.contentDescription?.toString()
val className = node.className?.toString()
if (text != null || contentDesc != null) {
array.add(buildJsonObject {
put("class", className ?: "")
text?.let { put("text", it) }
contentDesc?.let { put("content_description", it) }
put("clickable", node.isClickable)
put("editable", node.isEditable)
})
}
for (i in 0 until node.childCount) {
val child = node.getChild(i) ?: continue
traverseNode(child, array, depth + 1)
}
}
private fun findScrollableNode(node: AccessibilityNodeInfo): AccessibilityNodeInfo? {
if (node.isScrollable) return node
for (i in 0 until node.childCount) {
val child = node.getChild(i) ?: continue
val scrollable = findScrollableNode(child)
if (scrollable != null) return scrollable
}
return null
}
}

View File

@@ -0,0 +1,120 @@
package com.cellclaw.service
import android.app.Notification
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import com.cellclaw.CellClawApp
import com.cellclaw.R
import com.cellclaw.agent.AgentLoop
import com.cellclaw.ui.MainActivity
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
class CellClawService : Service() {
@Inject lateinit var agentLoop: AgentLoop
private var wakeLock: PowerManager.WakeLock? = null
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
acquireWakeLock()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> {
startForeground(NOTIFICATION_ID, buildNotification("CellClaw is running"))
agentLoop.loadHistory()
}
ACTION_STOP -> {
agentLoop.stop()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
ACTION_PAUSE -> {
agentLoop.pause()
updateNotification("CellClaw is paused")
}
ACTION_RESUME -> {
agentLoop.resume()
updateNotification("CellClaw is running")
}
}
return START_STICKY
}
override fun onDestroy() {
releaseWakeLock()
super.onDestroy()
}
private fun buildNotification(text: String): Notification {
val openIntent = PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val stopIntent = PendingIntent.getService(
this, 1,
Intent(this, CellClawService::class.java).apply { action = ACTION_STOP },
PendingIntent.FLAG_IMMUTABLE
)
val pauseIntent = PendingIntent.getService(
this, 2,
Intent(this, CellClawService::class.java).apply { action = ACTION_PAUSE },
PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, CellClawApp.CHANNEL_SERVICE)
.setContentTitle("CellClaw")
.setContentText(text)
.setSmallIcon(R.drawable.ic_notification)
.setContentIntent(openIntent)
.addAction(0, "Stop", stopIntent)
.addAction(0, "Pause", pauseIntent)
.setOngoing(true)
.setSilent(true)
.build()
}
private fun updateNotification(text: String) {
val notification = buildNotification(text)
val manager = getSystemService(NOTIFICATION_SERVICE) as android.app.NotificationManager
manager.notify(NOTIFICATION_ID, notification)
}
private fun acquireWakeLock() {
val powerManager = getSystemService(POWER_SERVICE) as PowerManager
wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"CellClaw::AgentWakeLock"
).apply {
acquire(10 * 60 * 1000L) // 10 minutes max
}
}
private fun releaseWakeLock() {
wakeLock?.let {
if (it.isHeld) it.release()
}
wakeLock = null
}
companion object {
const val ACTION_START = "com.cellclaw.START"
const val ACTION_STOP = "com.cellclaw.STOP"
const val ACTION_PAUSE = "com.cellclaw.PAUSE"
const val ACTION_RESUME = "com.cellclaw.RESUME"
const val NOTIFICATION_ID = 1
}
}

View File

@@ -0,0 +1,25 @@
package com.cellclaw.service.receivers
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
class BatteryReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
Intent.ACTION_BATTERY_LOW -> {
Log.d(TAG, "Battery low")
val notifyIntent = Intent("com.cellclaw.BATTERY_LOW").apply {
setPackage(context.packageName)
}
context.sendBroadcast(notifyIntent)
}
}
}
companion object {
private const val TAG = "BatteryReceiver"
}
}

View File

@@ -0,0 +1,31 @@
package com.cellclaw.service.receivers
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import com.cellclaw.service.CellClawService
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
Log.d(TAG, "Device booted, checking auto-start preference")
val prefs = context.getSharedPreferences("cellclaw_config", Context.MODE_PRIVATE)
val autoStart = prefs.getBoolean("auto_start_boot", false)
if (autoStart) {
Log.d(TAG, "Auto-starting CellClaw service")
val serviceIntent = Intent(context, CellClawService::class.java).apply {
action = CellClawService.ACTION_START
}
context.startForegroundService(serviceIntent)
}
}
companion object {
private const val TAG = "BootReceiver"
}
}

View File

@@ -0,0 +1,30 @@
package com.cellclaw.service.receivers
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.telephony.TelephonyManager
import android.util.Log
class PhoneStateReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != TelephonyManager.ACTION_PHONE_STATE_CHANGED) return
val state = intent.getStringExtra(TelephonyManager.EXTRA_STATE)
val number = intent.getStringExtra(TelephonyManager.EXTRA_INCOMING_NUMBER)
Log.d(TAG, "Phone state: $state, number: $number")
val notifyIntent = Intent("com.cellclaw.PHONE_STATE").apply {
putExtra("state", state)
number?.let { putExtra("number", it) }
setPackage(context.packageName)
}
context.sendBroadcast(notifyIntent)
}
companion object {
private const val TAG = "PhoneStateReceiver"
}
}

View File

@@ -0,0 +1,35 @@
package com.cellclaw.service.receivers
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.provider.Telephony
import android.util.Log
class SmsReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) return
val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent)
for (sms in messages) {
val sender = sms.displayOriginatingAddress
val body = sms.messageBody
Log.d(TAG, "SMS received from $sender: ${body.take(50)}")
// Notify the agent loop about the incoming SMS
val notifyIntent = Intent("com.cellclaw.SMS_RECEIVED").apply {
putExtra("sender", sender)
putExtra("body", body)
putExtra("timestamp", sms.timestampMillis)
setPackage(context.packageName)
}
context.sendBroadcast(notifyIntent)
}
}
companion object {
private const val TAG = "SmsReceiver"
}
}

View File

@@ -0,0 +1,89 @@
package com.cellclaw.skills
import javax.inject.Inject
import javax.inject.Singleton
data class Skill(
val name: String,
val description: String,
val trigger: String,
val steps: List<String>,
val tools: List<String> = emptyList()
)
@Singleton
class SkillParser @Inject constructor() {
/**
* Parse a SKILL.md-format string into a Skill object.
*
* Expected format:
* ```
* # Skill Name
* Description text
*
* ## Trigger
* trigger phrase or pattern
*
* ## Steps
* 1. Step one
* 2. Step two
*
* ## Tools
* - tool.name
* - other.tool
* ```
*/
fun parse(content: String): Skill? {
val lines = content.lines()
if (lines.isEmpty()) return null
var name = ""
var description = StringBuilder()
var trigger = ""
val steps = mutableListOf<String>()
val tools = mutableListOf<String>()
var section = "header"
for (line in lines) {
val trimmed = line.trim()
when {
trimmed.startsWith("# ") && !trimmed.startsWith("## ") -> {
name = trimmed.removePrefix("# ").trim()
section = "description"
}
trimmed == "## Trigger" || trimmed == "## trigger" -> section = "trigger"
trimmed == "## Steps" || trimmed == "## steps" -> section = "steps"
trimmed == "## Tools" || trimmed == "## tools" -> section = "tools"
trimmed.startsWith("## ") -> section = "other"
trimmed.isNotEmpty() -> when (section) {
"description" -> description.appendLine(trimmed)
"trigger" -> trigger = trimmed
"steps" -> {
val step = trimmed.removePrefix("- ").let {
if (it.matches(Regex("^\\d+\\.\\s.*"))) it.replaceFirst(Regex("^\\d+\\.\\s"), "")
else it
}
if (step.isNotBlank()) steps.add(step)
}
"tools" -> {
val tool = trimmed.removePrefix("- ").removePrefix("* ").trim()
if (tool.isNotBlank()) tools.add(tool)
}
}
}
}
if (name.isBlank()) return null
return Skill(
name = name,
description = description.toString().trim(),
trigger = trigger,
steps = steps,
tools = tools
)
}
}

View File

@@ -0,0 +1,110 @@
package com.cellclaw.skills
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SkillRegistry @Inject constructor(
@ApplicationContext private val context: Context,
private val parser: SkillParser
) {
private val _skills = MutableStateFlow<List<Skill>>(emptyList())
val skills: StateFlow<List<Skill>> = _skills.asStateFlow()
fun loadBundledSkills() {
val bundled = mutableListOf<Skill>()
// Load from assets
try {
val assetFiles = context.assets.list("skills") ?: emptyArray()
for (file in assetFiles) {
if (file.endsWith(".md")) {
val content = context.assets.open("skills/$file").bufferedReader().readText()
parser.parse(content)?.let { bundled.add(it) }
}
}
} catch (_: Exception) {
// No bundled skills directory
}
// Load from app-private storage (user-created skills)
val skillsDir = File(context.filesDir, "skills")
if (skillsDir.exists()) {
skillsDir.listFiles()?.filter { it.extension == "md" }?.forEach { file ->
parser.parse(file.readText())?.let { bundled.add(it) }
}
}
_skills.value = bundled
}
fun addSkill(skill: Skill) {
_skills.value = _skills.value + skill
// Persist to disk
val skillsDir = File(context.filesDir, "skills")
skillsDir.mkdirs()
val file = File(skillsDir, "${skill.name.lowercase().replace(" ", "_")}.md")
file.writeText(buildSkillMarkdown(skill))
}
fun removeSkill(name: String) {
_skills.value = _skills.value.filter { it.name != name }
val skillsDir = File(context.filesDir, "skills")
val file = File(skillsDir, "${name.lowercase().replace(" ", "_")}.md")
file.delete()
}
fun findByTrigger(input: String): Skill? {
return _skills.value.firstOrNull { skill ->
input.contains(skill.trigger, ignoreCase = true)
}
}
/** Build system prompt section describing available skills */
fun buildSkillsPrompt(): String {
val skillList = _skills.value
if (skillList.isEmpty()) return ""
return buildString {
appendLine("\n## Available Skills")
for (skill in skillList) {
appendLine("### ${skill.name}")
appendLine(skill.description)
appendLine("Trigger: \"${skill.trigger}\"")
if (skill.steps.isNotEmpty()) {
appendLine("Steps:")
skill.steps.forEachIndexed { i, step ->
appendLine(" ${i + 1}. $step")
}
}
appendLine()
}
}
}
private fun buildSkillMarkdown(skill: Skill): String = buildString {
appendLine("# ${skill.name}")
appendLine(skill.description)
appendLine()
appendLine("## Trigger")
appendLine(skill.trigger)
appendLine()
if (skill.steps.isNotEmpty()) {
appendLine("## Steps")
skill.steps.forEachIndexed { i, step ->
appendLine("${i + 1}. $step")
}
appendLine()
}
if (skill.tools.isNotEmpty()) {
appendLine("## Tools")
skill.tools.forEach { appendLine("- $it") }
}
}
}

View File

@@ -0,0 +1,98 @@
package com.cellclaw.tools
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.serialization.json.*
import javax.inject.Inject
class AppLaunchTool @Inject constructor(
@ApplicationContext private val context: Context
) : Tool {
override val name = "app.launch"
override val description = "Launch an installed app by package name or app name."
override val parameters = ToolParameters(
properties = mapOf(
"package_name" to ParameterProperty("string", "Package name (e.g. com.whatsapp)"),
"app_name" to ParameterProperty("string", "App name to search for (e.g. WhatsApp)")
)
)
override val requiresApproval = true
override suspend fun execute(params: JsonObject): ToolResult {
val packageName = params["package_name"]?.jsonPrimitive?.contentOrNull
val appName = params["app_name"]?.jsonPrimitive?.contentOrNull
val targetPackage = packageName ?: findPackageByName(appName ?: "")
?: return ToolResult.error("App not found. Provide a valid package_name or app_name.")
return try {
val intent = context.packageManager.getLaunchIntentForPackage(targetPackage)
?: return ToolResult.error("Cannot launch app: $targetPackage")
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
context.startActivity(intent)
ToolResult.success(buildJsonObject {
put("launched", true)
put("package", targetPackage)
})
} catch (e: Exception) {
ToolResult.error("Failed to launch app: ${e.message}")
}
}
private fun findPackageByName(name: String): String? {
val pm = context.packageManager
val apps = pm.getInstalledApplications(PackageManager.GET_META_DATA)
return apps.firstOrNull {
pm.getApplicationLabel(it).toString().equals(name, ignoreCase = true)
}?.packageName
}
}
class AppAutomateTool @Inject constructor(
@ApplicationContext private val context: Context
) : Tool {
override val name = "app.automate"
override val description = "Automate actions in other apps using AccessibilityService. Requires accessibility permission to be enabled."
override val parameters = ToolParameters(
properties = mapOf(
"action" to ParameterProperty("string", "Action to perform",
enum = listOf("tap", "type", "scroll", "back", "home", "recents", "read_screen")),
"text" to ParameterProperty("string", "Text to type (for 'type' action), or text of element to tap"),
"x" to ParameterProperty("integer", "X coordinate for tap"),
"y" to ParameterProperty("integer", "Y coordinate for tap"),
"direction" to ParameterProperty("string", "Scroll direction (for 'scroll' action)",
enum = listOf("up", "down", "left", "right"))
),
required = listOf("action")
)
override val requiresApproval = true
override suspend fun execute(params: JsonObject): ToolResult {
val action = params["action"]?.jsonPrimitive?.content
?: return ToolResult.error("Missing 'action' parameter")
// The actual execution is delegated to CellClawAccessibility service
return try {
val intent = Intent("com.cellclaw.ACCESSIBILITY_ACTION").apply {
putExtra("action", action)
params["text"]?.jsonPrimitive?.contentOrNull?.let { putExtra("text", it) }
params["x"]?.jsonPrimitive?.intOrNull?.let { putExtra("x", it) }
params["y"]?.jsonPrimitive?.intOrNull?.let { putExtra("y", it) }
params["direction"]?.jsonPrimitive?.contentOrNull?.let { putExtra("direction", it) }
setPackage(context.packageName)
}
context.sendBroadcast(intent)
ToolResult.success(buildJsonObject {
put("action", action)
put("status", "dispatched")
})
} catch (e: Exception) {
ToolResult.error("Automation failed: ${e.message}")
}
}
}

View File

@@ -0,0 +1,75 @@
package com.cellclaw.tools
import android.content.Context
import android.content.Intent
import android.net.Uri
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.serialization.json.*
import javax.inject.Inject
class BrowserOpenTool @Inject constructor(
@ApplicationContext private val context: Context
) : Tool {
override val name = "browser.open"
override val description = "Open a URL in the device's default browser."
override val parameters = ToolParameters(
properties = mapOf(
"url" to ParameterProperty("string", "URL to open")
),
required = listOf("url")
)
override val requiresApproval = false
override suspend fun execute(params: JsonObject): ToolResult {
val url = params["url"]?.jsonPrimitive?.content
?: return ToolResult.error("Missing 'url' parameter")
return try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
context.startActivity(intent)
ToolResult.success(buildJsonObject {
put("opened", true)
put("url", url)
})
} catch (e: Exception) {
ToolResult.error("Failed to open browser: ${e.message}")
}
}
}
class BrowserSearchTool @Inject constructor(
@ApplicationContext private val context: Context
) : Tool {
override val name = "browser.search"
override val description = "Perform a web search using the default browser."
override val parameters = ToolParameters(
properties = mapOf(
"query" to ParameterProperty("string", "Search query")
),
required = listOf("query")
)
override val requiresApproval = false
override suspend fun execute(params: JsonObject): ToolResult {
val query = params["query"]?.jsonPrimitive?.content
?: return ToolResult.error("Missing 'query' parameter")
return try {
val encodedQuery = Uri.encode(query)
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://www.google.com/search?q=$encodedQuery")).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
context.startActivity(intent)
ToolResult.success(buildJsonObject {
put("searched", true)
put("query", query)
})
} catch (e: Exception) {
ToolResult.error("Failed to search: ${e.message}")
}
}
}

View File

@@ -0,0 +1,147 @@
package com.cellclaw.tools
import android.content.ContentValues
import android.content.Context
import android.provider.CalendarContract
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.serialization.json.*
import java.util.Calendar
import javax.inject.Inject
class CalendarQueryTool @Inject constructor(
@ApplicationContext private val context: Context
) : Tool {
override val name = "calendar.query"
override val description = "Query calendar events. Can filter by date range."
override val parameters = ToolParameters(
properties = mapOf(
"days_ahead" to ParameterProperty("integer", "Number of days ahead to query (default 7)"),
"query" to ParameterProperty("string", "Search text in event title/description"),
"limit" to ParameterProperty("integer", "Max events to return (default 20)")
)
)
override val requiresApproval = false
override suspend fun execute(params: JsonObject): ToolResult {
val daysAhead = params["days_ahead"]?.jsonPrimitive?.intOrNull ?: 7
val query = params["query"]?.jsonPrimitive?.contentOrNull
val limit = params["limit"]?.jsonPrimitive?.intOrNull ?: 20
return try {
val now = System.currentTimeMillis()
val end = now + daysAhead * 24L * 60 * 60 * 1000
val uri = CalendarContract.Events.CONTENT_URI
var selection = "(${CalendarContract.Events.DTSTART} >= ? AND ${CalendarContract.Events.DTSTART} <= ?)"
val selectionArgs = mutableListOf(now.toString(), end.toString())
if (query != null) {
selection += " AND (${CalendarContract.Events.TITLE} LIKE ?)"
selectionArgs.add("%$query%")
}
val cursor = context.contentResolver.query(
uri,
arrayOf(
CalendarContract.Events._ID,
CalendarContract.Events.TITLE,
CalendarContract.Events.DESCRIPTION,
CalendarContract.Events.DTSTART,
CalendarContract.Events.DTEND,
CalendarContract.Events.EVENT_LOCATION,
CalendarContract.Events.ALL_DAY
),
selection,
selectionArgs.toTypedArray(),
"${CalendarContract.Events.DTSTART} ASC"
)
val events = buildJsonArray {
cursor?.use {
var count = 0
while (it.moveToNext() && count < limit) {
add(buildJsonObject {
put("id", it.getLong(0))
put("title", it.getString(1) ?: "")
put("description", it.getString(2) ?: "")
put("start", it.getLong(3))
put("end", it.getLong(4))
put("location", it.getString(5) ?: "")
put("all_day", it.getInt(6) == 1)
})
count++
}
}
}
ToolResult.success(events)
} catch (e: Exception) {
ToolResult.error("Failed to query calendar: ${e.message}")
}
}
}
class CalendarCreateTool @Inject constructor(
@ApplicationContext private val context: Context
) : Tool {
override val name = "calendar.create"
override val description = "Create a new calendar event."
override val parameters = ToolParameters(
properties = mapOf(
"title" to ParameterProperty("string", "Event title"),
"description" to ParameterProperty("string", "Event description"),
"start_time" to ParameterProperty("integer", "Start time as Unix timestamp in milliseconds"),
"end_time" to ParameterProperty("integer", "End time as Unix timestamp in milliseconds"),
"location" to ParameterProperty("string", "Event location"),
"all_day" to ParameterProperty("boolean", "Whether this is an all-day event")
),
required = listOf("title", "start_time")
)
override val requiresApproval = true
override suspend fun execute(params: JsonObject): ToolResult {
val title = params["title"]?.jsonPrimitive?.content
?: return ToolResult.error("Missing 'title' parameter")
val startTime = params["start_time"]?.jsonPrimitive?.longOrNull
?: return ToolResult.error("Missing 'start_time' parameter")
val endTime = params["end_time"]?.jsonPrimitive?.longOrNull
?: (startTime + 3600000) // default 1 hour
val description = params["description"]?.jsonPrimitive?.contentOrNull ?: ""
val location = params["location"]?.jsonPrimitive?.contentOrNull ?: ""
val allDay = params["all_day"]?.jsonPrimitive?.booleanOrNull ?: false
return try {
// Get default calendar ID
val calCursor = context.contentResolver.query(
CalendarContract.Calendars.CONTENT_URI,
arrayOf(CalendarContract.Calendars._ID),
"${CalendarContract.Calendars.IS_PRIMARY} = 1",
null, null
)
val calendarId = calCursor?.use {
if (it.moveToFirst()) it.getLong(0) else 1L
} ?: 1L
val values = ContentValues().apply {
put(CalendarContract.Events.CALENDAR_ID, calendarId)
put(CalendarContract.Events.TITLE, title)
put(CalendarContract.Events.DESCRIPTION, description)
put(CalendarContract.Events.DTSTART, startTime)
put(CalendarContract.Events.DTEND, endTime)
put(CalendarContract.Events.EVENT_LOCATION, location)
put(CalendarContract.Events.ALL_DAY, if (allDay) 1 else 0)
put(CalendarContract.Events.EVENT_TIMEZONE, java.util.TimeZone.getDefault().id)
}
val uri = context.contentResolver.insert(CalendarContract.Events.CONTENT_URI, values)
ToolResult.success(buildJsonObject {
put("created", true)
put("title", title)
put("event_uri", uri.toString())
})
} catch (e: Exception) {
ToolResult.error("Failed to create event: ${e.message}")
}
}
}

View File

@@ -0,0 +1,89 @@
package com.cellclaw.tools
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Environment
import android.provider.MediaStore
import androidx.core.content.FileProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.serialization.json.*
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import javax.inject.Inject
class CameraSnapTool @Inject constructor(
@ApplicationContext private val context: Context
) : Tool {
override val name = "camera.snap"
override val description = "Take a photo using the device camera."
override val parameters = ToolParameters(
properties = mapOf(
"facing" to ParameterProperty("string", "Camera facing: front or back",
enum = listOf("front", "back"))
)
)
override val requiresApproval = true
override suspend fun execute(params: JsonObject): ToolResult {
return try {
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val imageFile = File(
context.getExternalFilesDir(Environment.DIRECTORY_PICTURES),
"CELLCLAW_$timestamp.jpg"
)
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply {
val photoUri = FileProvider.getUriForFile(
context, "${context.packageName}.fileprovider", imageFile
)
putExtra(MediaStore.EXTRA_OUTPUT, photoUri)
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
context.startActivity(intent)
ToolResult.success(buildJsonObject {
put("photo_path", imageFile.absolutePath)
put("status", "camera_opened")
})
} catch (e: Exception) {
ToolResult.error("Failed to open camera: ${e.message}")
}
}
}
class CameraRecordTool @Inject constructor(
@ApplicationContext private val context: Context
) : Tool {
override val name = "camera.record"
override val description = "Start video recording using the device camera."
override val parameters = ToolParameters(
properties = mapOf(
"duration_seconds" to ParameterProperty("integer", "Maximum recording duration in seconds"),
"facing" to ParameterProperty("string", "Camera facing: front or back",
enum = listOf("front", "back"))
)
)
override val requiresApproval = true
override suspend fun execute(params: JsonObject): ToolResult {
return try {
val intent = Intent(MediaStore.ACTION_VIDEO_CAPTURE).apply {
val duration = params["duration_seconds"]?.jsonPrimitive?.intOrNull
if (duration != null) {
putExtra(MediaStore.EXTRA_DURATION_LIMIT, duration)
}
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
context.startActivity(intent)
ToolResult.success(buildJsonObject {
put("status", "recording_started")
})
} catch (e: Exception) {
ToolResult.error("Failed to start recording: ${e.message}")
}
}
}

View File

@@ -0,0 +1,63 @@
package com.cellclaw.tools
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.serialization.json.*
import javax.inject.Inject
class ClipboardReadTool @Inject constructor(
@ApplicationContext private val context: Context
) : Tool {
override val name = "clipboard.read"
override val description = "Read the current clipboard contents."
override val parameters = ToolParameters()
override val requiresApproval = false
override suspend fun execute(params: JsonObject): ToolResult {
return try {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = clipboard.primaryClip
val text = clip?.getItemAt(0)?.text?.toString() ?: ""
ToolResult.success(buildJsonObject {
put("text", text)
put("has_content", text.isNotEmpty())
})
} catch (e: Exception) {
ToolResult.error("Failed to read clipboard: ${e.message}")
}
}
}
class ClipboardWriteTool @Inject constructor(
@ApplicationContext private val context: Context
) : Tool {
override val name = "clipboard.write"
override val description = "Write text to the clipboard."
override val parameters = ToolParameters(
properties = mapOf(
"text" to ParameterProperty("string", "Text to copy to clipboard")
),
required = listOf("text")
)
override val requiresApproval = true
override suspend fun execute(params: JsonObject): ToolResult {
val text = params["text"]?.jsonPrimitive?.content
?: return ToolResult.error("Missing 'text' parameter")
return try {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("CellClaw", text))
ToolResult.success(buildJsonObject {
put("copied", true)
put("length", text.length)
})
} catch (e: Exception) {
ToolResult.error("Failed to write clipboard: ${e.message}")
}
}
}

View File

@@ -0,0 +1,146 @@
package com.cellclaw.tools
import android.content.ContentValues
import android.content.Context
import android.provider.ContactsContract
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.serialization.json.*
import javax.inject.Inject
class ContactsSearchTool @Inject constructor(
@ApplicationContext private val context: Context
) : Tool {
override val name = "contacts.search"
override val description = "Search contacts by name or phone number."
override val parameters = ToolParameters(
properties = mapOf(
"query" to ParameterProperty("string", "Search query (name or number)"),
"limit" to ParameterProperty("integer", "Max results (default 10)")
),
required = listOf("query")
)
override val requiresApproval = false
override suspend fun execute(params: JsonObject): ToolResult {
val query = params["query"]?.jsonPrimitive?.content
?: return ToolResult.error("Missing 'query' parameter")
val limit = params["limit"]?.jsonPrimitive?.intOrNull ?: 10
return try {
val uri = ContactsContract.CommonDataKinds.Phone.CONTENT_URI
val cursor = context.contentResolver.query(
uri,
arrayOf(
ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME,
ContactsContract.CommonDataKinds.Phone.NUMBER,
ContactsContract.CommonDataKinds.Phone.TYPE
),
"${ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME} LIKE ? OR ${ContactsContract.CommonDataKinds.Phone.NUMBER} LIKE ?",
arrayOf("%$query%", "%$query%"),
"${ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME} ASC"
)
val contacts = buildJsonArray {
cursor?.use {
var count = 0
while (it.moveToNext() && count < limit) {
add(buildJsonObject {
put("name", it.getString(0) ?: "")
put("number", it.getString(1) ?: "")
put("type", when (it.getInt(2)) {
ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE -> "mobile"
ContactsContract.CommonDataKinds.Phone.TYPE_HOME -> "home"
ContactsContract.CommonDataKinds.Phone.TYPE_WORK -> "work"
else -> "other"
})
})
count++
}
}
}
ToolResult.success(contacts)
} catch (e: Exception) {
ToolResult.error("Failed to search contacts: ${e.message}")
}
}
}
class ContactsAddTool @Inject constructor(
@ApplicationContext private val context: Context
) : Tool {
override val name = "contacts.add"
override val description = "Add a new contact with name and phone number."
override val parameters = ToolParameters(
properties = mapOf(
"name" to ParameterProperty("string", "Contact display name"),
"number" to ParameterProperty("string", "Phone number"),
"email" to ParameterProperty("string", "Email address (optional)")
),
required = listOf("name", "number")
)
override val requiresApproval = true
override suspend fun execute(params: JsonObject): ToolResult {
val name = params["name"]?.jsonPrimitive?.content
?: return ToolResult.error("Missing 'name' parameter")
val number = params["number"]?.jsonPrimitive?.content
?: return ToolResult.error("Missing 'number' parameter")
val email = params["email"]?.jsonPrimitive?.contentOrNull
return try {
val ops = ArrayList<android.content.ContentProviderOperation>()
ops.add(
android.content.ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI)
.withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, null)
.withValue(ContactsContract.RawContacts.ACCOUNT_NAME, null)
.build()
)
// Name
ops.add(
android.content.ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
.withValue(ContactsContract.Data.MIMETYPE,
ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
.withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, name)
.build()
)
// Phone
ops.add(
android.content.ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
.withValue(ContactsContract.Data.MIMETYPE,
ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE)
.withValue(ContactsContract.CommonDataKinds.Phone.NUMBER, number)
.withValue(ContactsContract.CommonDataKinds.Phone.TYPE,
ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE)
.build()
)
// Email
if (email != null) {
ops.add(
android.content.ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
.withValue(ContactsContract.Data.MIMETYPE,
ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE)
.withValue(ContactsContract.CommonDataKinds.Email.ADDRESS, email)
.build()
)
}
context.contentResolver.applyBatch(ContactsContract.AUTHORITY, ops)
ToolResult.success(buildJsonObject {
put("added", true)
put("name", name)
put("number", number)
})
} catch (e: Exception) {
ToolResult.error("Failed to add contact: ${e.message}")
}
}
}

View File

@@ -0,0 +1,144 @@
package com.cellclaw.tools
import android.content.Context
import android.os.Environment
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.serialization.json.*
import java.io.File
import javax.inject.Inject
class FileReadTool @Inject constructor(
@ApplicationContext private val context: Context
) : Tool {
override val name = "file.read"
override val description = "Read contents of a file."
override val parameters = ToolParameters(
properties = mapOf(
"path" to ParameterProperty("string", "File path (relative to app storage or absolute)"),
"max_lines" to ParameterProperty("integer", "Maximum lines to read (default 100)")
),
required = listOf("path")
)
override val requiresApproval = false
override suspend fun execute(params: JsonObject): ToolResult {
val path = params["path"]?.jsonPrimitive?.content
?: return ToolResult.error("Missing 'path' parameter")
val maxLines = params["max_lines"]?.jsonPrimitive?.intOrNull ?: 100
return try {
val file = resolvePath(path)
if (!file.exists()) return ToolResult.error("File not found: $path")
if (!file.canRead()) return ToolResult.error("Cannot read file: $path")
if (file.length() > 10 * 1024 * 1024) return ToolResult.error("File too large (>10MB)")
val lines = file.readLines()
val content = lines.take(maxLines).joinToString("\n")
ToolResult.success(buildJsonObject {
put("content", content)
put("lines", lines.size)
put("truncated", lines.size > maxLines)
put("size_bytes", file.length())
})
} catch (e: Exception) {
ToolResult.error("Failed to read file: ${e.message}")
}
}
private fun resolvePath(path: String): File {
return if (path.startsWith("/")) File(path)
else File(context.filesDir, path)
}
}
class FileWriteTool @Inject constructor(
@ApplicationContext private val context: Context
) : Tool {
override val name = "file.write"
override val description = "Write content to a file."
override val parameters = ToolParameters(
properties = mapOf(
"path" to ParameterProperty("string", "File path (relative to app storage or absolute)"),
"content" to ParameterProperty("string", "Content to write"),
"append" to ParameterProperty("boolean", "Append to file instead of overwriting (default false)")
),
required = listOf("path", "content")
)
override val requiresApproval = true
override suspend fun execute(params: JsonObject): ToolResult {
val path = params["path"]?.jsonPrimitive?.content
?: return ToolResult.error("Missing 'path' parameter")
val content = params["content"]?.jsonPrimitive?.content
?: return ToolResult.error("Missing 'content' parameter")
val append = params["append"]?.jsonPrimitive?.booleanOrNull ?: false
return try {
val file = if (path.startsWith("/")) File(path)
else File(context.filesDir, path)
file.parentFile?.mkdirs()
if (append) file.appendText(content)
else file.writeText(content)
ToolResult.success(buildJsonObject {
put("written", true)
put("path", file.absolutePath)
put("size_bytes", file.length())
})
} catch (e: Exception) {
ToolResult.error("Failed to write file: ${e.message}")
}
}
}
class FileListTool @Inject constructor(
@ApplicationContext private val context: Context
) : Tool {
override val name = "file.list"
override val description = "List files and directories at a given path."
override val parameters = ToolParameters(
properties = mapOf(
"path" to ParameterProperty("string", "Directory path (default: app storage)"),
"recursive" to ParameterProperty("boolean", "List recursively (default false)")
)
)
override val requiresApproval = false
override suspend fun execute(params: JsonObject): ToolResult {
val path = params["path"]?.jsonPrimitive?.contentOrNull
val recursive = params["recursive"]?.jsonPrimitive?.booleanOrNull ?: false
return try {
val dir = if (path != null) {
if (path.startsWith("/")) File(path) else File(context.filesDir, path)
} else {
context.filesDir
}
if (!dir.exists()) return ToolResult.error("Directory not found: $path")
if (!dir.isDirectory) return ToolResult.error("Not a directory: $path")
val files = if (recursive) dir.walkTopDown().toList() else dir.listFiles()?.toList() ?: emptyList()
val entries = buildJsonArray {
for (file in files) {
if (file == dir) continue
add(buildJsonObject {
put("name", file.name)
put("path", file.absolutePath)
put("is_directory", file.isDirectory)
put("size_bytes", if (file.isFile) file.length() else 0)
put("last_modified", file.lastModified())
})
}
}
ToolResult.success(entries)
} catch (e: Exception) {
ToolResult.error("Failed to list files: ${e.message}")
}
}
}

View File

@@ -0,0 +1,79 @@
package com.cellclaw.tools
import android.annotation.SuppressLint
import android.content.Context
import android.location.Geocoder
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import com.google.android.gms.tasks.CancellationTokenSource
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.serialization.json.*
import java.util.Locale
import javax.inject.Inject
import kotlin.coroutines.resume
class LocationTool @Inject constructor(
@ApplicationContext private val context: Context
) : Tool {
override val name = "location.get"
override val description = "Get the current GPS location with optional reverse geocoding."
override val parameters = ToolParameters(
properties = mapOf(
"geocode" to ParameterProperty("boolean", "Include reverse-geocoded address (default true)")
)
)
override val requiresApproval = false
@SuppressLint("MissingPermission")
override suspend fun execute(params: JsonObject): ToolResult {
val geocode = params["geocode"]?.jsonPrimitive?.booleanOrNull ?: true
return try {
val client = LocationServices.getFusedLocationProviderClient(context)
val cancellationToken = CancellationTokenSource()
val location = suspendCancellableCoroutine { cont ->
client.getCurrentLocation(
Priority.PRIORITY_HIGH_ACCURACY,
cancellationToken.token
).addOnSuccessListener { loc ->
cont.resume(loc)
}.addOnFailureListener {
cont.resume(null)
}
cont.invokeOnCancellation { cancellationToken.cancel() }
} ?: return ToolResult.error("Could not get location")
val result = buildJsonObject {
put("latitude", location.latitude)
put("longitude", location.longitude)
put("accuracy_meters", location.accuracy.toDouble())
location.altitude.let { put("altitude", it) }
if (geocode) {
try {
@Suppress("DEPRECATION")
val geocoder = Geocoder(context, Locale.getDefault())
val addresses = geocoder.getFromLocation(location.latitude, location.longitude, 1)
addresses?.firstOrNull()?.let { addr ->
put("address", buildJsonObject {
put("street", addr.getAddressLine(0) ?: "")
put("city", addr.locality ?: "")
put("state", addr.adminArea ?: "")
put("country", addr.countryName ?: "")
put("postal_code", addr.postalCode ?: "")
})
}
} catch (_: Exception) {
// Geocoding failed, just return coordinates
}
}
}
ToolResult.success(result)
} catch (e: Exception) {
ToolResult.error("Location error: ${e.message}")
}
}
}

View File

@@ -0,0 +1,60 @@
package com.cellclaw.tools
import android.app.NotificationManager
import android.content.Context
import androidx.core.app.NotificationCompat
import com.cellclaw.CellClawApp
import com.cellclaw.R
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.serialization.json.*
import javax.inject.Inject
class NotificationTool @Inject constructor(
@ApplicationContext private val context: Context
) : Tool {
override val name = "notification.send"
override val description = "Send a local notification to the user."
override val parameters = ToolParameters(
properties = mapOf(
"title" to ParameterProperty("string", "Notification title"),
"message" to ParameterProperty("string", "Notification body text"),
"priority" to ParameterProperty("string", "Priority: low, default, high",
enum = listOf("low", "default", "high"))
),
required = listOf("title", "message")
)
override val requiresApproval = false
override suspend fun execute(params: JsonObject): ToolResult {
val title = params["title"]?.jsonPrimitive?.content
?: return ToolResult.error("Missing 'title'")
val message = params["message"]?.jsonPrimitive?.content
?: return ToolResult.error("Missing 'message'")
val priority = when (params["priority"]?.jsonPrimitive?.contentOrNull) {
"high" -> NotificationCompat.PRIORITY_HIGH
"low" -> NotificationCompat.PRIORITY_LOW
else -> NotificationCompat.PRIORITY_DEFAULT
}
return try {
val notificationId = (System.currentTimeMillis() % Int.MAX_VALUE).toInt()
val notification = NotificationCompat.Builder(context, CellClawApp.CHANNEL_ALERTS)
.setContentTitle(title)
.setContentText(message)
.setSmallIcon(R.drawable.ic_notification)
.setPriority(priority)
.setAutoCancel(true)
.build()
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(notificationId, notification)
ToolResult.success(buildJsonObject {
put("sent", true)
put("notification_id", notificationId)
})
} catch (e: Exception) {
ToolResult.error("Failed to send notification: ${e.message}")
}
}
}

View File

@@ -0,0 +1,109 @@
package com.cellclaw.tools
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.CallLog
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.serialization.json.*
import javax.inject.Inject
class PhoneCallTool @Inject constructor(
@ApplicationContext private val context: Context
) : Tool {
override val name = "phone.call"
override val description = "Initiate a phone call to a given number."
override val parameters = ToolParameters(
properties = mapOf(
"number" to ParameterProperty("string", "Phone number to call")
),
required = listOf("number")
)
override val requiresApproval = true
override suspend fun execute(params: JsonObject): ToolResult {
val number = params["number"]?.jsonPrimitive?.content
?: return ToolResult.error("Missing 'number' parameter")
return try {
val intent = Intent(Intent.ACTION_CALL).apply {
data = Uri.parse("tel:$number")
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
context.startActivity(intent)
ToolResult.success(buildJsonObject {
put("calling", number)
})
} catch (e: Exception) {
ToolResult.error("Failed to initiate call: ${e.message}")
}
}
}
class PhoneLogTool @Inject constructor(
@ApplicationContext private val context: Context
) : Tool {
override val name = "phone.log"
override val description = "Read recent call history (incoming, outgoing, missed)."
override val parameters = ToolParameters(
properties = mapOf(
"limit" to ParameterProperty("integer", "Maximum number of entries (default 20)"),
"type" to ParameterProperty("string", "Filter by call type: incoming, outgoing, missed, or all",
enum = listOf("incoming", "outgoing", "missed", "all"))
)
)
override val requiresApproval = false
override suspend fun execute(params: JsonObject): ToolResult {
val limit = params["limit"]?.jsonPrimitive?.intOrNull ?: 20
val type = params["type"]?.jsonPrimitive?.contentOrNull ?: "all"
return try {
var selection: String? = null
when (type) {
"incoming" -> selection = "${CallLog.Calls.TYPE} = ${CallLog.Calls.INCOMING_TYPE}"
"outgoing" -> selection = "${CallLog.Calls.TYPE} = ${CallLog.Calls.OUTGOING_TYPE}"
"missed" -> selection = "${CallLog.Calls.TYPE} = ${CallLog.Calls.MISSED_TYPE}"
}
val cursor = context.contentResolver.query(
CallLog.Calls.CONTENT_URI,
arrayOf(
CallLog.Calls.NUMBER,
CallLog.Calls.TYPE,
CallLog.Calls.DATE,
CallLog.Calls.DURATION,
CallLog.Calls.CACHED_NAME
),
selection, null,
"${CallLog.Calls.DATE} DESC"
)
val logs = buildJsonArray {
cursor?.use {
var count = 0
while (it.moveToNext() && count < limit) {
val callType = when (it.getInt(1)) {
CallLog.Calls.INCOMING_TYPE -> "incoming"
CallLog.Calls.OUTGOING_TYPE -> "outgoing"
CallLog.Calls.MISSED_TYPE -> "missed"
else -> "unknown"
}
add(buildJsonObject {
put("number", it.getString(0) ?: "unknown")
put("type", callType)
put("date", it.getLong(2))
put("duration_seconds", it.getLong(3))
put("name", it.getString(4) ?: "")
})
count++
}
}
}
ToolResult.success(logs)
} catch (e: Exception) {
ToolResult.error("Failed to read call log: ${e.message}")
}
}
}

View File

@@ -0,0 +1,52 @@
package com.cellclaw.tools
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.*
import javax.inject.Inject
class ScriptExecTool @Inject constructor() : Tool {
override val name = "script.exec"
override val description = "Execute a shell command on the device. Use with caution."
override val parameters = ToolParameters(
properties = mapOf(
"command" to ParameterProperty("string", "Shell command to execute"),
"timeout_seconds" to ParameterProperty("integer", "Execution timeout in seconds (default 30, max 120)")
),
required = listOf("command")
)
override val requiresApproval = true
override suspend fun execute(params: JsonObject): ToolResult {
val command = params["command"]?.jsonPrimitive?.content
?: return ToolResult.error("Missing 'command' parameter")
val timeoutSeconds = (params["timeout_seconds"]?.jsonPrimitive?.intOrNull ?: 30)
.coerceIn(1, 120)
return withContext(Dispatchers.IO) {
try {
withTimeout(timeoutSeconds * 1000L) {
val process = ProcessBuilder("sh", "-c", command)
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().readText()
val exitCode = process.waitFor()
ToolResult.success(buildJsonObject {
put("exit_code", exitCode)
put("output", output.take(MAX_OUTPUT_LENGTH))
put("truncated", output.length > MAX_OUTPUT_LENGTH)
})
}
} catch (e: Exception) {
ToolResult.error("Script execution failed: ${e.message}")
}
}
}
companion object {
private const val MAX_OUTPUT_LENGTH = 10_000
}
}

View File

@@ -0,0 +1,109 @@
package com.cellclaw.tools
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.*
import javax.inject.Inject
import kotlin.coroutines.resume
class SensorTool @Inject constructor(
@ApplicationContext private val context: Context
) : Tool {
override val name = "sensor.read"
override val description = "Read device sensor data (accelerometer, gyroscope, light, proximity, etc.)."
override val parameters = ToolParameters(
properties = mapOf(
"sensor" to ParameterProperty("string", "Sensor type to read",
enum = listOf("accelerometer", "gyroscope", "light", "proximity", "pressure", "all"))
),
required = listOf("sensor")
)
override val requiresApproval = false
override suspend fun execute(params: JsonObject): ToolResult {
val sensorType = params["sensor"]?.jsonPrimitive?.content
?: return ToolResult.error("Missing 'sensor' parameter")
val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
return try {
if (sensorType == "all") {
val result = buildJsonObject {
putJsonArray("available_sensors") {
sensorManager.getSensorList(Sensor.TYPE_ALL).forEach {
add(it.name)
}
}
}
return ToolResult.success(result)
}
val androidSensorType = when (sensorType) {
"accelerometer" -> Sensor.TYPE_ACCELEROMETER
"gyroscope" -> Sensor.TYPE_GYROSCOPE
"light" -> Sensor.TYPE_LIGHT
"proximity" -> Sensor.TYPE_PROXIMITY
"pressure" -> Sensor.TYPE_PRESSURE
else -> return ToolResult.error("Unknown sensor type: $sensorType")
}
val sensor = sensorManager.getDefaultSensor(androidSensorType)
?: return ToolResult.error("Sensor '$sensorType' not available on this device")
val values = withTimeout(5000) {
suspendCancellableCoroutine<FloatArray> { cont ->
val listener = object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent) {
sensorManager.unregisterListener(this)
cont.resume(event.values.clone())
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
}
sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL)
cont.invokeOnCancellation { sensorManager.unregisterListener(listener) }
}
}
val result = buildJsonObject {
put("sensor", sensorType)
putJsonArray("values") { values.forEach { add(it.toDouble()) } }
when (sensorType) {
"accelerometer" -> {
put("x", values[0].toDouble())
put("y", values[1].toDouble())
put("z", values[2].toDouble())
put("unit", "m/s²")
}
"gyroscope" -> {
put("x", values[0].toDouble())
put("y", values[1].toDouble())
put("z", values[2].toDouble())
put("unit", "rad/s")
}
"light" -> {
put("lux", values[0].toDouble())
put("unit", "lux")
}
"proximity" -> {
put("distance_cm", values[0].toDouble())
put("unit", "cm")
}
"pressure" -> {
put("hpa", values[0].toDouble())
put("unit", "hPa")
}
}
}
ToolResult.success(result)
} catch (e: Exception) {
ToolResult.error("Sensor read error: ${e.message}")
}
}
}

View File

@@ -0,0 +1,83 @@
package com.cellclaw.tools
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.BatteryManager
import android.provider.Settings
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.serialization.json.*
import javax.inject.Inject
class SettingsTool @Inject constructor(
@ApplicationContext private val context: Context
) : Tool {
override val name = "settings.get"
override val description = "Get device settings and status: battery, WiFi, screen brightness, etc."
override val parameters = ToolParameters(
properties = mapOf(
"info" to ParameterProperty("string", "What to query",
enum = listOf("battery", "wifi", "brightness", "all"))
)
)
override val requiresApproval = false
override suspend fun execute(params: JsonObject): ToolResult {
val info = params["info"]?.jsonPrimitive?.contentOrNull ?: "all"
return try {
val result = buildJsonObject {
if (info == "battery" || info == "all") {
val batteryIntent = context.registerReceiver(null,
IntentFilter(Intent.ACTION_BATTERY_CHANGED))
val level = batteryIntent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1
val scale = batteryIntent?.getIntExtra(BatteryManager.EXTRA_SCALE, 100) ?: 100
val status = batteryIntent?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) ?: -1
val pct = (level * 100) / scale
putJsonObject("battery") {
put("percentage", pct)
put("charging", status == BatteryManager.BATTERY_STATUS_CHARGING ||
status == BatteryManager.BATTERY_STATUS_FULL)
put("status", when (status) {
BatteryManager.BATTERY_STATUS_CHARGING -> "charging"
BatteryManager.BATTERY_STATUS_DISCHARGING -> "discharging"
BatteryManager.BATTERY_STATUS_FULL -> "full"
BatteryManager.BATTERY_STATUS_NOT_CHARGING -> "not_charging"
else -> "unknown"
})
}
}
if (info == "wifi" || info == "all") {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = cm.activeNetwork
val caps = network?.let { cm.getNetworkCapabilities(it) }
putJsonObject("network") {
put("connected", network != null)
put("wifi", caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ?: false)
put("cellular", caps?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) ?: false)
}
}
if (info == "brightness" || info == "all") {
val brightness = Settings.System.getInt(
context.contentResolver,
Settings.System.SCREEN_BRIGHTNESS, 128
)
putJsonObject("display") {
put("brightness", brightness)
put("brightness_pct", (brightness * 100) / 255)
}
}
}
ToolResult.success(result)
} catch (e: Exception) {
ToolResult.error("Failed to get settings: ${e.message}")
}
}
}

View File

@@ -0,0 +1,106 @@
package com.cellclaw.tools
import android.content.Context
import android.net.Uri
import android.telephony.SmsManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.serialization.json.*
import javax.inject.Inject
class SmsReadTool @Inject constructor(
@ApplicationContext private val context: Context
) : Tool {
override val name = "sms.read"
override val description = "Read SMS messages from the inbox. Can filter by sender or limit count."
override val parameters = ToolParameters(
properties = mapOf(
"limit" to ParameterProperty("integer", "Maximum number of messages to return (default 10)"),
"from" to ParameterProperty("string", "Filter by sender phone number or contact name"),
"unread_only" to ParameterProperty("boolean", "Only return unread messages")
)
)
override val requiresApproval = false
override suspend fun execute(params: JsonObject): ToolResult {
val limit = params["limit"]?.jsonPrimitive?.intOrNull ?: 10
val from = params["from"]?.jsonPrimitive?.contentOrNull
val unreadOnly = params["unread_only"]?.jsonPrimitive?.booleanOrNull ?: false
return try {
val uri = Uri.parse("content://sms/inbox")
var selection: String? = null
var selectionArgs: Array<String>? = null
if (from != null) {
selection = "address LIKE ?"
selectionArgs = arrayOf("%$from%")
}
if (unreadOnly) {
selection = (selection?.let { "$it AND " } ?: "") + "read = 0"
}
val cursor = context.contentResolver.query(
uri, arrayOf("_id", "address", "body", "date", "read"),
selection, selectionArgs, "date DESC"
)
val messages = buildJsonArray {
cursor?.use {
var count = 0
while (it.moveToNext() && count < limit) {
add(buildJsonObject {
put("id", it.getLong(0))
put("from", it.getString(1) ?: "unknown")
put("body", it.getString(2) ?: "")
put("date", it.getLong(3))
put("read", it.getInt(4) == 1)
})
count++
}
}
}
ToolResult.success(messages)
} catch (e: Exception) {
ToolResult.error("Failed to read SMS: ${e.message}")
}
}
}
class SmsSendTool @Inject constructor(
@ApplicationContext private val context: Context
) : Tool {
override val name = "sms.send"
override val description = "Send an SMS text message to a phone number."
override val parameters = ToolParameters(
properties = mapOf(
"to" to ParameterProperty("string", "Recipient phone number"),
"message" to ParameterProperty("string", "Message text to send")
),
required = listOf("to", "message")
)
override val requiresApproval = true
override suspend fun execute(params: JsonObject): ToolResult {
val to = params["to"]?.jsonPrimitive?.content
?: return ToolResult.error("Missing 'to' parameter")
val message = params["message"]?.jsonPrimitive?.content
?: return ToolResult.error("Missing 'message' parameter")
return try {
@Suppress("DEPRECATION")
val smsManager = SmsManager.getDefault()
val parts = smsManager.divideMessage(message)
smsManager.sendMultipartTextMessage(to, null, parts, null, null)
ToolResult.success(buildJsonObject {
put("sent", true)
put("to", to)
put("message_length", message.length)
put("parts", parts.size)
})
} catch (e: Exception) {
ToolResult.error("Failed to send SMS: ${e.message}")
}
}
}

View File

@@ -0,0 +1,51 @@
package com.cellclaw.tools
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
/**
* Base interface for all CellClaw tools that the AI agent can invoke.
*/
interface Tool {
/** Unique tool name (e.g. "sms.read", "phone.call") */
val name: String
/** Human-readable description for the AI to understand when to use this tool */
val description: String
/** JSON Schema describing the tool's parameters */
val parameters: ToolParameters
/** Whether this tool requires user approval before execution */
val requiresApproval: Boolean
/** Execute the tool with the given parameters */
suspend fun execute(params: JsonObject): ToolResult
}
@Serializable
data class ToolParameters(
val type: String = "object",
val properties: Map<String, ParameterProperty> = emptyMap(),
val required: List<String> = emptyList()
)
@Serializable
data class ParameterProperty(
val type: String,
val description: String,
val enum: List<String>? = null
)
@Serializable
data class ToolResult(
val success: Boolean,
val data: JsonElement? = null,
val error: String? = null
) {
companion object {
fun success(data: JsonElement): ToolResult = ToolResult(success = true, data = data)
fun error(message: String): ToolResult = ToolResult(success = false, error = message)
}
}

View File

@@ -0,0 +1,43 @@
package com.cellclaw.tools
import javax.inject.Inject
import javax.inject.Singleton
/**
* Central registry for all available tools. Tools register themselves here
* and the agent loop queries this to know what tools are available.
*/
@Singleton
class ToolRegistry @Inject constructor() {
private val tools = mutableMapOf<String, Tool>()
fun register(tool: Tool) {
tools[tool.name] = tool
}
fun register(vararg toolList: Tool) {
toolList.forEach { register(it) }
}
fun get(name: String): Tool? = tools[name]
fun all(): List<Tool> = tools.values.toList()
fun names(): Set<String> = tools.keys.toSet()
/** Convert all registered tools to the format needed by AI provider APIs */
fun toApiSchema(): List<ToolApiDefinition> = tools.values.map { tool ->
ToolApiDefinition(
name = tool.name,
description = tool.description,
inputSchema = tool.parameters
)
}
}
data class ToolApiDefinition(
val name: String,
val description: String,
val inputSchema: ToolParameters
)

View File

@@ -0,0 +1,80 @@
package com.cellclaw.ui
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.cellclaw.config.AppConfig
import com.cellclaw.service.CellClawService
import com.cellclaw.ui.screens.*
import com.cellclaw.ui.theme.CellClawTheme
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject lateinit var appConfig: AppConfig
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
CellClawTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
val navController = rememberNavController()
val startDest = if (appConfig.isSetupComplete) "chat" else "setup"
NavHost(navController = navController, startDestination = startDest) {
composable("setup") {
SetupScreen(
onComplete = {
appConfig.isSetupComplete = true
startService()
navController.navigate("chat") {
popUpTo("setup") { inclusive = true }
}
}
)
}
composable("chat") {
ChatScreen(
onNavigateToSettings = { navController.navigate("settings") },
onNavigateToSkills = { navController.navigate("skills") },
onNavigateToApprovals = { navController.navigate("approvals") }
)
}
composable("settings") {
SettingsScreen(onBack = { navController.popBackStack() })
}
composable("skills") {
SkillsScreen(onBack = { navController.popBackStack() })
}
composable("approvals") {
ApprovalScreen(onBack = { navController.popBackStack() })
}
}
}
}
}
}
private fun startService() {
val intent = Intent(this, CellClawService::class.java).apply {
action = CellClawService.ACTION_START
}
startForegroundService(intent)
}
}

View File

@@ -0,0 +1,115 @@
package com.cellclaw.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.cellclaw.approval.ApprovalRequest
import com.cellclaw.approval.ApprovalResult
import com.cellclaw.ui.viewmodel.ApprovalViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ApprovalScreen(
onBack: () -> Unit,
viewModel: ApprovalViewModel = hiltViewModel()
) {
val requests by viewModel.requests.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Pending Approvals") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
}
)
}
) { padding ->
if (requests.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = androidx.compose.ui.Alignment.Center
) {
Text(
"No pending approvals",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(requests, key = { it.id }) { request ->
ApprovalCard(
request = request,
onApprove = { viewModel.respond(request.id, ApprovalResult.APPROVED) },
onDeny = { viewModel.respond(request.id, ApprovalResult.DENIED) },
onAlwaysAllow = { viewModel.respond(request.id, ApprovalResult.ALWAYS_ALLOW) }
)
}
}
}
}
}
@Composable
private fun ApprovalCard(
request: ApprovalRequest,
onApprove: () -> Unit,
onDeny: () -> Unit,
onAlwaysAllow: () -> Unit
) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = request.toolName,
style = MaterialTheme.typography.titleMedium
)
Spacer(Modifier.height(4.dp))
Text(
text = request.description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(4.dp))
Text(
text = request.parameters.toString().take(200),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
Spacer(Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(onClick = onDeny, modifier = Modifier.weight(1f)) {
Text("Deny")
}
Button(onClick = onApprove, modifier = Modifier.weight(1f)) {
Text("Approve")
}
TextButton(onClick = onAlwaysAllow) {
Text("Always")
}
}
}
}
}

View File

@@ -0,0 +1,202 @@
package com.cellclaw.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
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.draw.clip
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.cellclaw.ui.viewmodel.ChatViewModel
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatScreen(
onNavigateToSettings: () -> Unit,
onNavigateToSkills: () -> Unit,
onNavigateToApprovals: () -> Unit,
viewModel: ChatViewModel = hiltViewModel()
) {
val messages by viewModel.messages.collectAsState()
val agentState by viewModel.agentState.collectAsState()
val pendingApprovals by viewModel.pendingApprovals.collectAsState()
var inputText by remember { mutableStateOf("") }
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
Scaffold(
topBar = {
TopAppBar(
title = { Text("CellClaw") },
actions = {
if (pendingApprovals.isNotEmpty()) {
BadgedBox(badge = {
Badge { Text("${pendingApprovals.size}") }
}) {
IconButton(onClick = onNavigateToApprovals) {
Icon(Icons.Default.Notifications, "Approvals")
}
}
}
IconButton(onClick = onNavigateToSkills) {
Icon(Icons.Default.Star, "Skills")
}
IconButton(onClick = onNavigateToSettings) {
Icon(Icons.Default.Settings, "Settings")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
// Messages list
LazyColumn(
state = listState,
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(vertical = 8.dp)
) {
items(messages, key = { it.id }) { message ->
ChatBubble(message = message)
}
}
// Auto-scroll on new messages
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) {
listState.animateScrollToItem(messages.size - 1)
}
}
// Agent state indicator
if (agentState != "idle") {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth()
)
Text(
text = when (agentState) {
"thinking" -> "Thinking..."
"executing_tools" -> "Executing tools..."
"waiting_approval" -> "Waiting for approval..."
else -> agentState
},
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.primary
)
}
// Input bar
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = inputText,
onValueChange = { inputText = it },
modifier = Modifier.weight(1f),
placeholder = { Text("Message CellClaw...") },
shape = RoundedCornerShape(24.dp),
maxLines = 4
)
Spacer(modifier = Modifier.width(8.dp))
FilledIconButton(
onClick = {
if (inputText.isNotBlank()) {
viewModel.sendMessage(inputText.trim())
inputText = ""
scope.launch {
if (messages.isNotEmpty()) {
listState.animateScrollToItem(messages.size - 1)
}
}
}
},
enabled = inputText.isNotBlank()
) {
Icon(Icons.AutoMirrored.Filled.Send, "Send")
}
}
}
}
}
@Composable
fun ChatBubble(message: ChatMessage) {
val isUser = message.role == "user"
val alignment = if (isUser) Alignment.CenterEnd else Alignment.CenterStart
val bgColor = if (isUser) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.surfaceVariant
}
val textColor = if (isUser) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = alignment
) {
Column(
modifier = Modifier
.widthIn(max = 320.dp)
.clip(RoundedCornerShape(16.dp))
.background(bgColor)
.padding(12.dp)
) {
if (message.toolName != null) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Build,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = textColor.copy(alpha = 0.7f)
)
Spacer(Modifier.width(4.dp))
Text(
text = message.toolName,
style = MaterialTheme.typography.labelSmall,
color = textColor.copy(alpha = 0.7f)
)
}
Spacer(Modifier.height(4.dp))
}
Text(
text = message.content,
color = textColor,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
data class ChatMessage(
val id: Long,
val role: String,
val content: String,
val toolName: String? = null,
val timestamp: Long = System.currentTimeMillis()
)

View File

@@ -0,0 +1,161 @@
package com.cellclaw.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.cellclaw.agent.ToolApprovalPolicy
import com.cellclaw.ui.viewmodel.SettingsViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onBack: () -> Unit,
viewModel: SettingsViewModel = hiltViewModel()
) {
val model by viewModel.model.collectAsState()
val userName by viewModel.userName.collectAsState()
val autoStartOnBoot by viewModel.autoStartOnBoot.collectAsState()
val policies by viewModel.policies.collectAsState()
val hasApiKey by viewModel.hasApiKey.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Settings") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Provider section
Text("AI Provider", style = MaterialTheme.typography.titleMedium)
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("API Key")
Text(
if (hasApiKey) "Configured" else "Not set",
color = if (hasApiKey) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.error
)
}
Spacer(Modifier.height(12.dp))
var modelInput by remember { mutableStateOf(model) }
OutlinedTextField(
value = modelInput,
onValueChange = { modelInput = it },
label = { Text("Model") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
if (modelInput != model) {
TextButton(onClick = { viewModel.setModel(modelInput) }) {
Text("Save")
}
}
}
}
// User section
Text("User", style = MaterialTheme.typography.titleMedium)
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
var nameInput by remember { mutableStateOf(userName) }
OutlinedTextField(
value = nameInput,
onValueChange = { nameInput = it },
label = { Text("Your Name") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
if (nameInput != userName) {
TextButton(onClick = { viewModel.setUserName(nameInput) }) {
Text("Save")
}
}
}
}
// Service section
Text("Service", style = MaterialTheme.typography.titleMedium)
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("Start on boot")
Switch(
checked = autoStartOnBoot,
onCheckedChange = { viewModel.setAutoStartOnBoot(it) }
)
}
}
}
// Approval policies
Text("Tool Approval Policies", style = MaterialTheme.typography.titleMedium)
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
for ((tool, policy) in policies.entries.sortedBy { it.key }) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
tool,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
PolicyChip(
policy = policy,
onToggle = { viewModel.togglePolicy(tool) }
)
}
}
}
}
}
}
}
@Composable
private fun PolicyChip(policy: ToolApprovalPolicy, onToggle: () -> Unit) {
FilterChip(
selected = policy == ToolApprovalPolicy.AUTO,
onClick = onToggle,
label = {
Text(
when (policy) {
ToolApprovalPolicy.AUTO -> "Auto"
ToolApprovalPolicy.ASK -> "Ask"
ToolApprovalPolicy.DENY -> "Deny"
}
)
}
)
}

View File

@@ -0,0 +1,191 @@
package com.cellclaw.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.cellclaw.ui.viewmodel.SetupViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SetupScreen(
onComplete: () -> Unit,
viewModel: SetupViewModel = hiltViewModel()
) {
var apiKey by remember { mutableStateOf("") }
var userName by remember { mutableStateOf("") }
var showApiKey by remember { mutableStateOf(false) }
var currentStep by remember { mutableIntStateOf(0) }
Scaffold(
topBar = {
TopAppBar(title = { Text("Welcome to CellClaw") })
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Step indicator
LinearProgressIndicator(
progress = { (currentStep + 1) / 3f },
modifier = Modifier.fillMaxWidth()
)
when (currentStep) {
0 -> {
Text(
"Set up your AI provider",
style = MaterialTheme.typography.headlineMedium
)
Text(
"CellClaw uses cloud AI to process your requests. Enter your Anthropic API key to get started.",
style = MaterialTheme.typography.bodyLarge
)
OutlinedTextField(
value = apiKey,
onValueChange = { apiKey = it },
label = { Text("Anthropic API Key") },
leadingIcon = { Icon(Icons.Default.Key, null) },
trailingIcon = {
IconButton(onClick = { showApiKey = !showApiKey }) {
Icon(
if (showApiKey) Icons.Default.VisibilityOff
else Icons.Default.Visibility,
"Toggle visibility"
)
}
},
visualTransformation = if (showApiKey) VisualTransformation.None
else PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Button(
onClick = {
viewModel.saveApiKey(apiKey)
currentStep = 1
},
modifier = Modifier.fillMaxWidth(),
enabled = apiKey.startsWith("sk-")
) {
Text("Continue")
}
}
1 -> {
Text(
"Tell CellClaw about you",
style = MaterialTheme.typography.headlineMedium
)
Text(
"This helps CellClaw personalize its responses.",
style = MaterialTheme.typography.bodyLarge
)
OutlinedTextField(
value = userName,
onValueChange = { userName = it },
label = { Text("Your name") },
leadingIcon = { Icon(Icons.Default.Person, null) },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Button(
onClick = {
viewModel.saveUserName(userName)
currentStep = 2
},
modifier = Modifier.fillMaxWidth()
) {
Text("Continue")
}
TextButton(
onClick = { currentStep = 2 },
modifier = Modifier.fillMaxWidth()
) {
Text("Skip")
}
}
2 -> {
Text(
"Permissions",
style = MaterialTheme.typography.headlineMedium
)
Text(
"CellClaw needs permissions to access phone features. You'll be prompted when each feature is first used. Sensitive actions (sending SMS, making calls, running scripts) always ask for your approval by default.",
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.height(16.dp))
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Default approval policies:", style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(8.dp))
PolicyRow("Read SMS/Contacts/Calendar", "Auto-approve")
PolicyRow("Send SMS", "Ask first")
PolicyRow("Phone calls", "Ask first")
PolicyRow("Run scripts", "Ask first")
PolicyRow("App automation", "Ask first")
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(
"You can change these anytime in Settings.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Button(
onClick = onComplete,
modifier = Modifier.fillMaxWidth()
) {
Text("Start CellClaw")
}
}
}
}
}
}
@Composable
private fun PolicyRow(feature: String, policy: String) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(feature, style = MaterialTheme.typography.bodyMedium)
Text(
policy,
style = MaterialTheme.typography.bodyMedium,
color = if (policy == "Auto-approve") MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.tertiary
)
}
}

View File

@@ -0,0 +1,182 @@
package com.cellclaw.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.cellclaw.skills.Skill
import com.cellclaw.ui.viewmodel.SkillsViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SkillsScreen(
onBack: () -> Unit,
viewModel: SkillsViewModel = hiltViewModel()
) {
val skills by viewModel.skills.collectAsState()
var showAddDialog by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Skills") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
},
actions = {
IconButton(onClick = { showAddDialog = true }) {
Icon(Icons.Default.Add, "Add Skill")
}
}
)
}
) { padding ->
if (skills.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = androidx.compose.ui.Alignment.Center
) {
Column(horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally) {
Text(
"No skills configured",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(8.dp))
Text(
"Skills are reusable workflows the AI can follow.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
}
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(skills, key = { it.name }) { skill ->
SkillCard(
skill = skill,
onDelete = { viewModel.removeSkill(skill.name) }
)
}
}
}
}
if (showAddDialog) {
AddSkillDialog(
onDismiss = { showAddDialog = false },
onAdd = { name, description, trigger, steps ->
viewModel.addSkill(name, description, trigger, steps)
showAddDialog = false
}
)
}
}
@Composable
private fun SkillCard(skill: Skill, onDelete: () -> Unit) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(skill.name, style = MaterialTheme.typography.titleMedium)
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, "Delete", tint = MaterialTheme.colorScheme.error)
}
}
Text(skill.description, style = MaterialTheme.typography.bodyMedium)
if (skill.trigger.isNotBlank()) {
Spacer(Modifier.height(4.dp))
Text(
"Trigger: \"${skill.trigger}\"",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
if (skill.steps.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
skill.steps.forEachIndexed { i, step ->
Text("${i + 1}. $step", style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
@Composable
private fun AddSkillDialog(
onDismiss: () -> Unit,
onAdd: (name: String, description: String, trigger: String, steps: String) -> Unit
) {
var name by remember { mutableStateOf("") }
var description by remember { mutableStateOf("") }
var trigger by remember { mutableStateOf("") }
var steps by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("New Skill") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Description") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = trigger,
onValueChange = { trigger = it },
label = { Text("Trigger phrase") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = steps,
onValueChange = { steps = it },
label = { Text("Steps (one per line)") },
modifier = Modifier.fillMaxWidth(),
minLines = 3
)
}
},
confirmButton = {
Button(
onClick = { onAdd(name, description, trigger, steps) },
enabled = name.isNotBlank()
) {
Text("Add")
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
)
}

View File

@@ -0,0 +1,54 @@
package com.cellclaw.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Color(0xFF90CAF9),
secondary = Color(0xFFCE93D8),
tertiary = Color(0xFF80CBC4),
background = Color(0xFF121212),
surface = Color(0xFF1E1E1E),
onPrimary = Color.Black,
onSecondary = Color.Black,
onBackground = Color.White,
onSurface = Color.White,
)
private val LightColorScheme = lightColorScheme(
primary = Color(0xFF1565C0),
secondary = Color(0xFF7B1FA2),
tertiary = Color(0xFF00695C),
background = Color(0xFFFAFAFA),
surface = Color.White,
onPrimary = Color.White,
onSecondary = Color.White,
onBackground = Color.Black,
onSurface = Color.Black,
)
@Composable
fun CellClawTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context)
else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
content = content
)
}

View File

@@ -0,0 +1,25 @@
package com.cellclaw.ui.viewmodel
import androidx.lifecycle.ViewModel
import com.cellclaw.approval.ApprovalPolicyManager
import com.cellclaw.approval.ApprovalQueue
import com.cellclaw.approval.ApprovalRequest
import com.cellclaw.approval.ApprovalResult
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
@HiltViewModel
class ApprovalViewModel @Inject constructor(
private val approvalQueue: ApprovalQueue,
private val policyManager: ApprovalPolicyManager
) : ViewModel() {
val requests: StateFlow<List<ApprovalRequest>> = approvalQueue.requests
fun respond(requestId: String, result: ApprovalResult) {
val request = requests.value.find { it.id == requestId } ?: return
policyManager.handleResult(request.toolName, result)
approvalQueue.respond(requestId, result)
}
}

View File

@@ -0,0 +1,104 @@
package com.cellclaw.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.cellclaw.agent.AgentEvent
import com.cellclaw.agent.AgentLoop
import com.cellclaw.agent.AgentState
import com.cellclaw.approval.ApprovalQueue
import com.cellclaw.approval.ApprovalRequest
import com.cellclaw.config.AppConfig
import com.cellclaw.config.SecureKeyStore
import com.cellclaw.provider.AnthropicProvider
import com.cellclaw.ui.screens.ChatMessage
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class ChatViewModel @Inject constructor(
private val agentLoop: AgentLoop,
private val approvalQueue: ApprovalQueue,
private val appConfig: AppConfig,
private val secureKeyStore: SecureKeyStore,
private val anthropicProvider: AnthropicProvider
) : ViewModel() {
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
val messages: StateFlow<List<ChatMessage>> = _messages.asStateFlow()
private val _agentState = MutableStateFlow("idle")
val agentState: StateFlow<String> = _agentState.asStateFlow()
val pendingApprovals: StateFlow<List<ApprovalRequest>> = approvalQueue.requests
private var messageCounter = 0L
init {
configureProvider()
observeAgentEvents()
observeAgentState()
}
private fun configureProvider() {
val apiKey = secureKeyStore.getApiKey("anthropic") ?: return
anthropicProvider.configure(apiKey, appConfig.model)
}
private fun observeAgentEvents() {
viewModelScope.launch {
agentLoop.events.collect { event ->
when (event) {
is AgentEvent.UserMessage -> addMessage("user", event.text)
is AgentEvent.AssistantText -> addMessage("assistant", event.text)
is AgentEvent.ToolCallStart -> addMessage(
"tool", "Calling ${event.name}...",
toolName = event.name
)
is AgentEvent.ToolCallResult -> addMessage(
"tool",
if (event.result.success) "Done" else "Error: ${event.result.error}",
toolName = event.name
)
is AgentEvent.ToolCallDenied -> addMessage(
"tool", "Denied by user",
toolName = event.name
)
is AgentEvent.Error -> addMessage("error", event.message)
}
}
}
}
private fun observeAgentState() {
viewModelScope.launch {
agentLoop.state.collect { state ->
_agentState.value = when (state) {
AgentState.IDLE -> "idle"
AgentState.THINKING -> "thinking"
AgentState.EXECUTING_TOOLS -> "executing_tools"
AgentState.WAITING_APPROVAL -> "waiting_approval"
AgentState.PAUSED -> "paused"
AgentState.ERROR -> "error"
}
}
}
}
fun sendMessage(text: String) {
agentLoop.submitMessage(text)
}
private fun addMessage(role: String, content: String, toolName: String? = null) {
messageCounter++
_messages.value = _messages.value + ChatMessage(
id = messageCounter,
role = role,
content = content,
toolName = toolName
)
}
}

View File

@@ -0,0 +1,61 @@
package com.cellclaw.ui.viewmodel
import androidx.lifecycle.ViewModel
import com.cellclaw.agent.AutonomyPolicy
import com.cellclaw.agent.ToolApprovalPolicy
import com.cellclaw.config.AppConfig
import com.cellclaw.config.SecureKeyStore
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val appConfig: AppConfig,
private val secureKeyStore: SecureKeyStore,
private val autonomyPolicy: AutonomyPolicy
) : ViewModel() {
private val _model = MutableStateFlow(appConfig.model)
val model: StateFlow<String> = _model.asStateFlow()
private val _userName = MutableStateFlow(appConfig.userName)
val userName: StateFlow<String> = _userName.asStateFlow()
private val _autoStartOnBoot = MutableStateFlow(appConfig.autoStartOnBoot)
val autoStartOnBoot: StateFlow<Boolean> = _autoStartOnBoot.asStateFlow()
private val _policies = MutableStateFlow(autonomyPolicy.allPolicies())
val policies: StateFlow<Map<String, ToolApprovalPolicy>> = _policies.asStateFlow()
private val _hasApiKey = MutableStateFlow(secureKeyStore.hasApiKey("anthropic"))
val hasApiKey: StateFlow<Boolean> = _hasApiKey.asStateFlow()
fun setModel(model: String) {
appConfig.model = model
_model.value = model
}
fun setUserName(name: String) {
appConfig.userName = name
_userName.value = name
}
fun setAutoStartOnBoot(enabled: Boolean) {
appConfig.autoStartOnBoot = enabled
_autoStartOnBoot.value = enabled
}
fun togglePolicy(toolName: String) {
val current = autonomyPolicy.getPolicy(toolName)
val next = when (current) {
ToolApprovalPolicy.AUTO -> ToolApprovalPolicy.ASK
ToolApprovalPolicy.ASK -> ToolApprovalPolicy.DENY
ToolApprovalPolicy.DENY -> ToolApprovalPolicy.AUTO
}
autonomyPolicy.setPolicy(toolName, next)
_policies.value = autonomyPolicy.allPolicies()
}
}

View File

@@ -0,0 +1,22 @@
package com.cellclaw.ui.viewmodel
import androidx.lifecycle.ViewModel
import com.cellclaw.config.AppConfig
import com.cellclaw.config.SecureKeyStore
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@HiltViewModel
class SetupViewModel @Inject constructor(
private val appConfig: AppConfig,
private val secureKeyStore: SecureKeyStore
) : ViewModel() {
fun saveApiKey(apiKey: String) {
secureKeyStore.storeApiKey("anthropic", apiKey)
}
fun saveUserName(name: String) {
appConfig.userName = name
}
}

View File

@@ -0,0 +1,35 @@
package com.cellclaw.ui.viewmodel
import androidx.lifecycle.ViewModel
import com.cellclaw.skills.Skill
import com.cellclaw.skills.SkillRegistry
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
@HiltViewModel
class SkillsViewModel @Inject constructor(
private val skillRegistry: SkillRegistry
) : ViewModel() {
val skills: StateFlow<List<Skill>> = skillRegistry.skills
init {
skillRegistry.loadBundledSkills()
}
fun addSkill(name: String, description: String, trigger: String, stepsText: String) {
val steps = stepsText.lines().filter { it.isNotBlank() }
val skill = Skill(
name = name,
description = description,
trigger = trigger,
steps = steps
)
skillRegistry.addSkill(skill)
}
fun removeSkill(name: String) {
skillRegistry.removeSkill(name)
}
}

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8zM12,6c-3.31,0 -6,2.69 -6,6s2.69,6 6,6 6,-2.69 6,-6 -2.69,-6 -6,-6zM12,16c-2.21,0 -4,-1.79 -4,-4s1.79,-4 4,-4 4,1.79 4,4 -1.79,4 -4,4z"/>
</vector>

View File

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

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#1565C0</color>
</resources>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">CellClaw</string>
<string name="notification_channel_service">CellClaw Service</string>
<string name="notification_channel_approvals">Approval Requests</string>
<string name="notification_channel_alerts">Alerts</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.CellClaw" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeAllMask"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagDefault|flagReportViewIds|flagRequestFilterKeyEvents"
android:canPerformGestures="true"
android:canRetrieveWindowContent="true"
android:description="@string/app_name"
android:notificationTimeout="100"
android:settingsActivity="com.cellclaw.ui.MainActivity" />

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-files-path name="pictures" path="Pictures" />
<files-path name="internal" path="." />
</paths>

8
build.gradle.kts Normal file
View File

@@ -0,0 +1,8 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.hilt) apply false
alias(libs.plugins.ksp) apply false
}

4
gradle.properties Normal file
View File

@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

77
gradle/libs.versions.toml Normal file
View File

@@ -0,0 +1,77 @@
[versions]
agp = "8.7.3"
kotlin = "2.1.0"
ksp = "2.1.0-1.0.29"
coroutines = "1.9.0"
compose-bom = "2024.12.01"
activity-compose = "1.9.3"
lifecycle = "2.8.7"
navigation = "2.8.5"
room = "2.6.1"
hilt = "2.53.1"
hilt-navigation = "1.2.0"
okhttp = "4.12.0"
retrofit = "2.11.0"
serialization = "1.7.3"
biometric = "1.1.0"
camera = "1.4.1"
location = "21.3.0"
[libraries]
# Kotlin
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
# Compose
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" }
activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" }
# Lifecycle
lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
lifecycle-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
# Navigation
navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
# Room
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
# Hilt
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hilt-navigation" }
# Network
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-sse = { group = "com.squareup.okhttp3", name = "okhttp-sse", version.ref = "okhttp" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
# Biometric
biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" }
# Camera
camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "camera" }
camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camera" }
camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camera" }
# Location
location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "location" }
# AndroidX Core
core-ktx = { group = "androidx.core", name = "core-ktx", version = "1.15.0" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

24
settings.gradle.kts Normal file
View File

@@ -0,0 +1,24 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
@Suppress("UnstableApiUsage")
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "CellClaw"
include(":app")