mirror of
https://github.com/yuga-hashimoto/openclaw-assistant.git
synced 2026-10-19 10:04:04 +00:00
Fix hotword detection, Assistant UI launch, and audio/mic conflicts
This commit is contained in:
parent
4b692e9848
commit
3d6c8e9c3f
35 changed files with 3642 additions and 412 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
9
app/src/main/assets/model/README
Normal file
9
app/src/main/assets/model/README
Normal 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)
|
||||
|
||||
|
||||
BIN
app/src/main/assets/model/am/final.mdl
Normal file
BIN
app/src/main/assets/model/am/final.mdl
Normal file
Binary file not shown.
7
app/src/main/assets/model/conf/mfcc.conf
Normal file
7
app/src/main/assets/model/conf/mfcc.conf
Normal 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
|
||||
10
app/src/main/assets/model/conf/model.conf
Normal file
10
app/src/main/assets/model/conf/model.conf
Normal 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
|
||||
BIN
app/src/main/assets/model/graph/Gr.fst
Normal file
BIN
app/src/main/assets/model/graph/Gr.fst
Normal file
Binary file not shown.
BIN
app/src/main/assets/model/graph/HCLr.fst
Normal file
BIN
app/src/main/assets/model/graph/HCLr.fst
Normal file
Binary file not shown.
17
app/src/main/assets/model/graph/disambig_tid.int
Normal file
17
app/src/main/assets/model/graph/disambig_tid.int
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
10015
|
||||
10016
|
||||
10017
|
||||
10018
|
||||
10019
|
||||
10020
|
||||
10021
|
||||
10022
|
||||
10023
|
||||
10024
|
||||
10025
|
||||
10026
|
||||
10027
|
||||
10028
|
||||
10029
|
||||
10030
|
||||
10031
|
||||
166
app/src/main/assets/model/graph/phones/word_boundary.int
Normal file
166
app/src/main/assets/model/graph/phones/word_boundary.int
Normal 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
|
||||
BIN
app/src/main/assets/model/ivector/final.dubm
Normal file
BIN
app/src/main/assets/model/ivector/final.dubm
Normal file
Binary file not shown.
BIN
app/src/main/assets/model/ivector/final.ie
Normal file
BIN
app/src/main/assets/model/ivector/final.ie
Normal file
Binary file not shown.
BIN
app/src/main/assets/model/ivector/final.mat
Normal file
BIN
app/src/main/assets/model/ivector/final.mat
Normal file
Binary file not shown.
3
app/src/main/assets/model/ivector/global_cmvn.stats
Normal file
3
app/src/main/assets/model/ivector/global_cmvn.stats
Normal 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 ]
|
||||
1
app/src/main/assets/model/ivector/online_cmvn.conf
Normal file
1
app/src/main/assets/model/ivector/online_cmvn.conf
Normal file
|
|
@ -0,0 +1 @@
|
|||
# configuration file for apply-cmvn-online, used in the script ../local/run_online_decoding.sh
|
||||
2
app/src/main/assets/model/ivector/splice.conf
Normal file
2
app/src/main/assets/model/ivector/splice.conf
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
--left-context=3
|
||||
--right-context=3
|
||||
Binary file not shown.
337
app/src/main/java/com/openclaw/assistant/ChatActivity.kt
Normal file
337
app/src/main/java/com/openclaw/assistant/ChatActivity.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ class TTSManager(context: Context) {
|
|||
tts?.setLanguage(Locale.getDefault())
|
||||
}
|
||||
// 読み上げ速度調整
|
||||
tts?.setSpeechRate(1.0f)
|
||||
tts?.setSpeechRate(1.5f)
|
||||
tts?.setPitch(1.0f)
|
||||
|
||||
// 初期化待ちの発話があれば実行
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
13
app/src/main/java/com/openclaw/assistant/ui/theme/Color.kt
Normal file
13
app/src/main/java/com/openclaw/assistant/ui/theme/Color.kt
Normal 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)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
2319
crash_log.txt
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -10,7 +10,7 @@ dependencyResolutionManagement {
|
|||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url = uri("https://repo.picovoice.ai/android") }
|
||||
maven { url = uri("https://jitpack.io") }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue