Fix hotword detection, Assistant UI launch, and audio/mic conflicts

This commit is contained in:
Yu-ga 2026-01-31 17:33:07 +09:00
parent 4b692e9848
commit 3d6c8e9c3f
35 changed files with 3642 additions and 412 deletions

View file

@ -73,10 +73,12 @@ dependencies {
// Core Android
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
implementation("androidx.savedstate:savedstate-ktx:1.2.1")
implementation("androidx.activity:activity-compose:1.8.2")
// Compose
implementation(platform("androidx.compose:compose-bom:2024.01.00"))
implementation(platform("androidx.compose:compose-bom:2024.06.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
@ -93,8 +95,8 @@ dependencies {
// Encrypted SharedPreferences
implementation("androidx.security:security-crypto:1.1.0-alpha06")
// Picovoice Porcupine (Hotword Detection)
implementation("ai.picovoice:porcupine-android:3.0.2")
// Vosk
implementation("com.alphacephei:vosk-android:0.3.47")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
@ -103,7 +105,7 @@ dependencies {
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation(platform("androidx.compose:compose-bom:2024.01.00"))
androidTestImplementation(platform("androidx.compose:compose-bom:2024.06.00"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")

View file

@ -22,6 +22,7 @@
<application
android:allowBackup="false"
android:usesCleartextTraffic="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="false"
android:icon="@mipmap/ic_launcher"
@ -48,6 +49,12 @@
android:exported="false"
android:theme="@style/Theme.OpenClawAssistant" />
<!-- チャットActivity -->
<activity
android:name=".ChatActivity"
android:exported="false"
android:theme="@style/Theme.OpenClawAssistant" />
<!-- Voice Interaction Service (ホームボタン長押し) -->
<service
android:name=".service.OpenClawVoiceService"
@ -61,6 +68,10 @@
</intent-filter>
</service>
<service
android:name=".service.OpenClawSessionService"
android:permission="android.permission.BIND_VOICE_INTERACTION" />
<!-- Hotword Detection Service (常時聴取) -->
<service
android:name=".service.HotwordService"

View file

@ -0,0 +1,9 @@
US English model for mobile Vosk applications
Copyright 2020 Alpha Cephei Inc
Accuracy: 10.38 (tedlium test) 9.85 (librispeech test-clean)
Speed: 0.11xRT (desktop)
Latency: 0.15s (right context)

Binary file not shown.

View file

@ -0,0 +1,7 @@
--sample-frequency=16000
--use-energy=false
--num-mel-bins=40
--num-ceps=40
--low-freq=20
--high-freq=7600
--allow-downsample=true

View file

@ -0,0 +1,10 @@
--min-active=200
--max-active=3000
--beam=10.0
--lattice-beam=2.0
--acoustic-scale=1.0
--frame-subsampling-factor=3
--endpoint.silence-phones=1:2:3:4:5:6:7:8:9:10
--endpoint.rule2.min-trailing-silence=0.5
--endpoint.rule3.min-trailing-silence=0.75
--endpoint.rule4.min-trailing-silence=1.0

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,17 @@
10015
10016
10017
10018
10019
10020
10021
10022
10023
10024
10025
10026
10027
10028
10029
10030
10031

View file

@ -0,0 +1,166 @@
1 nonword
2 begin
3 end
4 internal
5 singleton
6 nonword
7 begin
8 end
9 internal
10 singleton
11 begin
12 end
13 internal
14 singleton
15 begin
16 end
17 internal
18 singleton
19 begin
20 end
21 internal
22 singleton
23 begin
24 end
25 internal
26 singleton
27 begin
28 end
29 internal
30 singleton
31 begin
32 end
33 internal
34 singleton
35 begin
36 end
37 internal
38 singleton
39 begin
40 end
41 internal
42 singleton
43 begin
44 end
45 internal
46 singleton
47 begin
48 end
49 internal
50 singleton
51 begin
52 end
53 internal
54 singleton
55 begin
56 end
57 internal
58 singleton
59 begin
60 end
61 internal
62 singleton
63 begin
64 end
65 internal
66 singleton
67 begin
68 end
69 internal
70 singleton
71 begin
72 end
73 internal
74 singleton
75 begin
76 end
77 internal
78 singleton
79 begin
80 end
81 internal
82 singleton
83 begin
84 end
85 internal
86 singleton
87 begin
88 end
89 internal
90 singleton
91 begin
92 end
93 internal
94 singleton
95 begin
96 end
97 internal
98 singleton
99 begin
100 end
101 internal
102 singleton
103 begin
104 end
105 internal
106 singleton
107 begin
108 end
109 internal
110 singleton
111 begin
112 end
113 internal
114 singleton
115 begin
116 end
117 internal
118 singleton
119 begin
120 end
121 internal
122 singleton
123 begin
124 end
125 internal
126 singleton
127 begin
128 end
129 internal
130 singleton
131 begin
132 end
133 internal
134 singleton
135 begin
136 end
137 internal
138 singleton
139 begin
140 end
141 internal
142 singleton
143 begin
144 end
145 internal
146 singleton
147 begin
148 end
149 internal
150 singleton
151 begin
152 end
153 internal
154 singleton
155 begin
156 end
157 internal
158 singleton
159 begin
160 end
161 internal
162 singleton
163 begin
164 end
165 internal
166 singleton

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,3 @@
[
1.682383e+11 -1.1595e+10 -1.521733e+10 4.32034e+09 -2.257938e+10 -1.969666e+10 -2.559265e+10 -1.535687e+10 -1.276854e+10 -4.494483e+09 -1.209085e+10 -5.64008e+09 -1.134847e+10 -3.419512e+09 -1.079542e+10 -4.145463e+09 -6.637486e+09 -1.11318e+09 -3.479773e+09 -1.245932e+08 -1.386961e+09 6.560655e+07 -2.436518e+08 -4.032432e+07 4.620046e+08 -7.714964e+07 9.551484e+08 -4.119761e+08 8.208582e+08 -7.117156e+08 7.457703e+08 -4.3106e+08 1.202726e+09 2.904036e+08 1.231931e+09 3.629848e+08 6.366939e+08 -4.586172e+08 -5.267629e+08 -3.507819e+08 1.679838e+09
1.741141e+13 8.92488e+11 8.743834e+11 8.848896e+11 1.190313e+12 1.160279e+12 1.300066e+12 1.005678e+12 9.39335e+11 8.089614e+11 7.927041e+11 6.882427e+11 6.444235e+11 5.151451e+11 4.825723e+11 3.210106e+11 2.720254e+11 1.772539e+11 1.248102e+11 6.691599e+10 3.599804e+10 1.207574e+10 1.679301e+09 4.594778e+08 5.821614e+09 1.451758e+10 2.55803e+10 3.43277e+10 4.245286e+10 4.784859e+10 4.988591e+10 4.925451e+10 5.074584e+10 4.9557e+10 4.407876e+10 3.421443e+10 3.138606e+10 2.539716e+10 1.948134e+10 1.381167e+10 0 ]

View file

@ -0,0 +1 @@
# configuration file for apply-cmvn-online, used in the script ../local/run_online_decoding.sh

View file

@ -0,0 +1,2 @@
--left-context=3
--right-context=3

View file

@ -0,0 +1,337 @@
package com.openclaw.assistant
import android.Manifest
import android.content.pm.PackageManager
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.*
import androidx.compose.runtime.*
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.LocalSoftwareKeyboardController
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.foundation.text.BasicTextField
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.openclaw.assistant.ui.chat.ChatMessage
import com.openclaw.assistant.ui.chat.ChatUiState
import com.openclaw.assistant.ui.chat.ChatViewModel
import com.openclaw.assistant.ui.theme.OpenClawAssistantTheme
import androidx.compose.material3.TextButton
class ChatActivity : ComponentActivity() {
private val viewModel: ChatViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Request Microphone permission if not granted
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
!= PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.RECORD_AUDIO),
REQUEST_RECORD_AUDIO
)
}
setContent {
OpenClawAssistantTheme {
val uiState by viewModel.uiState.collectAsState()
ChatScreen(
uiState = uiState,
onSendMessage = { viewModel.sendMessage(it) },
onStartListening = {
if (checkPermission()) {
viewModel.startListening()
} else {
Toast.makeText(this, "Microphone permission required", Toast.LENGTH_SHORT).show()
}
},
onStopListening = { viewModel.stopListening() },
onStopSpeaking = { viewModel.stopSpeaking() },
onBack = { finish() }
)
}
}
}
private fun checkPermission(): Boolean {
return ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
}
companion object {
private const val REQUEST_RECORD_AUDIO = 200
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatScreen(
uiState: ChatUiState,
onSendMessage: (String) -> Unit,
onStartListening: () -> Unit,
onStopListening: () -> Unit,
onStopSpeaking: () -> Unit,
onBack: () -> Unit
) {
var inputText by remember { mutableStateOf("") }
val listState = rememberLazyListState()
val keyboardController = LocalSoftwareKeyboardController.current
// Scroll to bottom when new messages arrive
LaunchedEffect(uiState.messages.size) {
if (uiState.messages.isNotEmpty()) {
listState.animateScrollToItem(uiState.messages.size - 1)
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
"OpenClaw",
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.primary,
navigationIconContentColor = MaterialTheme.colorScheme.primary
)
)
},
bottomBar = {
Column {
if (uiState.partialText.isNotBlank()) {
Text(
text = uiState.partialText,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(12.dp))
.padding(12.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
ChatInputArea(
value = inputText,
onValueChange = { inputText = it },
onSend = {
onSendMessage(inputText)
inputText = ""
keyboardController?.hide()
},
isListening = uiState.isListening,
onMicClick = {
if (uiState.isListening) onStopListening() else onStartListening()
}
)
}
}
) { paddingValues ->
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(bottom = 16.dp, top = 8.dp)
) {
items(uiState.messages) { message ->
MessageBubble(message = message)
}
if (uiState.isThinking) {
item {
ThinkingIndicator()
}
}
if (uiState.isSpeaking) {
item {
SpeakingIndicator(onStop = onStopSpeaking)
}
}
}
}
}
@Composable
fun MessageBubble(message: ChatMessage) {
val isUser = message.isUser
val alignment = if (isUser) Alignment.CenterEnd else Alignment.CenterStart
val containerColor = if (isUser) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface
val contentColor = if (isUser) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
// Friendly rounded shapes
val shape = if (isUser) {
RoundedCornerShape(18.dp, 18.dp, 4.dp, 18.dp)
} else {
RoundedCornerShape(18.dp, 18.dp, 18.dp, 4.dp)
}
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = alignment
) {
Column(horizontalAlignment = if (isUser) Alignment.End else Alignment.Start) {
Card(
colors = CardDefaults.cardColors(containerColor = containerColor),
shape = shape,
modifier = Modifier.widthIn(max = 300.dp)
) {
Text(
text = message.text,
color = contentColor,
modifier = Modifier.padding(16.dp),
fontSize = 16.sp,
lineHeight = 24.sp
)
}
}
}
}
@Composable
fun ThinkingIndicator() {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.CenterStart
) {
Row(
modifier = Modifier
.padding(vertical = 8.dp)
.background(MaterialTheme.colorScheme.surface, CircleShape)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
"Thinking...",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.primary,
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium
)
}
}
}
@Composable
fun SpeakingIndicator(onStop: () -> Unit) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.CenterStart
) {
Row(
modifier = Modifier
.padding(vertical = 8.dp)
.background(MaterialTheme.colorScheme.errorContainer, CircleShape)
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Mic, contentDescription = null, modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onErrorContainer)
Spacer(modifier = Modifier.width(8.dp))
Text(
"Listening...",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onErrorContainer,
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium
)
Spacer(modifier = Modifier.width(8.dp))
IconButton(onClick = onStop, modifier = Modifier.size(24.dp)) {
Icon(Icons.Default.Stop, contentDescription = "Stop", tint = MaterialTheme.colorScheme.onErrorContainer)
}
}
}
}
@Composable
fun ChatInputArea(
value: String,
onValueChange: (String) -> Unit,
onSend: () -> Unit,
isListening: Boolean,
onMicClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier
.weight(1f)
.padding(end = 8.dp),
placeholder = { Text("Ask OpenClaw...") },
maxLines = 4,
shape = RoundedCornerShape(24.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline,
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface
),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { if (value.isNotBlank()) onSend() })
)
FloatingActionButton(
onClick = if (value.isBlank()) onMicClick else onSend,
containerColor = if (value.isBlank() && isListening) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
shape = CircleShape
) {
Icon(
imageVector = if (value.isBlank()) {
if (isListening) Icons.Default.Stop else Icons.Default.Mic
} else {
Icons.AutoMirrored.Filled.Send
},
contentDescription = "Send or Mic",
tint = Color.White
)
}
}
}

