feat(android): expand AgentOverlay with voice mode state machine
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user