feat(android): auto-connect, assistant invocation, suggestion cards, and onboarding assistant step
- Auto-connect to server on app open when API key is configured - Show error card below top bar on connection errors - Fix VoiceInteractionService registration with RecognitionService stub - Voice session triggers overlay command panel instead of separate UI - Add suggestion cards (recent goals + defaults) to HomeScreen empty state - Add digital assistant setup step to onboarding with skip option - Add ACTION_SHOW_COMMAND_PANEL to ConnectionService and AgentOverlay Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -76,6 +76,15 @@
|
|||||||
android:permission="android.permission.BIND_VOICE_INTERACTION"
|
android:permission="android.permission.BIND_VOICE_INTERACTION"
|
||||||
android:exported="true" />
|
android:exported="true" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".voice.DroidClawRecognitionService"
|
||||||
|
android:permission="android.permission.BIND_VOICE_INTERACTION"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.speech.RecognitionService" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -5,6 +5,9 @@ import android.content.Intent
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
@@ -37,12 +40,22 @@ import androidx.navigation.compose.composable
|
|||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import com.thisux.droidclaw.ui.components.PermissionStatusBar
|
import com.thisux.droidclaw.ui.components.PermissionStatusBar
|
||||||
|
import com.thisux.droidclaw.model.ConnectionState
|
||||||
import com.thisux.droidclaw.ui.screens.HomeScreen
|
import com.thisux.droidclaw.ui.screens.HomeScreen
|
||||||
import com.thisux.droidclaw.ui.screens.LogsScreen
|
import com.thisux.droidclaw.ui.screens.LogsScreen
|
||||||
import com.thisux.droidclaw.ui.screens.OnboardingScreen
|
import com.thisux.droidclaw.ui.screens.OnboardingScreen
|
||||||
import com.thisux.droidclaw.ui.screens.SettingsScreen
|
import com.thisux.droidclaw.ui.screens.SettingsScreen
|
||||||
import com.thisux.droidclaw.ui.theme.DroidClawTheme
|
import com.thisux.droidclaw.ui.theme.DroidClawTheme
|
||||||
import com.thisux.droidclaw.ui.theme.InstrumentSerif
|
import com.thisux.droidclaw.ui.theme.InstrumentSerif
|
||||||
|
import com.thisux.droidclaw.ui.theme.StatusRed
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
sealed class Screen(val route: String, val label: String) {
|
sealed class Screen(val route: String, val label: String) {
|
||||||
data object Home : Screen("home", "Home")
|
data object Home : Screen("home", "Home")
|
||||||
@@ -69,6 +82,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
if (intent?.getBooleanExtra("request_audio_permission", false) == true) {
|
if (intent?.getBooleanExtra("request_audio_permission", false) == true) {
|
||||||
audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
}
|
}
|
||||||
|
autoConnectIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
@@ -85,6 +99,20 @@ class MainActivity : ComponentActivity() {
|
|||||||
service.overlay?.show()
|
service.overlay?.show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun autoConnectIfNeeded() {
|
||||||
|
if (ConnectionService.connectionState.value != com.thisux.droidclaw.model.ConnectionState.Disconnected) return
|
||||||
|
val app = application as DroidClawApp
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val apiKey = app.settingsStore.apiKey.first()
|
||||||
|
if (apiKey.isNotBlank()) {
|
||||||
|
val intent = Intent(this@MainActivity, ConnectionService::class.java).apply {
|
||||||
|
action = ConnectionService.ACTION_CONNECT
|
||||||
|
}
|
||||||
|
startForegroundService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -183,10 +211,31 @@ fun MainNavigation() {
|
|||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
val startDestination = if (hasOnboarded) Screen.Home.route else Screen.Onboarding.route
|
val startDestination = if (hasOnboarded) Screen.Home.route else Screen.Onboarding.route
|
||||||
|
|
||||||
|
val connectionState by ConnectionService.connectionState.collectAsState()
|
||||||
|
val errorMessage by ConnectionService.errorMessage.collectAsState()
|
||||||
|
|
||||||
|
Column(modifier = Modifier.padding(innerPadding)) {
|
||||||
|
if (showChrome && connectionState == ConnectionState.Error) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(StatusRed.copy(alpha = 0.15f))
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = errorMessage ?: "Connection error",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = StatusRed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = startDestination,
|
startDestination = startDestination,
|
||||||
modifier = Modifier.padding(innerPadding)
|
modifier = Modifier.weight(1f)
|
||||||
) {
|
) {
|
||||||
composable(Screen.Onboarding.route) {
|
composable(Screen.Onboarding.route) {
|
||||||
OnboardingScreen(
|
OnboardingScreen(
|
||||||
@@ -203,3 +252,4 @@ fun MainNavigation() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ class ConnectionService : LifecycleService() {
|
|||||||
const val ACTION_CONNECT = "com.thisux.droidclaw.CONNECT"
|
const val ACTION_CONNECT = "com.thisux.droidclaw.CONNECT"
|
||||||
const val ACTION_DISCONNECT = "com.thisux.droidclaw.DISCONNECT"
|
const val ACTION_DISCONNECT = "com.thisux.droidclaw.DISCONNECT"
|
||||||
const val ACTION_SEND_GOAL = "com.thisux.droidclaw.SEND_GOAL"
|
const val ACTION_SEND_GOAL = "com.thisux.droidclaw.SEND_GOAL"
|
||||||
|
const val ACTION_SHOW_COMMAND_PANEL = "com.thisux.droidclaw.SHOW_COMMAND_PANEL"
|
||||||
const val EXTRA_GOAL = "goal_text"
|
const val EXTRA_GOAL = "goal_text"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +109,9 @@ class ConnectionService : LifecycleService() {
|
|||||||
val goal = intent.getStringExtra(EXTRA_GOAL) ?: return START_NOT_STICKY
|
val goal = intent.getStringExtra(EXTRA_GOAL) ?: return START_NOT_STICKY
|
||||||
sendGoal(goal)
|
sendGoal(goal)
|
||||||
}
|
}
|
||||||
|
ACTION_SHOW_COMMAND_PANEL -> {
|
||||||
|
overlay?.showCommandPanel()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return START_NOT_STICKY
|
return START_NOT_STICKY
|
||||||
|
|||||||
@@ -177,6 +177,11 @@ class AgentOverlay(private val service: LifecycleService) {
|
|||||||
mode.value = OverlayMode.Idle
|
mode.value = OverlayMode.Idle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun showCommandPanel() {
|
||||||
|
hide()
|
||||||
|
commandPanel.show()
|
||||||
|
}
|
||||||
|
|
||||||
// ── Private: Pill overlay ───────────────────────────────
|
// ── Private: Pill overlay ───────────────────────────────
|
||||||
|
|
||||||
private fun showPill() {
|
private fun showPill() {
|
||||||
|
|||||||
@@ -45,16 +45,26 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.thisux.droidclaw.DroidClawApp
|
||||||
import com.thisux.droidclaw.connection.ConnectionService
|
import com.thisux.droidclaw.connection.ConnectionService
|
||||||
import com.thisux.droidclaw.model.AgentStep
|
import com.thisux.droidclaw.model.AgentStep
|
||||||
import com.thisux.droidclaw.model.ConnectionState
|
import com.thisux.droidclaw.model.ConnectionState
|
||||||
import com.thisux.droidclaw.model.GoalStatus
|
import com.thisux.droidclaw.model.GoalStatus
|
||||||
import com.thisux.droidclaw.ui.theme.StatusGreen
|
import com.thisux.droidclaw.ui.theme.StatusGreen
|
||||||
import com.thisux.droidclaw.ui.theme.StatusRed
|
import com.thisux.droidclaw.ui.theme.StatusRed
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
// Represents a message in the chat timeline
|
// Represents a message in the chat timeline
|
||||||
private sealed class ChatItem {
|
private sealed class ChatItem {
|
||||||
data class GoalMessage(val text: String) : ChatItem()
|
data class GoalMessage(val text: String) : ChatItem()
|
||||||
@@ -65,13 +75,28 @@ private sealed class ChatItem {
|
|||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen() {
|
fun HomeScreen() {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val app = context.applicationContext as DroidClawApp
|
||||||
val connectionState by ConnectionService.connectionState.collectAsState()
|
val connectionState by ConnectionService.connectionState.collectAsState()
|
||||||
val goalStatus by ConnectionService.currentGoalStatus.collectAsState()
|
val goalStatus by ConnectionService.currentGoalStatus.collectAsState()
|
||||||
val steps by ConnectionService.currentSteps.collectAsState()
|
val steps by ConnectionService.currentSteps.collectAsState()
|
||||||
val currentGoal by ConnectionService.currentGoal.collectAsState()
|
val currentGoal by ConnectionService.currentGoal.collectAsState()
|
||||||
|
val recentGoals by app.settingsStore.recentGoals.collectAsState(initial = emptyList())
|
||||||
|
|
||||||
var goalInput by remember { mutableStateOf("") }
|
var goalInput by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
val isConnected = connectionState == ConnectionState.Connected
|
||||||
|
val canSend = isConnected && goalStatus != GoalStatus.Running
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
// Build chat items: goal bubble → step bubbles → status bubble
|
// Build chat items: goal bubble → step bubbles → status bubble
|
||||||
val chatItems = remember(currentGoal, steps, goalStatus) {
|
val chatItems = remember(currentGoal, steps, goalStatus) {
|
||||||
buildList {
|
buildList {
|
||||||
@@ -105,7 +130,10 @@ fun HomeScreen() {
|
|||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.padding(horizontal = 20.dp)
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "What should I do?",
|
text = "What should I do?",
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
@@ -117,6 +145,33 @@ fun HomeScreen() {
|
|||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f)
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f)
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
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 = {
|
||||||
|
val intent = Intent(context, ConnectionService::class.java).apply {
|
||||||
|
action = ConnectionService.ACTION_SEND_GOAL
|
||||||
|
putExtra(ConnectionService.EXTRA_GOAL, suggestion)
|
||||||
|
}
|
||||||
|
context.startService(intent)
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (row.size < 2) {
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -366,6 +421,39 @@ private fun InputBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun formatTime(timestamp: Long): String {
|
private fun formatTime(timestamp: Long): String {
|
||||||
val sdf = SimpleDateFormat("HH:mm", Locale.getDefault())
|
val sdf = SimpleDateFormat("HH:mm", Locale.getDefault())
|
||||||
return sdf.format(Date(timestamp))
|
return sdf.format(Date(timestamp))
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package com.thisux.droidclaw.ui.screens
|
package com.thisux.droidclaw.ui.screens
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
|
import android.app.role.RoleManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.media.projection.MediaProjectionManager
|
import android.media.projection.MediaProjectionManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
@@ -32,6 +34,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -99,10 +102,22 @@ fun OnboardingScreen(onComplete: () -> Unit) {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
1 -> OnboardingStepTwo(
|
1 -> OnboardingStepTwo(
|
||||||
onGetStarted = {
|
onContinue = { currentStep = 2 }
|
||||||
|
)
|
||||||
|
2 -> OnboardingStepAssistant(
|
||||||
|
onContinue = {
|
||||||
|
scope.launch {
|
||||||
|
app.settingsStore.setHasOnboarded(true)
|
||||||
|
val intent = Intent(context, ConnectionService::class.java).apply {
|
||||||
|
action = ConnectionService.ACTION_CONNECT
|
||||||
|
}
|
||||||
|
context.startForegroundService(intent)
|
||||||
|
onComplete()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSkip = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
app.settingsStore.setHasOnboarded(true)
|
app.settingsStore.setHasOnboarded(true)
|
||||||
// Auto-connect
|
|
||||||
val intent = Intent(context, ConnectionService::class.java).apply {
|
val intent = Intent(context, ConnectionService::class.java).apply {
|
||||||
action = ConnectionService.ACTION_CONNECT
|
action = ConnectionService.ACTION_CONNECT
|
||||||
}
|
}
|
||||||
@@ -191,7 +206,7 @@ private fun OnboardingStepOne(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun OnboardingStepTwo(onGetStarted: () -> Unit) {
|
private fun OnboardingStepTwo(onContinue: () -> Unit) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val isCaptureAvailable by ScreenCaptureManager.isAvailable.collectAsState()
|
val isCaptureAvailable by ScreenCaptureManager.isAvailable.collectAsState()
|
||||||
|
|
||||||
@@ -312,14 +327,14 @@ private fun OnboardingStepTwo(onGetStarted: () -> Unit) {
|
|||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
onClick = onGetStarted,
|
onClick = onContinue,
|
||||||
enabled = allGranted,
|
enabled = allGranted,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(52.dp),
|
.height(52.dp),
|
||||||
shape = RoundedCornerShape(12.dp)
|
shape = RoundedCornerShape(12.dp)
|
||||||
) {
|
) {
|
||||||
Text("Get Started", style = MaterialTheme.typography.labelLarge)
|
Text("Continue", style = MaterialTheme.typography.labelLarge)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!allGranted) {
|
if (!allGranted) {
|
||||||
@@ -335,6 +350,92 @@ private fun OnboardingStepTwo(onGetStarted: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OnboardingStepAssistant(
|
||||||
|
onContinue: () -> Unit,
|
||||||
|
onSkip: () -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
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) {
|
||||||
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
|
if (event == Lifecycle.Event.ON_RESUME) {
|
||||||
|
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)
|
||||||
|
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 24.dp, vertical = 48.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Digital Assistant",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Set DroidClaw as your default digital assistant to invoke it with a long-press on the home button",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
OnboardingChecklistItem(
|
||||||
|
label = "Default Digital Assistant",
|
||||||
|
description = "Long-press home to open DroidClaw command panel",
|
||||||
|
isOk = isDefaultAssistant,
|
||||||
|
actionLabel = "Set",
|
||||||
|
onAction = {
|
||||||
|
context.startActivity(Intent(Settings.ACTION_VOICE_INPUT_SETTINGS))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = onContinue,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(52.dp),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Text("Get Started", style = MaterialTheme.typography.labelLarge)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
TextButton(onClick = onSkip) {
|
||||||
|
Text("Skip for now")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun OnboardingChecklistItem(
|
private fun OnboardingChecklistItem(
|
||||||
label: String,
|
label: String,
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.thisux.droidclaw.voice
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.speech.RecognitionService
|
||||||
|
|
||||||
|
class DroidClawRecognitionService : RecognitionService() {
|
||||||
|
override fun onStartListening(intent: Intent?, callback: Callback?) {}
|
||||||
|
override fun onCancel(callback: Callback?) {}
|
||||||
|
override fun onStopListening(callback: Callback?) {}
|
||||||
|
}
|
||||||
@@ -4,71 +4,14 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.service.voice.VoiceInteractionSession
|
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.connection.ConnectionService
|
||||||
import com.thisux.droidclaw.ui.theme.DroidClawTheme
|
|
||||||
|
|
||||||
class DroidClawVoiceSession(context: Context) : VoiceInteractionSession(context) {
|
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) {
|
override fun onShow(args: Bundle?, showFlags: Int) {
|
||||||
super.onShow(args, showFlags)
|
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 {
|
val intent = Intent(context, ConnectionService::class.java).apply {
|
||||||
action = ConnectionService.ACTION_SEND_GOAL
|
action = ConnectionService.ACTION_SHOW_COMMAND_PANEL
|
||||||
putExtra(ConnectionService.EXTRA_GOAL, goal)
|
|
||||||
}
|
}
|
||||||
context.startService(intent)
|
context.startService(intent)
|
||||||
hide()
|
hide()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<voice-interaction-service xmlns:android="http://schemas.android.com/apk/res/android"
|
<voice-interaction-service xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:sessionService="com.thisux.droidclaw.voice.DroidClawVoiceSessionService"
|
android:sessionService="com.thisux.droidclaw.voice.DroidClawVoiceSessionService"
|
||||||
|
android:recognitionService="com.thisux.droidclaw.voice.DroidClawRecognitionService"
|
||||||
android:settingsActivity="com.thisux.droidclaw.MainActivity"
|
android:settingsActivity="com.thisux.droidclaw.MainActivity"
|
||||||
android:supportsAssist="true"
|
android:supportsAssist="true"
|
||||||
android:supportsLaunchVoiceAssistFromKeyguard="false" />
|
android:supportsLaunchVoiceAssistFromKeyguard="false" />
|
||||||
|
|||||||
Reference in New Issue
Block a user