feat(android): expand AgentOverlay with voice mode state machine

This commit is contained in:
Sanju Sivalingam
2026-02-20 02:10:00 +05:30
parent 7b685b1b0f
commit 07f608a901

View File

@@ -6,20 +6,23 @@ import android.view.Gravity
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.WindowManager import android.view.WindowManager
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ComposeView
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleService import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.setViewTreeLifecycleOwner import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.savedstate.SavedStateRegistry import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import com.thisux.droidclaw.MainActivity import com.thisux.droidclaw.MainActivity
import com.thisux.droidclaw.model.OverlayMode
import com.thisux.droidclaw.ui.theme.DroidClawTheme
class AgentOverlay(private val service: LifecycleService) { class AgentOverlay(private val service: LifecycleService) {
private val windowManager = service.getSystemService(WindowManager::class.java) private val windowManager = service.getSystemService(WindowManager::class.java)
private var composeView: ComposeView? = null
private val savedStateOwner = object : SavedStateRegistryOwner { private val savedStateOwner = object : SavedStateRegistryOwner {
private val controller = SavedStateRegistryController.create(this) private val controller = SavedStateRegistryController.create(this)
@@ -28,7 +31,28 @@ class AgentOverlay(private val service: LifecycleService) {
init { controller.performRestore(null) } init { controller.performRestore(null) }
} }
private val layoutParams = WindowManager.LayoutParams( // ── State ───────────────────────────────────────────────
var mode = mutableStateOf(OverlayMode.Idle)
private set
var transcript = mutableStateOf("")
private set
// ── Callbacks (set by ConnectionService) ────────────────
var onVoiceSend: ((String) -> Unit)? = null
var onVoiceCancel: (() -> Unit)? = null
var onAudioChunk: ((String) -> Unit)? = null
// ── Views ───────────────────────────────────────────────
private var pillView: ComposeView? = null
private var borderView: ComposeView? = null
private var voicePanelView: ComposeView? = null
// ── Voice recorder ──────────────────────────────────────
private var voiceRecorder: VoiceRecorder? = null
// ── Layout params ───────────────────────────────────────
private val pillParams = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
@@ -40,8 +64,98 @@ class AgentOverlay(private val service: LifecycleService) {
y = 200 y = 200
} }
private val borderParams = WindowManager.LayoutParams(
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
PixelFormat.TRANSLUCENT
)
private val voicePanelParams = WindowManager.LayoutParams(
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
PixelFormat.TRANSLUCENT
)
// ── Public API ──────────────────────────────────────────
fun show() { fun show() {
if (composeView != null) return if (pillView != null) return
showPill()
}
fun hide() {
hidePill()
hideVoiceOverlay()
}
fun destroy() {
hide()
voiceRecorder?.stop()
voiceRecorder = null
}
fun startListening() {
val recorder = VoiceRecorder(
scope = service.lifecycleScope,
onChunk = { base64 -> onAudioChunk?.invoke(base64) }
)
if (!recorder.hasPermission(service)) {
val intent = Intent(service, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_SINGLE_TOP
putExtra("request_audio_permission", true)
}
service.startActivity(intent)
return
}
mode.value = OverlayMode.Listening
transcript.value = ""
hidePill()
showVoiceOverlay()
voiceRecorder = recorder
voiceRecorder?.start()
}
fun sendVoice() {
voiceRecorder?.stop()
voiceRecorder = null
mode.value = OverlayMode.Executing
hideVoiceOverlay()
showPill()
onVoiceSend?.invoke(transcript.value)
}
fun cancelVoice() {
voiceRecorder?.stop()
voiceRecorder = null
mode.value = OverlayMode.Idle
hideVoiceOverlay()
showPill()
onVoiceCancel?.invoke()
}
fun updateTranscript(text: String) {
transcript.value = text
}
fun returnToIdle() {
mode.value = OverlayMode.Idle
}
// ── Private: Pill overlay ───────────────────────────────
private fun showPill() {
if (pillView != null) return
val view = ComposeView(service).apply { val view = ComposeView(service).apply {
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
@@ -51,21 +165,58 @@ class AgentOverlay(private val service: LifecycleService) {
setupDrag(this) setupDrag(this)
} }
composeView = view pillView = view
windowManager.addView(view, layoutParams) windowManager.addView(view, pillParams)
} }
fun hide() { private fun hidePill() {
composeView?.let { pillView?.let { windowManager.removeView(it) }
windowManager.removeView(it) pillView = null
}
// ── Private: Voice overlay (border + panel) ─────────────
private fun showVoiceOverlay() {
if (borderView != null) return
val border = ComposeView(service).apply {
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
setViewTreeLifecycleOwner(service)
setViewTreeSavedStateRegistryOwner(savedStateOwner)
setContent {
DroidClawTheme { GradientBorder() }
}
} }
composeView = null borderView = border
windowManager.addView(border, borderParams)
val panel = ComposeView(service).apply {
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
setViewTreeLifecycleOwner(service)
setViewTreeSavedStateRegistryOwner(savedStateOwner)
setContent {
DroidClawTheme {
VoiceOverlayContent(
transcript = transcript.value,
onSend = { sendVoice() },
onCancel = { cancelVoice() }
)
}
}
}
voicePanelView = panel
windowManager.addView(panel, voicePanelParams)
} }
fun destroy() { private fun hideVoiceOverlay() {
hide() borderView?.let { windowManager.removeView(it) }
borderView = null
voicePanelView?.let { windowManager.removeView(it) }
voicePanelView = null
} }
// ── Private: Drag handling for pill ─────────────────────
private fun setupDrag(view: View) { private fun setupDrag(view: View) {
var initialX = 0 var initialX = 0
var initialY = 0 var initialY = 0
@@ -76,8 +227,8 @@ class AgentOverlay(private val service: LifecycleService) {
view.setOnTouchListener { _, event -> view.setOnTouchListener { _, event ->
when (event.action) { when (event.action) {
MotionEvent.ACTION_DOWN -> { MotionEvent.ACTION_DOWN -> {
initialX = layoutParams.x initialX = pillParams.x
initialY = layoutParams.y initialY = pillParams.y
initialTouchX = event.rawX initialTouchX = event.rawX
initialTouchY = event.rawY initialTouchY = event.rawY
isDragging = false isDragging = false
@@ -87,19 +238,23 @@ class AgentOverlay(private val service: LifecycleService) {
val dx = (event.rawX - initialTouchX).toInt() val dx = (event.rawX - initialTouchX).toInt()
val dy = (event.rawY - initialTouchY).toInt() val dy = (event.rawY - initialTouchY).toInt()
if (Math.abs(dx) > 10 || Math.abs(dy) > 10) isDragging = true if (Math.abs(dx) > 10 || Math.abs(dy) > 10) isDragging = true
layoutParams.x = initialX + dx pillParams.x = initialX + dx
layoutParams.y = initialY + dy pillParams.y = initialY + dy
windowManager.updateViewLayout(view, layoutParams) windowManager.updateViewLayout(view, pillParams)
true true
} }
MotionEvent.ACTION_UP -> { MotionEvent.ACTION_UP -> {
if (!isDragging) { if (!isDragging) {
val intent = Intent(service, MainActivity::class.java).apply { if (mode.value == OverlayMode.Idle) {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or startListening()
Intent.FLAG_ACTIVITY_SINGLE_TOP or } else {
Intent.FLAG_ACTIVITY_REORDER_TO_FRONT 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)
} }
service.startActivity(intent)
} }
true true
} }