commit e57292c25dad8c3153957c6f7af5dca9fd2875de Author: JordanTheJet Date: Wed Feb 18 13:05:06 2026 -0500 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9262b1e --- /dev/null +++ b/.gitignore @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..c1e0ab5 --- /dev/null +++ b/app/build.gradle.kts @@ -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) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..70ae5a9 --- /dev/null +++ b/app/proguard-rules.pro @@ -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 * diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..3cdc9c5 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/assets/skills/daily_briefing.md b/app/src/main/assets/skills/daily_briefing.md new file mode 100644 index 0000000..05227b6 --- /dev/null +++ b/app/src/main/assets/skills/daily_briefing.md @@ -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 diff --git a/app/src/main/assets/skills/quick_reply.md b/app/src/main/assets/skills/quick_reply.md new file mode 100644 index 0000000..2bee4db --- /dev/null +++ b/app/src/main/assets/skills/quick_reply.md @@ -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 diff --git a/app/src/main/kotlin/com/cellclaw/CellClawApp.kt b/app/src/main/kotlin/com/cellclaw/CellClawApp.kt new file mode 100644 index 0000000..94bc736 --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/CellClawApp.kt @@ -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" + } +} diff --git a/app/src/main/kotlin/com/cellclaw/agent/AgentLoop.kt b/app/src/main/kotlin/com/cellclaw/agent/AgentLoop.kt new file mode 100644 index 0000000..c46fe5e --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/agent/AgentLoop.kt @@ -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 = _state.asStateFlow() + + private val _events = MutableSharedFlow(replay = 0, extraBufferCapacity = 64) + val events: SharedFlow = _events.asSharedFlow() + + private val conversationHistory = mutableListOf() + 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() + val toolCalls = mutableListOf() + + 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() + + 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() +} diff --git a/app/src/main/kotlin/com/cellclaw/agent/AutonomyPolicy.kt b/app/src/main/kotlin/com/cellclaw/agent/AutonomyPolicy.kt new file mode 100644 index 0000000..37ae28b --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/agent/AutonomyPolicy.kt @@ -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() + + 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 = policies.toMap() +} + +enum class ToolApprovalPolicy { + AUTO, // Always allow + ASK, // Prompt user + DENY // Never allow +} diff --git a/app/src/main/kotlin/com/cellclaw/agent/TaskQueue.kt b/app/src/main/kotlin/com/cellclaw/agent/TaskQueue.kt new file mode 100644 index 0000000..77c8c09 --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/agent/TaskQueue.kt @@ -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>(emptyList()) + val tasks: StateFlow> = _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 } diff --git a/app/src/main/kotlin/com/cellclaw/approval/ApprovalPolicy.kt b/app/src/main/kotlin/com/cellclaw/approval/ApprovalPolicy.kt new file mode 100644 index 0000000..5956117 --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/approval/ApprovalPolicy.kt @@ -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) + } + } +} diff --git a/app/src/main/kotlin/com/cellclaw/approval/ApprovalQueue.kt b/app/src/main/kotlin/com/cellclaw/approval/ApprovalQueue.kt new file mode 100644 index 0000000..151e6f9 --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/approval/ApprovalQueue.kt @@ -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>() + + private val _requests = MutableStateFlow>(emptyList()) + val requests: StateFlow> = _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() + 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 +} diff --git a/app/src/main/kotlin/com/cellclaw/approval/BiometricGate.kt b/app/src/main/kotlin/com/cellclaw/approval/BiometricGate.kt new file mode 100644 index 0000000..7d5f1f5 --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/approval/BiometricGate.kt @@ -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() + 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() + } +} diff --git a/app/src/main/kotlin/com/cellclaw/config/AppConfig.kt b/app/src/main/kotlin/com/cellclaw/config/AppConfig.kt new file mode 100644 index 0000000..55f2098 --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/config/AppConfig.kt @@ -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() +} diff --git a/app/src/main/kotlin/com/cellclaw/config/Identity.kt b/app/src/main/kotlin/com/cellclaw/config/Identity.kt new file mode 100644 index 0000000..3821576 --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/config/Identity.kt @@ -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.""" + } +} diff --git a/app/src/main/kotlin/com/cellclaw/config/SecureKeyStore.kt b/app/src/main/kotlin/com/cellclaw/config/SecureKeyStore.kt new file mode 100644 index 0000000..e012a56 --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/config/SecureKeyStore.kt @@ -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 + } +} diff --git a/app/src/main/kotlin/com/cellclaw/di/AppModule.kt b/app/src/main/kotlin/com/cellclaw/di/AppModule.kt new file mode 100644 index 0000000..bd66122 --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/di/AppModule.kt @@ -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 + ) + } + } +} diff --git a/app/src/main/kotlin/com/cellclaw/memory/ConversationStore.kt b/app/src/main/kotlin/com/cellclaw/memory/ConversationStore.kt new file mode 100644 index 0000000..ad17dd3 --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/memory/ConversationStore.kt @@ -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 { + return messageDao.getRecent(currentConversationId, limit).reversed() + } + + suspend fun getAllMessages(): List { + 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 +} diff --git a/app/src/main/kotlin/com/cellclaw/memory/MemoryDb.kt b/app/src/main/kotlin/com/cellclaw/memory/MemoryDb.kt new file mode 100644 index 0000000..25e5424 --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/memory/MemoryDb.kt @@ -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 + + @Query("SELECT * FROM messages WHERE conversation_id = :conversationId ORDER BY timestamp ASC") + suspend fun getAll(conversationId: String = "default"): List + + @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 + + @Query("SELECT * FROM memory_facts ORDER BY updated_at DESC") + suspend fun getAll(): List + + @Query("SELECT * FROM memory_facts WHERE `key` LIKE '%' || :query || '%' OR `value` LIKE '%' || :query || '%'") + suspend fun search(query: String): List + + @Query("DELETE FROM memory_facts WHERE id = :id") + suspend fun delete(id: Long) +} diff --git a/app/src/main/kotlin/com/cellclaw/memory/SemanticMemory.kt b/app/src/main/kotlin/com/cellclaw/memory/SemanticMemory.kt new file mode 100644 index 0000000..149d6fb --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/memory/SemanticMemory.kt @@ -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 { + return factDao.getByCategory(category) + } + + suspend fun recallAll(): List { + return factDao.getAll() + } + + suspend fun search(query: String): List { + 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}") + } + } + } + } +} diff --git a/app/src/main/kotlin/com/cellclaw/provider/AnthropicProvider.kt b/app/src/main/kotlin/com/cellclaw/provider/AnthropicProvider.kt new file mode 100644 index 0000000..975bdc6 --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/provider/AnthropicProvider.kt @@ -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 = 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() + 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) diff --git a/app/src/main/kotlin/com/cellclaw/provider/OpenAIProvider.kt b/app/src/main/kotlin/com/cellclaw/provider/OpenAIProvider.kt new file mode 100644 index 0000000..6468486 --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/provider/OpenAIProvider.kt @@ -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 = 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() + .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() + + // 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" + } +} diff --git a/app/src/main/kotlin/com/cellclaw/provider/Provider.kt b/app/src/main/kotlin/com/cellclaw/provider/Provider.kt new file mode 100644 index 0000000..ffb1f6e --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/provider/Provider.kt @@ -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 +} + +@Serializable +data class CompletionRequest( + val systemPrompt: String, + val messages: List, + val tools: List = emptyList(), + val maxTokens: Int = 4096 +) + +@Serializable +data class Message( + val role: Role, + val content: List +) { + 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, + 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() +} diff --git a/app/src/main/kotlin/com/cellclaw/provider/ToolSchema.kt b/app/src/main/kotlin/com/cellclaw/provider/ToolSchema.kt new file mode 100644 index 0000000..8a41bd7 --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/provider/ToolSchema.kt @@ -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): 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): 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) } } + } + } + } + }) + } + } +} diff --git a/app/src/main/kotlin/com/cellclaw/service/CellClawAccessibility.kt b/app/src/main/kotlin/com/cellclaw/service/CellClawAccessibility.kt new file mode 100644 index 0000000..446e977 --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/service/CellClawAccessibility.kt @@ -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 + } +} diff --git a/app/src/main/kotlin/com/cellclaw/service/CellClawService.kt b/app/src/main/kotlin/com/cellclaw/service/CellClawService.kt new file mode 100644 index 0000000..c445e6c --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/service/CellClawService.kt @@ -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 + } +} diff --git a/app/src/main/kotlin/com/cellclaw/service/receivers/BatteryReceiver.kt b/app/src/main/kotlin/com/cellclaw/service/receivers/BatteryReceiver.kt new file mode 100644 index 0000000..6b18d57 --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/service/receivers/BatteryReceiver.kt @@ -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" + } +} diff --git a/app/src/main/kotlin/com/cellclaw/service/receivers/BootReceiver.kt b/app/src/main/kotlin/com/cellclaw/service/receivers/BootReceiver.kt new file mode 100644 index 0000000..1b699d8 --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/service/receivers/BootReceiver.kt @@ -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" + } +} diff --git a/app/src/main/kotlin/com/cellclaw/service/receivers/PhoneStateReceiver.kt b/app/src/main/kotlin/com/cellclaw/service/receivers/PhoneStateReceiver.kt new file mode 100644 index 0000000..83b8773 --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/service/receivers/PhoneStateReceiver.kt @@ -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" + } +} diff --git a/app/src/main/kotlin/com/cellclaw/service/receivers/SmsReceiver.kt b/app/src/main/kotlin/com/cellclaw/service/receivers/SmsReceiver.kt new file mode 100644 index 0000000..3e99645 --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/service/receivers/SmsReceiver.kt @@ -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" + } +} diff --git a/app/src/main/kotlin/com/cellclaw/skills/SkillParser.kt b/app/src/main/kotlin/com/cellclaw/skills/SkillParser.kt new file mode 100644 index 0000000..26e3dca --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/skills/SkillParser.kt @@ -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, + val tools: List = 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() + val tools = mutableListOf() + + 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 + ) + } +} diff --git a/app/src/main/kotlin/com/cellclaw/skills/SkillRegistry.kt b/app/src/main/kotlin/com/cellclaw/skills/SkillRegistry.kt new file mode 100644 index 0000000..0cc19f3 --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/skills/SkillRegistry.kt @@ -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>(emptyList()) + val skills: StateFlow> = _skills.asStateFlow() + + fun loadBundledSkills() { + val bundled = mutableListOf() + + // 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") } + } + } +} diff --git a/app/src/main/kotlin/com/cellclaw/tools/AppControlTool.kt b/app/src/main/kotlin/com/cellclaw/tools/AppControlTool.kt new file mode 100644 index 0000000..0f04f4f --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/tools/AppControlTool.kt @@ -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}") + } + } +} diff --git a/app/src/main/kotlin/com/cellclaw/tools/BrowserTool.kt b/app/src/main/kotlin/com/cellclaw/tools/BrowserTool.kt new file mode 100644 index 0000000..f7aaaf0 --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/tools/BrowserTool.kt @@ -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}") + } + } +} diff --git a/app/src/main/kotlin/com/cellclaw/tools/CalendarTool.kt b/app/src/main/kotlin/com/cellclaw/tools/CalendarTool.kt new file mode 100644 index 0000000..a79730b --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/tools/CalendarTool.kt @@ -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}") + } + } +} diff --git a/app/src/main/kotlin/com/cellclaw/tools/CameraTool.kt b/app/src/main/kotlin/com/cellclaw/tools/CameraTool.kt new file mode 100644 index 0000000..e08307b --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/tools/CameraTool.kt @@ -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}") + } + } +} diff --git a/app/src/main/kotlin/com/cellclaw/tools/ClipboardTool.kt b/app/src/main/kotlin/com/cellclaw/tools/ClipboardTool.kt new file mode 100644 index 0000000..05d017a --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/tools/ClipboardTool.kt @@ -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}") + } + } +} diff --git a/app/src/main/kotlin/com/cellclaw/tools/ContactsTool.kt b/app/src/main/kotlin/com/cellclaw/tools/ContactsTool.kt new file mode 100644 index 0000000..7c64617 --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/tools/ContactsTool.kt @@ -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() + + 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}") + } + } +} diff --git a/app/src/main/kotlin/com/cellclaw/tools/FileTool.kt b/app/src/main/kotlin/com/cellclaw/tools/FileTool.kt new file mode 100644 index 0000000..ae0c57f --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/tools/FileTool.kt @@ -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}") + } + } +} diff --git a/app/src/main/kotlin/com/cellclaw/tools/LocationTool.kt b/app/src/main/kotlin/com/cellclaw/tools/LocationTool.kt new file mode 100644 index 0000000..162c1c3 --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/tools/LocationTool.kt @@ -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}") + } + } +} diff --git a/app/src/main/kotlin/com/cellclaw/tools/NotificationTool.kt b/app/src/main/kotlin/com/cellclaw/tools/NotificationTool.kt new file mode 100644 index 0000000..96dc56f --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/tools/NotificationTool.kt @@ -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}") + } + } +} diff --git a/app/src/main/kotlin/com/cellclaw/tools/PhoneTool.kt b/app/src/main/kotlin/com/cellclaw/tools/PhoneTool.kt new file mode 100644 index 0000000..332c276 --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/tools/PhoneTool.kt @@ -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}") + } + } +} diff --git a/app/src/main/kotlin/com/cellclaw/tools/ScriptExecTool.kt b/app/src/main/kotlin/com/cellclaw/tools/ScriptExecTool.kt new file mode 100644 index 0000000..46c0ceb --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/tools/ScriptExecTool.kt @@ -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 + } +} diff --git a/app/src/main/kotlin/com/cellclaw/tools/SensorTool.kt b/app/src/main/kotlin/com/cellclaw/tools/SensorTool.kt new file mode 100644 index 0000000..c4bc4bf --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/tools/SensorTool.kt @@ -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 { 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}") + } + } +} diff --git a/app/src/main/kotlin/com/cellclaw/tools/SettingsTool.kt b/app/src/main/kotlin/com/cellclaw/tools/SettingsTool.kt new file mode 100644 index 0000000..9d15d22 --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/tools/SettingsTool.kt @@ -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}") + } + } +} diff --git a/app/src/main/kotlin/com/cellclaw/tools/SmsTool.kt b/app/src/main/kotlin/com/cellclaw/tools/SmsTool.kt new file mode 100644 index 0000000..176a1da --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/tools/SmsTool.kt @@ -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? = 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}") + } + } +} diff --git a/app/src/main/kotlin/com/cellclaw/tools/Tool.kt b/app/src/main/kotlin/com/cellclaw/tools/Tool.kt new file mode 100644 index 0000000..1bbffa9 --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/tools/Tool.kt @@ -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 = emptyMap(), + val required: List = emptyList() +) + +@Serializable +data class ParameterProperty( + val type: String, + val description: String, + val enum: List? = 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) + } +} diff --git a/app/src/main/kotlin/com/cellclaw/tools/ToolRegistry.kt b/app/src/main/kotlin/com/cellclaw/tools/ToolRegistry.kt new file mode 100644 index 0000000..da934db --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/tools/ToolRegistry.kt @@ -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() + + 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 = tools.values.toList() + + fun names(): Set = tools.keys.toSet() + + /** Convert all registered tools to the format needed by AI provider APIs */ + fun toApiSchema(): List = 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 +) diff --git a/app/src/main/kotlin/com/cellclaw/ui/MainActivity.kt b/app/src/main/kotlin/com/cellclaw/ui/MainActivity.kt new file mode 100644 index 0000000..7ef7dd6 --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/ui/MainActivity.kt @@ -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) + } +} diff --git a/app/src/main/kotlin/com/cellclaw/ui/screens/ApprovalScreen.kt b/app/src/main/kotlin/com/cellclaw/ui/screens/ApprovalScreen.kt new file mode 100644 index 0000000..02f3c29 --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/ui/screens/ApprovalScreen.kt @@ -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") + } + } + } + } +} diff --git a/app/src/main/kotlin/com/cellclaw/ui/screens/ChatScreen.kt b/app/src/main/kotlin/com/cellclaw/ui/screens/ChatScreen.kt new file mode 100644 index 0000000..96c22b7 --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/ui/screens/ChatScreen.kt @@ -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() +) diff --git a/app/src/main/kotlin/com/cellclaw/ui/screens/SettingsScreen.kt b/app/src/main/kotlin/com/cellclaw/ui/screens/SettingsScreen.kt new file mode 100644 index 0000000..e7cfb0a --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/ui/screens/SettingsScreen.kt @@ -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" + } + ) + } + ) +} diff --git a/app/src/main/kotlin/com/cellclaw/ui/screens/SetupScreen.kt b/app/src/main/kotlin/com/cellclaw/ui/screens/SetupScreen.kt new file mode 100644 index 0000000..1ee9609 --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/ui/screens/SetupScreen.kt @@ -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 + ) + } +} diff --git a/app/src/main/kotlin/com/cellclaw/ui/screens/SkillsScreen.kt b/app/src/main/kotlin/com/cellclaw/ui/screens/SkillsScreen.kt new file mode 100644 index 0000000..40c45b8 --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/ui/screens/SkillsScreen.kt @@ -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") } + } + ) +} diff --git a/app/src/main/kotlin/com/cellclaw/ui/theme/Theme.kt b/app/src/main/kotlin/com/cellclaw/ui/theme/Theme.kt new file mode 100644 index 0000000..86e58a7 --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/ui/theme/Theme.kt @@ -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 + ) +} diff --git a/app/src/main/kotlin/com/cellclaw/ui/viewmodel/ApprovalViewModel.kt b/app/src/main/kotlin/com/cellclaw/ui/viewmodel/ApprovalViewModel.kt new file mode 100644 index 0000000..ee33b01 --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/ui/viewmodel/ApprovalViewModel.kt @@ -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> = 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) + } +} diff --git a/app/src/main/kotlin/com/cellclaw/ui/viewmodel/ChatViewModel.kt b/app/src/main/kotlin/com/cellclaw/ui/viewmodel/ChatViewModel.kt new file mode 100644 index 0000000..bb29602 --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/ui/viewmodel/ChatViewModel.kt @@ -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>(emptyList()) + val messages: StateFlow> = _messages.asStateFlow() + + private val _agentState = MutableStateFlow("idle") + val agentState: StateFlow = _agentState.asStateFlow() + + val pendingApprovals: StateFlow> = 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 + ) + } +} diff --git a/app/src/main/kotlin/com/cellclaw/ui/viewmodel/SettingsViewModel.kt b/app/src/main/kotlin/com/cellclaw/ui/viewmodel/SettingsViewModel.kt new file mode 100644 index 0000000..806c598 --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/ui/viewmodel/SettingsViewModel.kt @@ -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 = _model.asStateFlow() + + private val _userName = MutableStateFlow(appConfig.userName) + val userName: StateFlow = _userName.asStateFlow() + + private val _autoStartOnBoot = MutableStateFlow(appConfig.autoStartOnBoot) + val autoStartOnBoot: StateFlow = _autoStartOnBoot.asStateFlow() + + private val _policies = MutableStateFlow(autonomyPolicy.allPolicies()) + val policies: StateFlow> = _policies.asStateFlow() + + private val _hasApiKey = MutableStateFlow(secureKeyStore.hasApiKey("anthropic")) + val hasApiKey: StateFlow = _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() + } +} diff --git a/app/src/main/kotlin/com/cellclaw/ui/viewmodel/SetupViewModel.kt b/app/src/main/kotlin/com/cellclaw/ui/viewmodel/SetupViewModel.kt new file mode 100644 index 0000000..4cd9a7f --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/ui/viewmodel/SetupViewModel.kt @@ -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 + } +} diff --git a/app/src/main/kotlin/com/cellclaw/ui/viewmodel/SkillsViewModel.kt b/app/src/main/kotlin/com/cellclaw/ui/viewmodel/SkillsViewModel.kt new file mode 100644 index 0000000..316a3be --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/ui/viewmodel/SkillsViewModel.kt @@ -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> = 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) + } +} diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 0000000..8cfebd2 --- /dev/null +++ b/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.xml b/app/src/main/res/mipmap-hdpi/ic_launcher.xml new file mode 100644 index 0000000..2a280b9 --- /dev/null +++ b/app/src/main/res/mipmap-hdpi/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..936f22b --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #1565C0 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..28571cc --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + CellClaw + CellClaw Service + Approval Requests + Alerts + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..7b5b9e5 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +