feat: add Wear OS assistant support with ASSIST Activity fix (closes #58) (#190)

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:
Yu-ga
2026-03-07 07:35:37 -08:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent 0b1bbf2c64
commit 3f25c03b86
21 changed files with 1414 additions and 0 deletions
+1
View File
@@ -16,3 +16,4 @@ dependencyResolutionManagement {
rootProject.name = "OpenClawAssistant"
include(":app")
include(":wear")
+71
View File
@@ -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")
}
+11
View File
@@ -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.** { *; }
+108
View File
@@ -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"
}
}
@@ -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>
+9
View File
@@ -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>
+36
View File
@@ -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" />