diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index fde33c7..cd6001f 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -28,13 +28,17 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/java/com/thisux/droidclaw/MainActivity.kt b/android/app/src/main/java/com/thisux/droidclaw/MainActivity.kt
index f47cfc9..a661562 100644
--- a/android/app/src/main/java/com/thisux/droidclaw/MainActivity.kt
+++ b/android/app/src/main/java/com/thisux/droidclaw/MainActivity.kt
@@ -3,10 +3,12 @@ package com.thisux.droidclaw
import android.Manifest
import android.content.Intent
import android.os.Bundle
+import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
+import com.thisux.droidclaw.connection.ConnectionService
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
@@ -75,6 +77,14 @@ class MainActivity : ComponentActivity() {
audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
}
+
+ override fun onResume() {
+ super.onResume()
+ val service = ConnectionService.instance ?: return
+ if (Settings.canDrawOverlays(this)) {
+ service.overlay?.show()
+ }
+ }
}
@OptIn(ExperimentalMaterial3Api::class)
diff --git a/android/app/src/main/java/com/thisux/droidclaw/connection/CommandRouter.kt b/android/app/src/main/java/com/thisux/droidclaw/connection/CommandRouter.kt
index cce74e7..1bea837 100644
--- a/android/app/src/main/java/com/thisux/droidclaw/connection/CommandRouter.kt
+++ b/android/app/src/main/java/com/thisux/droidclaw/connection/CommandRouter.kt
@@ -12,6 +12,7 @@ import com.thisux.droidclaw.model.PongMessage
import com.thisux.droidclaw.model.ResultResponse
import com.thisux.droidclaw.model.ScreenResponse
import com.thisux.droidclaw.model.ServerMessage
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
class CommandRouter(
@@ -27,6 +28,10 @@ class CommandRouter(
val currentGoal = MutableStateFlow("")
val currentSessionId = MutableStateFlow(null)
+ // Called before/after screen capture to hide/show overlays that would pollute the agent's view
+ var beforeScreenCapture: (() -> Unit)? = null
+ var afterScreenCapture: (() -> Unit)? = null
+
private var gestureExecutor: GestureExecutor? = null
fun updateGestureExecutor() {
@@ -87,7 +92,7 @@ class CommandRouter(
}
}
- private fun handleGetScreen(requestId: String) {
+ private suspend fun handleGetScreen(requestId: String) {
updateGestureExecutor()
val svc = DroidClawAccessibilityService.instance
val elements = svc?.getScreenTree() ?: emptyList()
@@ -97,7 +102,11 @@ class CommandRouter(
var screenshot: String? = null
if (elements.isEmpty()) {
+ // Hide overlays so the agent gets a clean screenshot
+ beforeScreenCapture?.invoke()
+ delay(150) // wait for virtual display to render a clean frame
val bytes = captureManager?.capture()
+ afterScreenCapture?.invoke()
if (bytes != null) {
screenshot = Base64.encodeToString(bytes, Base64.NO_WRAP)
}
diff --git a/android/app/src/main/java/com/thisux/droidclaw/connection/ConnectionService.kt b/android/app/src/main/java/com/thisux/droidclaw/connection/ConnectionService.kt
index e1d9fa6..22cefae 100644
--- a/android/app/src/main/java/com/thisux/droidclaw/connection/ConnectionService.kt
+++ b/android/app/src/main/java/com/thisux/droidclaw/connection/ConnectionService.kt
@@ -147,6 +147,13 @@ class ConnectionService : LifecycleService() {
webSocket = ws
val router = CommandRouter(ws, captureManager)
+ router.beforeScreenCapture = { overlay?.hideVignette() }
+ router.afterScreenCapture = {
+ if (currentGoalStatus.value == GoalStatus.Running &&
+ Settings.canDrawOverlays(this@ConnectionService)) {
+ overlay?.showVignette()
+ }
+ }
commandRouter = router
launch {
@@ -173,7 +180,24 @@ class ConnectionService : LifecycleService() {
}
launch { ws.errorMessage.collect { errorMessage.value = it } }
launch { router.currentSteps.collect { currentSteps.value = it } }
- launch { router.currentGoalStatus.collect { currentGoalStatus.value = it } }
+ launch {
+ router.currentGoalStatus.collect { status ->
+ currentGoalStatus.value = status
+ if (status == GoalStatus.Running) {
+ if (Settings.canDrawOverlays(this@ConnectionService)) {
+ overlay?.showVignette()
+ }
+ } else {
+ overlay?.hideVignette()
+ }
+ if (status == GoalStatus.Completed) {
+ val goal = router.currentGoal.value
+ if (goal.isNotBlank()) {
+ (application as DroidClawApp).settingsStore.addRecentGoal(goal)
+ }
+ }
+ }
+ }
launch { router.currentGoal.collect { currentGoal.value = it } }
acquireWakeLock()
@@ -206,6 +230,7 @@ class ConnectionService : LifecycleService() {
}
private fun disconnect() {
+ overlay?.hideVignette()
overlay?.hide()
webSocket?.disconnect()
webSocket = null
diff --git a/android/app/src/main/java/com/thisux/droidclaw/data/SettingsStore.kt b/android/app/src/main/java/com/thisux/droidclaw/data/SettingsStore.kt
index d141a10..c8c435e 100644
--- a/android/app/src/main/java/com/thisux/droidclaw/data/SettingsStore.kt
+++ b/android/app/src/main/java/com/thisux/droidclaw/data/SettingsStore.kt
@@ -9,6 +9,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
+import org.json.JSONArray
val Context.dataStore: DataStore by preferencesDataStore(name = "settings")
@@ -18,6 +19,7 @@ object SettingsKeys {
val DEVICE_NAME = stringPreferencesKey("device_name")
val AUTO_CONNECT = booleanPreferencesKey("auto_connect")
val HAS_ONBOARDED = booleanPreferencesKey("has_onboarded")
+ val RECENT_GOALS = stringPreferencesKey("recent_goals")
}
class SettingsStore(private val context: Context) {
@@ -61,4 +63,25 @@ class SettingsStore(private val context: Context) {
suspend fun setHasOnboarded(value: Boolean) {
context.dataStore.edit { it[SettingsKeys.HAS_ONBOARDED] = value }
}
+
+ val recentGoals: Flow> = context.dataStore.data.map { prefs ->
+ val json = prefs[SettingsKeys.RECENT_GOALS] ?: "[]"
+ try {
+ JSONArray(json).let { arr ->
+ (0 until arr.length()).map { arr.getString(it) }
+ }
+ } catch (_: Exception) { emptyList() }
+ }
+
+ suspend fun addRecentGoal(goal: String) {
+ context.dataStore.edit { prefs ->
+ val current = try {
+ JSONArray(prefs[SettingsKeys.RECENT_GOALS] ?: "[]").let { arr ->
+ (0 until arr.length()).map { arr.getString(it) }
+ }
+ } catch (_: Exception) { emptyList() }
+ val updated = (listOf(goal) + current.filter { it != goal }).take(5)
+ prefs[SettingsKeys.RECENT_GOALS] = JSONArray(updated).toString()
+ }
+ }
}
diff --git a/android/app/src/main/java/com/thisux/droidclaw/overlay/AgentOverlay.kt b/android/app/src/main/java/com/thisux/droidclaw/overlay/AgentOverlay.kt
index d750e42..68bf974 100644
--- a/android/app/src/main/java/com/thisux/droidclaw/overlay/AgentOverlay.kt
+++ b/android/app/src/main/java/com/thisux/droidclaw/overlay/AgentOverlay.kt
@@ -17,12 +17,16 @@ import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import com.thisux.droidclaw.MainActivity
+import com.thisux.droidclaw.connection.ConnectionService
+import com.thisux.droidclaw.model.GoalStatus
import com.thisux.droidclaw.model.OverlayMode
import com.thisux.droidclaw.ui.theme.DroidClawTheme
class AgentOverlay(private val service: LifecycleService) {
private val windowManager = service.getSystemService(WindowManager::class.java)
+ private val dismissTarget = DismissTargetView(service)
+ private val vignetteOverlay = VignetteOverlay(service)
private val savedStateOwner = object : SavedStateRegistryOwner {
private val controller = SavedStateRegistryController.create(this)
@@ -50,6 +54,20 @@ class AgentOverlay(private val service: LifecycleService) {
// ── Voice recorder ──────────────────────────────────────
private var voiceRecorder: VoiceRecorder? = null
+ // ── Command panel ───────────────────────────────────────
+ private val commandPanel = CommandPanelOverlay(
+ service = service,
+ onSubmitGoal = { goal ->
+ val intent = Intent(service, ConnectionService::class.java).apply {
+ action = ConnectionService.ACTION_SEND_GOAL
+ putExtra(ConnectionService.EXTRA_GOAL, goal)
+ }
+ service.startService(intent)
+ },
+ onStartVoice = { startListening() },
+ onDismiss = { show() }
+ )
+
// ── Layout params ───────────────────────────────────────
private val pillParams = WindowManager.LayoutParams(
@@ -93,14 +111,21 @@ class AgentOverlay(private val service: LifecycleService) {
fun hide() {
hidePill()
hideVoiceOverlay()
+ dismissTarget.hide()
}
fun destroy() {
hide()
+ commandPanel.destroy()
+ vignetteOverlay.destroy()
voiceRecorder?.stop()
voiceRecorder = null
}
+ fun showVignette() = vignetteOverlay.show()
+
+ fun hideVignette() = vignetteOverlay.hide()
+
fun startListening() {
val recorder = VoiceRecorder(
scope = service.lifecycleScope,
@@ -237,25 +262,37 @@ class AgentOverlay(private val service: LifecycleService) {
MotionEvent.ACTION_MOVE -> {
val dx = (event.rawX - initialTouchX).toInt()
val dy = (event.rawY - initialTouchY).toInt()
- if (Math.abs(dx) > 10 || Math.abs(dy) > 10) isDragging = true
- pillParams.x = initialX + dx
- pillParams.y = initialY + dy
- windowManager.updateViewLayout(view, pillParams)
+ if (!isDragging && (Math.abs(dx) > 10 || Math.abs(dy) > 10)) {
+ isDragging = true
+ dismissTarget.show()
+ }
+ if (isDragging) {
+ pillParams.x = initialX + dx
+ pillParams.y = initialY + dy
+ windowManager.updateViewLayout(view, pillParams)
+ }
true
}
MotionEvent.ACTION_UP -> {
- if (!isDragging) {
- if (mode.value == OverlayMode.Idle) {
- startListening()
+ if (isDragging) {
+ val dismissed = dismissTarget.isOverTarget(event.rawX, event.rawY)
+ dismissTarget.hide()
+ if (dismissed) {
+ // Reset position to default so next show() starts clean
+ pillParams.x = 0
+ pillParams.y = 200
+ hide()
+ }
+ } else {
+ // Tap: if running, stop goal; otherwise show command panel
+ if (ConnectionService.currentGoalStatus.value == GoalStatus.Running) {
+ ConnectionService.instance?.stopGoal()
} else {
- val intent = Intent(service, MainActivity::class.java).apply {
- flags = Intent.FLAG_ACTIVITY_NEW_TASK or
- Intent.FLAG_ACTIVITY_SINGLE_TOP or
- Intent.FLAG_ACTIVITY_REORDER_TO_FRONT
- }
- service.startActivity(intent)
+ hide()
+ commandPanel.show()
}
}
+ isDragging = false
true
}
else -> false
diff --git a/android/app/src/main/java/com/thisux/droidclaw/overlay/CommandPanelOverlay.kt b/android/app/src/main/java/com/thisux/droidclaw/overlay/CommandPanelOverlay.kt
new file mode 100644
index 0000000..8538049
--- /dev/null
+++ b/android/app/src/main/java/com/thisux/droidclaw/overlay/CommandPanelOverlay.kt
@@ -0,0 +1,349 @@
+package com.thisux.droidclaw.overlay
+
+import android.graphics.PixelFormat
+import android.view.View
+import android.view.WindowManager
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+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.Mic
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleService
+import androidx.lifecycle.setViewTreeLifecycleOwner
+import androidx.savedstate.SavedStateRegistry
+import androidx.savedstate.SavedStateRegistryController
+import androidx.savedstate.SavedStateRegistryOwner
+import androidx.savedstate.setViewTreeSavedStateRegistryOwner
+import com.thisux.droidclaw.DroidClawApp
+import com.thisux.droidclaw.connection.ConnectionService
+import com.thisux.droidclaw.model.ConnectionState
+import com.thisux.droidclaw.model.GoalStatus
+import com.thisux.droidclaw.ui.theme.DroidClawTheme
+
+class CommandPanelOverlay(
+ private val service: LifecycleService,
+ private val onSubmitGoal: (String) -> Unit,
+ private val onStartVoice: () -> Unit,
+ private val onDismiss: () -> Unit
+) {
+ private val windowManager = service.getSystemService(WindowManager::class.java)
+ private var composeView: ComposeView? = null
+
+ private val savedStateOwner = object : SavedStateRegistryOwner {
+ private val controller = SavedStateRegistryController.create(this)
+ override val lifecycle: Lifecycle get() = service.lifecycle
+ override val savedStateRegistry: SavedStateRegistry get() = controller.savedStateRegistry
+ init { controller.performRestore(null) }
+ }
+
+ private val layoutParams = WindowManager.LayoutParams(
+ WindowManager.LayoutParams.MATCH_PARENT,
+ WindowManager.LayoutParams.MATCH_PARENT,
+ WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
+ WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
+ PixelFormat.TRANSLUCENT
+ ).apply {
+ softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
+ }
+
+ fun show() {
+ if (composeView != null) return
+ val view = ComposeView(service).apply {
+ importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
+ setViewTreeLifecycleOwner(service)
+ setViewTreeSavedStateRegistryOwner(savedStateOwner)
+ setContent {
+ CommandPanelContent(
+ onSubmitGoal = { goal ->
+ hide()
+ onSubmitGoal(goal)
+ onDismiss()
+ },
+ onStartVoice = {
+ hide()
+ onStartVoice()
+ },
+ onDismiss = {
+ hide()
+ onDismiss()
+ }
+ )
+ }
+ }
+ windowManager.addView(view, layoutParams)
+ composeView = view
+ }
+
+ fun hide() {
+ composeView?.let { windowManager.removeView(it) }
+ composeView = null
+ }
+
+ fun isShowing() = composeView != null
+
+ fun destroy() = hide()
+}
+
+private val DEFAULT_SUGGESTIONS = listOf(
+ "Open WhatsApp and reply to the last message",
+ "Take a screenshot and save it",
+ "Turn on Do Not Disturb",
+ "Search for nearby restaurants on Maps"
+)
+
+@Composable
+private fun CommandPanelContent(
+ onSubmitGoal: (String) -> Unit,
+ onStartVoice: () -> Unit,
+ onDismiss: () -> Unit
+) {
+ DroidClawTheme {
+ val context = LocalContext.current
+ val app = context.applicationContext as DroidClawApp
+ val recentGoals by app.settingsStore.recentGoals.collectAsState(initial = emptyList())
+
+ val connectionState by ConnectionService.connectionState.collectAsState()
+ val goalStatus by ConnectionService.currentGoalStatus.collectAsState()
+ val isConnected = connectionState == ConnectionState.Connected
+ val canSend = isConnected && goalStatus != GoalStatus.Running
+
+ var goalInput by remember { mutableStateOf("") }
+
+ // Auto-dismiss if a goal starts running
+ LaunchedEffect(goalStatus) {
+ if (goalStatus == GoalStatus.Running) {
+ onDismiss()
+ }
+ }
+
+ // Build suggestion list: recent goals first, fill remaining with defaults
+ val suggestions = remember(recentGoals) {
+ val combined = mutableListOf()
+ combined.addAll(recentGoals.take(4))
+ for (default in DEFAULT_SUGGESTIONS) {
+ if (combined.size >= 4) break
+ if (default !in combined) combined.add(default)
+ }
+ combined.take(4)
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ // Scrim - tap to dismiss
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Black.copy(alpha = 0.6f))
+ .clickable(
+ indication = null,
+ interactionSource = remember { MutableInteractionSource() }
+ ) { onDismiss() }
+ )
+
+ // Bottom card
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .align(Alignment.BottomCenter)
+ .imePadding()
+ .clickable(
+ indication = null,
+ interactionSource = remember { MutableInteractionSource() }
+ ) { /* consume clicks so they don't reach scrim */ },
+ shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
+ color = MaterialTheme.colorScheme.surface,
+ tonalElevation = 3.dp
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp, vertical = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ // Handle bar
+ Box(
+ modifier = Modifier
+ .width(40.dp)
+ .height(4.dp)
+ .clip(RoundedCornerShape(2.dp))
+ .background(
+ MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
+ )
+ .align(Alignment.CenterHorizontally)
+ )
+
+ Text(
+ text = "What can I help with?",
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+
+ // 2x2 suggestion grid
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ for (row in suggestions.chunked(2)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ for (suggestion in row) {
+ SuggestionCard(
+ text = suggestion,
+ enabled = canSend,
+ onClick = { onSubmitGoal(suggestion) },
+ modifier = Modifier.weight(1f)
+ )
+ }
+ if (row.size < 2) {
+ Spacer(modifier = Modifier.weight(1f))
+ }
+ }
+ }
+ }
+
+ // Text input
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ val sendEnabled = canSend && goalInput.isNotBlank()
+
+ TextField(
+ value = goalInput,
+ onValueChange = { goalInput = it },
+ placeholder = {
+ Text(
+ if (!isConnected) "Not connected"
+ else "Enter a goal...",
+ style = MaterialTheme.typography.bodyMedium
+ )
+ },
+ modifier = Modifier.weight(1f),
+ enabled = canSend,
+ singleLine = true,
+ shape = RoundedCornerShape(24.dp),
+ colors = TextFieldDefaults.colors(
+ focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
+ unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.2f),
+ disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.1f),
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ disabledIndicatorColor = Color.Transparent
+ )
+ )
+
+ IconButton(
+ onClick = { onStartVoice() },
+ enabled = canSend,
+ colors = IconButtonDefaults.iconButtonColors(
+ containerColor = if (canSend)
+ MaterialTheme.colorScheme.secondaryContainer
+ else Color.Transparent
+ )
+ ) {
+ Icon(
+ Icons.Filled.Mic,
+ contentDescription = "Voice",
+ tint = if (canSend)
+ MaterialTheme.colorScheme.onSecondaryContainer
+ else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
+ )
+ }
+
+ IconButton(
+ onClick = {
+ if (goalInput.isNotBlank()) onSubmitGoal(goalInput)
+ },
+ enabled = sendEnabled,
+ colors = IconButtonDefaults.iconButtonColors(
+ containerColor = if (sendEnabled)
+ MaterialTheme.colorScheme.primary
+ else Color.Transparent
+ )
+ ) {
+ Icon(
+ Icons.AutoMirrored.Filled.Send,
+ contentDescription = "Send",
+ tint = if (sendEnabled)
+ MaterialTheme.colorScheme.onPrimary
+ else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SuggestionCard(
+ text: String,
+ enabled: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Card(
+ onClick = onClick,
+ modifier = modifier.height(72.dp),
+ enabled = enabled,
+ shape = RoundedCornerShape(16.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
+ )
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(12.dp),
+ contentAlignment = Alignment.CenterStart
+ ) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/thisux/droidclaw/overlay/DismissTargetView.kt b/android/app/src/main/java/com/thisux/droidclaw/overlay/DismissTargetView.kt
new file mode 100644
index 0000000..3876e9e
--- /dev/null
+++ b/android/app/src/main/java/com/thisux/droidclaw/overlay/DismissTargetView.kt
@@ -0,0 +1,121 @@
+package com.thisux.droidclaw.overlay
+
+import android.graphics.PixelFormat
+import android.view.Gravity
+import android.view.View
+import android.view.WindowManager
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleService
+import androidx.lifecycle.setViewTreeLifecycleOwner
+import androidx.savedstate.SavedStateRegistry
+import androidx.savedstate.SavedStateRegistryController
+import androidx.savedstate.SavedStateRegistryOwner
+import androidx.savedstate.setViewTreeSavedStateRegistryOwner
+
+class DismissTargetView(private val service: LifecycleService) {
+
+ private val windowManager = service.getSystemService(WindowManager::class.java)
+ private var composeView: ComposeView? = null
+
+ private val density = service.resources.displayMetrics.density
+ private var targetCenterX = 0f
+ private var targetCenterY = 0f
+ private var targetRadiusPx = 36f * density
+
+ private val savedStateOwner = object : SavedStateRegistryOwner {
+ private val controller = SavedStateRegistryController.create(this)
+ override val lifecycle: Lifecycle get() = service.lifecycle
+ override val savedStateRegistry: SavedStateRegistry get() = controller.savedStateRegistry
+ init { controller.performRestore(null) }
+ }
+
+ private val layoutParams = WindowManager.LayoutParams(
+ WindowManager.LayoutParams.MATCH_PARENT,
+ WindowManager.LayoutParams.WRAP_CONTENT,
+ WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
+ WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
+ WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
+ WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
+ PixelFormat.TRANSLUCENT
+ ).apply {
+ gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
+ }
+
+ fun show() {
+ if (composeView != null) return
+
+ // Compute target coordinates synchronously before showing the view
+ val metrics = windowManager.currentWindowMetrics
+ val screenWidth = metrics.bounds.width().toFloat()
+ val screenHeight = metrics.bounds.height().toFloat()
+ targetCenterX = screenWidth / 2f
+ // The circle is 56dp from bottom edge + 36dp (half of 72dp circle)
+ targetCenterY = screenHeight - (56f + 36f) * density
+
+ val view = ComposeView(service).apply {
+ importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
+ setViewTreeLifecycleOwner(service)
+ setViewTreeSavedStateRegistryOwner(savedStateOwner)
+ setContent { DismissTargetContent() }
+ }
+ composeView = view
+ windowManager.addView(view, layoutParams)
+ }
+
+ fun hide() {
+ composeView?.let { windowManager.removeView(it) }
+ composeView = null
+ }
+
+ fun destroy() = hide()
+
+ fun isOverTarget(rawX: Float, rawY: Float): Boolean {
+ if (composeView == null) return false
+ val dx = rawX - targetCenterX
+ val dy = rawY - targetCenterY
+ // Use generous hit radius (1.5x visual radius) for easier targeting
+ val hitRadius = targetRadiusPx * 1.5f
+ return (dx * dx + dy * dy) <= (hitRadius * hitRadius)
+ }
+}
+
+@Composable
+private fun DismissTargetContent() {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 56.dp),
+ contentAlignment = Alignment.BottomCenter
+ ) {
+ Box(
+ modifier = Modifier
+ .size(72.dp)
+ .clip(CircleShape)
+ .background(Color(0xCC333333)),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "Dismiss",
+ tint = Color.White,
+ modifier = Modifier.size(28.dp)
+ )
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/thisux/droidclaw/overlay/GradientBorder.kt b/android/app/src/main/java/com/thisux/droidclaw/overlay/GradientBorder.kt
index 316e3f9..9bccb97 100644
--- a/android/app/src/main/java/com/thisux/droidclaw/overlay/GradientBorder.kt
+++ b/android/app/src/main/java/com/thisux/droidclaw/overlay/GradientBorder.kt
@@ -19,11 +19,11 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
private val GradientColors = listOf(
- Color(0xFF8B5CF6), // purple
- Color(0xFF3B82F6), // blue
- Color(0xFF06B6D4), // cyan
- Color(0xFF10B981), // green
- Color(0xFF8B5CF6), // purple (loop)
+ Color(0xFFC62828), // crimson red
+ Color(0xFFEF5350), // crimson light
+ Color(0xFFFFB300), // golden accent
+ Color(0xFFEF5350), // crimson light
+ Color(0xFFC62828), // crimson red (loop)
)
@Composable
diff --git a/android/app/src/main/java/com/thisux/droidclaw/overlay/OverlayContent.kt b/android/app/src/main/java/com/thisux/droidclaw/overlay/OverlayContent.kt
index a793ba7..89f2df4 100644
--- a/android/app/src/main/java/com/thisux/droidclaw/overlay/OverlayContent.kt
+++ b/android/app/src/main/java/com/thisux/droidclaw/overlay/OverlayContent.kt
@@ -2,28 +2,16 @@ package com.thisux.droidclaw.overlay
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.LinearEasing
-import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Image
import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.IconButtonDefaults
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -33,12 +21,12 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
+import com.thisux.droidclaw.R
import com.thisux.droidclaw.connection.ConnectionService
import com.thisux.droidclaw.model.ConnectionState
import com.thisux.droidclaw.model.GoalStatus
@@ -49,16 +37,14 @@ private val Green = Color(0xFF4CAF50)
private val Blue = Color(0xFF2196F3)
private val Red = Color(0xFFF44336)
private val Gray = Color(0xFF9E9E9E)
-private val PillBackground = Color(0xE6212121)
+private val IconBackground = Color(0xFF1A1A1A)
@Composable
fun OverlayContent() {
DroidClawTheme {
val connectionState by ConnectionService.connectionState.collectAsState()
val goalStatus by ConnectionService.currentGoalStatus.collectAsState()
- val steps by ConnectionService.currentSteps.collectAsState()
- // Auto-reset Completed/Failed back to Idle after 3s
var displayStatus by remember { mutableStateOf(goalStatus) }
LaunchedEffect(goalStatus) {
displayStatus = goalStatus
@@ -70,102 +56,67 @@ fun OverlayContent() {
val isConnected = connectionState == ConnectionState.Connected
- val dotColor by animateColorAsState(
+ val ringColor by animateColorAsState(
targetValue = when {
!isConnected -> Gray
- displayStatus == GoalStatus.Running -> Blue
- displayStatus == GoalStatus.Failed -> Red
+ displayStatus == GoalStatus.Running -> Red
+ displayStatus == GoalStatus.Completed -> Blue
+ displayStatus == GoalStatus.Failed -> Gray
else -> Green
},
- label = "dotColor"
+ label = "ringColor"
)
- val statusText = when {
- !isConnected -> "Offline"
- displayStatus == GoalStatus.Running -> {
- val last = steps.lastOrNull()
- if (last != null) {
- val label = last.reasoning.ifBlank {
- // Extract just the action name from the JSON string
- Regex("""action[=:]?\s*(\w+)""").find(last.action)?.groupValues?.get(1) ?: "working"
- }
- "${last.step}: $label"
- } else "Running..."
- }
- displayStatus == GoalStatus.Completed -> "Done"
- displayStatus == GoalStatus.Failed -> "Stopped"
- else -> "Ready"
- }
+ val isRunning = isConnected && displayStatus == GoalStatus.Running
- Row(
- modifier = Modifier
- .clip(RoundedCornerShape(24.dp))
- .background(PillBackground)
- .height(48.dp)
- .widthIn(min = 100.dp, max = 220.dp)
- .padding(horizontal = 12.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(8.dp)
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.size(52.dp)
) {
- StatusDot(
- color = dotColor,
- pulse = isConnected && displayStatus == GoalStatus.Running
+ // Background circle
+ Box(
+ modifier = Modifier
+ .size(52.dp)
+ .clip(CircleShape)
+ .background(IconBackground)
)
- Text(
- text = statusText,
- color = Color.White,
- fontSize = 13.sp,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.weight(1f, fill = false)
- )
-
- if (isConnected && displayStatus == GoalStatus.Running) {
- IconButton(
- onClick = { ConnectionService.instance?.stopGoal() },
- modifier = Modifier.size(28.dp),
- colors = IconButtonDefaults.iconButtonColors(
- contentColor = Color.White.copy(alpha = 0.8f)
- )
- ) {
- Icon(
- imageVector = Icons.Default.Close,
- contentDescription = "Stop goal",
- modifier = Modifier.size(16.dp)
- )
- }
+ if (isRunning) {
+ // Spinning progress ring
+ val transition = rememberInfiniteTransition(label = "spin")
+ val rotation by transition.animateFloat(
+ initialValue = 0f,
+ targetValue = 360f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(1200, easing = LinearEasing)
+ ),
+ label = "rotation"
+ )
+ CircularProgressIndicator(
+ modifier = Modifier.size(52.dp),
+ color = ringColor,
+ strokeWidth = 3.dp,
+ strokeCap = StrokeCap.Round
+ )
+ } else {
+ // Static colored ring
+ CircularProgressIndicator(
+ progress = { 1f },
+ modifier = Modifier.size(52.dp),
+ color = ringColor,
+ strokeWidth = 3.dp,
+ strokeCap = StrokeCap.Round
+ )
}
+
+ // App icon
+ Image(
+ painter = painterResource(R.drawable.ic_launcher_foreground),
+ contentDescription = "DroidClaw",
+ modifier = Modifier
+ .size(40.dp)
+ .clip(CircleShape)
+ )
}
}
}
-
-@Composable
-private fun StatusDot(color: Color, pulse: Boolean) {
- if (pulse) {
- val transition = rememberInfiniteTransition(label = "pulse")
- val alpha by transition.animateFloat(
- initialValue = 1f,
- targetValue = 0.3f,
- animationSpec = infiniteRepeatable(
- animation = tween(800, easing = LinearEasing),
- repeatMode = RepeatMode.Reverse
- ),
- label = "pulseAlpha"
- )
- Box(
- modifier = Modifier
- .size(10.dp)
- .alpha(alpha)
- .clip(CircleShape)
- .background(color)
- )
- } else {
- Box(
- modifier = Modifier
- .size(10.dp)
- .clip(CircleShape)
- .background(color)
- )
- }
-}
diff --git a/android/app/src/main/java/com/thisux/droidclaw/overlay/VignetteOverlay.kt b/android/app/src/main/java/com/thisux/droidclaw/overlay/VignetteOverlay.kt
new file mode 100644
index 0000000..b3703ce
--- /dev/null
+++ b/android/app/src/main/java/com/thisux/droidclaw/overlay/VignetteOverlay.kt
@@ -0,0 +1,128 @@
+package com.thisux.droidclaw.overlay
+
+import android.graphics.PixelFormat
+import android.view.View
+import android.view.WindowManager
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.platform.ComposeView
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleService
+import androidx.lifecycle.setViewTreeLifecycleOwner
+import androidx.savedstate.SavedStateRegistry
+import androidx.savedstate.SavedStateRegistryController
+import androidx.savedstate.SavedStateRegistryOwner
+import androidx.savedstate.setViewTreeSavedStateRegistryOwner
+
+private val CrimsonGlow = Color(0xFFC62828)
+
+class VignetteOverlay(private val service: LifecycleService) {
+
+ private val windowManager = service.getSystemService(WindowManager::class.java)
+ private var composeView: ComposeView? = null
+
+ private val savedStateOwner = object : SavedStateRegistryOwner {
+ private val controller = SavedStateRegistryController.create(this)
+ override val lifecycle: Lifecycle get() = service.lifecycle
+ override val savedStateRegistry: SavedStateRegistry get() = controller.savedStateRegistry
+ init { controller.performRestore(null) }
+ }
+
+ private val layoutParams = WindowManager.LayoutParams(
+ WindowManager.LayoutParams.MATCH_PARENT,
+ WindowManager.LayoutParams.MATCH_PARENT,
+ WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
+ WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
+ WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
+ WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or
+ WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
+ PixelFormat.TRANSLUCENT
+ )
+
+ fun show() {
+ if (composeView != null) return
+ val view = ComposeView(service).apply {
+ importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
+ setViewTreeLifecycleOwner(service)
+ setViewTreeSavedStateRegistryOwner(savedStateOwner)
+ setContent { VignetteContent() }
+ }
+ composeView = view
+ windowManager.addView(view, layoutParams)
+ }
+
+ fun hide() {
+ composeView?.let { windowManager.removeView(it) }
+ composeView = null
+ }
+
+ fun destroy() = hide()
+}
+
+@Composable
+private fun VignetteContent() {
+ val transition = rememberInfiniteTransition(label = "vignettePulse")
+ val alpha by transition.animateFloat(
+ initialValue = 0.5f,
+ targetValue = 1.0f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(2200, easing = FastOutSlowInEasing),
+ repeatMode = RepeatMode.Reverse
+ ),
+ label = "vignetteAlpha"
+ )
+
+ Canvas(modifier = Modifier.fillMaxSize()) {
+ drawVignette(alpha)
+ }
+}
+
+private fun DrawScope.drawVignette(alpha: Float) {
+ val edgeColor = CrimsonGlow.copy(alpha = 0.4f * alpha)
+ val glowWidth = size.minDimension * 0.35f
+
+ // Top edge
+ drawRect(
+ brush = Brush.verticalGradient(
+ colors = listOf(edgeColor, Color.Transparent),
+ startY = 0f,
+ endY = glowWidth
+ )
+ )
+ // Bottom edge
+ drawRect(
+ brush = Brush.verticalGradient(
+ colors = listOf(Color.Transparent, edgeColor),
+ startY = size.height - glowWidth,
+ endY = size.height
+ )
+ )
+ // Left edge
+ drawRect(
+ brush = Brush.horizontalGradient(
+ colors = listOf(edgeColor, Color.Transparent),
+ startX = 0f,
+ endX = glowWidth
+ )
+ )
+ // Right edge
+ drawRect(
+ brush = Brush.horizontalGradient(
+ colors = listOf(Color.Transparent, edgeColor),
+ startX = size.width - glowWidth,
+ endX = size.width
+ )
+ )
+}
diff --git a/android/app/src/main/java/com/thisux/droidclaw/overlay/VoiceOverlayContent.kt b/android/app/src/main/java/com/thisux/droidclaw/overlay/VoiceOverlayContent.kt
index b3a68e1..890158f 100644
--- a/android/app/src/main/java/com/thisux/droidclaw/overlay/VoiceOverlayContent.kt
+++ b/android/app/src/main/java/com/thisux/droidclaw/overlay/VoiceOverlayContent.kt
@@ -42,7 +42,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-private val AccentPurple = Color(0xFF8B5CF6)
+private val AccentCrimson = Color(0xFFC62828)
private val PanelBackground = Color(0xCC1A1A1A)
@Composable
@@ -118,7 +118,7 @@ fun VoiceOverlayContent(
onClick = onSend,
enabled = transcript.isNotEmpty(),
colors = ButtonDefaults.buttonColors(
- containerColor = AccentPurple,
+ containerColor = AccentCrimson,
contentColor = Color.White
),
modifier = Modifier.weight(1f)
@@ -153,6 +153,6 @@ private fun ListeningIndicator() {
.size(48.dp)
.alpha(alpha)
.clip(CircleShape)
- .background(AccentPurple)
+ .background(AccentCrimson)
)
}
diff --git a/android/app/src/main/java/com/thisux/droidclaw/ui/screens/SettingsScreen.kt b/android/app/src/main/java/com/thisux/droidclaw/ui/screens/SettingsScreen.kt
index 07ffbca..ef6250c 100644
--- a/android/app/src/main/java/com/thisux/droidclaw/ui/screens/SettingsScreen.kt
+++ b/android/app/src/main/java/com/thisux/droidclaw/ui/screens/SettingsScreen.kt
@@ -1,10 +1,12 @@
package com.thisux.droidclaw.ui.screens
import android.app.Activity
+import android.app.role.RoleManager
import android.content.Context
import android.content.Intent
import android.media.projection.MediaProjectionManager
import android.net.Uri
+import android.os.Build
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -96,6 +98,14 @@ fun SettingsScreen() {
var hasOverlayPermission by remember {
mutableStateOf(Settings.canDrawOverlays(context))
}
+ var isDefaultAssistant by remember {
+ mutableStateOf(
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ val rm = context.getSystemService(Context.ROLE_SERVICE) as RoleManager
+ rm.isRoleHeld(RoleManager.ROLE_ASSISTANT)
+ } else false
+ )
+ }
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
@@ -106,6 +116,10 @@ fun SettingsScreen() {
hasCaptureConsent = isCaptureAvailable || ScreenCaptureManager.hasConsent()
isBatteryExempt = BatteryOptimization.isIgnoringBatteryOptimizations(context)
hasOverlayPermission = Settings.canDrawOverlays(context)
+ isDefaultAssistant = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ val rm = context.getSystemService(Context.ROLE_SERVICE) as RoleManager
+ rm.isRoleHeld(RoleManager.ROLE_ASSISTANT)
+ } else false
}
}
lifecycleOwner.lifecycle.addObserver(observer)
@@ -300,6 +314,17 @@ fun SettingsScreen() {
}
)
+ ChecklistItem(
+ label = "Default digital assistant",
+ isOk = isDefaultAssistant,
+ actionLabel = "Set",
+ onAction = {
+ context.startActivity(
+ Intent(Settings.ACTION_VOICE_INPUT_SETTINGS)
+ )
+ }
+ )
+
Spacer(modifier = Modifier.height(16.dp))
}
}
diff --git a/android/app/src/main/java/com/thisux/droidclaw/voice/DroidClawVoiceInteractionService.kt b/android/app/src/main/java/com/thisux/droidclaw/voice/DroidClawVoiceInteractionService.kt
new file mode 100644
index 0000000..e10e840
--- /dev/null
+++ b/android/app/src/main/java/com/thisux/droidclaw/voice/DroidClawVoiceInteractionService.kt
@@ -0,0 +1,5 @@
+package com.thisux.droidclaw.voice
+
+import android.service.voice.VoiceInteractionService
+
+class DroidClawVoiceInteractionService : VoiceInteractionService()
diff --git a/android/app/src/main/java/com/thisux/droidclaw/voice/DroidClawVoiceSession.kt b/android/app/src/main/java/com/thisux/droidclaw/voice/DroidClawVoiceSession.kt
new file mode 100644
index 0000000..a7e3d64
--- /dev/null
+++ b/android/app/src/main/java/com/thisux/droidclaw/voice/DroidClawVoiceSession.kt
@@ -0,0 +1,76 @@
+package com.thisux.droidclaw.voice
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.service.voice.VoiceInteractionSession
+import android.view.View
+import androidx.compose.ui.platform.ComposeView
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import androidx.lifecycle.setViewTreeLifecycleOwner
+import androidx.savedstate.SavedStateRegistry
+import androidx.savedstate.SavedStateRegistryController
+import androidx.savedstate.SavedStateRegistryOwner
+import androidx.savedstate.setViewTreeSavedStateRegistryOwner
+import com.thisux.droidclaw.connection.ConnectionService
+import com.thisux.droidclaw.ui.theme.DroidClawTheme
+
+class DroidClawVoiceSession(context: Context) : VoiceInteractionSession(context) {
+
+ private val lifecycleOwner = object : LifecycleOwner {
+ val registry = LifecycleRegistry(this)
+ override val lifecycle: Lifecycle get() = registry
+ }
+
+ private val savedStateOwner = object : SavedStateRegistryOwner {
+ private val controller = SavedStateRegistryController.create(this)
+ override val lifecycle: Lifecycle get() = lifecycleOwner.lifecycle
+ override val savedStateRegistry: SavedStateRegistry get() = controller.savedStateRegistry
+ init { controller.performRestore(null) }
+ }
+
+ override fun onCreateContentView(): View {
+ lifecycleOwner.registry.currentState = Lifecycle.State.CREATED
+
+ return ComposeView(context).apply {
+ setViewTreeLifecycleOwner(lifecycleOwner)
+ setViewTreeSavedStateRegistryOwner(savedStateOwner)
+ setContent {
+ DroidClawTheme {
+ GoalInputSheet(
+ onSubmit = { goal -> submitGoal(goal) },
+ onDismiss = { hide() }
+ )
+ }
+ }
+ }
+ }
+
+ override fun onShow(args: Bundle?, showFlags: Int) {
+ super.onShow(args, showFlags)
+ lifecycleOwner.registry.currentState = Lifecycle.State.STARTED
+ lifecycleOwner.registry.currentState = Lifecycle.State.RESUMED
+ }
+
+ override fun onHide() {
+ lifecycleOwner.registry.currentState = Lifecycle.State.STARTED
+ lifecycleOwner.registry.currentState = Lifecycle.State.CREATED
+ super.onHide()
+ }
+
+ override fun onDestroy() {
+ lifecycleOwner.registry.currentState = Lifecycle.State.DESTROYED
+ super.onDestroy()
+ }
+
+ private fun submitGoal(goal: String) {
+ val intent = Intent(context, ConnectionService::class.java).apply {
+ action = ConnectionService.ACTION_SEND_GOAL
+ putExtra(ConnectionService.EXTRA_GOAL, goal)
+ }
+ context.startService(intent)
+ hide()
+ }
+}
diff --git a/android/app/src/main/java/com/thisux/droidclaw/voice/DroidClawVoiceSessionService.kt b/android/app/src/main/java/com/thisux/droidclaw/voice/DroidClawVoiceSessionService.kt
new file mode 100644
index 0000000..da1753c
--- /dev/null
+++ b/android/app/src/main/java/com/thisux/droidclaw/voice/DroidClawVoiceSessionService.kt
@@ -0,0 +1,11 @@
+package com.thisux.droidclaw.voice
+
+import android.os.Bundle
+import android.service.voice.VoiceInteractionSession
+import android.service.voice.VoiceInteractionSessionService
+
+class DroidClawVoiceSessionService : VoiceInteractionSessionService() {
+ override fun onNewSession(args: Bundle?): VoiceInteractionSession {
+ return DroidClawVoiceSession(this)
+ }
+}
diff --git a/android/app/src/main/java/com/thisux/droidclaw/voice/GoalInputSheet.kt b/android/app/src/main/java/com/thisux/droidclaw/voice/GoalInputSheet.kt
new file mode 100644
index 0000000..b9bd6f6
--- /dev/null
+++ b/android/app/src/main/java/com/thisux/droidclaw/voice/GoalInputSheet.kt
@@ -0,0 +1,158 @@
+package com.thisux.droidclaw.voice
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Send
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.collectAsState
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import com.thisux.droidclaw.connection.ConnectionService
+import com.thisux.droidclaw.model.ConnectionState
+import com.thisux.droidclaw.model.GoalStatus
+
+@Composable
+fun GoalInputSheet(
+ onSubmit: (String) -> Unit,
+ onDismiss: () -> Unit
+) {
+ val connectionState by ConnectionService.connectionState.collectAsState()
+ val goalStatus by ConnectionService.currentGoalStatus.collectAsState()
+
+ val isConnected = connectionState == ConnectionState.Connected
+ val isRunning = goalStatus == GoalStatus.Running
+ val canSend = isConnected && !isRunning
+
+ var text by remember { mutableStateOf("") }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Black.copy(alpha = 0.4f))
+ .clickable(
+ indication = null,
+ interactionSource = remember { MutableInteractionSource() }
+ ) { onDismiss() }
+ ) {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .align(Alignment.BottomCenter)
+ .clickable(
+ indication = null,
+ interactionSource = remember { MutableInteractionSource() }
+ ) { /* consume clicks so they don't dismiss */ },
+ shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
+ tonalElevation = 6.dp,
+ color = MaterialTheme.colorScheme.surface
+ ) {
+ Column(
+ modifier = Modifier
+ .padding(horizontal = 16.dp)
+ .padding(top = 12.dp, bottom = 24.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ // Drag handle
+ Box(
+ modifier = Modifier
+ .width(40.dp)
+ .height(4.dp)
+ .clip(RoundedCornerShape(2.dp))
+ .background(
+ MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
+ )
+ .align(Alignment.CenterHorizontally)
+ )
+
+ Text(
+ text = "What should I do?",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ TextField(
+ value = text,
+ onValueChange = { text = it },
+ placeholder = {
+ Text(
+ when {
+ !isConnected -> "Not connected"
+ isRunning -> "Agent is working..."
+ else -> "Enter a goal..."
+ },
+ style = MaterialTheme.typography.bodyMedium
+ )
+ },
+ modifier = Modifier.weight(1f),
+ enabled = canSend,
+ singleLine = true,
+ shape = RoundedCornerShape(24.dp),
+ colors = TextFieldDefaults.colors(
+ focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant
+ .copy(alpha = 0.3f),
+ unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant
+ .copy(alpha = 0.2f),
+ disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant
+ .copy(alpha = 0.1f),
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ disabledIndicatorColor = Color.Transparent
+ )
+ )
+
+ IconButton(
+ onClick = {
+ if (text.isNotBlank()) onSubmit(text.trim())
+ },
+ enabled = canSend && text.isNotBlank(),
+ colors = IconButtonDefaults.iconButtonColors(
+ containerColor = if (canSend && text.isNotBlank())
+ MaterialTheme.colorScheme.primary
+ else Color.Transparent
+ )
+ ) {
+ Icon(
+ Icons.AutoMirrored.Filled.Send,
+ contentDescription = "Send goal",
+ tint = if (canSend && text.isNotBlank())
+ MaterialTheme.colorScheme.onPrimary
+ else
+ MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/res/xml/voice_interaction_service.xml b/android/app/src/main/res/xml/voice_interaction_service.xml
new file mode 100644
index 0000000..ef25a6f
--- /dev/null
+++ b/android/app/src/main/res/xml/voice_interaction_service.xml
@@ -0,0 +1,6 @@
+
+