View file

@ -16,6 +16,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
@ -24,14 +25,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.text.font.FontWeight
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalLifecycleOwner
import com.openclaw.assistant.data.SettingsRepository
import com.openclaw.assistant.service.HotwordService
import com.openclaw.assistant.ui.theme.OpenClawAssistantTheme
@ -102,11 +103,6 @@ class MainActivity : ComponentActivity() {
private fun toggleHotwordService(enabled: Boolean) {
settings.hotwordEnabled = enabled
if (enabled) {
if (!settings.isConfigured()) {
Toast.makeText(this, "Please configure Webhook URL first", Toast.LENGTH_SHORT).show()
settings.hotwordEnabled = false
return
}
HotwordService.start(this)
Toast.makeText(this, "Hotword detection started", Toast.LENGTH_SHORT).show()
} else {
@ -124,11 +120,11 @@ fun MainScreen(
onOpenAssistantSettings: () -> Unit,
onToggleHotword: (Boolean) -> Unit
) {
// Re-check configuration when returning from settings
var isConfigured by remember { mutableStateOf(settings.isConfigured()) }
var hotwordEnabled by remember { mutableStateOf(settings.hotwordEnabled) }
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
@ -197,7 +193,6 @@ fun MainScreen(
switchValue = hotwordEnabled,
onSwitchChange = { enabled ->
if (enabled && !isConfigured) {
// Don't enable if not configured
return@ActionCard
}
hotwordEnabled = enabled
@ -221,6 +216,21 @@ fun MainScreen(
Spacer(modifier = Modifier.height(24.dp))
// Manual Chat
val context = LocalContext.current
ActionCard(
icon = Icons.AutoMirrored.Filled.Chat,
title = "In-App Chat",
description = "Text & Voice Conversation",
actionText = "Open Chat",
onClick = {
val intent = Intent(context, ChatActivity::class.java)
context.startActivity(intent)
}
)
Spacer(modifier = Modifier.height(24.dp))
// Configuration warning
if (!isConfigured) {
WarningCard(

View file

@ -16,6 +16,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
@ -60,11 +61,13 @@ fun SettingsScreen(
var authToken by remember { mutableStateOf(settings.authToken) }
var showAuthToken by remember { mutableStateOf(false) }
var isTesting by remember { mutableStateOf(false) }
var testResult by remember { mutableStateOf<TestResult?>(null) }
val scope = rememberCoroutineScope()
val context = LocalContext.current
val apiClient = remember { OpenClawClient() }
var isTesting by remember { mutableStateOf(false) }
var testResult by remember { mutableStateOf<TestResult?>(null) }
Scaffold(
topBar = {
@ -82,7 +85,7 @@ fun SettingsScreen(
settings.authToken = authToken
onSave()
},
enabled = webhookUrl.isNotBlank()
enabled = webhookUrl.isNotBlank() && !isTesting
) {
Text("Save")
}
@ -145,54 +148,66 @@ fun SettingsScreen(
Spacer(modifier = Modifier.height(24.dp))
// Test Connection Button
// Test Connection Button (v2 layout with improved feedback)
Button(
onClick = {
if (webhookUrl.isBlank()) return@Button
isTesting = true
testResult = null
scope.launch {
val result = apiClient.sendMessage(
webhookUrl = webhookUrl,
message = "ping",
sessionId = "test-${System.currentTimeMillis()}",
authToken = authToken.takeIf { it.isNotBlank() }
)
testResult = result.fold(
onSuccess = { response ->
val text = response.getResponseText()
if (text != null) {
TestResult(success = true, message = "Connected! Response: $text")
} else {
TestResult(success = false, message = "No response text")
try {
isTesting = true
testResult = null
val result = apiClient.testConnection(webhookUrl, authToken)
result.fold(
onSuccess = {
testResult = TestResult(success = true, message = "Connection Successful!")
// Also mark as verified in repository
settings.webhookUrl = webhookUrl
settings.authToken = authToken
settings.isVerified = true
},
onFailure = {
testResult = TestResult(success = false, message = "Connection Failed: ${it.message}")
Toast.makeText(context, "Error: ${it.message}", Toast.LENGTH_LONG).show()
}
},
onFailure = { error ->
TestResult(success = false, message = error.message ?: "Connection failed")
}
)
isTesting = false
)
} catch (e: Exception) {
testResult = TestResult(success = false, message = "Error: ${e.message}")
} finally {
isTesting = false
}
}
},
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = when {
testResult?.success == true -> Color(0xFF4CAF50)
testResult?.success == false -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.primary
}
),
enabled = webhookUrl.isNotBlank() && !isTesting
) {
if (isTesting) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
Text("Testing...")
} else {
Icon(Icons.Default.NetworkCheck, contentDescription = null)
val icon = when {
testResult == null -> Icons.Default.NetworkCheck
testResult?.success == true -> Icons.Default.Check
else -> Icons.Default.Error
}
Icon(icon, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Test Connection")
Text(testResult?.message ?: "Test Connection")
}
}
// Test Result
// Detailed Test Result Card
testResult?.let { result ->
Spacer(modifier = Modifier.height(12.dp))
Card(
@ -220,7 +235,7 @@ fun SettingsScreen(
}
}
Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.height(32.dp))
// Tips
Card(

View file

@ -57,8 +57,9 @@ class OpenClawClient {
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
val errorBody = response.body?.string() ?: response.message
return@withContext Result.failure(
IOException("HTTP ${response.code}: ${response.message}")
IOException("HTTP ${response.code}: $errorBody")
)
}
@ -78,6 +79,72 @@ class OpenClawClient {
}
}
/**
* Test connection to the webhook
*/
suspend fun testConnection(
webhookUrl: String,
authToken: String?
): Result<Boolean> = withContext(Dispatchers.IO) {
try {
// Try a HEAD request first (lightweight)
var requestBuilder = Request.Builder()
.url(webhookUrl)
.head()
if (!authToken.isNullOrBlank()) {
requestBuilder.addHeader("Authorization", "Bearer $authToken")
}
var request = requestBuilder.build()
try {
client.newCall(request).execute().use { response ->
if (response.isSuccessful) return@withContext Result.success(true)
// If Method Not Allowed (405), try POST
if (response.code == 405) {
// Fallthrough to POST
} else {
return@withContext Result.failure(IOException("HTTP ${response.code}"))
}
}
} catch (e: Exception) {
// Fallthrough to POST on error (some servers reject HEAD)
}
// Fallback: POST with dummy data
val requestBody = JsonObject().apply {
addProperty("message", "ping")
addProperty("session_id", "test-connection")
}
val jsonBody = gson.toJson(requestBody)
.toRequestBody("application/json; charset=utf-8".toMediaType())
requestBuilder = Request.Builder()
.url(webhookUrl)
.post(jsonBody)
.addHeader("Content-Type", "application/json")
if (!authToken.isNullOrBlank()) {
requestBuilder.addHeader("Authorization", "Bearer $authToken")
}
request = requestBuilder.build()
client.newCall(request).execute().use { response ->
if (response.isSuccessful) {
Result.success(true)
} else {
val errorBody = response.body?.string() ?: response.message
Result.failure(IOException("HTTP ${response.code}: $errorBody"))
}
}
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Extract response text from various JSON formats
*/

View file

@ -26,7 +26,12 @@ class SettingsRepository(context: Context) {
// Webhook URL (required)
var webhookUrl: String
get() = prefs.getString(KEY_WEBHOOK_URL, "") ?: ""
set(value) = prefs.edit().putString(KEY_WEBHOOK_URL, value).apply()
set(value) {
if (value != webhookUrl) {
prefs.edit().putString(KEY_WEBHOOK_URL, value).apply()
isVerified = false
}
}
// Auth Token (optional)
var authToken: String
@ -41,18 +46,21 @@ class SettingsRepository(context: Context) {
}
set(value) = prefs.edit().putString(KEY_SESSION_ID, value).apply()
// Picovoice Access Key (hardcoded)
val picovoiceAccessKey: String
get() = PICOVOICE_ACCESS_KEY
// Hotword enabled
var hotwordEnabled: Boolean
get() = prefs.getBoolean(KEY_HOTWORD_ENABLED, false)
set(value) = prefs.edit().putBoolean(KEY_HOTWORD_ENABLED, value).apply()
// Connection Verified
var isVerified: Boolean
get() = prefs.getBoolean(KEY_IS_VERIFIED, false)
set(value) = prefs.edit().putBoolean(KEY_IS_VERIFIED, value).apply()
// Check if configured
fun isConfigured(): Boolean {
return webhookUrl.isNotBlank()
return webhookUrl.isNotBlank() && isVerified
}
// Generate new session ID
@ -71,9 +79,9 @@ class SettingsRepository(context: Context) {
private const val KEY_AUTH_TOKEN = "auth_token"
private const val KEY_SESSION_ID = "session_id"
private const val KEY_HOTWORD_ENABLED = "hotword_enabled"
private const val KEY_IS_VERIFIED = "is_verified"
// Hardcoded Picovoice Access Key
private const val PICOVOICE_ACCESS_KEY = "NvmV8rffJPNXTVlKl1kctpOBQv1pHIycrLkEG1u8aMvNFGp/i7MWHQ=="
@Volatile
private var instance: SettingsRepository? = null

View file

@ -7,6 +7,7 @@ import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.IBinder
import android.util.Log
@ -15,29 +16,29 @@ import com.openclaw.assistant.MainActivity
import com.openclaw.assistant.R
import com.openclaw.assistant.api.OpenClawClient
import com.openclaw.assistant.data.SettingsRepository
import com.openclaw.assistant.speech.SpeechRecognizerManager
import com.openclaw.assistant.speech.SpeechResult
import com.openclaw.assistant.speech.TTSManager
import ai.picovoice.porcupine.*
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collectLatest
import java.io.File
import java.io.FileOutputStream
import org.vosk.Model
import org.vosk.Recognizer
import org.vosk.android.RecognitionListener as VoskRecognitionListener
import org.vosk.android.SpeechService
import org.vosk.android.StorageService
import java.io.IOException
import org.json.JSONObject
/**
* ホットワード検知サービス
* バックグラウンドで常時聴取し、ウェイクワード「OpenClaw」を検知したら音声認識開始
* ホットワード検知サービス (Vosk)
*/
class HotwordService : Service() {
class HotwordService : Service(), VoskRecognitionListener {
companion object {
private const val TAG = "HotwordService"
private const val NOTIFICATION_ID = 1001
private const val CHANNEL_ID = "hotword_channel"
private const val SAMPLE_RATE = 16000.0f
const val ACTION_RESUME_HOTWORD = "com.openclaw.assistant.ACTION_RESUME_HOTWORD"
const val ACTION_PAUSE_HOTWORD = "com.openclaw.assistant.ACTION_PAUSE_HOTWORD"
// カスタムウェイクワードファイル名(assetsに配置)
private const val WAKE_WORD_FILE = "openclaw_android.ppn"
fun start(context: Context) {
val intent = Intent(context, HotwordService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@ -53,31 +54,57 @@ class HotwordService : Service() {
}
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var porcupineManager: PorcupineManager? = null
private var model: Model? = null
private var speechService: SpeechService? = null
private lateinit var settings: SettingsRepository
private lateinit var apiClient: OpenClawClient
private lateinit var speechManager: SpeechRecognizerManager
private lateinit var ttsManager: TTSManager
private var isListeningForCommand = false
private var isSessionActive = false
private val controlReceiver = object : android.content.BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
ACTION_PAUSE_HOTWORD -> {
Log.d(TAG, "Pause signal received")
isSessionActive = true
speechService?.stop()
speechService?.shutdown()
speechService = null
isListeningForCommand = false
}
ACTION_RESUME_HOTWORD -> {
Log.d(TAG, "Resume signal received")
isSessionActive = false
resumeHotwordDetection()
}
}
}
}
override fun onCreate() {
super.onCreate()
settings = SettingsRepository.getInstance(this)
apiClient = OpenClawClient()
speechManager = SpeechRecognizerManager(this)
ttsManager = TTSManager(this)
createNotificationChannel()
val filter = IntentFilter().apply {
addAction(ACTION_RESUME_HOTWORD)
addAction(ACTION_PAUSE_HOTWORD)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(controlReceiver, filter, Context.RECEIVER_EXPORTED)
} else {
registerReceiver(controlReceiver, filter)
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(TAG, "Service started")
startForeground(NOTIFICATION_ID, createNotification())
startHotwordDetection()
initVosk()
return START_STICKY
}
@ -85,11 +112,11 @@ class HotwordService : Service() {
override fun onDestroy() {
super.onDestroy()
Log.d(TAG, "Service destroyed")
try {
unregisterReceiver(controlReceiver)
} catch (e: Exception) {}
scope.cancel()
stopHotwordDetection()
speechManager.destroy()
speechService?.shutdown()
ttsManager.shutdown()
}
@ -99,225 +126,162 @@ class HotwordService : Service() {
CHANNEL_ID,
getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_LOW
).apply {
description = getString(R.string.notification_channel_description)
setShowBadge(false)
}
)
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(channel)
}
}
private fun createNotification(): Notification {
val pendingIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE
)
val pendingIntent = PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.notification_title))
.setContentText("Say \"Open Claw\" to activate")
.setSmallIcon(R.drawable.ic_mic)
.setContentIntent(pendingIntent)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
/**
* カスタムキーワードファイルをassetsから内部ストレージにコピー
*/
private fun copyKeywordFile(): String? {
return try {
val outputFile = File(filesDir, WAKE_WORD_FILE)
// 既にコピー済みならスキップ
if (outputFile.exists()) {
return outputFile.absolutePath
}
// assetsからコピー
assets.open(WAKE_WORD_FILE).use { input ->
FileOutputStream(outputFile).use { output ->
input.copyTo(output)
private fun initVosk() {
scope.launch(Dispatchers.IO) {
try {
val modelPath = copyAssets()
if (modelPath != null) {
model = Model(modelPath)
withContext(Dispatchers.Main) {
if (!isSessionActive) startHotwordListening()
}
}
} catch (e: Exception) {
Log.e(TAG, "Init error", e)
}
Log.d(TAG, "Keyword file copied to: ${outputFile.absolutePath}")
outputFile.absolutePath
} catch (e: Exception) {
Log.e(TAG, "Failed to copy keyword file", e)
null
}
}
private fun startHotwordDetection() {
val accessKey = settings.picovoiceAccessKey
if (accessKey.isBlank()) {
Log.e(TAG, "Picovoice access key not configured")
stopSelf()
return
}
private fun copyAssets(): String? {
val targetDir = java.io.File(filesDir, "model")
try {
// カスタムキーワードファイルをコピー
val keywordPath = copyKeywordFile()
val builder = PorcupineManager.Builder()
.setAccessKey(accessKey)
.setSensitivity(0.7f)
if (keywordPath != null) {
// カスタムキーワード「OpenClaw」を使用
Log.d(TAG, "Using custom keyword: OpenClaw")
builder.setKeywordPath(keywordPath)
} else {
// フォールバック: ビルトインキーワード「Porcupine」を使用
Log.w(TAG, "Custom keyword not found, falling back to 'Porcupine'")
builder.setKeyword(Porcupine.BuiltInKeyword.PORCUPINE)
}
copyAssetFolder(assets, "model", targetDir.absolutePath)
return targetDir.absolutePath
} catch (e: Exception) {
return null
}
}
porcupineManager = builder.build(this) { keywordIndex ->
Log.d(TAG, "Hotword 'OpenClaw' detected!")
private fun copyAssetFolder(assetManager: android.content.res.AssetManager, fromAssetPath: String, toPath: String): Boolean {
try {
val files = assetManager.list(fromAssetPath) ?: return false
java.io.File(toPath).mkdirs()
var res = true
for (file in files) {
if (file.contains(".")) {
res = res and copyAsset(assetManager, "$fromAssetPath/$file", "$toPath/$file")
} else {
res = res and copyAssetFolder(assetManager, "$fromAssetPath/$file", "$toPath/$file")
}
}
return res
} catch (e: Exception) {
return false
}
}
private fun copyAsset(assetManager: android.content.res.AssetManager, fromAssetPath: String, toPath: String): Boolean {
var inStream: java.io.`InputStream`? = null
var outStream: java.io.OutputStream? = null
try {
inStream = assetManager.open(fromAssetPath)
java.io.File(toPath).createNewFile()
outStream = java.io.FileOutputStream(toPath)
inStream.copyTo(outStream)
return true
} catch (e: Exception) {
return false
} finally {
inStream?.close()
outStream?.close()
}
}
private fun startHotwordListening() {
if (model == null || isSessionActive) return
try {
val rec = Recognizer(model, SAMPLE_RATE, "[\"open claw\", \"hey claw\", \"okay claw\"]")
speechService = SpeechService(rec, SAMPLE_RATE)
speechService?.startListening(this)
Log.d(TAG, "Hotword listening started")
} catch (e: IOException) {
Log.e(TAG, "Start error", e)
}
}
override fun onPartialResult(hypothesis: String?) {}
override fun onResult(hypothesis: String?) {
if (isListeningForCommand || isSessionActive) return
hypothesis?.let {
val json = JSONObject(it)
val text = json.optString("text", "")
if (text.contains("open claw") || text.contains("hey claw")) {
Log.d(TAG, "Hotword detected: $text")
onHotwordDetected()
}
porcupineManager?.start()
Log.d(TAG, "Hotword detection started")
} catch (e: PorcupineException) {
Log.e(TAG, "Failed to start Porcupine", e)
// カスタムキーワードで失敗した場合、ビルトインで再試行
tryFallbackKeyword(accessKey)
}
}
private fun tryFallbackKeyword(accessKey: String) {
try {
Log.d(TAG, "Trying fallback keyword 'Porcupine'")
porcupineManager = PorcupineManager.Builder()
.setAccessKey(accessKey)
.setKeyword(Porcupine.BuiltInKeyword.PORCUPINE)
.setSensitivity(0.7f)
.build(this) { keywordIndex ->
Log.d(TAG, "Hotword detected (fallback)!")
onHotwordDetected()
}
override fun onFinalResult(hypothesis: String?) {
onResult(hypothesis)
}
porcupineManager?.start()
Log.d(TAG, "Fallback hotword detection started")
} catch (e: PorcupineException) {
Log.e(TAG, "Fallback also failed", e)
stopSelf()
override fun onError(exception: Exception?) {
Log.e(TAG, "Vosk Error: " + exception?.message)
if (isSessionActive) return
scope.launch {
delay(3000)
if (!isSessionActive) resumeHotwordDetection()
}
}
private fun stopHotwordDetection() {
try {
porcupineManager?.stop()
porcupineManager?.delete()
porcupineManager = null
} catch (e: Exception) {
Log.e(TAG, "Error stopping Porcupine", e)
override fun onTimeout() {
if (!isListeningForCommand && !isSessionActive) {
speechService?.startListening(this)
}
}
private fun onHotwordDetected() {
if (isListeningForCommand) return
isListeningForCommand = true
isSessionActive = true
// ホットワード検知を一時停止
porcupineManager?.stop()
// 確認音を鳴らす(オプション)
playConfirmationBeep()
// 音声認識開始
startCommandListening()
}
private fun playConfirmationBeep() {
// 短いビープ音で応答(TTSで代用)
scope.launch {
// 「Yes」と短く応答
ttsManager.speak("Yes")
}
}
private fun startCommandListening() {
scope.launch {
var recognizedText: String? = null
speechManager.startListening("ja-JP").collectLatest { result ->
when (result) {
is SpeechResult.Result -> {
Log.d(TAG, "Command recognized: ${result.text}")
recognizedText = result.text
}
is SpeechResult.Error -> {
Log.e(TAG, "Speech error: ${result.message}")
resumeHotwordDetection()
}
else -> {}
}
}
// 音声認識完了後、OpenClawに送信
recognizedText?.let { text ->
sendToOpenClaw(text)
} ?: run {
resumeHotwordDetection()
}
}
}
private fun sendToOpenClaw(message: String) {
if (!settings.isConfigured()) {
Log.e(TAG, "Webhook not configured")
scope.launch {
ttsManager.speak("Configuration required")
resumeHotwordDetection()
}
return
}
Log.d(TAG, "Hotword Detected! Launching Assistant UI...")
speechService?.stop()
speechService?.shutdown()
speechService = null
scope.launch {
val result = apiClient.sendMessage(
webhookUrl = settings.webhookUrl,
message = message,
sessionId = settings.sessionId,
authToken = settings.authToken.takeIf { it.isNotBlank() }
)
result.fold(
onSuccess = { response ->
val responseText = response.getResponseText()
if (responseText != null) {
ttsManager.speak(responseText)
}
resumeHotwordDetection()
},
onFailure = { error ->
Log.e(TAG, "API error", error)
ttsManager.speak("An error occurred")
resumeHotwordDetection()
}
)
ttsManager.speak("Yes")
delay(500)
val intent = Intent(Intent.ACTION_VOICE_COMMAND).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_ANIMATION
}
try {
startActivity(intent)
} catch (e: Exception) {
Log.e(TAG, "Launch failed", e)
isSessionActive = false
resumeHotwordDetection()
}
}
}
private fun resumeHotwordDetection() {
if (isSessionActive) return
isListeningForCommand = false
scope.launch {
delay(500) // 少し待ってから再開
try {
porcupineManager?.start()
Log.d(TAG, "Hotword detection resumed")
} catch (e: Exception) {
Log.e(TAG, "Error resuming hotword detection", e)
delay(500)
if (!isSessionActive && speechService == null) {
startHotwordListening()
}
}
}

View file

@ -1,6 +1,7 @@
package com.openclaw.assistant.service
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.service.voice.VoiceInteractionSession
import android.util.Log
@ -34,12 +35,18 @@ import com.openclaw.assistant.speech.SpeechResult
import com.openclaw.assistant.speech.TTSManager
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collectLatest
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.lifecycle.setViewTreeViewModelStoreOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
/**
* Voice Interaction Session
* 実際の音声対話を処理
*/
class OpenClawSession(context: Context) : VoiceInteractionSession(context) {
class OpenClawSession(context: Context) : VoiceInteractionSession(context),
androidx.lifecycle.LifecycleOwner,
androidx.savedstate.SavedStateRegistryOwner,
androidx.lifecycle.ViewModelStoreOwner {
companion object {
private const val TAG = "OpenClawSession"
@ -64,24 +71,30 @@ class OpenClawSession(context: Context) : VoiceInteractionSession(context) {
ttsManager = TTSManager(context)
}
override fun onShow(args: Bundle?, showFlags: Int) {
super.onShow(args, showFlags)
Log.d(TAG, "Session shown")
// 設定チェック
if (!settings.isConfigured()) {
currentState.value = AssistantState.ERROR
errorMessage.value = "Please configure Webhook URL"
displayText.value = "Configuration required"
return
}
private val lifecycleRegistry = androidx.lifecycle.LifecycleRegistry(this)
private val savedStateRegistryController = androidx.savedstate.SavedStateRegistryController.create(this)
// Audio Cue
private val toneGenerator = android.media.ToneGenerator(android.media.AudioManager.STREAM_MUSIC, 100)
// 音声認識開始
startListening()
}
override val lifecycle: androidx.lifecycle.Lifecycle
get() = lifecycleRegistry
override val savedStateRegistry: androidx.savedstate.SavedStateRegistry
get() = savedStateRegistryController.savedStateRegistry
override fun onCreateContentView(): View {
// Initialize lifecycle and saved state
savedStateRegistryController.performAttach()
savedStateRegistryController.performRestore(null)
lifecycleRegistry.handleLifecycleEvent(androidx.lifecycle.Lifecycle.Event.ON_CREATE)
val composeView = ComposeView(context).apply {
// Set ViewTree owners using extensions
setViewTreeLifecycleOwner(this@OpenClawSession)
setViewTreeViewModelStoreOwner(this@OpenClawSession)
setViewTreeSavedStateRegistryOwner(this@OpenClawSession)
setContent {
AssistantUI(
state = currentState.value,
@ -96,24 +109,107 @@ class OpenClawSession(context: Context) : VoiceInteractionSession(context) {
return composeView
}
override fun onShow(args: Bundle?, showFlags: Int) {
super.onShow(args, showFlags)
lifecycleRegistry.handleLifecycleEvent(androidx.lifecycle.Lifecycle.Event.ON_START)
lifecycleRegistry.handleLifecycleEvent(androidx.lifecycle.Lifecycle.Event.ON_RESUME)
Log.d(TAG, "Session shown")
// PAUSE Hotword Service to prevent microphone conflict
sendPauseBroadcast()
// ... rest of onShow ...
// 設定チェック
if (!settings.isConfigured()) {
currentState.value = AssistantState.ERROR
errorMessage.value = "Please configure Webhook URL"
displayText.value = "Configuration required"
return
}
// 音声認識開始
startListening()
}
override fun onHide() {
super.onHide()
lifecycleRegistry.handleLifecycleEvent(androidx.lifecycle.Lifecycle.Event.ON_PAUSE)
lifecycleRegistry.handleLifecycleEvent(androidx.lifecycle.Lifecycle.Event.ON_STOP)
scope.cancel()
speechManager.destroy()
ttsManager.stop()
// Resume Hotword
sendResumeBroadcast()
}
override fun onDestroy() {
super.onDestroy()
lifecycleRegistry.handleLifecycleEvent(androidx.lifecycle.Lifecycle.Event.ON_DESTROY)
// Resume Hotword (safety)
sendResumeBroadcast()
ttsManager.shutdown()
toneGenerator.release()
}
private fun sendPauseBroadcast() {
val intent = Intent("com.openclaw.assistant.ACTION_PAUSE_HOTWORD")
// Explicit package set for security if preferred, but implicit is fine for local broadcast
intent.setPackage(context.packageName)
context.sendBroadcast(intent)
}
private fun sendResumeBroadcast() {
val intent = Intent("com.openclaw.assistant.ACTION_RESUME_HOTWORD")
intent.setPackage(context.packageName)
context.sendBroadcast(intent)
}
// Must implement ViewModelStoreOwner for Compose
override val viewModelStore: androidx.lifecycle.ViewModelStore = androidx.lifecycle.ViewModelStore()
private var listeningJob: Job? = null
private fun startListening() {
currentState.value = AssistantState.LISTENING
displayText.value = "Listening..."
// Cancel previous listening job if any
listeningJob?.cancel()
// Don't show "Listening..." yet. Show "Processing..." or keep previous state
// until the recognizer is actually ready.
currentState.value = AssistantState.PROCESSING
displayText.value = "" // Clear text
partialText.value = ""
errorMessage.value = null
scope.launch {
listeningJob = scope.launch {
// Request audio focus
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as android.media.AudioManager
audioManager.requestAudioFocus(null, android.media.AudioManager.STREAM_MUSIC, android.media.AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
// Ensure previous resources are cleaned up
delay(200)
speechManager.startListening("ja-JP").collectLatest { result ->
when (result) {
is SpeechResult.Ready -> {
Log.d(TAG, "Speech ready")
// NOW we are ready to listen
currentState.value = AssistantState.LISTENING
displayText.value = "Listening..."
toneGenerator.startTone(android.media.ToneGenerator.TONE_PROP_BEEP)
}
is SpeechResult.Listening -> {
currentState.value = AssistantState.LISTENING
}
is SpeechResult.RmsChanged -> {
// 音量レベル(アニメーション用)
if (currentState.value != AssistantState.LISTENING) {
currentState.value = AssistantState.LISTENING
displayText.value = "Listening..."
}
}
is SpeechResult.RmsChanged -> {}
is SpeechResult.PartialResult -> {
partialText.value = result.text
}
@ -128,8 +224,14 @@ class OpenClawSession(context: Context) : VoiceInteractionSession(context) {
}
is SpeechResult.Error -> {
Log.e(TAG, "Speech error: ${result.message}")
currentState.value = AssistantState.ERROR
errorMessage.value = result.message
if (result.message.contains("ビジー")) {
delay(500)
startListening() // Auto-retry
} else {
currentState.value = AssistantState.ERROR
errorMessage.value = result.message
}
}
}
}
@ -138,7 +240,7 @@ class OpenClawSession(context: Context) : VoiceInteractionSession(context) {
private fun sendToOpenClaw(message: String) {
currentState.value = AssistantState.THINKING
displayText.value = "Thinking..."
displayText.value = "" // Clear text to avoid duplication with status "Thinking..."
scope.launch {
val result = apiClient.sendMessage(
@ -178,7 +280,7 @@ class OpenClawSession(context: Context) : VoiceInteractionSession(context) {
val success = ttsManager.speak(text)
if (success) {
// 読み上げ完了後、連続会話のため再度リスニング開始
delay(500)
delay(1000)
startListening()
} else {
currentState.value = AssistantState.ERROR
@ -187,17 +289,7 @@ class OpenClawSession(context: Context) : VoiceInteractionSession(context) {
}
}
override fun onHide() {
super.onHide()
scope.cancel()
speechManager.destroy()
ttsManager.stop()
}
override fun onDestroy() {
super.onDestroy()
ttsManager.shutdown()
}
// Removed duplicates as they are now handled in the main block above
}
/**

View file

@ -16,10 +16,10 @@ import java.util.Locale
*/
class SpeechRecognizerManager(private val context: Context) {
private var speechRecognizer: SpeechRecognizer? = null
private var recognizer: SpeechRecognizer? = null
/**
* 音声認識が利用可能かチェック
* 音声認識を利用可能かチェック
*/
fun isAvailable(): Boolean {
return SpeechRecognizer.isRecognitionAvailable(context)
@ -35,70 +35,84 @@ class SpeechRecognizerManager(private val context: Context) {
return@callbackFlow
}
speechRecognizer = SpeechRecognizer.createSpeechRecognizer(context).apply {
setRecognitionListener(object : RecognitionListener {
override fun onReadyForSpeech(params: Bundle?) {
trySend(SpeechResult.Ready)
}
override fun onBeginningOfSpeech() {
trySend(SpeechResult.Listening)
}
override fun onRmsChanged(rmsdB: Float) {
// 音量レベルの変化(UI表示用)
trySend(SpeechResult.RmsChanged(rmsdB))
}
override fun onBufferReceived(buffer: ByteArray?) {}
override fun onEndOfSpeech() {
trySend(SpeechResult.Processing)
}
override fun onError(error: Int) {
val errorMessage = when (error) {
SpeechRecognizer.ERROR_AUDIO -> "オーディオエラー"
SpeechRecognizer.ERROR_CLIENT -> "クライアントエラー"
SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "権限が不足しています"
SpeechRecognizer.ERROR_NETWORK -> "ネットワークエラー"
SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "ネットワークタイムアウト"
SpeechRecognizer.ERROR_NO_MATCH -> "認識できませんでした"
SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "認識サービスがビジー"
SpeechRecognizer.ERROR_SERVER -> "サーバーエラー"
SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "音声入力がありませんでした"
else -> "不明なエラー ($error)"
}
trySend(SpeechResult.Error(errorMessage))
close()
}
override fun onResults(results: Bundle?) {
val matches = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
val confidence = results?.getFloatArray(SpeechRecognizer.CONFIDENCE_SCORES)
if (!matches.isNullOrEmpty()) {
trySend(SpeechResult.Result(
text = matches[0],
confidence = confidence?.getOrNull(0) ?: 0f,
alternatives = matches.drop(1)
))
} else {
trySend(SpeechResult.Error("認識結果がありません"))
}
close()
}
override fun onPartialResults(partialResults: Bundle?) {
val matches = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
if (!matches.isNullOrEmpty()) {
trySend(SpeechResult.PartialResult(matches[0]))
}
}
override fun onEvent(eventType: Int, params: Bundle?) {}
})
// Clean slate: Ensure any previous instance is destroyed
try {
recognizer?.destroy()
} catch (e: Exception) {
// Ignore
}
recognizer = null
// Use application context to avoid activity/service lifecycle leaks
val appContext = context.applicationContext
val newRecognizer = SpeechRecognizer.createSpeechRecognizer(appContext)
recognizer = newRecognizer
newRecognizer.setRecognitionListener(object : RecognitionListener {
override fun onReadyForSpeech(params: Bundle?) {
trySend(SpeechResult.Ready)
}
override fun onBeginningOfSpeech() {
trySend(SpeechResult.Listening)
}
override fun onRmsChanged(rmsdB: Float) {
trySend(SpeechResult.RmsChanged(rmsdB))
}
override fun onBufferReceived(buffer: ByteArray?) {}
override fun onEndOfSpeech() {
trySend(SpeechResult.Processing)
}
override fun onError(error: Int) {
val errorMessage = when (error) {
SpeechRecognizer.ERROR_AUDIO -> "オーディオエラー"
SpeechRecognizer.ERROR_CLIENT -> "クライアントエラー"
SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "権限が不足しています"
SpeechRecognizer.ERROR_NETWORK -> "ネットワークエラー"
SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "ネットワークタイムアウト"
SpeechRecognizer.ERROR_NO_MATCH -> "認識できませんでした"
SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "認識サービスがビジー - 再試行します"
SpeechRecognizer.ERROR_SERVER -> "サーバーエラー"
SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "音声入力がありませんでした"
else -> "不明なエラー ($error)"
}
trySend(SpeechResult.Error(errorMessage))
// If busy, we don't necessarily close immediately if we want to retry manually in session,
// but for now, let the session handle the error result.
close()
}
override fun onResults(results: Bundle?) {
val matches = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
val confidence = results?.getFloatArray(SpeechRecognizer.CONFIDENCE_SCORES)
if (!matches.isNullOrEmpty()) {
trySend(SpeechResult.Result(
text = matches[0],
confidence = confidence?.getOrNull(0) ?: 0f,
alternatives = matches.drop(1)
))
} else {
trySend(SpeechResult.Error("認識結果がありません"))
}
close()
}
override fun onPartialResults(partialResults: Bundle?) {
val matches = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
if (!matches.isNullOrEmpty()) {
trySend(SpeechResult.PartialResult(matches[0]))
}
}
override fun onEvent(eventType: Int, params: Bundle?) {}
})
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
@ -111,28 +125,49 @@ class SpeechRecognizerManager(private val context: Context) {
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS, 1500L)
}
speechRecognizer?.startListening(intent)
// Run on Main thread
kotlinx.coroutines.Dispatchers.Main.dispatch(kotlin.coroutines.EmptyCoroutineContext, Runnable {
try {
newRecognizer.startListening(intent)
} catch (e: Exception) {
trySend(SpeechResult.Error("開始エラー: ${e.message}"))
close()
}
})
awaitClose {
stopListening()
kotlinx.coroutines.Dispatchers.Main.dispatch(kotlin.coroutines.EmptyCoroutineContext, Runnable {
try {
// Cancel before destroy is safer
newRecognizer.cancel()
newRecognizer.destroy()
} catch (e: Exception) {
// Ignore
}
if (recognizer == newRecognizer) {
recognizer = null
}
})
}
}
/**
* 音声認識を停止
* Stop listening manually
*/
fun stopListening() {
speechRecognizer?.stopListening()
speechRecognizer?.cancel()
speechRecognizer?.destroy()
speechRecognizer = null
fun stopListening() {
// No-op, flow cancellation triggers cleanup
}
/**
* リソースを解放
* Completely destroy the recognizer resources
*/
fun destroy() {
stopListening()
fun destroy() {
try {
recognizer?.destroy()
} catch (e: Exception) {
// Ignore
}
recognizer = null
}
}

View file

@ -30,7 +30,7 @@ class TTSManager(context: Context) {
tts?.setLanguage(Locale.getDefault())
}
// 読み上げ速度調整
tts?.setSpeechRate(1.0f)
tts?.setSpeechRate(1.5f)
tts?.setPitch(1.0f)
// 初期化待ちの発話があれば実行

View file

@ -0,0 +1,155 @@
package com.openclaw.assistant.ui.chat
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.openclaw.assistant.api.OpenClawClient
import com.openclaw.assistant.data.SettingsRepository
import com.openclaw.assistant.speech.SpeechRecognizerManager
import com.openclaw.assistant.speech.SpeechResult
import com.openclaw.assistant.speech.TTSManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.util.UUID
data class ChatMessage(
val id: String = UUID.randomUUID().toString(),
val text: String,
val isUser: Boolean,
val timestamp: Long = System.currentTimeMillis()
)
data class ChatUiState(
val messages: List<ChatMessage> = emptyList(),
val isListening: Boolean = false,
val isThinking: Boolean = false,
val isSpeaking: Boolean = false,
val error: String? = null,
val partialText: String = "" // For real-time speech transcription
)
class ChatViewModel(application: Application) : AndroidViewModel(application) {
private val _uiState = MutableStateFlow(ChatUiState())
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
private val settings = SettingsRepository.getInstance(application)
private val apiClient = OpenClawClient()
private val speechManager = SpeechRecognizerManager(application)
private val ttsManager = TTSManager(application)
fun sendMessage(text: String) {
if (text.isBlank()) return
// Add user message
addMessage(text, isUser = true)
_uiState.update { it.copy(isThinking = true) }
viewModelScope.launch {
try {
val result = apiClient.sendMessage(
webhookUrl = settings.webhookUrl,
message = text,
sessionId = settings.sessionId,
authToken = settings.authToken.takeIf { it.isNotBlank() }
)
result.fold(
onSuccess = { response ->
val responseText = response.getResponseText() ?: "No response"
addMessage(responseText, isUser = false)
_uiState.update { it.copy(isThinking = false) }
speak(responseText)
},
onFailure = { error ->
_uiState.update { it.copy(isThinking = false, error = error.message) }
addMessage("Error: ${error.message}", isUser = false)
}
)
} catch (e: Exception) {
_uiState.update { it.copy(isThinking = false, error = e.message) }
}
}
}
private var lastInputWasVoice = false
private var listeningJob: kotlinx.coroutines.Job? = null
fun startListening() {
if (_uiState.value.isListening) return
lastInputWasVoice = true // Mark as voice input
listeningJob?.cancel() // Cancel existing
// Stop TTS if speaking
ttsManager.stop()
_uiState.update { it.copy(isListening = true, partialText = "") }
listeningJob = viewModelScope.launch {
speechManager.startListening("ja-JP").collect { result ->
when (result) {
is SpeechResult.PartialResult -> {
_uiState.update { it.copy(partialText = result.text) }
}
is SpeechResult.Result -> {
_uiState.update { it.copy(isListening = false, partialText = "") }
sendMessage(result.text)
}
is SpeechResult.Error -> {
_uiState.update { it.copy(isListening = false, error = result.message) }
// Only stop loop on fatal errors? Or restart?
// For now, let's treat error as stop
lastInputWasVoice = false
}
else -> {}
}
}
}
}
fun stopListening() {
lastInputWasVoice = false // User manually stopped
listeningJob?.cancel()
_uiState.update { it.copy(isListening = false) }
}
private fun speak(text: String) {
viewModelScope.launch {
_uiState.update { it.copy(isSpeaking = true) }
val success = ttsManager.speak(text)
_uiState.update { it.copy(isSpeaking = false) }
// If it was a voice conversation, continue listening
if (success && lastInputWasVoice) {
// Wait for TTS to fully release audio focus
kotlinx.coroutines.delay(800)
// Restart listening
startListening()
}
}
}
fun stopSpeaking() {
lastInputWasVoice = false // Stop loop if manually stopped
ttsManager.stop()
_uiState.update { it.copy(isSpeaking = false) }
}
private fun addMessage(text: String, isUser: Boolean) {
val message = ChatMessage(text = text, isUser = isUser)
_uiState.update { state ->
state.copy(messages = state.messages + message)
}
}
override fun onCleared() {
super.onCleared()
speechManager.destroy()
ttsManager.shutdown()
}
}

View file

@ -0,0 +1,13 @@
package com.openclaw.assistant.ui.theme
import androidx.compose.ui.graphics.Color
// Lobster Theme Palette
val OpenClawOrange = Color(0xFFFF5722) // Primary Lobster Color
val OpenClawPopYellow = Color(0xFFFFD600) // Pop Background
val OpenClawDarkGrey = Color(0xFF121212) // Background
val OpenClawSurfaceGrey = Color(0xFF1E1E1E) // Surface
val OpenClawBlue = Color(0xFF283593) // Deprecated: Kept for compat but should be avoided
val OpenClawTextPrimary = Color(0xFFEEEEEE)
val OpenClawTextSecondary = Color(0xFFB0B0B0)
val OpenClawError = Color(0xFFD32F2F)

View file

@ -1,63 +1,41 @@
package com.openclaw.assistant.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val DarkColorScheme = darkColorScheme(
primary = Color(0xFF90CAF9),
secondary = Color(0xFF80DEEA),
tertiary = Color(0xFFA5D6A7),
background = Color(0xFF121212),
surface = Color(0xFF1E1E1E),
onPrimary = Color.Black,
onSecondary = Color.Black,
onTertiary = Color.Black,
onBackground = Color.White,
onSurface = Color.White
)
private val LightColorScheme = lightColorScheme(
primary = Color(0xFF1976D2),
secondary = Color(0xFF00ACC1),
tertiary = Color(0xFF43A047),
background = Color(0xFFFAFAFA),
surface = Color.White,
private val OpenClawColorScheme = darkColorScheme(
primary = OpenClawOrange,
secondary = OpenClawPopYellow,
tertiary = OpenClawOrange,
background = OpenClawDarkGrey,
surface = OpenClawSurfaceGrey,
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F)
onBackground = OpenClawTextPrimary,
onSurface = OpenClawTextPrimary,
error = OpenClawError
)
@Composable
fun OpenClawAssistantTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
// Always use the OpenClaw dark scheme
val colorScheme = OpenClawColorScheme
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.surface.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
window.statusBarColor = colorScheme.background.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = false
}
}

View file

@ -1,14 +1,22 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group android:scaleX="0.5"
android:scaleY="0.5"
android:translateX="27"
android:translateY="27">
<path
android:fillColor="#FFFFFF"
android:pathData="M54,62c7.18,0 12.96,-5.81 12.96,-13L67,27c0,-7.19 -5.81,-13 -13,-13s-13,5.81 -13,13v22c0,7.19 5.81,13 13,13zM76.97,49c0,13 -11.01,22.1 -22.97,22.1S31.03,62 31.03,49H23c0,14.78 11.79,27 26,29.15V95h10V78.15c14.21,-2.09 26,-14.32 26,-29.15h-8.03z"/>
</group>
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Lobster Body (Orange) -->
<path
android:fillColor="#FF5722"
android:pathData="M12,2 C9,2 7,5 7,10 L7,16 C7,19 9,21 12,21 C15,21 17,19 17,16 L17,10 C17,5 15,2 12,2 Z"/>
<!-- Tail Segments (Darker Orange) -->
<path
android:fillColor="#D84315"
android:pathData="M8,16 L16,16 L16,17 L8,17 Z M9,18 L15,18 L15,19 L9,19 Z M10,20 L14,20 L14,21 L10,21 Z"/>
<!-- Claws (Left and Right - Pop Style) -->
<path
android:fillColor="#FF5722"
android:pathData="M6,6 C4,4 2,6 2,8 C2,9.5 3.5,10 5,10 L7,10 L7,9 C7,7.5 6.5,6.5 6,6 Z M18,6 C20,4 22,6 22,8 C22,9.5 20.5,10 19,10 L17,10 L17,9 C17,7.5 17.5,6.5 18,6 Z"/>
</vector>

View file

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#1976D2</color>
<color name="ic_launcher_background">#FFD600</color>
</resources>

View file

@ -12,6 +12,7 @@
<string name="session_id_label">Session ID</string>
<string name="user_id_label">User ID</string>
<string name="save_button">Save</string>
<string name="test_connection_button">Test Connection</string>
<string name="hotword_enabled_label">Enable Hotword Detection</string>
<string name="picovoice_key_label">Picovoice Access Key</string>
<string name="picovoice_key_hint">Get from free account</string>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<voice-interaction-service xmlns:android="http://schemas.android.com/apk/res/android"
android:sessionService="com.openclaw.assistant.service.OpenClawVoiceService"
android:sessionService="com.openclaw.assistant.service.OpenClawSessionService"
android:recognitionService="com.openclaw.assistant.service.OpenClawVoiceService"
android:supportsAssist="true"
android:supportsLaunchVoiceAssistFromKeyguard="true"

2319
crash_log.txt Normal file

File diff suppressed because it is too large Load diff

View file

@ -10,7 +10,7 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url = uri("https://repo.picovoice.ai/android") }
maven { url = uri("https://jitpack.io") }
}
}