feat(android): add overlay command panel, dismiss target, vignette, voice integration, and theme updates

- Add CommandPanelOverlay with suggestion cards and text input for goals
- Add DismissTargetView for drag-to-dismiss floating pill
- Add VignetteOverlay for crimson glow during agent execution
- Integrate voice mic button in command panel
- Add VoiceInteractionService for system assistant registration
- Store recent goals in DataStore for command panel suggestions
- Update GradientBorder and VoiceOverlayContent to DroidClaw crimson/golden theme
- Fix default assistant settings to use ACTION_VOICE_INPUT_SETTINGS
- Merge upstream voice overlay architecture with local overlay features
This commit is contained in:
Somasundaram Mahesh
2026-02-20 06:23:00 +05:30
parent 2411f47914
commit 474395e8c4
18 changed files with 1085 additions and 129 deletions

View File

@@ -28,13 +28,17 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:label="@string/app_name"
android:theme="@style/Theme.DroidClaw">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.ASSIST" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<service
@@ -54,6 +58,24 @@
android:foregroundServiceType="connectedDevice"
android:exported="false" />
<service
android:name=".voice.DroidClawVoiceInteractionService"
android:label="@string/app_name"
android:permission="android.permission.BIND_VOICE_INTERACTION"
android:exported="true">
<intent-filter>
<action android:name="android.service.voice.VoiceInteractionService" />
</intent-filter>
<meta-data
android:name="android.voice_interaction"
android:resource="@xml/voice_interaction_service" />
</service>
<service
android:name=".voice.DroidClawVoiceSessionService"
android:permission="android.permission.BIND_VOICE_INTERACTION"
android:exported="true" />
</application>
</manifest>

View File

@@ -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)

View File

@@ -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<String?>(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)
}

View File

@@ -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

View File

@@ -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<Preferences> 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<List<String>> = 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()
}
}
}

View File

@@ -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
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 {
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)
// Tap: if running, stop goal; otherwise show command panel
if (ConnectionService.currentGoalStatus.value == GoalStatus.Running) {
ConnectionService.instance?.stopGoal()
} else {
hide()
commandPanel.show()
}
}
isDragging = false
true
}
else -> false

View File

@@ -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<String>()
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
)
}
}
}

View File

@@ -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)
)
}
}
}

View File

@@ -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

View File

@@ -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
)
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)
)
}
}
}
}
}
@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"
)
// Background circle
Box(
modifier = Modifier
.size(10.dp)
.alpha(alpha)
.size(52.dp)
.clip(CircleShape)
.background(color)
.background(IconBackground)
)
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 {
Box(
modifier = Modifier
.size(10.dp)
.clip(CircleShape)
.background(color)
// 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)
)
}
}
}

View File

@@ -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
)
)
}

View File

@@ -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)
)
}

View File

@@ -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))
}
}

View File

@@ -0,0 +1,5 @@
package com.thisux.droidclaw.voice
import android.service.voice.VoiceInteractionService
class DroidClawVoiceInteractionService : VoiceInteractionService()

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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)
)
}
}
}
}
}
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<voice-interaction-service xmlns:android="http://schemas.android.com/apk/res/android"
android:sessionService="com.thisux.droidclaw.voice.DroidClawVoiceSessionService"
android:settingsActivity="com.thisux.droidclaw.MainActivity"
android:supportsAssist="true"
android:supportsLaunchVoiceAssistFromKeyguard="false" />