mirror of
https://github.com/yuga-hashimoto/openclaw-assistant.git
synced 2026-08-14 08:47:12 +00:00
Adds a self-contained :wear module that registers OpenClaw as a Wear OS assistant. Fixes the root cause from PR #63 where the app did not appear in the Wear OS assistant picker. Root cause fix: - PR #63 placed android.intent.action.ASSIST on the VoiceInteractionService intent-filter (incorrect). Wear OS populates the assistant picker by enumerating Activities, not Services. - This PR adds WearAssistActivity with the ASSIST intent-filter, which makes OpenClaw visible in Settings > Apps > Default Apps > Assist app. Architecture: - :wear is fully self-contained (no :shared library extraction needed) - Zero changes to :app — phone app is unaffected - Avoids the 106-file conflict storm from PR #63's :shared approach Components: - WearAssistActivity: handles ASSIST intent, full voice pipeline (listen → API → TTS), critical for assistant picker visibility - WearMainActivity: settings screen (URL, token, TTS toggle) on watch - WearVoiceInteractionService / WearSessionService / WearSession: VIS path for when OpenClaw is set as default assistant - WearSettingsRepository: encrypted SharedPreferences (5 keys only) - WearApiClient: OkHttp client, OpenAI-compatible chat/completions format - WearRecognitionService: required stub for VoiceInteraction framework - WearSessionForegroundService: keeps process alive during conversation Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
0b1bbf2c64
commit
3f25c03b86
@@ -16,3 +16,4 @@ dependencyResolutionManagement {
|
||||
|
||||
rootProject.name = "OpenClawAssistant"
|
||||
include(":app")
|
||||
include(":wear")
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.openclaw.assistant.wear"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.openclaw.assistant.wear"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.8"
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Wear OS UI
|
||||
implementation("androidx.wear:wear:1.3.0")
|
||||
implementation("androidx.wear.compose:compose-material:1.3.1")
|
||||
implementation("androidx.wear.compose:compose-foundation:1.3.1")
|
||||
|
||||
// Material Icons (Mic, MicOff, Settings, etc.)
|
||||
implementation("androidx.compose.material:material-icons-extended:1.6.7")
|
||||
|
||||
// Core Android
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||
implementation("androidx.activity:activity-compose:1.8.2")
|
||||
|
||||
// Networking
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation("com.google.code.gson:gson:2.10.1")
|
||||
|
||||
// Encrypted SharedPreferences
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
|
||||
// Coroutines
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
-keepattributes *Annotation*
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# OkHttp
|
||||
-dontwarn okhttp3.**
|
||||
-dontwarn okio.**
|
||||
-keep class okhttp3.** { *; }
|
||||
|
||||
# Gson
|
||||
-keepattributes Signature
|
||||
-keep class com.google.gson.** { *; }
|
||||
@@ -0,0 +1,108 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- Mark this as a Wear OS app -->
|
||||
<uses-feature android:name="android.hardware.type.watch" />
|
||||
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- Queried for TTS engine and speech recognizer detection -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.TTS_SERVICE" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.speech.RecognitionService" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:name=".WearApplication"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@android:style/Theme.DeviceDefault">
|
||||
|
||||
<!-- Standalone Wear OS app (no companion phone app required) -->
|
||||
<meta-data
|
||||
android:name="com.google.android.wearable.standalone"
|
||||
android:value="true" />
|
||||
|
||||
<!--
|
||||
CRITICAL FIX for Issue #58:
|
||||
This Activity's ASSIST intent-filter is what makes OpenClaw appear in the
|
||||
Wear OS assistant picker (Settings > Apps > Default Apps > Assist app).
|
||||
PR #63 incorrectly put this on the VoiceInteractionService — Wear OS only
|
||||
enumerates Activities, not Services, when populating the assistant picker.
|
||||
-->
|
||||
<activity
|
||||
android:name=".WearAssistActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@android:style/Theme.DeviceDefault.NoActionBar">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.ASSIST" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Launcher: Settings / Status screen -->
|
||||
<activity
|
||||
android:name=".WearMainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!--
|
||||
VoiceInteractionService entry point.
|
||||
NOTE: android.intent.action.ASSIST is intentionally NOT here.
|
||||
It lives on WearAssistActivity above — that is what the assistant picker reads.
|
||||
-->
|
||||
<service
|
||||
android:name=".service.WearVoiceInteractionService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_VOICE_INTERACTION">
|
||||
<meta-data
|
||||
android:name="android.voice_interaction"
|
||||
android:resource="@xml/voice_interaction_service" />
|
||||
<intent-filter>
|
||||
<action android:name="android.service.voice.VoiceInteractionService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- Session factory (creates WearSession instances) -->
|
||||
<service
|
||||
android:name=".service.WearSessionService"
|
||||
android:permission="android.permission.BIND_VOICE_INTERACTION" />
|
||||
|
||||
<!-- Stub RecognitionService required by the VoiceInteraction framework -->
|
||||
<service
|
||||
android:name=".service.WearRecognitionService"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:permission="android.permission.BIND_RECOGNITION_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.speech.RecognitionService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- Foreground service: keeps process alive and mic warm during conversation -->
|
||||
<service
|
||||
android:name=".service.WearSessionForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="microphone" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.openclaw.assistant.wear
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonObject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.IOException
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Minimal HTTP client for the Wear OS app.
|
||||
* Sends messages in OpenAI-compatible chat/completions format.
|
||||
* Self-contained: does NOT depend on the phone :app module.
|
||||
*/
|
||||
class WearApiClient {
|
||||
|
||||
private val httpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(120, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val gson = Gson()
|
||||
private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
suspend fun sendMessage(
|
||||
chatUrl: String,
|
||||
message: String,
|
||||
sessionId: String = UUID.randomUUID().toString(),
|
||||
authToken: String? = null
|
||||
): Result<String> = withContext(Dispatchers.IO) {
|
||||
if (chatUrl.isBlank()) {
|
||||
return@withContext Result.failure(IllegalArgumentException("URL not configured"))
|
||||
}
|
||||
try {
|
||||
val messages = JsonArray().apply {
|
||||
add(JsonObject().apply {
|
||||
addProperty("role", "user")
|
||||
addProperty("content", message)
|
||||
})
|
||||
}
|
||||
val body = JsonObject().apply {
|
||||
addProperty("model", "openclaw")
|
||||
addProperty("user", sessionId)
|
||||
add("messages", messages)
|
||||
}
|
||||
|
||||
val requestBuilder = Request.Builder()
|
||||
.url(chatUrl)
|
||||
.post(gson.toJson(body).toRequestBody(jsonMediaType))
|
||||
|
||||
if (!authToken.isNullOrBlank()) {
|
||||
requestBuilder.addHeader("Authorization", "Bearer $authToken")
|
||||
}
|
||||
|
||||
httpClient.newCall(requestBuilder.build()).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(
|
||||
IOException("HTTP ${response.code}: ${response.message}")
|
||||
)
|
||||
}
|
||||
val responseBody = response.body?.string()
|
||||
?: return@withContext Result.failure(IOException("Empty response"))
|
||||
|
||||
val text = extractContent(responseBody)
|
||||
if (text.isNullOrBlank()) {
|
||||
Result.failure(IOException("No content in response"))
|
||||
} else {
|
||||
Result.success(text)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractContent(json: String): String? {
|
||||
return try {
|
||||
val obj = gson.fromJson(json, JsonObject::class.java)
|
||||
// Standard OpenAI format: choices[0].message.content
|
||||
obj.getAsJsonArray("choices")
|
||||
?.get(0)?.asJsonObject
|
||||
?.getAsJsonObject("message")
|
||||
?.get("content")?.asString
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.openclaw.assistant.wear
|
||||
|
||||
import android.app.Application
|
||||
|
||||
class WearApplication : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
// Pre-initialize settings repository singleton
|
||||
WearSettingsRepository.getInstance(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
package com.openclaw.assistant.wear
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.speech.RecognitionListener
|
||||
import android.speech.RecognizerIntent
|
||||
import android.speech.SpeechRecognizer
|
||||
import android.speech.tts.TextToSpeech
|
||||
import android.speech.tts.UtteranceProgressListener
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.MicOff
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
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.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
|
||||
import androidx.wear.compose.material.Button
|
||||
import androidx.wear.compose.material.CircularProgressIndicator
|
||||
import androidx.wear.compose.material.Icon
|
||||
import androidx.wear.compose.material.MaterialTheme
|
||||
import androidx.wear.compose.material.Scaffold
|
||||
import androidx.wear.compose.material.Text
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Assistant entry point for Wear OS.
|
||||
*
|
||||
* This Activity's ASSIST intent-filter is the critical fix for Issue #58:
|
||||
* Wear OS enumerates Activities (not Services) when building the assistant
|
||||
* picker list. By declaring this Activity with android.intent.action.ASSIST,
|
||||
* OpenClaw appears in Settings > Apps > Default Apps > Assist app.
|
||||
*
|
||||
* Flow: ASSIST intent → listen → API call → TTS → finish()
|
||||
*/
|
||||
class WearAssistActivity : ComponentActivity() {
|
||||
|
||||
private val settings by lazy { WearSettingsRepository.getInstance(this) }
|
||||
private val apiClient = WearApiClient()
|
||||
|
||||
private var speechRecognizer: SpeechRecognizer? = null
|
||||
private var tts: TextToSpeech? = null
|
||||
private var ttsReady = false
|
||||
|
||||
private var uiState by mutableStateOf(UiState.CHECKING)
|
||||
private var displayText by mutableStateOf("")
|
||||
private var userQuery by mutableStateOf("")
|
||||
private var errorMessage by mutableStateOf<String?>(null)
|
||||
|
||||
private val requestMicPermission = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted ->
|
||||
if (granted) startListening()
|
||||
else showError(getString(R.string.error_speech))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContent {
|
||||
MaterialTheme {
|
||||
Scaffold(modifier = Modifier.fillMaxSize().background(Color.Black)) {
|
||||
ScalingLazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(56.dp)
|
||||
.clip(CircleShape)
|
||||
.background(stateColor(uiState))
|
||||
.clickable {
|
||||
when (uiState) {
|
||||
UiState.ERROR -> startListening()
|
||||
else -> {}
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when (uiState) {
|
||||
UiState.PROCESSING -> CircularProgressIndicator(
|
||||
indicatorColor = Color.White,
|
||||
trackColor = Color.Transparent,
|
||||
strokeWidth = 3.dp
|
||||
)
|
||||
UiState.NOT_CONFIGURED, UiState.ERROR -> Icon(
|
||||
imageVector = Icons.Default.MicOff,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
else -> Icon(
|
||||
imageVector = Icons.Default.Mic,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState == UiState.NOT_CONFIGURED) {
|
||||
item {
|
||||
Text(
|
||||
text = getString(R.string.error_config_required),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.body2,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
item {
|
||||
Button(
|
||||
onClick = {
|
||||
startActivity(Intent(this@WearAssistActivity, WearMainActivity::class.java))
|
||||
finish()
|
||||
},
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Settings,
|
||||
contentDescription = getString(R.string.action_open_settings),
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (userQuery.isNotBlank()) {
|
||||
item {
|
||||
Text(
|
||||
text = userQuery,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.button,
|
||||
color = Color.LightGray
|
||||
)
|
||||
}
|
||||
}
|
||||
if (displayText.isNotBlank() && uiState != UiState.LISTENING) {
|
||||
item {
|
||||
Text(
|
||||
text = displayText,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.body1
|
||||
)
|
||||
}
|
||||
}
|
||||
if (errorMessage != null) {
|
||||
item {
|
||||
Text(
|
||||
text = errorMessage!!,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.caption2,
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
}
|
||||
item { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!settings.isConfigured()) {
|
||||
uiState = UiState.NOT_CONFIGURED
|
||||
return
|
||||
}
|
||||
|
||||
initTts()
|
||||
checkPermissionAndListen()
|
||||
}
|
||||
|
||||
private fun initTts() {
|
||||
tts = TextToSpeech(this) { status ->
|
||||
ttsReady = (status == TextToSpeech.SUCCESS)
|
||||
if (ttsReady && settings.speechLanguage.isNotBlank()) {
|
||||
tts?.language = Locale.forLanguageTag(settings.speechLanguage)
|
||||
}
|
||||
tts?.setSpeechRate(settings.ttsSpeed)
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkPermissionAndListen() {
|
||||
if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) {
|
||||
startListening()
|
||||
} else {
|
||||
requestMicPermission.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startListening() {
|
||||
if (!SpeechRecognizer.isRecognitionAvailable(this)) {
|
||||
showError(getString(R.string.error_speech))
|
||||
return
|
||||
}
|
||||
uiState = UiState.LISTENING
|
||||
errorMessage = null
|
||||
displayText = ""
|
||||
userQuery = ""
|
||||
|
||||
speechRecognizer?.destroy()
|
||||
speechRecognizer = SpeechRecognizer.createSpeechRecognizer(this).apply {
|
||||
setRecognitionListener(object : RecognitionListener {
|
||||
override fun onResults(results: Bundle?) {
|
||||
val text = results
|
||||
?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
|
||||
?.firstOrNull() ?: ""
|
||||
if (text.isBlank()) {
|
||||
showError(getString(R.string.error_no_response))
|
||||
} else {
|
||||
userQuery = text
|
||||
callApi(text)
|
||||
}
|
||||
}
|
||||
override fun onError(error: Int) {
|
||||
Log.w(TAG, "Speech error: $error")
|
||||
showError("${getString(R.string.error_speech)} ($error)")
|
||||
}
|
||||
override fun onReadyForSpeech(params: Bundle?) {}
|
||||
override fun onBeginningOfSpeech() {}
|
||||
override fun onRmsChanged(rmsdB: Float) {}
|
||||
override fun onBufferReceived(buffer: ByteArray?) {}
|
||||
override fun onEndOfSpeech() { uiState = UiState.PROCESSING }
|
||||
override fun onPartialResults(partialResults: Bundle?) {
|
||||
partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
|
||||
?.firstOrNull()?.let { userQuery = it }
|
||||
}
|
||||
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)
|
||||
if (settings.speechLanguage.isNotBlank()) {
|
||||
putExtra(RecognizerIntent.EXTRA_LANGUAGE, settings.speechLanguage)
|
||||
}
|
||||
}
|
||||
startListening(intent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun callApi(text: String) {
|
||||
uiState = UiState.PROCESSING
|
||||
lifecycleScope.launch {
|
||||
val result = apiClient.sendMessage(
|
||||
chatUrl = settings.getChatUrl(),
|
||||
message = text,
|
||||
authToken = settings.authToken.takeIf { it.isNotBlank() }
|
||||
)
|
||||
result.onSuccess { response ->
|
||||
displayText = response
|
||||
speak(response)
|
||||
}.onFailure { e ->
|
||||
Log.e(TAG, "API error", e)
|
||||
showError(e.message ?: getString(R.string.error_network))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun speak(text: String) {
|
||||
if (!settings.ttsEnabled || !ttsReady) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
uiState = UiState.SPEAKING
|
||||
tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
||||
override fun onStart(utteranceId: String?) {}
|
||||
override fun onDone(utteranceId: String?) { runOnUiThread { finish() } }
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onError(utteranceId: String?) { runOnUiThread { finish() } }
|
||||
})
|
||||
tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, "assist_utterance")
|
||||
}
|
||||
|
||||
private fun showError(message: String) {
|
||||
uiState = UiState.ERROR
|
||||
errorMessage = message
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
speechRecognizer?.destroy()
|
||||
tts?.shutdown()
|
||||
}
|
||||
|
||||
private fun stateColor(state: UiState): Color = when (state) {
|
||||
UiState.LISTENING -> Color(0xFF4CAF50)
|
||||
UiState.PROCESSING -> Color(0xFFFFC107)
|
||||
UiState.SPEAKING -> Color(0xFF2196F3)
|
||||
UiState.ERROR, UiState.NOT_CONFIGURED -> Color(0xFFF44336)
|
||||
UiState.CHECKING -> Color(0xFF9E9E9E)
|
||||
}
|
||||
|
||||
private enum class UiState {
|
||||
CHECKING, NOT_CONFIGURED, LISTENING, PROCESSING, SPEAKING, ERROR
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "WearAssistActivity"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package com.openclaw.assistant.wear
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
|
||||
import androidx.wear.compose.material.Button
|
||||
import androidx.wear.compose.material.Chip
|
||||
import androidx.wear.compose.material.ChipDefaults
|
||||
import androidx.wear.compose.material.MaterialTheme
|
||||
import androidx.wear.compose.material.Scaffold
|
||||
import androidx.wear.compose.material.Text
|
||||
|
||||
/**
|
||||
* Settings and status screen for the Wear OS app.
|
||||
* Allows the user to configure the server URL and auth token directly on the watch.
|
||||
*/
|
||||
class WearMainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val settings = WearSettingsRepository.getInstance(this)
|
||||
|
||||
setContent {
|
||||
var urlText by remember { mutableStateOf(settings.httpUrl) }
|
||||
var tokenText by remember { mutableStateOf(settings.authToken) }
|
||||
var ttsEnabled by remember { mutableStateOf(settings.ttsEnabled) }
|
||||
|
||||
val inputTextStyle = TextStyle(color = Color.White, fontSize = 12.sp)
|
||||
val inputBoxModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.border(1.dp, Color.Gray, RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp)
|
||||
|
||||
MaterialTheme {
|
||||
Scaffold(modifier = Modifier.fillMaxSize().background(Color.Black)) {
|
||||
ScalingLazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = getString(R.string.settings_title),
|
||||
style = MaterialTheme.typography.title3,
|
||||
textAlign = TextAlign.Center,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
// Status chip
|
||||
item {
|
||||
Chip(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = {
|
||||
Text(
|
||||
if (settings.isConfigured())
|
||||
getString(R.string.status_configured)
|
||||
else
|
||||
getString(R.string.status_not_configured),
|
||||
style = MaterialTheme.typography.caption1
|
||||
)
|
||||
},
|
||||
onClick = {},
|
||||
colors = ChipDefaults.chipColors(
|
||||
backgroundColor = if (settings.isConfigured())
|
||||
Color(0xFF1B5E20) else Color(0xFF4E342E)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Server URL label
|
||||
item {
|
||||
Text(
|
||||
text = getString(R.string.server_url_label),
|
||||
style = MaterialTheme.typography.caption2,
|
||||
color = Color.Gray,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
item {
|
||||
Box(modifier = inputBoxModifier) {
|
||||
BasicTextField(
|
||||
value = urlText,
|
||||
onValueChange = { urlText = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textStyle = inputTextStyle,
|
||||
cursorBrush = SolidColor(Color.White),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Uri,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Auth Token label
|
||||
item {
|
||||
Text(
|
||||
text = getString(R.string.auth_token_label),
|
||||
style = MaterialTheme.typography.caption2,
|
||||
color = Color.Gray,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
item {
|
||||
Box(modifier = inputBoxModifier) {
|
||||
BasicTextField(
|
||||
value = tokenText,
|
||||
onValueChange = { tokenText = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textStyle = inputTextStyle,
|
||||
cursorBrush = SolidColor(Color.White),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TTS toggle (Chip that toggles on click)
|
||||
item {
|
||||
Chip(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = { ttsEnabled = !ttsEnabled },
|
||||
label = {
|
||||
Text(
|
||||
"${getString(R.string.tts_enabled_label)}: ${if (ttsEnabled) "ON" else "OFF"}",
|
||||
style = MaterialTheme.typography.caption1
|
||||
)
|
||||
},
|
||||
colors = ChipDefaults.chipColors(
|
||||
backgroundColor = if (ttsEnabled) Color(0xFF1B5E20) else Color(0xFF4E342E)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Save button
|
||||
item {
|
||||
Button(
|
||||
onClick = {
|
||||
settings.httpUrl = urlText.trim()
|
||||
settings.authToken = tokenText.trim()
|
||||
settings.ttsEnabled = ttsEnabled
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp)
|
||||
) {
|
||||
Text(getString(R.string.save_button))
|
||||
}
|
||||
}
|
||||
|
||||
// Open system assistant settings
|
||||
item {
|
||||
Button(
|
||||
onClick = {
|
||||
startActivity(Intent(Settings.ACTION_VOICE_INPUT_SETTINGS))
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
getString(R.string.open_assistant_settings),
|
||||
style = MaterialTheme.typography.caption1,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.openclaw.assistant.wear
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
|
||||
/**
|
||||
* Secure settings storage for the Wear OS app.
|
||||
* Self-contained: does NOT depend on the phone :app module.
|
||||
*/
|
||||
class WearSettingsRepository private constructor(context: Context) {
|
||||
|
||||
private val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
private val prefs: SharedPreferences = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
PREFS_NAME,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
|
||||
var httpUrl: String
|
||||
get() = prefs.getString(KEY_HTTP_URL, "") ?: ""
|
||||
set(value) = prefs.edit().putString(KEY_HTTP_URL, value).apply()
|
||||
|
||||
var authToken: String
|
||||
get() = prefs.getString(KEY_AUTH_TOKEN, "") ?: ""
|
||||
set(value) = prefs.edit().putString(KEY_AUTH_TOKEN, value).apply()
|
||||
|
||||
var ttsEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_TTS_ENABLED, true)
|
||||
set(value) = prefs.edit().putBoolean(KEY_TTS_ENABLED, value).apply()
|
||||
|
||||
var speechLanguage: String
|
||||
get() = prefs.getString(KEY_SPEECH_LANGUAGE, "") ?: ""
|
||||
set(value) = prefs.edit().putString(KEY_SPEECH_LANGUAGE, value).apply()
|
||||
|
||||
var ttsSpeed: Float
|
||||
get() = prefs.getFloat(KEY_TTS_SPEED, 1.1f)
|
||||
set(value) = prefs.edit().putFloat(KEY_TTS_SPEED, value).apply()
|
||||
|
||||
fun isConfigured(): Boolean = httpUrl.isNotBlank()
|
||||
|
||||
fun getChatUrl(): String {
|
||||
val base = httpUrl.trim().trimEnd('/')
|
||||
return if (base.isBlank()) "" else "$base/v1/chat/completions"
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREFS_NAME = "wear_openclaw_prefs"
|
||||
private const val KEY_HTTP_URL = "http_url"
|
||||
private const val KEY_AUTH_TOKEN = "auth_token"
|
||||
private const val KEY_TTS_ENABLED = "tts_enabled"
|
||||
private const val KEY_SPEECH_LANGUAGE = "speech_language"
|
||||
private const val KEY_TTS_SPEED = "tts_speed"
|
||||
|
||||
@Volatile
|
||||
private var instance: WearSettingsRepository? = null
|
||||
|
||||
fun getInstance(context: Context): WearSettingsRepository =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: WearSettingsRepository(context.applicationContext).also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.openclaw.assistant.wear.service
|
||||
|
||||
import android.content.Intent
|
||||
import android.speech.RecognitionService
|
||||
|
||||
/**
|
||||
* Stub RecognitionService required by the VoiceInteractionService framework.
|
||||
* Actual speech recognition is handled in WearSession/WearAssistActivity
|
||||
* via SpeechRecognizer directly.
|
||||
*/
|
||||
class WearRecognitionService : RecognitionService() {
|
||||
override fun onStartListening(intent: Intent?, listener: Callback?) {}
|
||||
override fun onCancel(listener: Callback?) {}
|
||||
override fun onStopListening(listener: Callback?) {}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package com.openclaw.assistant.wear.service
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.service.voice.VoiceInteractionSession
|
||||
import android.speech.RecognitionListener
|
||||
import android.speech.RecognizerIntent
|
||||
import android.speech.SpeechRecognizer
|
||||
import android.speech.tts.TextToSpeech
|
||||
import android.speech.tts.UtteranceProgressListener
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
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.ComposeView
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LifecycleRegistry
|
||||
import androidx.lifecycle.setViewTreeLifecycleOwner
|
||||
import androidx.savedstate.SavedStateRegistry
|
||||
import androidx.savedstate.SavedStateRegistryController
|
||||
import androidx.savedstate.SavedStateRegistryOwner
|
||||
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
|
||||
import androidx.wear.compose.material.CircularProgressIndicator
|
||||
import androidx.wear.compose.material.Icon
|
||||
import androidx.wear.compose.material.MaterialTheme
|
||||
import androidx.wear.compose.material.Scaffold
|
||||
import androidx.wear.compose.material.Text
|
||||
import com.openclaw.assistant.wear.WearApiClient
|
||||
import com.openclaw.assistant.wear.WearSettingsRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Locale
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.MicOff
|
||||
|
||||
private enum class SessionState {
|
||||
LISTENING, PROCESSING, SPEAKING, ERROR, DONE
|
||||
}
|
||||
|
||||
/**
|
||||
* VoiceInteractionSession for Wear OS.
|
||||
* Handles the full voice pipeline: listen → API call → TTS.
|
||||
* Triggered via VoiceInteractionService (long-press hardware button when set as default).
|
||||
*/
|
||||
class WearSession(context: Context) : VoiceInteractionSession(context),
|
||||
LifecycleOwner, SavedStateRegistryOwner {
|
||||
|
||||
private val lifecycleRegistry = LifecycleRegistry(this)
|
||||
private val savedStateRegistryController = SavedStateRegistryController.create(this)
|
||||
override val lifecycle: Lifecycle get() = lifecycleRegistry
|
||||
override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
private val settings by lazy { WearSettingsRepository.getInstance(context) }
|
||||
private val apiClient = WearApiClient()
|
||||
|
||||
private var speechRecognizer: SpeechRecognizer? = null
|
||||
private var tts: TextToSpeech? = null
|
||||
private var ttsReady = false
|
||||
|
||||
private var state by mutableStateOf(SessionState.LISTENING)
|
||||
private var displayText by mutableStateOf("")
|
||||
private var userQuery by mutableStateOf("")
|
||||
private var errorMessage by mutableStateOf<String?>(null)
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
savedStateRegistryController.performRestore(null)
|
||||
lifecycleRegistry.currentState = Lifecycle.State.CREATED
|
||||
initTts()
|
||||
}
|
||||
|
||||
override fun onCreateContentView(): View {
|
||||
val composeView = ComposeView(context).apply {
|
||||
setViewTreeLifecycleOwner(this@WearSession)
|
||||
setViewTreeSavedStateRegistryOwner(this@WearSession)
|
||||
setContent {
|
||||
MaterialTheme {
|
||||
Scaffold(modifier = Modifier.fillMaxSize().background(Color.Black)) {
|
||||
ScalingLazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(52.dp)
|
||||
.clip(CircleShape)
|
||||
.background(stateColor(state))
|
||||
.clickable { if (state == SessionState.ERROR) startListening() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when (state) {
|
||||
SessionState.PROCESSING -> CircularProgressIndicator(
|
||||
indicatorColor = Color.White,
|
||||
trackColor = Color.Transparent,
|
||||
strokeWidth = 3.dp
|
||||
)
|
||||
SessionState.ERROR -> Icon(
|
||||
imageVector = Icons.Default.MicOff,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
else -> Icon(
|
||||
imageVector = Icons.Default.Mic,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (userQuery.isNotBlank()) {
|
||||
item {
|
||||
Text(
|
||||
text = userQuery,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.button,
|
||||
color = Color.LightGray
|
||||
)
|
||||
}
|
||||
}
|
||||
if (displayText.isNotBlank()) {
|
||||
item {
|
||||
Text(
|
||||
text = displayText,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.body1
|
||||
)
|
||||
}
|
||||
}
|
||||
if (errorMessage != null) {
|
||||
item {
|
||||
Text(
|
||||
text = errorMessage!!,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.caption2,
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
}
|
||||
item { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return composeView
|
||||
}
|
||||
|
||||
override fun onShow(args: Bundle?, showFlags: Int) {
|
||||
super.onShow(args, showFlags)
|
||||
lifecycleRegistry.currentState = Lifecycle.State.RESUMED
|
||||
startListening()
|
||||
}
|
||||
|
||||
override fun onHide() {
|
||||
super.onHide()
|
||||
lifecycleRegistry.currentState = Lifecycle.State.CREATED
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
|
||||
scope.cancel()
|
||||
speechRecognizer?.destroy()
|
||||
tts?.shutdown()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun initTts() {
|
||||
tts = TextToSpeech(context) { status ->
|
||||
ttsReady = (status == TextToSpeech.SUCCESS)
|
||||
if (ttsReady && settings.speechLanguage.isNotBlank()) {
|
||||
tts?.language = Locale.forLanguageTag(settings.speechLanguage)
|
||||
}
|
||||
tts?.setSpeechRate(settings.ttsSpeed)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startListening() {
|
||||
if (!SpeechRecognizer.isRecognitionAvailable(context)) {
|
||||
setError("Speech recognition unavailable")
|
||||
return
|
||||
}
|
||||
state = SessionState.LISTENING
|
||||
errorMessage = null
|
||||
displayText = ""
|
||||
userQuery = ""
|
||||
|
||||
speechRecognizer?.destroy()
|
||||
speechRecognizer = SpeechRecognizer.createSpeechRecognizer(context).apply {
|
||||
setRecognitionListener(object : RecognitionListener {
|
||||
override fun onResults(results: Bundle?) {
|
||||
val text = results
|
||||
?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
|
||||
?.firstOrNull() ?: ""
|
||||
if (text.isBlank()) {
|
||||
setError("No speech detected")
|
||||
} else {
|
||||
userQuery = text
|
||||
callApi(text)
|
||||
}
|
||||
}
|
||||
override fun onError(error: Int) {
|
||||
setError("Speech error ($error)")
|
||||
}
|
||||
override fun onReadyForSpeech(params: Bundle?) {}
|
||||
override fun onBeginningOfSpeech() {}
|
||||
override fun onRmsChanged(rmsdB: Float) {}
|
||||
override fun onBufferReceived(buffer: ByteArray?) {}
|
||||
override fun onEndOfSpeech() { state = SessionState.PROCESSING }
|
||||
override fun onPartialResults(partialResults: Bundle?) {
|
||||
partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
|
||||
?.firstOrNull()?.let { userQuery = it }
|
||||
}
|
||||
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)
|
||||
if (settings.speechLanguage.isNotBlank()) {
|
||||
putExtra(RecognizerIntent.EXTRA_LANGUAGE, settings.speechLanguage)
|
||||
}
|
||||
}
|
||||
startListening(intent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun callApi(text: String) {
|
||||
state = SessionState.PROCESSING
|
||||
scope.launch {
|
||||
val result = apiClient.sendMessage(
|
||||
chatUrl = settings.getChatUrl(),
|
||||
message = text,
|
||||
authToken = settings.authToken.takeIf { it.isNotBlank() }
|
||||
)
|
||||
result.onSuccess { response ->
|
||||
displayText = response
|
||||
speak(response)
|
||||
}.onFailure { e ->
|
||||
Log.e(TAG, "API error", e)
|
||||
setError(e.message ?: "Network error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun speak(text: String) {
|
||||
if (!settings.ttsEnabled || !ttsReady) {
|
||||
finishSession()
|
||||
return
|
||||
}
|
||||
state = SessionState.SPEAKING
|
||||
tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
||||
override fun onStart(utteranceId: String?) {}
|
||||
override fun onDone(utteranceId: String?) { finishSession() }
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onError(utteranceId: String?) { finishSession() }
|
||||
})
|
||||
tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, "wear_session")
|
||||
}
|
||||
|
||||
private fun setError(message: String) {
|
||||
state = SessionState.ERROR
|
||||
errorMessage = message
|
||||
}
|
||||
|
||||
private fun finishSession() {
|
||||
state = SessionState.DONE
|
||||
hide()
|
||||
}
|
||||
|
||||
private fun stateColor(s: SessionState): Color = when (s) {
|
||||
SessionState.LISTENING -> Color(0xFF4CAF50)
|
||||
SessionState.PROCESSING -> Color(0xFFFFC107)
|
||||
SessionState.SPEAKING -> Color(0xFF2196F3)
|
||||
SessionState.ERROR -> Color(0xFFF44336)
|
||||
SessionState.DONE -> Color(0xFF9E9E9E)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "WearSession"
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package com.openclaw.assistant.wear.service
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.openclaw.assistant.wear.R
|
||||
|
||||
/**
|
||||
* Foreground service that keeps the process alive and holds a partial wake lock
|
||||
* during a voice session. Prevents the OS from freezing the process mid-conversation.
|
||||
*/
|
||||
class WearSessionForegroundService : Service() {
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
createNotificationChannel()
|
||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(getString(R.string.notification_session_title))
|
||||
.setContentText(getString(R.string.notification_session_content))
|
||||
.setSmallIcon(android.R.drawable.ic_btn_speak_now)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.build()
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
getString(R.string.notification_session_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "wear_session"
|
||||
private const val NOTIFICATION_ID = 1001
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.openclaw.assistant.wear.service
|
||||
|
||||
import android.os.Bundle
|
||||
import android.service.voice.VoiceInteractionSession
|
||||
import android.service.voice.VoiceInteractionSessionService
|
||||
|
||||
/**
|
||||
* Session factory — creates WearSession instances for each voice interaction
|
||||
* triggered via the VoiceInteractionService path.
|
||||
*/
|
||||
class WearSessionService : VoiceInteractionSessionService() {
|
||||
|
||||
override fun onNewSession(args: Bundle?): VoiceInteractionSession {
|
||||
return WearSession(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.openclaw.assistant.wear.service
|
||||
|
||||
import android.os.Bundle
|
||||
import android.service.voice.VoiceInteractionService
|
||||
import android.service.voice.VoiceInteractionSession
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Wear OS Voice Interaction Service entry point.
|
||||
* Activated when OpenClaw is selected as the default assistant and the
|
||||
* user long-presses the hardware button.
|
||||
*
|
||||
* NOTE: The android.intent.action.ASSIST intent is on WearAssistActivity,
|
||||
* NOT on this service — that is what makes OpenClaw visible in the Wear OS
|
||||
* assistant picker (Settings > Apps > Default Apps > Assist app).
|
||||
*/
|
||||
class WearVoiceInteractionService : VoiceInteractionService() {
|
||||
|
||||
private var isReady = false
|
||||
private var pendingSession = false
|
||||
|
||||
override fun onReady() {
|
||||
super.onReady()
|
||||
isReady = true
|
||||
Log.d(TAG, "onReady")
|
||||
if (pendingSession) {
|
||||
pendingSession = false
|
||||
launchSession()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onShutdown() {
|
||||
super.onShutdown()
|
||||
isReady = false
|
||||
pendingSession = false
|
||||
Log.d(TAG, "onShutdown")
|
||||
}
|
||||
|
||||
private fun launchSession() {
|
||||
try {
|
||||
showSession(Bundle(), VoiceInteractionSession.SHOW_WITH_ASSIST)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "showSession failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "WearVIS"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Claw icon on dark background -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M54,20 C54,20 38,34 38,50 C38,58 42,64 46,68 L42,78 C42,78 50,74 54,74 C58,74 66,78 66,78 L62,68 C66,64 70,58 70,50 C70,34 54,20 54,20 Z" />
|
||||
<path
|
||||
android:fillColor="#CCCCCC"
|
||||
android:pathData="M46,68 L42,78 L54,72 L66,78 L62,68 C58,72 50,72 46,68 Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#1A1A1A</color>
|
||||
<color name="color_listening">#4CAF50</color>
|
||||
<color name="color_thinking">#FFC107</color>
|
||||
<color name="color_speaking">#2196F3</color>
|
||||
<color name="color_error">#F44336</color>
|
||||
<color name="color_idle">#9E9E9E</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">OpenClaw</string>
|
||||
|
||||
<!-- Settings screen -->
|
||||
<string name="settings_title">OpenClaw Settings</string>
|
||||
<string name="server_url_label">Server URL</string>
|
||||
<string name="auth_token_label">Auth Token (optional)</string>
|
||||
<string name="save_button">Save</string>
|
||||
<string name="status_not_configured">Not configured. Tap to set up.</string>
|
||||
<string name="status_configured">Ready</string>
|
||||
<string name="open_assistant_settings">Open Assist Settings</string>
|
||||
<string name="tts_enabled_label">Voice Response</string>
|
||||
|
||||
<!-- Session foreground service notification -->
|
||||
<string name="notification_session_channel_name">Voice Session</string>
|
||||
<string name="notification_session_title">Conversation active</string>
|
||||
<string name="notification_session_content">OpenClaw is listening</string>
|
||||
|
||||
<!-- Assistant states -->
|
||||
<string name="state_listening">Listening\u2026</string>
|
||||
<string name="state_processing">Processing\u2026</string>
|
||||
<string name="state_thinking">Thinking\u2026</string>
|
||||
<string name="state_speaking">Speaking\u2026</string>
|
||||
<string name="state_error">Error</string>
|
||||
<string name="state_ready">Ready</string>
|
||||
|
||||
<!-- Errors -->
|
||||
<string name="error_config_required">Configure server URL first</string>
|
||||
<string name="error_no_response">No response</string>
|
||||
<string name="error_network">Network error</string>
|
||||
<string name="error_speech">Speech error</string>
|
||||
<string name="action_retry">Retry</string>
|
||||
<string name="action_close">Close</string>
|
||||
<string name="action_open_settings">Open Settings</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
<certificates src="user" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<voice-interaction-service xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:sessionService="com.openclaw.assistant.wear.service.WearSessionService"
|
||||
android:recognitionService="com.openclaw.assistant.wear.service.WearRecognitionService"
|
||||
android:supportsAssist="true"
|
||||
android:supportsLaunchVoiceAssistFromKeyguard="true"
|
||||
android:supportsLocalInteraction="true" />
|
||||
Reference in New Issue
Block a user