Import official OpenClaw Android app (apps/android @ 71a59512ba476df3328cf485d84748121cf341f2)

Pristine fork source for PROJ-0088. v1 will turn this into a WebView web
shell; the WebSocket node infrastructure stays intact for v2 heartbeat.
This commit is contained in:
Brendan Greenlee 2026-08-01 15:13:34 +00:00
commit 7a380c40ed
656 changed files with 209982 additions and 0 deletions

15
wear/BEHAVIOR.md Normal file
View file

@ -0,0 +1,15 @@
# Wear OS behavior contract
The watch is a paired-phone companion. It never asks for, receives, or stores Gateway credentials, TLS pins, or device-signing identity.
- Given a reachable paired phone and connected Gateway, opening the watch app lists recent non-global sessions. Selecting one shows the latest bounded text transcript.
- Given multiple reachable phones, RPC responses and events are accepted only from the currently preferred phone. A preferred-phone change reloads canonical state before replaying live events.
- Given an unavailable phone or offline Gateway, the app shows one clear recovery state and a refresh action. It does not fall back to direct Gateway access.
- Given a paired phone that advertises agent and Gateway controls, the watch can select the phone's active agent and connect or disconnect its Gateway. Older phones omit those controls and continue serving the original companion surface.
- Given a selected session, text input or Wear speech recognition sends one idempotent, non-delivering chat request through the phone. An ambiguous retry reuses its request identity. An active run can be aborted.
- Given a selected session and microphone permission, Real-Time Talk streams bounded PCM audio over one temporary bidirectional Data Layer channel to that session on the selected phone. One watch owns the relay at a time. Stopping Talk, changing phones, losing the Gateway, or losing the channel closes capture and playback without exposing Gateway credentials to the watch.
- Given ordered chat events, the transcript shows the phone's bounded canonical stream projection. Given a missing sequence or changed phone-process epoch, the app discards uncertain stream state and reloads canonical history. Events racing that snapshot replay only when they share its epoch and are newer than its response watermark, and stream text reconciles without duplication or a reload loop. A legacy phone without response watermarks lets the next event establish its live baseline.
- Given a final assistant message while the app is not visible and notifications are allowed, the watch shows one local-only notification with direct reply. Phone-process recreation rediscovers the reachable watch before delivery. If the preferred phone changes before a notification reply, recovery opens the app to reload the session instead of retrying the stale phone.
- Given Android 13 or newer without notification permission, Controls offers both an explicit request action and direct access to the watch's app-notification settings. Granting or revoking permission outside the app is reflected when the app resumes, and denying it leaves the rest of the companion usable.
- Given a theme or automatic-speech selection, the watch persists that local UI preference without sending it to the phone or Gateway.
- Given the OpenClaw Tile, tapping its mascot Talk action opens the watch app directly on Voice, while the secondary Open edge action starts on Chat. The tile uses the official Android app launcher artwork from the phone manifest's `@mipmap/ic_launcher_foreground` resource with the Core app's dark visual tokens. Neither launch starts microphone capture. Tile rendering performs no phone or network work and persists no cache.

126
wear/build.gradle.kts Normal file
View file

@ -0,0 +1,126 @@
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.ktlint)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}
val openClawAndroidVersionFile = rootProject.file("Config/Version.properties")
val openClawAndroidVersionProperties =
Properties().apply {
if (!openClawAndroidVersionFile.isFile) {
error("Missing Android version properties. Run `pnpm android:version:sync`.")
}
openClawAndroidVersionFile.inputStream().use(::load)
}
fun requireOpenClawAndroidVersionProperty(name: String): String =
openClawAndroidVersionProperties.getProperty(name)?.trim()?.takeIf { it.isNotEmpty() }
?: error("Missing $name in Config/Version.properties. Run `pnpm android:version:sync`.")
val openClawAndroidPhoneVersionCode = requireOpenClawAndroidVersionProperty("OPENCLAW_ANDROID_VERSION_CODE").toInt()
val openClawAndroidBuildNumber = openClawAndroidPhoneVersionCode % 100
check(openClawAndroidBuildNumber in 1..49) {
"Android build number must be 01 through 49; Wear reserves 51 through 99."
}
val openClawAndroidWearVersionCode = openClawAndroidPhoneVersionCode + 50
check(openClawAndroidWearVersionCode <= 2_100_000_000) { "Wear versionCode exceeds the Android platform maximum." }
// Data Layer delivery requires the phone and watch packages to share one certificate.
evaluationDependsOn(":app")
val phoneReleaseSigning =
project(":app")
.extensions
.getByType<com.android.build.api.dsl.ApplicationExtension>()
.signingConfigs
.findByName("release")
android {
namespace = "ai.openclaw.wear"
compileSdk = 37
defaultConfig {
// Data Layer traffic is scoped to matching package names and signatures.
applicationId = "ai.openclaw.app"
minSdk = 31
targetSdk = 36
versionCode = openClawAndroidWearVersionCode
versionName = requireOpenClawAndroidVersionProperty("OPENCLAW_ANDROID_VERSION_NAME")
}
buildTypes {
release {
if (phoneReleaseSigning != null) {
signingConfig = phoneReleaseSigning
}
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
buildFeatures {
compose = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
lint {
lintConfig = rootProject.file("app/lint.xml")
warningsAsErrors = true
}
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
allWarningsAsErrors.set(true)
}
}
ktlint {
android.set(true)
ignoreFailures.set(false)
filter {
exclude("**/build/**")
}
}
dependencies {
val composeBom = platform(libs.androidx.compose.bom)
implementation(composeBom)
implementation(project(":wear-shared"))
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.wear.compose.foundation)
implementation(libs.androidx.wear.compose.material3)
implementation(libs.androidx.wear.input)
implementation(libs.androidx.wear.tiles)
implementation(libs.androidx.wear.protolayout)
implementation(libs.androidx.wear.protolayout.material)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization.json)
implementation(libs.play.services.wearable)
debugImplementation(libs.androidx.compose.ui.tooling)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.robolectric)
}

2
wear/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,2 @@
-keepattributes *Annotation*
-keepclassmembers class ai.openclaw.wear.** { *; }

View file

@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-feature
android:name="android.hardware.type.watch"
android:required="true" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<queries>
<intent>
<action android:name="android.speech.action.RECOGNIZE_SPEECH" />
</intent>
</queries>
<application
android:name=".WearApplication"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.OpenClawWear">
<meta-data
android:name="com.google.android.wearable.standalone"
android:value="false" />
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=".main">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".WearProxyListenerService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="com.google.android.gms.wearable.MESSAGE_RECEIVED" />
<data
android:scheme="wear"
android:host="*"
android:pathPrefix="/openclaw/wear/v1/" />
</intent-filter>
<intent-filter>
<action android:name="com.google.android.gms.wearable.CAPABILITY_CHANGED" />
<data
android:scheme="wear"
android:host="*"
android:path="/openclaw_phone_proxy_v1" />
</intent-filter>
</service>
<service
android:name=".OpenClawTileService"
android:description="@string/tile_description"
android:exported="true"
android:icon="@mipmap/ic_launcher_foreground"
android:label="@string/tile_label"
android:permission="com.google.android.wearable.permission.BIND_TILE_PROVIDER">
<intent-filter>
<action android:name="androidx.wear.tiles.action.BIND_TILE_PROVIDER" />
</intent-filter>
<meta-data
android:name="androidx.wear.tiles.PREVIEW"
android:resource="@drawable/tile_preview" />
</service>
<receiver
android:name=".WearReplyReceiver"
android:exported="false" />
</application>
</manifest>

View file

@ -0,0 +1,574 @@
package ai.openclaw.wear
import ai.openclaw.wear.shared.WearRealtimeTalkRole
import android.Manifest
import android.app.Activity
import android.app.RemoteInput
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.speech.RecognizerIntent
import android.view.HapticFeedbackConstants
import androidx.activity.ComponentActivity
import androidx.activity.compose.LocalActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.wear.compose.material3.AppScaffold
import androidx.wear.input.RemoteInputIntentHelper
import kotlinx.coroutines.delay
import java.util.Locale
internal const val extraWearLaunchTarget = "openclaw.wear.launchTarget"
internal enum class WearLaunchTarget(
val rawValue: String,
val initialPage: WearHomePage,
) {
Chat("chat", WearHomePage.Chat),
Voice("voice", WearHomePage.Voice),
;
companion object {
fun fromRawValue(raw: String?): WearLaunchTarget = entries.firstOrNull { target -> target.rawValue == raw?.trim()?.lowercase() } ?: Chat
}
}
internal fun parseWearLaunchTarget(intent: Intent?): WearLaunchTarget = WearLaunchTarget.fromRawValue(intent?.getStringExtra(extraWearLaunchTarget))
internal fun consumeWearLaunchTarget(intent: Intent?): WearLaunchTarget =
parseWearLaunchTarget(intent).also {
intent?.removeExtra(extraWearLaunchTarget)
}
internal data class WearNavigationRequest(
val id: Int,
val target: WearLaunchTarget,
)
internal data class WearLaunchState(
val initialTarget: WearLaunchTarget = WearLaunchTarget.Chat,
val navigationRequest: WearNavigationRequest? = null,
val nextRequestId: Int = 0,
) {
fun next(intent: Intent?): WearLaunchState {
val requestId = nextRequestId + 1
return copy(
navigationRequest =
WearNavigationRequest(
id = requestId,
target = consumeWearLaunchTarget(intent),
),
nextRequestId = requestId,
)
}
fun handled(requestId: Int): WearLaunchState = if (navigationRequest?.id == requestId) copy(navigationRequest = null) else this
companion object {
fun initial(intent: Intent?): WearLaunchState = WearLaunchState(initialTarget = consumeWearLaunchTarget(intent))
}
}
@Composable
internal fun WearLaunchContent(
launchState: WearLaunchState,
content: @Composable (WearHomePage, WearNavigationRequest?) -> Unit,
) {
// Warm launches are pager events. Keeping this composition identity stable preserves
// pending-reply, autospeak, and real-time UI state owned below this boundary.
content(launchState.initialTarget.initialPage, launchState.navigationRequest)
}
internal fun shouldRecreateForScreenshotMode(
currentScene: WearScreenshotScene?,
intent: Intent?,
screenshotModeEnabled: Boolean,
): Boolean =
screenshotModeEnabled &&
(currentScene != null || parseWearScreenshotModeIntent(intent) != null)
class MainActivity : ComponentActivity() {
private val viewModel: WearViewModel by viewModels()
private var screenshotScene: WearScreenshotScene? = null
private var launchState by mutableStateOf(WearLaunchState())
private val screenshotModeEnabled: Boolean
get() = applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (screenshotModeEnabled) {
screenshotScene = parseWearScreenshotModeIntent(intent)
}
launchState = WearLaunchState.initial(intent)
setContent {
val scene = screenshotScene
if (scene == null) {
WearLaunchContent(launchState) { initialPage, navigationRequest ->
OpenClawWearApp(
viewModel = viewModel,
settingsStore = remember { WearSettingsStore(applicationContext) },
speaker = remember { WearReplySpeaker(applicationContext) },
initialPage = initialPage,
navigationRequest = navigationRequest,
onNavigationRequestHandled = { requestId ->
launchState = launchState.handled(requestId)
},
)
}
} else {
OpenClawWearScreenshotApp(scene)
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
if (shouldRecreateForScreenshotMode(screenshotScene, intent, screenshotModeEnabled)) {
recreate()
return
}
launchState = launchState.next(intent)
}
override fun onStart() {
super.onStart()
if (screenshotScene == null) {
(application as WearApplication).onActivityStarted()
}
}
override fun onStop() {
if (screenshotScene == null) {
(application as WearApplication).onActivityStopped()
}
super.onStop()
}
}
@Composable
internal fun OpenClawWearApp(
viewModel: WearViewModel,
settingsStore: WearSettingsStore,
speaker: WearReplySpeaker,
initialPage: WearHomePage = WearHomePage.Chat,
navigationRequest: WearNavigationRequest? = null,
onNavigationRequestHandled: (Int) -> Unit = {},
) {
val state by viewModel.state.collectAsState()
val snapshot = state.toConversationSnapshot()
val speaking by speaker.isSpeaking.collectAsState()
val view = LocalView.current
val lifecycleOwner = LocalLifecycleOwner.current
val activity = LocalActivity.current
val initialSettings = remember(settingsStore) { settingsStore.read() }
var interaction by remember { mutableStateOf(WearInteractionState.READY) }
var themeMode by remember { mutableStateOf(initialSettings.themeMode) }
var autoSpeak by remember { mutableStateOf(initialSettings.autoSpeak) }
var notificationsGranted by remember {
mutableStateOf(
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(view.context, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED,
)
}
var expectedAssistantKey by remember { mutableStateOf<String?>(null) }
var awaitingReplySessionId by remember { mutableStateOf<String?>(null) }
var awaitingReply by remember { mutableStateOf(false) }
var previousRealtimeSnapshot by remember { mutableStateOf(snapshot) }
var realtimeThinkingTurnId by remember { mutableStateOf<String?>(null) }
val speakPrompt = stringResource(R.string.speak_to_agent)
val messageLabel = stringResource(R.string.message)
val messageTitle = stringResource(R.string.message_agent)
val sendLabel = stringResource(R.string.send)
fun submitMessage(rawMessage: String) {
val message = rawMessage.trim()
val sessionId = snapshot?.activeSessionId
if (message.isEmpty()) {
interaction = WearInteractionState.READY
return
}
if (state.sending || !state.streamText.isNullOrBlank()) {
interaction = WearInteractionState.READY
view.performHapticFeedback(HapticFeedbackConstants.REJECT)
return
}
if (!state.connected || sessionId == null) {
interaction = WearInteractionState.READY
view.performHapticFeedback(HapticFeedbackConstants.REJECT)
return
}
expectedAssistantKey = snapshot.latestAssistantMessage()?.stableKey()
awaitingReplySessionId = sessionId
awaitingReply = true
interaction = WearInteractionState.SENDING
speaker.stop()
viewModel.sendReply(message)
}
val speechLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val transcript =
result.data
?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
?.firstOrNull()
if (result.resultCode == Activity.RESULT_OK && !transcript.isNullOrBlank()) {
submitMessage(transcript)
} else {
interaction = WearInteractionState.READY
}
}
val textLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val text =
result.data
?.let(RemoteInput::getResultsFromIntent)
?.getCharSequence(REMOTE_INPUT_KEY)
?.toString()
if (result.resultCode == Activity.RESULT_OK && !text.isNullOrBlank()) {
submitMessage(text)
} else {
interaction = WearInteractionState.READY
}
}
fun startRealtimeTalk() {
speaker.stop()
viewModel.startRealtimeTalk()
}
fun leaveConversationContext() {
awaitingReply = false
awaitingReplySessionId = null
expectedAssistantKey = null
interaction = WearInteractionState.READY
speaker.stop()
}
val audioPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) {
startRealtimeTalk()
} else {
interaction = WearInteractionState.ERROR
view.performHapticFeedback(HapticFeedbackConstants.REJECT)
}
}
val notificationPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
notificationsGranted = granted
}
DisposableEffect(lifecycleOwner, view.context) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
notificationsGranted =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(view.context, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
fun toggleRealtimeTalk() {
if (state.talkBusy || state.controlBusy) return
if (state.realtimeTalk.active || state.realtimeCapturing) {
viewModel.stopRealtimeTalk()
return
}
if (!state.connected || state.selectedSession == null) return
if (
ContextCompat.checkSelfPermission(view.context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
) {
startRealtimeTalk()
} else {
audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
}
LaunchedEffect(
snapshot?.activeSessionId,
state.messages,
state.activeRunId,
state.sending,
state.failure,
awaitingReply,
) {
if (!awaitingReply) return@LaunchedEffect
val activeSnapshot = snapshot
if (
state.failure != null ||
activeSnapshot == null ||
activeSnapshot.activeSessionId != awaitingReplySessionId
) {
awaitingReply = false
awaitingReplySessionId = null
expectedAssistantKey = null
interaction = WearInteractionState.READY
return@LaunchedEffect
}
if (state.sending || state.activeRunId != null) return@LaunchedEffect
val reply =
newAssistantReplyForSession(
awaitingSessionId = awaitingReplySessionId,
activeSessionId = activeSnapshot.activeSessionId,
expectedAssistantKey = expectedAssistantKey,
latestAssistantMessage = activeSnapshot.latestAssistantMessage(),
)
if (reply != null) {
awaitingReply = false
awaitingReplySessionId = null
expectedAssistantKey = null
interaction = WearInteractionState.READY
view.performHapticFeedback(HapticFeedbackConstants.CONFIRM)
if (autoSpeak) speaker.speak(reply.text)
}
}
LaunchedEffect(snapshot?.realtimeTalk) {
val next = snapshot
realtimeThinkingTurnId =
if (next == null) {
null
} else {
nextRealtimeThinkingTurnId(previousRealtimeSnapshot, next, realtimeThinkingTurnId)
}
previousRealtimeSnapshot = next
}
LaunchedEffect(realtimeThinkingTurnId) {
val turnId = realtimeThinkingTurnId ?: return@LaunchedEffect
delay(MINIMUM_REALTIME_THINKING_VISIBLE_MILLIS)
if (realtimeThinkingTurnId == turnId) {
realtimeThinkingTurnId = null
}
}
DisposableEffect(speaker) {
onDispose(speaker::shutdown)
}
val failure =
state.failure
?: WearConversationFailure.PHONE_UNAVAILABLE.takeIf {
state.phoneNodeId == null && !state.loading
}
val resolvedInteraction =
when {
state.failure != null -> WearInteractionState.ERROR
state.sending -> WearInteractionState.SENDING
state.activeRunId != null -> WearInteractionState.AGENT_WORKING
else -> interaction
}
OpenClawWearTheme(themeMode = themeMode) {
AppScaffold {
OpenClawWearScreens(
snapshot = snapshot,
failure = failure,
loading = state.loading,
interaction = resolvedInteraction,
speaking = speaking,
realtimeCapturing = state.realtimeCapturing,
realtimePlaying = state.realtimePlaying,
realtimeMouthLevel = state.realtimeMouthLevel,
realtimePlaybackFailed = state.realtimePlaybackFailed,
realtimeThinkingOverride = realtimeThinkingTurnId != null,
actionBusy =
state.loading ||
state.sending ||
state.talkBusy ||
state.controlBusy ||
state.activeRunId != null ||
!state.streamText.isNullOrBlank() ||
state.realtimeTalk.active ||
state.realtimeCapturing ||
state.realtimePlaying,
inputEnabled = state.connected && snapshot?.activeSessionId != null,
canAbort = state.activeRunId != null || !state.streamText.isNullOrBlank(),
themeMode = themeMode,
autoSpeak = autoSpeak,
notificationsGranted = notificationsGranted,
initialPage = initialPage,
navigationRequest = navigationRequest,
onNavigationRequestHandled = onNavigationRequestHandled,
onTalk = {
if (!state.connected || snapshot?.activeSessionId == null) return@OpenClawWearScreens
interaction = WearInteractionState.LISTENING
val intent =
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
.putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.getDefault().toLanguageTag())
.putExtra(RecognizerIntent.EXTRA_PROMPT, speakPrompt)
try {
speechLauncher.launch(intent)
} catch (_: ActivityNotFoundException) {
interaction = WearInteractionState.ERROR
view.performHapticFeedback(HapticFeedbackConstants.REJECT)
}
},
onType = {
if (!state.connected || snapshot?.activeSessionId == null) return@OpenClawWearScreens
interaction = WearInteractionState.TYPING
val remoteInput =
RemoteInput
.Builder(REMOTE_INPUT_KEY)
.setLabel(messageLabel)
.build()
val intent =
RemoteInputIntentHelper
.createActionRemoteInputIntent()
.also { inputIntent ->
RemoteInputIntentHelper.putRemoteInputsExtra(inputIntent, listOf(remoteInput))
RemoteInputIntentHelper.putTitleExtra(inputIntent, messageTitle)
RemoteInputIntentHelper.putConfirmLabelExtra(inputIntent, sendLabel)
}
textLauncher.launch(intent)
},
onRealtimeTalk = ::toggleRealtimeTalk,
onAbort = {
awaitingReply = false
awaitingReplySessionId = null
expectedAssistantKey = null
interaction = WearInteractionState.READY
speaker.stop()
viewModel.abort()
},
onSelectAgent = { agentId ->
leaveConversationContext()
viewModel.selectAgent(agentId)
},
onSelectSession = { sessionKey ->
state.sessions.firstOrNull { it.key == sessionKey }?.let { session ->
leaveConversationContext()
viewModel.openSession(session)
}
},
onSelectModel = { modelRef ->
leaveConversationContext()
viewModel.selectModel(modelRef)
},
onRefresh = viewModel::refresh,
onGatewayEnabledChange = { enabled ->
speaker.stop()
viewModel.setGatewayEnabled(enabled)
},
onThemeModeChange = { selectedMode ->
themeMode = selectedMode
settingsStore.writeThemeMode(selectedMode)
},
onAutoSpeakChange = { enabled ->
autoSpeak = enabled
settingsStore.writeAutoSpeak(enabled)
if (!enabled) speaker.stop()
},
onRequestNotifications = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
},
onOpenNotificationSettings = {
if (activity != null) {
val notificationSettings =
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, activity.packageName)
try {
activity.startActivity(notificationSettings)
} catch (_: ActivityNotFoundException) {
activity.startActivity(
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
"package:${activity.packageName}".toUri(),
),
)
}
}
},
onSpeakLatest = {
snapshot.latestAssistantMessage()?.text?.let(speaker::speak)
},
onStopSpeaking = speaker::stop,
)
}
}
}
private fun WearConversationSnapshot?.latestAssistantMessage(): WearChatMessage? =
this
?.messages
?.lastOrNull { message ->
message.chatRole == WearChatRole.ASSISTANT && message.text.isNotBlank()
}
private fun WearChatMessage.stableKey(): String = id ?: role + ":" + timestamp + ":" + text.hashCode()
internal fun newAssistantReplyForSession(
awaitingSessionId: String?,
activeSessionId: String?,
expectedAssistantKey: String?,
latestAssistantMessage: WearChatMessage?,
): WearChatMessage? =
latestAssistantMessage?.takeIf { message ->
awaitingSessionId != null &&
awaitingSessionId == activeSessionId &&
message.stableKey() != expectedAssistantKey
}
internal fun nextRealtimeThinkingTurnId(
previous: WearConversationSnapshot?,
next: WearConversationSnapshot,
currentTurnId: String?,
): String? {
if (!next.realtimeTalk.active) return null
return newlyCompletedRealtimeUserTurnId(previous, next) ?: currentTurnId
}
internal fun newlyCompletedRealtimeUserTurnId(
previous: WearConversationSnapshot?,
next: WearConversationSnapshot,
): String? {
if (previous?.realtimeTalk?.active != true || !next.realtimeTalk.active) return null
val previousFinalUserTurnIds =
previous.realtimeTalk.conversation
.asSequence()
.filter { entry -> entry.role == WearRealtimeTalkRole.USER && !entry.streaming }
.map { entry -> entry.id }
.toSet()
return next.realtimeTalk.conversation
.lastOrNull { entry ->
entry.role == WearRealtimeTalkRole.USER &&
!entry.streaming &&
entry.id !in previousFinalUserTurnIds
}?.id
}
internal const val REPLY_RESULT_KEY = "openclaw_watch_message"
private const val REMOTE_INPUT_KEY = REPLY_RESULT_KEY
private const val MINIMUM_REALTIME_THINKING_VISIBLE_MILLIS = 900L

View file

@ -0,0 +1,104 @@
package ai.openclaw.wear
import android.content.ComponentName
import android.content.Context
import androidx.wear.protolayout.ActionBuilders
import androidx.wear.protolayout.TimelineBuilders
import androidx.wear.protolayout.layout.androidImageResource
import androidx.wear.protolayout.layout.imageResource
import androidx.wear.protolayout.material3.ButtonDefaults.filledTonalButtonColors
import androidx.wear.protolayout.material3.ColorScheme
import androidx.wear.protolayout.material3.MaterialScope
import androidx.wear.protolayout.material3.avatarButton
import androidx.wear.protolayout.material3.avatarImage
import androidx.wear.protolayout.material3.primaryLayout
import androidx.wear.protolayout.material3.text
import androidx.wear.protolayout.material3.textEdgeButton
import androidx.wear.protolayout.modifiers.LayoutModifier
import androidx.wear.protolayout.modifiers.clickable
import androidx.wear.protolayout.modifiers.contentDescription
import androidx.wear.protolayout.types.argb
import androidx.wear.protolayout.types.layoutString
import androidx.wear.tiles.Material3TileService
import androidx.wear.tiles.RequestBuilders
import androidx.wear.tiles.TileBuilders
class OpenClawTileService :
Material3TileService(
allowDynamicTheme = false,
defaultColorScheme = openClawTileColorScheme,
) {
override suspend fun MaterialScope.tileResponse(requestParams: RequestBuilders.TileRequest): TileBuilders.Tile {
val talkAction = wearLaunchAction(this@OpenClawTileService, WearLaunchTarget.Voice)
val openAction = wearLaunchAction(this@OpenClawTileService, WearLaunchTarget.Chat)
val talkClickable = clickable(action = talkAction, id = "talk_openclaw")
val openClickable = clickable(action = openAction, id = "open_openclaw")
val layout =
primaryLayout(
titleSlot = { text(getString(R.string.app_name).layoutString) },
mainSlot = {
avatarButton(
onClick = talkClickable,
modifier = LayoutModifier.contentDescription(getString(R.string.talk)),
avatarContent = {
avatarImage(
resource =
imageResource(
androidImage = androidImageResource(R.mipmap.ic_launcher_foreground),
),
protoLayoutResourceId = "openclaw_core_mascot",
)
},
labelContent = {
text(
wearUppercase(
getString(R.string.talk),
resources.configuration.locales[0],
).layoutString,
)
},
secondaryLabelContent = { text(getString(R.string.tile_phone_proxy).layoutString) },
)
},
bottomSlot = {
textEdgeButton(
onClick = openClickable,
modifier = LayoutModifier.contentDescription(getString(R.string.tile_open)),
colors = filledTonalButtonColors(),
labelContent = { text(getString(R.string.tile_open).layoutString) },
)
},
)
return TileBuilders.Tile
.Builder()
.setTileTimeline(TimelineBuilders.Timeline.fromLayoutElement(layout))
.build()
}
}
private val openClawTileColorScheme =
ColorScheme(
primary = 0xFFFFFFFF.argb,
primaryDim = 0xFFA8A8A8.argb,
primaryContainer = 0xFFFFFFFF.argb,
onPrimary = 0xFF050505.argb,
onPrimaryContainer = 0xFF050505.argb,
surfaceContainerLow = 0xFF0A0A0A.argb,
surfaceContainer = 0xFF111111.argb,
surfaceContainerHigh = 0xFF1A1A1A.argb,
onSurface = 0xFFF8F8F8.argb,
onSurfaceVariant = 0xFFA8A8A8.argb,
outline = 0xFF3A3A3A.argb,
outlineVariant = 0xFF242424.argb,
background = 0xFF030303.argb,
onBackground = 0xFFF8F8F8.argb,
)
internal fun wearLaunchAction(
context: Context,
target: WearLaunchTarget,
): ActionBuilders.LaunchAction =
ActionBuilders.launchAction(
ComponentName(context, MainActivity::class.java),
mapOf(extraWearLaunchTarget to ActionBuilders.stringExtra(target.rawValue)),
)

View file

@ -0,0 +1,41 @@
package ai.openclaw.wear
import android.app.Application
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import java.util.concurrent.atomic.AtomicInteger
internal class VisibleActivityTracker {
private val count = AtomicInteger()
fun onStarted() {
count.incrementAndGet()
}
fun onStopped() {
count.updateAndGet { current -> (current - 1).coerceAtLeast(0) }
}
fun isVisible(): Boolean = count.get() > 0
}
class WearApplication : Application() {
internal val processScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
internal val proxyClient: WearProxyClient by lazy {
WearProxyClient.create(context = this)
}
internal val gatewayRepository: WearGatewayRepository by lazy {
WearGatewayRepository(proxyClient)
}
private val visibleActivities = VisibleActivityTracker()
internal fun onActivityStarted() = visibleActivities.onStarted()
internal fun onActivityStopped() = visibleActivities.onStopped()
internal fun isActivityVisible(): Boolean = visibleActivities.isVisible()
}

View file

@ -0,0 +1,52 @@
package ai.openclaw.wear
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
internal val wearSpeechAudioAttributes: AudioAttributes =
AudioAttributes
.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
internal class WearAudioFocusController(
context: Context,
private val onFocusLost: () -> Unit,
) {
private val audioManager =
context.applicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private val focusRequest =
AudioFocusRequest
.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
.setAudioAttributes(wearSpeechAudioAttributes)
.setOnAudioFocusChangeListener { focusChange ->
when (focusChange) {
AudioManager.AUDIOFOCUS_LOSS,
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT,
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK,
-> {
hasFocus = false
onFocusLost()
}
}
}.build()
@Volatile private var hasFocus = false
fun request(): Boolean {
if (hasFocus) return true
hasFocus =
audioManager.requestAudioFocus(focusRequest) ==
AudioManager.AUDIOFOCUS_REQUEST_GRANTED
return hasFocus
}
fun abandon() {
if (!hasFocus) return
audioManager.abandonAudioFocusRequest(focusRequest)
hasFocus = false
}
}

View file

@ -0,0 +1,124 @@
package ai.openclaw.wear
import ai.openclaw.wear.shared.WearProxyCapability
import ai.openclaw.wear.shared.WearRealtimeTalkSnapshot
internal enum class WearGatewayState {
CONNECTED,
DISCONNECTED,
}
internal enum class WearChatRole {
USER,
ASSISTANT,
SYSTEM,
}
internal val WearChatMessage.chatRole: WearChatRole
get() =
when (role.lowercase()) {
"user" -> WearChatRole.USER
"assistant" -> WearChatRole.ASSISTANT
else -> WearChatRole.SYSTEM
}
internal data class WearAgentSummary(
val id: String,
val name: String,
val emoji: String?,
val selected: Boolean,
)
internal data class WearSessionSummary(
val id: String,
val title: String?,
val updatedAtEpochMillis: Long?,
val selected: Boolean,
)
internal data class WearModelSummary(
val ref: String,
val name: String,
val selected: Boolean,
)
internal data class WearConversationSnapshot(
val gatewayState: WearGatewayState,
val activeAgentId: String? = null,
val agents: List<WearAgentSummary> = emptyList(),
val agentControlsSupported: Boolean = false,
val gatewayControlsSupported: Boolean = false,
val activeSessionId: String? = null,
val sessions: List<WearSessionSummary> = emptyList(),
val models: List<WearModelSummary> = emptyList(),
val modelControlsSupported: Boolean = false,
val messages: List<WearChatMessage> = emptyList(),
val streamingAssistantText: String? = null,
val pendingRunCount: Int = 0,
val selectedModelRef: String? = null,
val failure: WearConversationFailure? = null,
val realtimeTalk: WearRealtimeTalkSnapshot = WearRealtimeTalkSnapshot(),
)
internal enum class WearConversationFailure {
PHONE_UNAVAILABLE,
PHONE_NOT_READY,
GATEWAY_OFFLINE,
NOT_FOUND,
ACTION_REJECTED,
INCOMPATIBLE,
INTERNAL_ERROR,
}
internal enum class WearInteractionState {
READY,
LISTENING,
TYPING,
SENDING,
AGENT_WORKING,
ERROR,
}
internal fun WearUiState.toConversationSnapshot(): WearConversationSnapshot? {
if (phoneNodeId == null) return null
return WearConversationSnapshot(
gatewayState = if (connected) WearGatewayState.CONNECTED else WearGatewayState.DISCONNECTED,
activeAgentId = activeAgentId,
agents =
agents.map { agent ->
WearAgentSummary(
id = agent.id,
name = agent.name,
emoji = agent.emoji,
selected = agent.id == activeAgentId,
)
},
agentControlsSupported = WearProxyCapability.AgentControls in proxyCapabilities,
gatewayControlsSupported = WearProxyCapability.GatewayControls in proxyCapabilities,
activeSessionId = selectedSession?.key,
sessions =
sessions.map { session ->
WearSessionSummary(
id = session.key,
title = session.title,
updatedAtEpochMillis = session.updatedAt,
selected = session.key == selectedSession?.key,
)
},
models =
models.map { model ->
WearModelSummary(
ref = model.ref,
name = model.name,
selected = model.ref == selectedModelRef,
)
},
modelControlsSupported = WearProxyCapability.ModelControls in proxyCapabilities,
messages = messages,
streamingAssistantText = streamText,
pendingRunCount = if (activeRunId != null) 1 else 0,
selectedModelRef = selectedModelRef,
failure = failure,
realtimeTalk = realtimeTalk,
)
}

View file

@ -0,0 +1,520 @@
package ai.openclaw.wear
import ai.openclaw.wear.shared.WearProxyCapability
import ai.openclaw.wear.shared.WearRealtimeTalkCodec
import ai.openclaw.wear.shared.WearRealtimeTalkSnapshot
import ai.openclaw.wear.shared.WearRpcMethod
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
import java.util.UUID
internal data class WearProxyStatus(
val connected: Boolean,
val activeAgentId: String?,
val activeSessionKey: String?,
val selectedModelRef: String?,
val capabilities: Set<WearProxyCapability>,
val eventSequence: Long?,
val phoneNodeId: String,
val eventStreamId: String? = null,
)
internal data class WearAgent(
val id: String,
val name: String,
val emoji: String?,
val selected: Boolean,
)
internal data class WearAgentList(
val agents: List<WearAgent>,
val eventSequence: Long?,
val phoneNodeId: String,
val eventStreamId: String? = null,
)
internal data class WearSession(
val key: String,
val title: String?,
val updatedAt: Long?,
val hasActiveRun: Boolean,
val phoneNodeId: String,
val agentId: String? = null,
val modelRef: String? = null,
)
internal data class WearSessionList(
val sessions: List<WearSession>,
val eventSequence: Long?,
val phoneNodeId: String,
val eventStreamId: String? = null,
val activeAgentId: String? = null,
val selectedSessionValid: Boolean = false,
)
internal data class WearModel(
val ref: String,
val name: String,
)
internal data class WearModelList(
val models: List<WearModel>,
val eventSequence: Long?,
val phoneNodeId: String,
val eventStreamId: String? = null,
)
internal data class WearModelSelection(
val selectedModelRef: String,
val eventSequence: Long?,
val phoneNodeId: String,
val eventStreamId: String? = null,
)
internal data class WearChatMessage(
val id: String?,
val role: String,
val text: String,
val timestamp: Long?,
)
internal data class WearTranscript(
val sessionKey: String,
val messages: List<WearChatMessage>,
val activeRunId: String?,
val activeText: String?,
val selectedModelRef: String?,
val eventSequence: Long?,
val phoneNodeId: String,
val eventStreamId: String? = null,
)
internal data class WearChatEvent(
val sessionKey: String?,
val runId: String?,
val state: String?,
val deltaText: String?,
val replace: Boolean,
val streamText: String?,
val streamTextComplete: Boolean,
val message: WearChatMessage?,
)
internal data class WearSendAttempt(
val sessionKey: String,
val message: String,
val idempotencyKey: String,
val phoneNodeId: String,
)
internal class WearSendAttemptTracker(
private val newId: () -> String = { UUID.randomUUID().toString() },
) {
private var ambiguousAttempt: WearSendAttempt? = null
fun begin(
sessionKey: String,
message: String,
phoneNodeId: String,
): WearSendAttempt {
ambiguousAttempt
?.takeIf { it.sessionKey == sessionKey && it.message == message && it.phoneNodeId == phoneNodeId }
?.let { return it }
ambiguousAttempt = null
return WearSendAttempt(sessionKey, message, "wear-${newId()}", phoneNodeId)
}
fun markAmbiguous(attempt: WearSendAttempt) {
ambiguousAttempt = attempt
}
fun markSucceeded(attempt: WearSendAttempt) {
if (ambiguousAttempt == attempt) ambiguousAttempt = null
}
}
internal class WearGatewayRepository(
private val requester: WearRpcRequester,
) {
suspend fun status(expectedNodeId: String? = null): WearProxyStatus {
val response = requester.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, expectedNodeId)
val result = response.payload.asObject("proxy.status")
return WearProxyStatus(
connected = result.boolean("connected") ?: false,
activeAgentId = result.string("activeAgentId"),
activeSessionKey = result.string("activeSessionKey"),
selectedModelRef = result.string("selectedModelRef"),
capabilities = result.proxyCapabilities(),
eventStreamId = response.eventStreamId,
eventSequence = response.eventSequence,
phoneNodeId = response.sourceNodeId,
)
}
suspend fun agents(
expectedNodeId: String,
capabilities: Set<WearProxyCapability>,
): WearAgentList {
capabilities.require(WearProxyCapability.AgentControls)
val response =
requester.request(
WearRpcMethod.AgentsList,
buildJsonObject {},
expectedNodeId,
requirePreferredNode = true,
)
val result = response.payload.asObject("agents.list")
return WearAgentList(
agents =
(result["agents"] as? JsonArray)
.orEmpty()
.mapNotNull(::parseAgent),
eventStreamId = response.eventStreamId,
eventSequence = response.eventSequence,
phoneNodeId = response.sourceNodeId,
)
}
suspend fun selectAgent(
agentId: String,
phoneNodeId: String,
capabilities: Set<WearProxyCapability>,
) {
capabilities.require(WearProxyCapability.AgentControls)
requester.request(
WearRpcMethod.AgentsSelect,
buildJsonObject { put("agentId", agentId) },
phoneNodeId,
requirePreferredNode = true,
)
}
suspend fun models(
expectedNodeId: String,
capabilities: Set<WearProxyCapability>,
selectedModelRef: String? = null,
): WearModelList {
capabilities.require(WearProxyCapability.ModelControls)
val response =
requester.request(
WearRpcMethod.ModelsList,
buildJsonObject {
selectedModelRef?.let { put("selectedModelRef", it) }
},
expectedNodeId,
requirePreferredNode = true,
)
val result = response.payload.asObject("models.list")
return WearModelList(
models =
(result["models"] as? JsonArray)
.orEmpty()
.mapNotNull(::parseModel),
eventStreamId = response.eventStreamId,
eventSequence = response.eventSequence,
phoneNodeId = response.sourceNodeId,
)
}
suspend fun selectModel(
sessionKey: String,
modelRef: String,
phoneNodeId: String,
capabilities: Set<WearProxyCapability>,
): WearModelSelection {
capabilities.require(WearProxyCapability.ModelControls)
val response =
requester.request(
WearRpcMethod.ModelsSelect,
buildJsonObject {
put("sessionKey", sessionKey)
put("modelRef", modelRef)
},
phoneNodeId,
requirePreferredNode = true,
)
val selectedModelRef =
response.payload
.asObject("models.select")
.string("selectedModelRef")
?: throw WearProxyException("invalid_response", "models.select returned invalid data")
return WearModelSelection(
selectedModelRef = selectedModelRef,
eventStreamId = response.eventStreamId,
eventSequence = response.eventSequence,
phoneNodeId = response.sourceNodeId,
)
}
suspend fun setGatewayEnabled(
enabled: Boolean,
phoneNodeId: String,
capabilities: Set<WearProxyCapability>,
): WearProxyStatus {
capabilities.require(WearProxyCapability.GatewayControls)
val method = if (enabled) WearRpcMethod.GatewayConnect else WearRpcMethod.GatewayDisconnect
val response =
requester.request(
method,
buildJsonObject {},
phoneNodeId,
requirePreferredNode = true,
)
val result = response.payload.asObject(if (enabled) "gateway.connect" else "gateway.disconnect")
return WearProxyStatus(
connected = result.boolean("connected") ?: false,
activeAgentId = result.string("activeAgentId"),
activeSessionKey = result.string("activeSessionKey"),
selectedModelRef = result.string("selectedModelRef"),
capabilities = result.proxyCapabilities(),
eventStreamId = response.eventStreamId,
eventSequence = response.eventSequence,
phoneNodeId = response.sourceNodeId,
)
}
suspend fun sessions(
expectedNodeId: String? = null,
selectedSessionKey: String? = null,
capabilities: Set<WearProxyCapability> = emptySet(),
): WearSessionList {
val response =
requester
.request(
WearRpcMethod.SessionsList,
buildJsonObject {
put("limit", 30)
if (WearProxyCapability.SessionSelectionLookup in capabilities) {
selectedSessionKey?.takeIf(String::isNotBlank)?.let { put("selectedSessionKey", it) }
}
},
expectedNodeId,
)
val result = response.payload.asObject("sessions.list")
return WearSessionList(
sessions =
(result["sessions"] as? JsonArray)
.orEmpty()
.mapNotNull { parseSession(it, response.sourceNodeId) },
eventStreamId = response.eventStreamId,
eventSequence = response.eventSequence,
phoneNodeId = response.sourceNodeId,
activeAgentId = result.string("activeAgentId"),
selectedSessionValid = result.boolean("selectedSessionValid") ?: false,
)
}
suspend fun history(
sessionKey: String,
expectedNodeId: String,
): WearTranscript {
val response =
requester
.request(
WearRpcMethod.ChatHistory,
buildJsonObject {
put("sessionKey", sessionKey)
put("limit", 20)
put("maxChars", 2_000)
},
expectedNodeId,
)
val result = response.payload.asObject("chat.history")
val inFlight = result["inFlightRun"] as? JsonObject
return WearTranscript(
sessionKey = result.string("sessionKey") ?: sessionKey,
messages = (result["messages"] as? JsonArray).orEmpty().mapNotNull(::parseChatMessage),
activeRunId = inFlight?.string("runId"),
activeText = inFlight?.string("text"),
selectedModelRef = result.string("selectedModelRef"),
eventStreamId = response.eventStreamId,
eventSequence = response.eventSequence,
phoneNodeId = response.sourceNodeId,
)
}
suspend fun send(
attempt: WearSendAttempt,
requirePreferredPhone: Boolean = false,
) {
requester.request(
WearRpcMethod.ChatSend,
buildJsonObject {
put("sessionKey", attempt.sessionKey)
put("message", attempt.message)
put("idempotencyKey", attempt.idempotencyKey)
},
attempt.phoneNodeId,
requirePreferredNode = requirePreferredPhone,
)
}
suspend fun abort(
sessionKey: String,
runId: String?,
phoneNodeId: String,
) {
requester.request(
WearRpcMethod.ChatAbort,
buildJsonObject {
put("sessionKey", sessionKey)
runId?.let { put("runId", it) }
},
phoneNodeId,
requirePreferredNode = true,
)
}
suspend fun startRealtimeTalk(
sessionKey: String,
attemptId: String,
language: String?,
phoneNodeId: String,
attemptScopedAudio: Boolean,
): WearRealtimeTalkSnapshot {
val response =
requester.request(
WearRpcMethod.TalkStart,
buildJsonObject {
put("sessionKey", sessionKey)
put("attemptId", attemptId)
language?.let { put("language", it) }
if (attemptScopedAudio) put("attemptScopedAudio", true)
},
phoneNodeId,
requirePreferredNode = true,
)
return WearRealtimeTalkCodec.decode(response.payload)
}
suspend fun stopRealtimeTalk(
phoneNodeId: String,
attemptId: String,
): WearRealtimeTalkSnapshot {
val response =
requester.request(
WearRpcMethod.TalkStop,
buildJsonObject { put("attemptId", attemptId) },
phoneNodeId,
requirePreferredNode = true,
)
return WearRealtimeTalkCodec.decode(response.payload)
}
}
internal fun parseWearChatEvent(payload: JsonElement?): WearChatEvent? {
val source = payload as? JsonObject ?: return null
return WearChatEvent(
sessionKey = source.string("sessionKey"),
runId = source.string("runId"),
state = source.string("state"),
deltaText = source.string("deltaText"),
replace = source.boolean("replace") ?: false,
streamText = source.string("streamText"),
streamTextComplete = source.boolean("streamTextComplete") ?: false,
message = parseChatMessage(source["message"]),
)
}
private fun parseSession(
element: JsonElement,
phoneNodeId: String,
): WearSession? {
val source = element as? JsonObject ?: return null
val key = source.string("key") ?: return null
val title =
source.string("displayName")
?: source.string("label")
?: key.substringAfterLast(':').ifBlank { "Session" }
return WearSession(
key = key,
title = title,
updatedAt = source.long("updatedAt") ?: source.long("lastActivityAt"),
hasActiveRun = source.boolean("hasActiveRun") ?: false,
phoneNodeId = phoneNodeId,
agentId = source.string("agentId"),
modelRef = source.string("modelRef"),
)
}
private fun parseModel(element: JsonElement): WearModel? {
val source = element as? JsonObject ?: return null
val ref = source.string("ref") ?: return null
return WearModel(
ref = ref,
name = source.string("name") ?: ref,
)
}
private fun parseAgent(element: JsonElement): WearAgent? {
val source = element as? JsonObject ?: return null
val id = source.string("id") ?: return null
return WearAgent(
id = id,
name = source.string("name") ?: id,
emoji = source.string("emoji"),
selected = source.boolean("selected") ?: false,
)
}
internal fun parseChatMessage(element: JsonElement?): WearChatMessage? {
val source = element as? JsonObject ?: return null
val role = source.string("role") ?: return null
val text = contentText(source["content"])
if (text.isBlank()) return null
return WearChatMessage(
id = source.string("id"),
role = role,
text = text,
timestamp = source.long("timestamp"),
)
}
private fun contentText(element: JsonElement?): String =
when (element) {
is JsonPrimitive -> element.contentOrNull.orEmpty()
is JsonArray ->
element
.mapNotNull { part ->
when (part) {
is JsonPrimitive -> part.contentOrNull
is JsonObject -> part.string("text")
else -> null
}
}.filter { it.isNotBlank() }
.joinToString("\n")
else -> ""
}
private fun JsonElement.asObject(method: String): JsonObject = this as? JsonObject ?: throw WearProxyException("invalid_response", "$method returned invalid data")
private fun JsonObject.string(name: String): String? = (this[name] as? JsonPrimitive)?.takeIf { it.isString }?.contentOrNull
private fun JsonObject.boolean(name: String): Boolean? = (this[name] as? JsonPrimitive)?.takeUnless { it.isString }?.booleanOrNull
private fun JsonObject.long(name: String): Long? = (this[name] as? JsonPrimitive)?.takeUnless { it.isString }?.longOrNull
private fun JsonObject.proxyCapabilities(): Set<WearProxyCapability> =
(this["capabilities"] as? JsonArray)
.orEmpty()
.mapNotNull { element ->
(element as? JsonPrimitive)
?.takeIf(JsonPrimitive::isString)
?.contentOrNull
?.let(WearProxyCapability::fromWireValue)
}.toSet()
private fun Set<WearProxyCapability>.require(capability: WearProxyCapability) {
if (capability !in this) {
// Old phones omit capability negotiation. Fail before sending an RPC they
// cannot decode so the paired app remains usable during staggered updates.
throw WearProxyException("unsupported_peer", "Update OpenClaw on the paired phone")
}
}

View file

@ -0,0 +1,13 @@
package ai.openclaw.wear
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalConfiguration
import java.util.Locale
@Composable
internal fun localizedWearUppercase(value: String): String = wearUppercase(value, LocalConfiguration.current.locales[0])
internal fun wearUppercase(
value: String,
locale: Locale = Locale.getDefault(),
): String = value.uppercase(locale)

View file

@ -0,0 +1,573 @@
package ai.openclaw.wear
import ai.openclaw.wear.shared.WearDecodeResult
import ai.openclaw.wear.shared.WearEventType
import ai.openclaw.wear.shared.WearMessage
import ai.openclaw.wear.shared.WearProtocol
import ai.openclaw.wear.shared.WearProtocolCodec
import ai.openclaw.wear.shared.WearRpcMethod
import android.content.Context
import com.google.android.gms.tasks.Task
import com.google.android.gms.wearable.CapabilityClient
import com.google.android.gms.wearable.Wearable
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
internal fun interface WearNodeResolver {
suspend fun reachablePhoneNodeId(): String?
}
internal fun interface WearMessageTransport {
suspend fun send(
nodeId: String,
path: String,
data: ByteArray,
)
}
internal interface WearRpcRequester {
suspend fun request(
method: WearRpcMethod,
params: JsonObject,
expectedNodeId: String?,
requirePreferredNode: Boolean = false,
): WearRpcResult
}
internal data class WearRpcResult(
val payload: JsonElement,
val eventSequence: Long?,
val sourceNodeId: String,
val eventStreamId: String? = null,
)
internal data class WearInboundEvent(
val sourceNodeId: String,
val sequence: Long,
val event: WearEventType,
val payload: JsonElement?,
val streamId: String? = null,
)
internal class WearProxyException(
val code: String,
override val message: String,
) : IllegalStateException(message)
internal class WearProxyClient private constructor(
private val nodeResolver: WearNodeResolver,
private val transport: WearMessageTransport,
) : WearRpcRequester {
private val pending = ConcurrentHashMap<String, PendingWearRequest>()
private val preferredPhoneLock = Any()
private var preferredPhoneGeneration = 0L
private var registeredPhone: PreferredPhoneRegistration? = null
private val inboundMutex = Mutex()
private val mutableEvents =
MutableSharedFlow<WearInboundEvent>(
extraBufferCapacity = MAX_BUFFERED_EVENTS,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val events: SharedFlow<WearInboundEvent> = mutableEvents
private val mutablePreferredPhoneChanges =
MutableSharedFlow<String?>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val preferredPhoneChanges: SharedFlow<String?> = mutablePreferredPhoneChanges
override suspend fun request(
method: WearRpcMethod,
params: JsonObject,
expectedNodeId: String?,
requirePreferredNode: Boolean,
): WearRpcResult {
var attemptedPreferredPhone: PreferredPhoneRegistration? = null
val result =
withTimeoutOrNull(WearProtocol.RPC_REQUEST_TIMEOUT_MILLIS) {
requestBeforeDeadline(method, params, expectedNodeId, requirePreferredNode) { registration ->
attemptedPreferredPhone = registration
}
}
if (result != null) return result
// MessageClient success only proves the request was queued. A silent phone
// must be rediscovered just like a node that rejected the send outright.
invalidatePreferredPhone(attemptedPreferredPhone)
throw WearProxyException("timeout", "Paired phone did not respond")
}
private suspend fun requestBeforeDeadline(
method: WearRpcMethod,
params: JsonObject,
expectedNodeId: String?,
requirePreferredNode: Boolean,
recordPreferredPhoneAttempt: (PreferredPhoneRegistration?) -> Unit,
): WearRpcResult {
// Stateful RPCs stay on the phone that supplied their session/transcript.
// Rediscovery here could route a shared session key to a different phone.
val preferredPhone =
when {
requirePreferredNode || expectedNodeId == null -> resolvePreferredPhone()
else -> preferredPhoneRegistration(expectedNodeId)
}
val nodeId = expectedNodeId ?: checkNotNull(preferredPhone).nodeId
if (requirePreferredNode && expectedNodeId != null && preferredPhone?.nodeId != expectedNodeId) {
throw WearProxyException("phone_changed", "Preferred phone changed during request")
}
recordPreferredPhoneAttempt(preferredPhone?.takeIf { it.nodeId == nodeId })
val requestId = UUID.randomUUID().toString()
val response = CompletableDeferred<WearMessage.Response>()
val pendingRequest =
PendingWearRequest(
nodeId = nodeId,
response = response,
preferredPhone = preferredPhone?.takeIf { it.nodeId == nodeId },
)
check(pending.putIfAbsent(requestId, pendingRequest) == null)
return try {
try {
transport.send(
nodeId = nodeId,
path = WearProtocol.REQUEST_PATH,
data =
WearProtocolCodec.encode(
WearMessage.Request(requestId = requestId, method = method, params = params),
),
)
} catch (_: CancellationException) {
currentCoroutineContext().ensureActive()
invalidatePreferredPhone(preferredPhone?.takeIf { it.nodeId == nodeId })
throw WearProxyException("phone_unavailable", "Paired phone is unavailable")
} catch (_: Throwable) {
invalidatePreferredPhone(preferredPhone?.takeIf { it.nodeId == nodeId })
throw WearProxyException("phone_unavailable", "Paired phone is unavailable")
}
val envelope = response.await()
if (
(expectedNodeId == null || requirePreferredNode || method.requiresPreferredSnapshotSource()) &&
currentPreferredPhone()?.nodeId != nodeId
) {
throw WearProxyException("phone_changed", "Preferred phone changed during request")
}
if (!envelope.ok) {
val error = envelope.error
throw WearProxyException(error?.code ?: "unavailable", error?.message ?: "Phone proxy request failed")
}
WearRpcResult(
payload = envelope.result ?: buildJsonObject {},
// Phone and watch can update independently. A missing v1 watermark means
// unknown, so the next event establishes the legacy phone's live baseline.
eventStreamId = envelope.eventStreamId,
eventSequence = envelope.eventSequence,
sourceNodeId = nodeId,
)
} finally {
pending.remove(requestId, pendingRequest)
}
}
suspend fun handleMessage(
sourceNodeId: String,
path: String,
data: ByteArray,
): WearInboundEvent? =
inboundMutex.withLock {
val message = (WearProtocolCodec.decode(data) as? WearDecodeResult.Success)?.message ?: return@withLock null
when {
path == WearProtocol.RESPONSE_PATH && message is WearMessage.Response -> {
pending[message.requestId]
?.takeIf { it.nodeId == sourceNodeId }
?.let { request ->
// Correlation is the reachability proof. Advance the registration
// before a concurrently expiring request can invalidate it.
confirmPreferredPhoneResponse(request.preferredPhone)
request.response.complete(message)
}
null
}
path == WearProtocol.EVENT_PATH && message is WearMessage.Event -> {
if (!acceptEventSource(sourceNodeId)) return@withLock null
val inbound =
WearInboundEvent(
sourceNodeId = sourceNodeId,
streamId = message.streamId,
sequence = message.sequence,
event = message.event,
payload = message.payload,
)
mutableEvents.tryEmit(inbound)
inbound
}
else -> null
}
}
private suspend fun resolvePhoneNode(): String =
try {
nodeResolver.reachablePhoneNodeId()
} catch (_: CancellationException) {
// Play Services can cancel its Task while this request remains active.
// Preserve actual caller cancellation; map transport cancellation below.
currentCoroutineContext().ensureActive()
throw WearProxyException("phone_unavailable", "Paired phone is unavailable")
} catch (_: Throwable) {
throw WearProxyException("phone_unavailable", "Paired phone is unavailable")
} ?: throw WearProxyException("phone_unavailable", "Paired phone is unavailable")
private suspend fun acceptEventSource(sourceNodeId: String): Boolean {
val preferredPhone = currentPreferredPhone()
if (preferredPhone != null) {
return preferredPhone.nodeId == sourceNodeId
}
return try {
resolvePreferredPhone().nodeId == sourceNodeId
} catch (err: CancellationException) {
throw err
} catch (_: WearProxyException) {
false
}
}
/** A unique directly connected phone becomes the preferred routing source immediately. */
fun updatePreferredPhoneNodeId(nodeId: String) {
val changed =
synchronized(preferredPhoneLock) {
val changed = registeredPhone?.nodeId != nodeId
preferredPhoneGeneration += 1
registeredPhone = PreferredPhoneRegistration(nodeId, preferredPhoneGeneration)
changed
}
if (changed) mutablePreferredPhoneChanges.tryEmit(nodeId)
}
/** Capability callbacks are not reachability-filtered, so ambiguous results force fresh discovery. */
fun invalidatePreferredPhoneNode() {
val changed =
synchronized(preferredPhoneLock) {
val changed = registeredPhone != null
preferredPhoneGeneration += 1
registeredPhone = null
changed
}
if (changed) mutablePreferredPhoneChanges.tryEmit(null)
}
private suspend fun resolvePreferredPhone(): PreferredPhoneRegistration {
// Capability callbacks can invalidate the route while discovery suspends.
// Only a result from the same generation may repopulate it.
val discoveryGeneration =
synchronized(preferredPhoneLock) {
registeredPhone?.let { return it }
preferredPhoneGeneration
}
val resolved = resolvePhoneNode()
return synchronized(preferredPhoneLock) {
registeredPhone ?: if (preferredPhoneGeneration == discoveryGeneration) {
preferredPhoneGeneration += 1
PreferredPhoneRegistration(resolved, preferredPhoneGeneration).also { registeredPhone = it }
} else {
null
}
} ?: throw WearProxyException("phone_unavailable", "Paired phone is unavailable")
}
private fun currentPreferredPhone(): PreferredPhoneRegistration? =
synchronized(preferredPhoneLock) {
registeredPhone
}
private fun preferredPhoneRegistration(nodeId: String): PreferredPhoneRegistration? =
synchronized(preferredPhoneLock) {
registeredPhone?.takeIf { it.nodeId == nodeId }
}
private fun invalidatePreferredPhone(registration: PreferredPhoneRegistration?) {
if (registration == null) return
val invalidated =
synchronized(preferredPhoneLock) {
if (registeredPhone == registration) {
// A capability callback can refresh the same node while an older request
// is failing. Clear only the registration that owned this transport attempt.
preferredPhoneGeneration += 1
registeredPhone = null
true
} else {
false
}
}
if (invalidated) mutablePreferredPhoneChanges.tryEmit(null)
}
private fun confirmPreferredPhoneResponse(registration: PreferredPhoneRegistration?) {
if (registration == null) return
synchronized(preferredPhoneLock) {
if (registeredPhone == registration) {
// Any correlated response proves this registration is reachable. Advance
// it so an older parallel request cannot clear it on a later timeout.
preferredPhoneGeneration += 1
registeredPhone = registration.copy(generation = preferredPhoneGeneration)
}
}
}
private data class PreferredPhoneRegistration(
val nodeId: String,
val generation: Long,
)
private data class PendingWearRequest(
val nodeId: String,
val response: CompletableDeferred<WearMessage.Response>,
val preferredPhone: PreferredPhoneRegistration?,
)
companion object {
private const val MAX_BUFFERED_EVENTS = 64
fun create(context: Context): WearProxyClient {
val appContext = context.applicationContext
val capabilityClient = Wearable.getCapabilityClient(appContext)
val messageClient = Wearable.getMessageClient(appContext)
return WearProxyClient(
nodeResolver =
WearNodeResolver {
selectReachablePhoneNodeId(
capabilityClient
.getCapability(WearProtocol.PHONE_CAPABILITY, CapabilityClient.FILTER_REACHABLE)
.await()
.nodes
.map { node -> WearReachablePhoneNode(id = node.id, isNearby = node.isNearby) },
)
},
transport =
WearMessageTransport { nodeId, path, data ->
messageClient.sendMessage(nodeId, path, data).await()
},
)
}
internal fun createForTests(
nodeResolver: WearNodeResolver,
transport: WearMessageTransport,
): WearProxyClient = WearProxyClient(nodeResolver, transport)
}
}
internal data class WearReachablePhoneNode(
val id: String,
val isNearby: Boolean,
)
internal fun selectReachablePhoneNodeId(nodes: Collection<WearReachablePhoneNode>): String? {
val distinctNodes = nodes.distinctBy(WearReachablePhoneNode::id)
val nearbyNodes = distinctNodes.filter(WearReachablePhoneNode::isNearby)
return when {
nearbyNodes.size == 1 -> nearbyNodes.single().id
nearbyNodes.isNotEmpty() -> null
distinctNodes.size == 1 -> distinctNodes.single().id
else -> null
}
}
private fun WearRpcMethod.requiresPreferredSnapshotSource(): Boolean = this == WearRpcMethod.ProxyStatus || this == WearRpcMethod.SessionsList || this == WearRpcMethod.ChatHistory
internal enum class WearSequenceDecision {
Accepted,
AwaitingSnapshot,
GapOrReset,
}
internal data class WearResponseRequest(
val responseGeneration: Long,
val eventGeneration: Long,
)
internal class WearEventSequenceTracker {
private var streamId: String? = null
private var lastSequence: Long? = null
private var awaitingSnapshot = false
private var responseGeneration = 0L
private var eventGeneration = 0L
@Synchronized
fun adoptSnapshot(
streamId: String?,
sequence: Long?,
) {
eventGeneration += 1
if (sequence == null) {
this.streamId = streamId
lastSequence = null
awaitingSnapshot = false
return
}
val previous = lastSequence
val streamChanged = this.streamId != streamId && (this.streamId != null || streamId != null)
this.streamId = streamId
if (awaitingSnapshot || previous == null || streamChanged || sequence > previous) lastSequence = sequence
awaitingSnapshot = false
}
@Synchronized
fun accept(
streamId: String?,
sequence: Long,
): WearSequenceDecision {
if (awaitingSnapshot) return WearSequenceDecision.AwaitingSnapshot
val previous = lastSequence
if (previous == null) {
this.streamId = streamId
lastSequence = sequence
eventGeneration += 1
return WearSequenceDecision.Accepted
}
if (this.streamId != streamId && (this.streamId != null || streamId != null)) {
awaitingSnapshot = true
eventGeneration += 1
return WearSequenceDecision.GapOrReset
}
if (sequence == previous + 1) {
lastSequence = sequence
eventGeneration += 1
return WearSequenceDecision.Accepted
}
// Stream epochs expose phone restarts even when the new process happens to
// produce the next numeric sequence. Legacy null epochs still use gap detection.
awaitingSnapshot = true
eventGeneration += 1
return WearSequenceDecision.GapOrReset
}
// Only the newest model RPC may mutate UI state. The event generation also
// rejects legacy unwatermarked responses when live state advanced meanwhile.
@Synchronized
fun beginResponseRequest(): WearResponseRequest {
responseGeneration += 1
return WearResponseRequest(responseGeneration = responseGeneration, eventGeneration = eventGeneration)
}
@Synchronized
fun invalidateResponseRequests() {
responseGeneration += 1
}
@Synchronized
fun isResponseCurrent(
request: WearResponseRequest,
streamId: String?,
sequence: Long?,
): Boolean {
if (request.responseGeneration != responseGeneration) return false
if (awaitingSnapshot) return false
if (this.streamId != streamId && (this.streamId != null || streamId != null)) return false
val currentSequence = lastSequence
return if (sequence == null) {
request.eventGeneration == eventGeneration
} else {
sequence == currentSequence
}
}
@Synchronized
fun requireSnapshot() {
awaitingSnapshot = true
eventGeneration += 1
}
}
internal class WearEventSourceTracker {
private var sourceNodeId: String? = null
fun adopt(sourceNodeId: String) {
this.sourceNodeId = sourceNodeId
}
fun reset() {
sourceNodeId = null
}
fun changed(sourceNodeId: String): Boolean {
val previous = this.sourceNodeId
this.sourceNodeId = sourceNodeId
return previous != null && previous != sourceNodeId
}
}
internal class WearEventResyncBuffer(
private val capacity: Int = MAX_BUFFERED_EVENTS,
) {
// The response watermark splits events already captured by a snapshot from
// later events that raced its delivery. A bounded overflow reappears as a gap.
private val events = LinkedHashMap<Pair<String?, Long>, WearInboundEvent>()
private var buffering = false
@Synchronized
fun begin() {
events.clear()
buffering = true
}
@Synchronized
fun start(event: WearInboundEvent) {
begin()
appendLocked(event)
}
@Synchronized
fun append(event: WearInboundEvent) {
if (buffering) appendLocked(event)
}
@Synchronized
fun drainAfterSnapshot(
streamId: String?,
sequence: Long?,
): List<WearInboundEvent> {
if (!buffering) return emptyList()
buffering = false
val pending =
if (sequence == null) {
// A legacy snapshot has no ordering boundary. It already represents
// pre-response state, so replay could duplicate it; the next live event
// establishes the new sequence baseline.
emptyList()
} else {
events.values
.filter { event -> event.streamId == streamId && event.sequence > sequence }
.sortedBy(WearInboundEvent::sequence)
}
events.clear()
return pending
}
private fun appendLocked(event: WearInboundEvent) {
events[event.streamId to event.sequence] = event
while (events.size > capacity) events.remove(events.keys.first())
}
private companion object {
const val MAX_BUFFERED_EVENTS = 64
}
}
private suspend fun <T> Task<T>.await(): T =
suspendCancellableCoroutine { continuation ->
addOnSuccessListener { value -> if (continuation.isActive) continuation.resume(value) }
addOnFailureListener { error -> if (continuation.isActive) continuation.resumeWithException(error) }
addOnCanceledListener { continuation.cancel() }
}

View file

@ -0,0 +1,46 @@
package ai.openclaw.wear
import ai.openclaw.wear.shared.WearEventType
import ai.openclaw.wear.shared.WearProtocol
import com.google.android.gms.wearable.CapabilityInfo
import com.google.android.gms.wearable.MessageEvent
import com.google.android.gms.wearable.WearableListenerService
import kotlinx.coroutines.runBlocking
class WearProxyListenerService : WearableListenerService() {
override fun onCapabilityChanged(capabilityInfo: CapabilityInfo) {
if (capabilityInfo.name != WearProtocol.PHONE_CAPABILITY) return
val preferredNodeId =
selectReachablePhoneNodeId(
capabilityInfo.nodes.map { node ->
WearReachablePhoneNode(id = node.id, isNearby = node.isNearby)
},
)
val proxyClient = (application as? WearApplication)?.proxyClient ?: return
if (preferredNodeId == null) {
// Capability callbacks contain reachable nodes. Multiple routes remain
// ambiguous, so force the next request through fresh discovery.
proxyClient.invalidatePreferredPhoneNode()
} else {
proxyClient.updatePreferredPhoneNodeId(preferredNodeId)
}
}
override fun onMessageReceived(messageEvent: MessageEvent) {
if (messageEvent.path != WearProtocol.RESPONSE_PATH && messageEvent.path != WearProtocol.EVENT_PATH) return
val app = application as? WearApplication ?: return
// WearableListenerService callbacks use its background looper. Finish the
// bounded Data Layer work before returning so Android retains the service.
runBlocking {
val event =
app.proxyClient.handleMessage(
sourceNodeId = messageEvent.sourceNodeId,
path = messageEvent.path,
data = messageEvent.data,
) ?: return@runBlocking
if (event.event == WearEventType.Chat && !app.isActivityVisible()) {
WearReplyNotifier(applicationContext).show(event)
}
}
}
}

View file

@ -0,0 +1,613 @@
package ai.openclaw.wear
import ai.openclaw.wear.shared.WearProtocol
import ai.openclaw.wear.shared.WearProxyCapability
import ai.openclaw.wear.shared.WearRealtimeAudioFrameType
import ai.openclaw.wear.shared.WearRealtimeAudioFraming
import ai.openclaw.wear.shared.WearRealtimeTalkSnapshot
import android.annotation.SuppressLint
import android.content.Context
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.AudioTrack
import android.media.MediaRecorder
import android.os.SystemClock
import com.google.android.gms.tasks.Task
import com.google.android.gms.wearable.ChannelClient
import com.google.android.gms.wearable.Wearable
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import java.io.InputStream
import java.io.OutputStream
import java.util.Locale
import java.util.concurrent.atomic.AtomicLong
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.math.ceil
import kotlin.math.sqrt
internal class WearRealtimeTalkClient(
context: Context,
private val repository: WearGatewayRepository,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val channelClient = Wearable.getChannelClient(context.applicationContext)
private val lifecycleLock = Mutex()
private val channelLock = Mutex()
private val audioLock = Any()
private val audioFocus =
WearAudioFocusController(context) {
activeAttempt?.let { attempt -> scope.launch { clearOutput(attempt, resumeCapture = true) } }
}
private val _isCapturing = MutableStateFlow(false)
val isCapturing: StateFlow<Boolean> = _isCapturing
private val _isPlaying = MutableStateFlow(false)
val isPlaying: StateFlow<Boolean> = _isPlaying
private val _mouthLevel = MutableStateFlow(0f)
val mouthLevel: StateFlow<Float> = _mouthLevel
private val _channelFailed = MutableStateFlow(false)
val channelFailed: StateFlow<Boolean> = _channelFailed
private val attemptGeneration = AtomicLong()
@Volatile private var activeAttempt: ActiveAttempt? = null
@Volatile private var audioRecord: AudioRecord? = null
private var captureJob: Job? = null
private var readJob: Job? = null
private var playbackIdleJob: Job? = null
private var mouthJob: Job? = null
private var mouthFrames: Channel<Float>? = null
private val mouthLevelAccumulator = Pcm16MouthLevelAccumulator()
private var audioTrack: AudioTrack? = null
private var playbackEndsAtMillis = 0L
internal data class ChannelResources(
val channel: ChannelClient.Channel,
val input: InputStream,
val output: OutputStream,
)
internal data class ActiveAttempt(
val nodeId: String,
val attemptId: String,
val generation: Long,
val resources: ChannelResources,
)
suspend fun start(
session: WearSession,
attemptId: String,
capabilities: Set<WearProxyCapability>,
): WearRealtimeTalkSnapshot =
lifecycleLock.withLock {
val nodeId = session.phoneNodeId
val attemptScopedAudio = WearProxyCapability.AttemptScopedRealtimeAudio in capabilities
var resources: ChannelResources? = null
var channelOpened = false
var activatedAttempt: ActiveAttempt? = null
try {
resources = openChannel(nodeId, attemptId, attemptScopedAudio)
channelOpened = true
val language =
Locale
.getDefault()
.language
.lowercase(Locale.ROOT)
.takeIf { value -> value.length == ISO_639_1_LANGUAGE_LENGTH }
val snapshot =
repository.startRealtimeTalk(
sessionKey = session.key,
attemptId = attemptId,
language = language,
phoneNodeId = nodeId,
attemptScopedAudio = attemptScopedAudio,
)
val attempt =
ActiveAttempt(
nodeId = nodeId,
attemptId = attemptId,
generation = attemptGeneration.incrementAndGet(),
resources = checkNotNull(resources),
)
activate(attempt)
activatedAttempt = attempt
resources = null
startReader(attempt)
startCapture(attempt)
snapshot
} catch (err: Throwable) {
closeChannel(resources)
activatedAttempt?.let(::closeLocal)
if (channelOpened) {
// Finish ambiguous-start cleanup before another attempt can acquire
// the lifecycle lock and create a replacement relay for this Watch.
withContext(NonCancellable) { runCatching { repository.stopRealtimeTalk(nodeId, attemptId) } }
}
throw err
}
}
suspend fun stop(): WearRealtimeTalkSnapshot =
lifecycleLock.withLock {
val attempt = activeAttempt
try {
if (attempt == null) {
WearRealtimeTalkSnapshot()
} else {
repository.stopRealtimeTalk(attempt.nodeId, attempt.attemptId)
}
} finally {
closeLocal(attempt)
}
}
fun shutdown() {
closeLocal()
scope.cancel()
}
fun disconnectLocal() {
closeLocal()
}
private suspend fun openChannel(
nodeId: String,
attemptId: String,
attemptScopedAudio: Boolean,
): ChannelResources {
var lastError: Throwable? = null
repeat(CHANNEL_OPEN_ATTEMPTS) { attempt ->
var opened: ChannelClient.Channel? = null
var input: InputStream? = null
var output: OutputStream? = null
try {
opened =
channelClient
.openChannel(nodeId, wearRealtimeAudioChannelPath(attemptId, attemptScopedAudio))
.awaitRealtimeTask()
input = channelClient.getInputStream(opened).awaitRealtimeTask()
output = channelClient.getOutputStream(opened).awaitRealtimeTask()
return ChannelResources(opened, input, output)
} catch (err: Throwable) {
withContext(NonCancellable) {
input.closeQuietly()
output.closeQuietly()
opened?.let { channel -> runCatching { channelClient.close(channel).awaitRealtimeTask() } }
}
if (err is CancellationException) throw err
lastError = err
if (attempt + 1 < CHANNEL_OPEN_ATTEMPTS) delay(CHANNEL_RETRY_DELAY_MILLIS)
}
}
throw WearProxyException("phone_unavailable", lastError?.message ?: "Unable to open Watch audio channel")
}
private fun startReader(attempt: ActiveAttempt) {
val reader =
scope.launch(start = CoroutineStart.LAZY) {
try {
while (isCurrent(attempt)) {
val frame = WearRealtimeAudioFraming.read(attempt.resources.input) ?: break
if (!isCurrent(attempt)) break
when (frame.type) {
WearRealtimeAudioFrameType.OUTPUT_PCM -> writeOutput(attempt, frame.payload)
WearRealtimeAudioFrameType.CLEAR_OUTPUT -> clearOutput(attempt, resumeCapture = true)
WearRealtimeAudioFrameType.INPUT_PCM -> error("Phone sent an invalid Watch audio frame")
}
}
handleChannelFailure(attempt)
} catch (err: CancellationException) {
throw err
} catch (_: Throwable) {
handleChannelFailure(attempt)
}
}
val installed =
synchronized(audioLock) {
if (!isCurrent(attempt)) {
false
} else {
readJob?.cancel()
readJob = reader
true
}
}
if (installed) reader.start() else reader.cancel()
}
@SuppressLint("MissingPermission")
private fun startCapture(attempt: ActiveAttempt) {
synchronized(audioLock) { startCaptureLocked(attempt) }
}
@SuppressLint("MissingPermission")
private fun startCaptureLocked(attempt: ActiveAttempt) {
if (_isCapturing.value || _isPlaying.value || !isCurrent(attempt)) return
val frameBytes =
WearProtocol.REALTIME_AUDIO_SAMPLE_RATE_HZ * PCM_16_BYTES *
WearProtocol.REALTIME_AUDIO_FRAME_MILLIS / 1_000
val minimumBuffer =
AudioRecord.getMinBufferSize(
WearProtocol.REALTIME_AUDIO_SAMPLE_RATE_HZ,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
)
require(minimumBuffer > 0)
val recorder =
AudioRecord
.Builder()
.setAudioSource(MediaRecorder.AudioSource.VOICE_RECOGNITION)
.setAudioFormat(
AudioFormat
.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(WearProtocol.REALTIME_AUDIO_SAMPLE_RATE_HZ)
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
.build(),
).setBufferSizeInBytes(maxOf(minimumBuffer * 2, frameBytes * 4))
.build()
check(recorder.state == AudioRecord.STATE_INITIALIZED)
audioRecord = recorder
recorder.startRecording()
_isCapturing.value = true
captureJob =
scope.launch {
val buffer = ByteArray(frameBytes)
try {
while (
currentCoroutineContext().isActive &&
_isCapturing.value &&
audioRecord === recorder &&
isCurrent(attempt)
) {
val read = recorder.read(buffer, 0, buffer.size)
val evenBytes = read - (read and 1)
if (!_isCapturing.value || audioRecord !== recorder) break
check(read >= 0) { "Watch microphone read failed: $read" }
if (evenBytes == 0) {
yield()
continue
}
sendInputFrame(attempt, buffer.copyOf(evenBytes))
}
} catch (err: CancellationException) {
throw err
} catch (_: Throwable) {
handleChannelFailure(attempt)
} finally {
runCatching { recorder.stop() }
runCatching { recorder.release() }
if (audioRecord === recorder) audioRecord = null
}
}
}
private suspend fun sendInputFrame(
attempt: ActiveAttempt,
payload: ByteArray,
) {
channelLock.withLock {
if (!isCurrent(attempt)) return
withContext(Dispatchers.IO) {
WearRealtimeAudioFraming.write(attempt.resources.output, WearRealtimeAudioFrameType.INPUT_PCM, payload)
}
}
}
private fun writeOutput(
attempt: ActiveAttempt,
bytes: ByteArray,
) {
synchronized(audioLock) {
if (!isCurrent(attempt)) return
if (!_isPlaying.value) {
pauseCaptureLocked()
check(audioFocus.request())
}
val mouthLevels = mouthLevelAccumulator.append(bytes)
val track = audioTrack ?: createAudioTrack(bytes.size).also { audioTrack = it }
var written = 0
while (written < bytes.size) {
val count = track.write(bytes, written, bytes.size - written)
if (count <= 0) break
written += count
}
check(written == bytes.size)
if (track.playState != AudioTrack.PLAYSTATE_PLAYING) track.play()
_isPlaying.value = true
if (mouthLevels.isNotEmpty()) {
val timeline = mouthTimelineLocked()
mouthLevels.forEach { level -> timeline.trySend(level) }
}
val durationMillis =
((written / PCM_16_BYTES.toDouble()) / WearProtocol.REALTIME_AUDIO_SAMPLE_RATE_HZ * 1_000.0)
.toLong()
.coerceAtLeast(1L)
playbackEndsAtMillis = maxOf(SystemClock.elapsedRealtime(), playbackEndsAtMillis) + durationMillis
schedulePlaybackIdle(attempt)
}
}
private fun mouthTimelineLocked(): Channel<Float> {
mouthFrames?.let { return it }
val frames =
Channel<Float>(
capacity = MOUTH_QUEUE_CAPACITY,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
mouthFrames = frames
mouthJob =
scope.launch {
try {
for (level in frames) {
_mouthLevel.value = level
delay(MOUTH_FRAME_MILLIS.toLong())
}
} finally {
if (mouthFrames === frames) _mouthLevel.value = 0f
}
}
return frames
}
private fun createAudioTrack(frameBytes: Int): AudioTrack {
val minimumBuffer =
AudioTrack.getMinBufferSize(
WearProtocol.REALTIME_AUDIO_SAMPLE_RATE_HZ,
AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT,
)
require(minimumBuffer > 0)
return AudioTrack
.Builder()
.setAudioAttributes(wearSpeechAudioAttributes)
.setAudioFormat(
AudioFormat
.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(WearProtocol.REALTIME_AUDIO_SAMPLE_RATE_HZ)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.build(),
).setTransferMode(AudioTrack.MODE_STREAM)
.setBufferSizeInBytes(maxOf(minimumBuffer * 2, frameBytes * 4))
.build()
.also { check(it.state == AudioTrack.STATE_INITIALIZED) }
}
private fun schedulePlaybackIdle(attempt: ActiveAttempt) {
playbackIdleJob?.cancel()
val scheduledPlaybackEndMillis = playbackEndsAtMillis
val finalFrameDurationMillis = mouthLevelAccumulator.pendingFrameDurationMillis()
playbackIdleJob =
scope.launch {
if (finalFrameDurationMillis > 0L) {
val finalFrameStartsAtMillis = scheduledPlaybackEndMillis - finalFrameDurationMillis
// Emit the residual at its cumulative sample position; clear/reset below
// discards it only when the matching AudioTrack tail is also discarded.
while (SystemClock.elapsedRealtime() < finalFrameStartsAtMillis) delay(MOUTH_FRAME_MILLIS.toLong())
synchronized(audioLock) {
if (isCurrent(attempt) && playbackEndsAtMillis == scheduledPlaybackEndMillis) {
mouthLevelAccumulator.flush().forEach { level -> mouthTimelineLocked().trySend(level) }
}
}
}
while (SystemClock.elapsedRealtime() < scheduledPlaybackEndMillis) delay(MOUTH_FRAME_MILLIS.toLong())
delay(PLAYBACK_DRAIN_GRACE_MILLIS)
synchronized(audioLock) {
if (
isCurrent(attempt) &&
playbackEndsAtMillis == scheduledPlaybackEndMillis &&
SystemClock.elapsedRealtime() >= scheduledPlaybackEndMillis
) {
clearOutputLocked(attempt, resumeCapture = true)
}
}
}
}
private fun clearOutput(
attempt: ActiveAttempt,
resumeCapture: Boolean,
) {
synchronized(audioLock) {
if (isCurrent(attempt)) clearOutputLocked(attempt, resumeCapture)
}
}
private fun clearOutputLocked(
attempt: ActiveAttempt?,
resumeCapture: Boolean,
) {
playbackIdleJob?.cancel()
playbackIdleJob = null
playbackEndsAtMillis = 0L
val activeMouthFrames = mouthFrames
mouthFrames = null
activeMouthFrames?.close()
mouthJob?.cancel()
mouthJob = null
mouthLevelAccumulator.reset()
_mouthLevel.value = 0f
runCatching {
audioTrack?.pause()
audioTrack?.flush()
audioTrack?.stop()
audioTrack?.release()
}
audioTrack = null
_isPlaying.value = false
audioFocus.abandon()
if (resumeCapture && attempt != null && isCurrent(attempt)) {
runCatching { startCaptureLocked(attempt) }
}
}
private fun pauseCaptureLocked() {
_isCapturing.value = false
captureJob?.cancel()
captureJob = null
val recorder = audioRecord
audioRecord = null
runCatching { recorder?.stop() }
runCatching { recorder?.release() }
}
private fun handleChannelFailure(attempt: ActiveAttempt) {
if (!closeLocal(attempt, failed = true)) return
scope.launch { runCatching { repository.stopRealtimeTalk(attempt.nodeId, attempt.attemptId) } }
}
private fun activate(attempt: ActiveAttempt) {
synchronized(audioLock) {
check(activeAttempt == null)
_channelFailed.value = false
activeAttempt = attempt
}
}
private fun closeLocal(
expected: ActiveAttempt? = activeAttempt,
failed: Boolean = false,
): Boolean {
val attempt =
synchronized(audioLock) {
val current = activeAttempt ?: return false
if (expected != null && current.generation != expected.generation) return false
activeAttempt = null
if (failed) _channelFailed.value = true
readJob?.cancel()
readJob = null
pauseCaptureLocked()
clearOutputLocked(attempt = null, resumeCapture = false)
current
}
scope.launch { closeChannel(attempt.resources) }
return true
}
private suspend fun closeChannel(resources: ChannelResources?) {
if (resources == null) return
resources.input.closeQuietly()
resources.output.closeQuietly()
runCatching { channelClient.close(resources.channel).awaitRealtimeTask() }
}
private fun isCurrent(attempt: ActiveAttempt): Boolean = activeAttempt?.generation == attempt.generation
private companion object {
const val CHANNEL_OPEN_ATTEMPTS = 2
const val CHANNEL_RETRY_DELAY_MILLIS = 250L
const val ISO_639_1_LANGUAGE_LENGTH = 2
const val MOUTH_QUEUE_CAPACITY = 256
const val PCM_16_BYTES = 2
const val PLAYBACK_DRAIN_GRACE_MILLIS = 120L
}
}
internal fun wearRealtimeAudioChannelPath(
attemptId: String,
attemptScopedAudio: Boolean,
): String =
if (attemptScopedAudio) {
WearProtocol.realtimeAudioChannelPath(attemptId)
} else {
// v2026.7.2 shipped the fixed path. Keep it for staggered phone/Watch updates.
WearProtocol.LEGACY_REALTIME_AUDIO_CHANNEL_PATH
}
internal fun pcm16LeMouthLevels(
pcm: ByteArray,
sampleRateHz: Int = WearProtocol.REALTIME_AUDIO_SAMPLE_RATE_HZ,
frameMillis: Int = MOUTH_FRAME_MILLIS,
): List<Float> =
Pcm16MouthLevelAccumulator(sampleRateHz, frameMillis).run {
append(pcm) + flush()
}
internal class Pcm16MouthLevelAccumulator(
private val sampleRateHz: Int = WearProtocol.REALTIME_AUDIO_SAMPLE_RATE_HZ,
frameMillis: Int = MOUTH_FRAME_MILLIS,
) {
private val samplesPerFrame: Int
private var squareSum = 0.0
private var sampleCount = 0
init {
require(sampleRateHz > 0 && frameMillis > 0)
samplesPerFrame = (sampleRateHz * frameMillis / 1_000).coerceAtLeast(1)
}
fun append(pcm: ByteArray): List<Float> {
require(pcm.size % PCM_BYTES_PER_SAMPLE == 0)
return buildList {
var byteIndex = 0
while (byteIndex < pcm.size) {
val low = pcm[byteIndex].toInt() and 0xff
val high = pcm[byteIndex + 1].toInt()
val sample = ((high shl 8) or low).toShort().toInt()
val normalized = sample / 32_768.0
squareSum += normalized * normalized
sampleCount += 1
byteIndex += PCM_BYTES_PER_SAMPLE
if (sampleCount == samplesPerFrame) add(finishFrame())
}
}
}
fun flush(): List<Float> = if (sampleCount == 0) emptyList() else listOf(finishFrame())
fun reset() {
squareSum = 0.0
sampleCount = 0
}
fun pendingFrameDurationMillis(): Long =
if (sampleCount == 0) {
0L
} else {
ceil(sampleCount * 1_000.0 / sampleRateHz).toLong()
}
private fun finishFrame(): Float {
val rms = sqrt(squareSum / sampleCount)
val gated = ((rms - RMS_NOISE_GATE) / RMS_SPEECH_RANGE).coerceIn(0.0, 1.0)
reset()
return sqrt(gated).toFloat()
}
}
internal const val MOUTH_FRAME_MILLIS = 20
private const val PCM_BYTES_PER_SAMPLE = 2
private const val RMS_NOISE_GATE = 0.015
private const val RMS_SPEECH_RANGE = 0.2
private fun java.io.Closeable?.closeQuietly() {
runCatching { this?.close() }
}
private suspend fun <T> Task<T>.awaitRealtimeTask(): T =
suspendCancellableCoroutine { continuation ->
addOnSuccessListener { value -> if (continuation.isActive) continuation.resume(value) }
addOnFailureListener { error -> if (continuation.isActive) continuation.resumeWithException(error) }
addOnCanceledListener { continuation.cancel() }
}

View file

@ -0,0 +1,286 @@
package ai.openclaw.wear
import android.Manifest
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.Person
import androidx.core.app.RemoteInput
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import java.security.MessageDigest
internal class WearReplyNotifier(
private val context: Context,
) {
fun show(inbound: WearInboundEvent) {
val event = parseWearChatEvent(inbound.payload) ?: return
if (event.state != "final") return
val message = event.message ?: return
if (message.role != "assistant") return
val sessionKey = event.sessionKey ?: return
if (!notificationsAllowed()) return
createChannel()
val fallbackIdentity =
event.runId
?: listOf(
"source:${inbound.sourceNodeId}",
"stream:${inbound.streamId ?: "legacy"}",
"sequence:${inbound.sequence}",
).joinToString("\u0000")
val notificationTag = replyNotificationTag(sessionKey, message, fallbackIdentity)
val requestCode = NOTIFICATION_ID
val replyAction = createReplyAction(sessionKey, notificationTag, inbound.sourceNodeId)
val openPendingIntent = createOpenAppIntent(requestCode)
val agent = Person.Builder().setName("OpenClaw").build()
val notification =
NotificationCompat
.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(context.getString(R.string.notification_title))
.setContentText(message.text)
.setContentIntent(openPendingIntent)
.setAutoCancel(true)
.setLocalOnly(true)
.setOnlyAlertOnce(true)
.setStyle(
NotificationCompat
.MessagingStyle(agent)
.addMessage(message.text, message.timestamp ?: System.currentTimeMillis(), agent),
).addAction(replyAction)
.build()
notify(notificationTag, notification)
}
fun showReplyFailure(
sessionKey: String,
notificationTag: String,
phoneNodeId: String,
) {
if (!notificationsAllowed()) return
createChannel()
val notification =
NotificationCompat
.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(context.getString(R.string.notification_reply_failed_title))
.setContentText(context.getString(R.string.notification_reply_failed_text))
.setAutoCancel(true)
.setLocalOnly(true)
.addAction(createReplyAction(sessionKey, notificationTag, phoneNodeId))
.build()
notify(notificationTag, notification)
}
fun showPreferredPhoneChanged(notificationTag: String) {
if (!notificationsAllowed()) return
createChannel()
val notification =
NotificationCompat
.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(context.getString(R.string.notification_phone_changed_title))
.setContentText(context.getString(R.string.notification_phone_changed_text))
.setContentIntent(createOpenAppIntent(NOTIFICATION_ID))
.setAutoCancel(true)
.setLocalOnly(true)
.build()
notify(notificationTag, notification)
}
private fun createOpenAppIntent(requestCode: Int): PendingIntent =
PendingIntent.getActivity(
context,
requestCode,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
private fun createReplyAction(
sessionKey: String,
notificationTag: String,
phoneNodeId: String,
): NotificationCompat.Action {
val replyIntent =
Intent(context, WearReplyReceiver::class.java).apply {
action = replyPendingIntentAction(sessionKey, notificationTag)
putExtra(EXTRA_SESSION_KEY, sessionKey)
putExtra(EXTRA_NOTIFICATION_TAG, notificationTag)
putExtra(EXTRA_PHONE_NODE_ID, phoneNodeId)
}
val replyPendingIntent =
PendingIntent.getBroadcast(
context,
NOTIFICATION_ID,
replyIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_ONE_SHOT,
)
val remoteInput =
RemoteInput
.Builder(REPLY_RESULT_KEY)
.setLabel(context.getString(R.string.notification_reply))
.build()
return NotificationCompat.Action
.Builder(
R.drawable.ic_notification,
context.getString(R.string.notification_reply),
replyPendingIntent,
).addRemoteInput(remoteInput)
.setAllowGeneratedReplies(true)
.build()
}
private fun createChannel() {
val manager = context.getSystemService(NotificationManager::class.java)
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
context.getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_DEFAULT,
),
)
}
private fun notificationsAllowed(): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
@SuppressLint("MissingPermission")
private fun notify(
notificationTag: String,
notification: android.app.Notification,
) {
if (!notificationsAllowed()) return
try {
NotificationManagerCompat.from(context).notify(notificationTag, NOTIFICATION_ID, notification)
} catch (_: SecurityException) {
// Permission can be revoked between the explicit check and notify().
}
}
private companion object {
const val CHANNEL_ID = "openclaw_wear_replies"
}
}
class WearReplyReceiver : BroadcastReceiver() {
override fun onReceive(
context: Context,
intent: Intent,
) {
val sessionKey = intent.getStringExtra(EXTRA_SESSION_KEY)?.takeIf { it.isNotBlank() } ?: return
val notificationTag = intent.getStringExtra(EXTRA_NOTIFICATION_TAG)?.takeIf { it.isNotBlank() } ?: return
val phoneNodeId = intent.getStringExtra(EXTRA_PHONE_NODE_ID)?.takeIf { it.isNotBlank() } ?: return
val reply =
RemoteInput
.getResultsFromIntent(intent)
?.getCharSequence(REPLY_RESULT_KEY)
?.toString()
?.trim()
?.takeIf { it.isNotEmpty() } ?: return
val pendingResult = goAsync()
val app =
context.applicationContext as? WearApplication
?: run {
pendingResult.finish()
return
}
app.processScope.launch {
try {
// Broadcast receivers have a short execution window. Bound discovery,
// transport, and response wait together so finish() always wins the race.
withTimeout(REPLY_BROADCAST_TIMEOUT_MS) {
app.gatewayRepository.send(
WearSendAttempt(
sessionKey = sessionKey,
message = reply,
idempotencyKey = notificationReplyIdempotencyKey(sessionKey, notificationTag, reply),
phoneNodeId = phoneNodeId,
),
requirePreferredPhone = true,
)
}
NotificationManagerCompat.from(context).cancel(notificationTag, NOTIFICATION_ID)
} catch (err: TimeoutCancellationException) {
Log.w(LOG_TAG, "Wear notification reply timed out", err)
WearReplyNotifier(context.applicationContext).showReplyFailure(sessionKey, notificationTag, phoneNodeId)
} catch (err: CancellationException) {
throw err
} catch (err: Throwable) {
Log.w(LOG_TAG, "Wear notification reply failed", err)
val notifier = WearReplyNotifier(context.applicationContext)
when (notificationReplyFailureAction(err)) {
NotificationReplyFailureAction.RetrySamePhone ->
notifier.showReplyFailure(sessionKey, notificationTag, phoneNodeId)
NotificationReplyFailureAction.OpenApp -> notifier.showPreferredPhoneChanged(notificationTag)
}
} finally {
pendingResult.finish()
}
}
}
}
internal const val EXTRA_SESSION_KEY = "openclaw_wear_session_key"
internal const val EXTRA_NOTIFICATION_TAG = "openclaw_wear_notification_tag"
internal const val EXTRA_PHONE_NODE_ID = "openclaw_wear_phone_node_id"
internal fun replyNotificationTag(
sessionKey: String,
message: WearChatMessage,
fallbackIdentity: String,
): String {
val messageIdentity =
when {
message.id != null -> "id:${message.id}"
message.timestamp != null -> "timestamp:${message.timestamp}\u0000${message.role}\u0000${message.text}"
else -> "fallback:$fallbackIdentity"
}
return "ai.openclaw.wear.NOTIFICATION.${sha256("$sessionKey\u0000$messageIdentity")}"
}
internal fun replyPendingIntentAction(
sessionKey: String,
notificationTag: String,
): String = "ai.openclaw.wear.REPLY.${sha256("$sessionKey\u0000$notificationTag")}"
internal fun notificationReplyIdempotencyKey(
sessionKey: String,
notificationTag: String,
reply: String,
): String = "wear-notification-${sha256("$sessionKey\u0000$notificationTag\u0000$reply")}"
internal enum class NotificationReplyFailureAction {
RetrySamePhone,
OpenApp,
}
internal fun notificationReplyFailureAction(error: Throwable): NotificationReplyFailureAction =
if (error is WearProxyException && error.code == "phone_changed") {
NotificationReplyFailureAction.OpenApp
} else {
NotificationReplyFailureAction.RetrySamePhone
}
private fun sha256(value: String): String {
val digest = MessageDigest.getInstance("SHA-256").digest(value.encodeToByteArray())
return digest.joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) }
}
private const val LOG_TAG = "OpenClawWear"
private const val NOTIFICATION_ID = 7301
private const val REPLY_BROADCAST_TIMEOUT_MS = 5_000L

View file

@ -0,0 +1,117 @@
package ai.openclaw.wear
import android.content.Context
import android.os.Bundle
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.Locale
import java.util.UUID
internal class WearReplySpeaker(
context: Context,
) {
private val _isSpeaking = MutableStateFlow(false)
val isSpeaking: StateFlow<Boolean> = _isSpeaking.asStateFlow()
private val audioFocus = WearAudioFocusController(context, ::stop)
private var engine: TextToSpeech? = null
private var ready = false
private var pendingText: String? = null
init {
val created =
TextToSpeech(context.applicationContext) { status ->
ready = status == TextToSpeech.SUCCESS
if (ready) {
engine?.language = Locale.getDefault()
engine?.setAudioAttributes(wearSpeechAudioAttributes)
pendingText?.let(::speak)
} else {
pendingText = null
_isSpeaking.value = false
audioFocus.abandon()
}
}
engine = created
created.setOnUtteranceProgressListener(
object : UtteranceProgressListener() {
override fun onStart(utteranceId: String) {
_isSpeaking.value = true
}
override fun onDone(utteranceId: String) {
_isSpeaking.value = false
audioFocus.abandon()
}
@Suppress("OVERRIDE_DEPRECATION")
override fun onError(utteranceId: String) {
_isSpeaking.value = false
audioFocus.abandon()
}
override fun onError(
utteranceId: String,
errorCode: Int,
) {
_isSpeaking.value = false
audioFocus.abandon()
}
override fun onStop(
utteranceId: String,
interrupted: Boolean,
) {
_isSpeaking.value = false
audioFocus.abandon()
}
},
)
if (ready) {
created.language = Locale.getDefault()
created.setAudioAttributes(wearSpeechAudioAttributes)
pendingText?.let(::speak)
}
}
fun speak(text: String) {
val normalized = text.trim().takeIf(String::isNotEmpty) ?: return
if (!ready) {
pendingText = normalized
return
}
pendingText = null
audioFocus.request()
val result =
engine?.speak(
normalized,
TextToSpeech.QUEUE_FLUSH,
Bundle(),
UUID.randomUUID().toString(),
)
if (result == TextToSpeech.ERROR) {
_isSpeaking.value = false
audioFocus.abandon()
}
}
fun stop() {
pendingText = null
engine?.stop()
_isSpeaking.value = false
audioFocus.abandon()
}
fun shutdown() {
pendingText = null
engine?.stop()
engine?.shutdown()
engine = null
ready = false
_isSpeaking.value = false
audioFocus.abandon()
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,124 @@
package ai.openclaw.wear
import android.content.Intent
import androidx.compose.runtime.Composable
import androidx.wear.compose.material3.AppScaffold
internal const val extraWearScreenshotMode = "openclaw.screenshotMode"
internal const val extraWearScreenshotScene = "openclaw.screenshotScene"
internal enum class WearScreenshotScene(
val rawValue: String,
val initialPage: WearHomePage,
) {
Chat("chat", WearHomePage.Chat),
Voice("voice", WearHomePage.Voice),
Controls("controls", WearHomePage.Controls),
;
companion object {
fun fromRawValue(raw: String?): WearScreenshotScene = entries.firstOrNull { scene -> scene.rawValue == raw?.trim()?.lowercase() } ?: Chat
}
}
internal fun parseWearScreenshotModeIntent(intent: Intent?): WearScreenshotScene? {
if (intent?.getBooleanExtra(extraWearScreenshotMode, false) != true) return null
return WearScreenshotScene.fromRawValue(intent.getStringExtra(extraWearScreenshotScene))
}
internal object WearScreenshotFixture {
val snapshot =
WearConversationSnapshot(
gatewayState = WearGatewayState.CONNECTED,
activeAgentId = "main",
agents =
listOf(
WearAgentSummary(
id = "main",
name = "Molty",
emoji = "M",
selected = true,
),
),
agentControlsSupported = true,
gatewayControlsSupported = true,
activeSessionId = "release-planning",
sessions =
listOf(
WearSessionSummary(
id = "release-planning",
title = "Release planning",
updatedAtEpochMillis = 1_783_555_320_000,
selected = true,
),
),
models =
listOf(
WearModelSummary(
ref = "openai/gpt-5.2",
name = "GPT-5.2",
selected = true,
),
),
modelControlsSupported = true,
messages =
listOf(
WearChatMessage(
id = "release-question",
role = "user",
text = "Is the Android release ready?",
timestamp = 1_783_555_260_000,
),
WearChatMessage(
id = "release-answer",
role = "assistant",
text = "Ready after the final store checks.",
timestamp = 1_783_555_320_000,
),
),
selectedModelRef = "openai/gpt-5.2",
)
}
@Composable
internal fun OpenClawWearScreenshotApp(scene: WearScreenshotScene) {
OpenClawWearTheme(themeMode = WearThemeMode.Dark) {
AppScaffold {
OpenClawWearScreens(
snapshot = WearScreenshotFixture.snapshot,
failure = null,
loading = false,
interaction = WearInteractionState.READY,
speaking = false,
realtimeCapturing = false,
realtimePlaying = false,
realtimeMouthLevel = 0f,
realtimePlaybackFailed = false,
realtimeThinkingOverride = false,
actionBusy = false,
inputEnabled = true,
canAbort = false,
themeMode = WearThemeMode.Dark,
autoSpeak = false,
notificationsGranted = true,
initialPage = scene.initialPage,
voiceSwipeHintEnabled = false,
onTalk = {},
onType = {},
onRealtimeTalk = {},
onAbort = {},
onSelectAgent = {},
onSelectSession = {},
onSelectModel = {},
onRefresh = {},
onGatewayEnabledChange = {},
onThemeModeChange = {},
onAutoSpeakChange = {},
onRequestNotifications = {},
onOpenNotificationSettings = {},
onSpeakLatest = {},
onStopSpeaking = {},
)
}
}
}

View file

@ -0,0 +1,606 @@
package ai.openclaw.wear
import android.animation.ValueAnimator
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.PowerManager
import androidx.annotation.RequiresApi
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.MotionDurationScale
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.withTransform
import androidx.compose.ui.graphics.vector.PathParser
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner
import kotlinx.coroutines.flow.collect
import kotlin.coroutines.coroutineContext
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.exp
import kotlin.math.max
import kotlin.math.sin
// Canonical 120x120 mascot geometry from ui/public/favicon.svg. Parts stay
// separate so the original silhouette can react without substituting artwork.
private val BodyPath by lazy {
PathParser()
.parsePathString(
"M60 10 C30 10 15 35 15 55 C15 75 30 95 45 100 L45 110 L55 110 L55 100 " +
"C55 100 60 102 65 100 L65 110 L75 110 L75 100 C90 95 105 75 105 55 C105 35 90 10 60 10Z",
).toPath()
}
private val LeftClawPath by lazy {
PathParser().parsePathString("M20 45 C5 40 0 50 5 60 C10 70 20 65 25 55 C28 48 25 45 20 45Z").toPath()
}
private val RightClawPath by lazy {
PathParser().parsePathString("M100 45 C115 40 120 50 115 60 C110 70 100 65 95 55 C92 48 95 45 100 45Z").toPath()
}
private val LeftAntennaPath by lazy { PathParser().parsePathString("M45 15 Q35 5 30 8").toPath() }
private val RightAntennaPath by lazy { PathParser().parsePathString("M75 15 Q85 5 90 8").toPath() }
private val CoralBright = Color(0xFFFF4D4D)
private val CoralDark = Color(0xFF991B1B)
private val EyeDark = Color(0xFF050810)
private val EyeGlow = Color(0xFF00E5CC)
private val Tongue = Color(0xFFFF9EAE)
private val LeftClawPivot = Offset(26f, 53f)
private val RightClawPivot = Offset(94f, 53f)
private val LeftAntennaPivot = Offset(37.5f, 11f)
private val RightAntennaPivot = Offset(82.5f, 11f)
private val LeftEyeCenter = Offset(45f, 35f)
private val RightEyeCenter = Offset(75f, 35f)
internal data class WearAvatarPose(
val floatOffset: Float,
val bodyTilt: Float,
val bodyStretch: Float,
val antennaDegrees: Float,
val antennaDroop: Float,
val leftClawDegrees: Float,
val rightClawDegrees: Float,
val eyeOpenness: Float,
val gaze: Offset,
val mouthLevel: Float,
val haloPulse: Float,
)
@Composable
internal fun WearTalkAvatar(
state: RealtimeVoiceButtonState,
mouthLevel: Float,
syntheticSpeech: Boolean,
accent: Color,
danger: Color,
modifier: Modifier = Modifier,
animatorScaleSource: WearAnimatorScaleSource? = null,
motionDurationScale: MotionDurationScale? = null,
frameClock: WearAvatarFrameClock = ComposeWearAvatarFrameClock,
onAnimationStateChanged: ((WearAvatarAnimationState) -> Unit)? = null,
) {
val animationScale = rememberAnimatorDurationScale(animatorScaleSource, motionDurationScale)
val animationsEnabled = animationScale > 0f
val latestState by rememberUpdatedState(state)
val latestMouthLevel by rememberUpdatedState(mouthLevel)
val latestSyntheticSpeech by rememberUpdatedState(syntheticSpeech)
var animationSeconds by remember { mutableFloatStateOf(0f) }
var smoothedMouth by remember { mutableFloatStateOf(0f) }
LaunchedEffect(animationScale, frameClock) {
if (!animationsEnabled) {
animationSeconds = 0f
smoothedMouth = 0f
return@LaunchedEffect
}
var lastFrameNanos = 0L
while (true) {
frameClock.awaitFrame { frameNanos ->
if (lastFrameNanos != 0L) {
val deltaSeconds =
scaledAvatarDeltaSeconds(
deltaSeconds = (frameNanos - lastFrameNanos) / 1_000_000_000f,
durationScale = animationScale,
)
animationSeconds = (animationSeconds + deltaSeconds) % AVATAR_ANIMATION_CYCLE_SECONDS
val targetMouth =
if (latestState == RealtimeVoiceButtonState.SPEAKING) {
max(
latestMouthLevel.coerceIn(0f, 1f),
if (latestSyntheticSpeech) syntheticSpeechMouth(animationSeconds) else 0f,
)
} else {
0f
}
smoothedMouth = smoothAvatarMouth(smoothedMouth, targetMouth, deltaSeconds)
}
lastFrameNanos = frameNanos
}
}
}
val motionInputs = avatarMotionInputs(animationsEnabled, animationSeconds, smoothedMouth)
val pose = avatarPoseAt(state, motionInputs.animationSeconds, motionInputs.mouthLevel)
val stateColor = if (state == RealtimeVoiceButtonState.ERROR) danger else accent
SideEffect {
onAnimationStateChanged?.invoke(
WearAvatarAnimationState(
durationScale = animationScale,
animationSeconds = motionInputs.animationSeconds,
mouthLevel = motionInputs.mouthLevel,
),
)
}
Canvas(modifier = modifier) {
val unit = size.minDimension
val center = Offset(size.width / 2f, size.height / 2f)
drawCircle(
color = stateColor.copy(alpha = 0.3f + (0.28f * pose.haloPulse)),
radius = unit * (0.455f + (0.012f * pose.haloPulse)),
center = center,
style = Stroke(width = unit * 0.025f),
)
val artScale = unit / CANONICAL_ART_BOX
val artLeft = center.x - ((CANONICAL_ART_SIZE * artScale) / 2f)
val artTop = center.y - ((CANONICAL_ART_SIZE * artScale) / 2f) + (unit * 0.025f)
withTransform({ translate(left = artLeft, top = artTop) }) {
withTransform({ scale(artScale, artScale, pivot = Offset.Zero) }) {
drawCanonicalAvatar(pose, state, motionInputs.animationSeconds)
}
}
}
}
@Composable
internal fun rememberAnimatorDurationScale(
animatorScaleSource: WearAnimatorScaleSource? = null,
motionDurationScale: MotionDurationScale? = null,
): Float {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val effectiveScaleSource =
animatorScaleSource
?: remember(context, lifecycleOwner) {
AndroidWearAnimatorScaleSource(context.applicationContext, lifecycleOwner)
}
val effectiveScale = rememberEffectiveAnimatorScale(effectiveScaleSource)
var canonicalScale by remember(motionDurationScale) {
mutableFloatStateOf(motionDurationScale?.scaleFactor?.coerceAtLeast(0f) ?: 1f)
}
LaunchedEffect(motionDurationScale) {
val composeScale = motionDurationScale ?: coroutineContext[MotionDurationScale]
if (composeScale == null) {
canonicalScale = 1f
return@LaunchedEffect
}
// Compose lazily starts its Android scale observer from this getter, which
// may write snapshot state and therefore must run before snapshotFlow.
canonicalScale = composeScale.scaleFactor.coerceAtLeast(0f)
snapshotFlow { composeScale.scaleFactor.coerceAtLeast(0f) }
.collect { scale -> canonicalScale = scale }
}
return resolvedAvatarAnimationScale(canonicalScale, effectiveScale)
}
@Composable
private fun rememberEffectiveAnimatorScale(source: WearAnimatorScaleSource): Float {
var effectiveScale by remember(source) { mutableFloatStateOf(source.currentScale()) }
DisposableEffect(source) {
effectiveScale = source.currentScale()
val subscription = source.subscribe { scale -> effectiveScale = scale.coerceAtLeast(0f) }
onDispose { subscription.dispose() }
}
return effectiveScale
}
internal fun resolvedAvatarAnimationScale(
canonicalScale: Float,
effectiveScale: Float,
): Float = if (canonicalScale > 0f && effectiveScale > 0f) canonicalScale else 0f
internal fun interface WearAnimatorScaleSubscription {
fun dispose()
}
internal interface WearAnimatorScaleSource {
fun currentScale(): Float
fun subscribe(onScaleChanged: (Float) -> Unit): WearAnimatorScaleSubscription
}
internal class AndroidWearAnimatorScaleSource(
private val context: Context,
private val lifecycleOwner: LifecycleOwner,
) : WearAnimatorScaleSource {
private val powerManager = context.getSystemService(PowerManager::class.java)
override fun currentScale(): Float =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ValueAnimator.getDurationScale().coerceAtLeast(0f)
} else {
// Compose owns the user duration scale. Legacy Android exposes no listener
// for Battery Saver's separate override, so keep only that signal here.
if (powerManager.isPowerSaveMode) 0f else 1f
}
override fun subscribe(onScaleChanged: (Float) -> Unit): WearAnimatorScaleSubscription =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
subscribeToDurationScale(onScaleChanged)
} else {
subscribeToLegacyEffectiveScale(onScaleChanged)
}
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
private fun subscribeToDurationScale(
onScaleChanged: (Float) -> Unit,
): WearAnimatorScaleSubscription {
val listener =
ValueAnimator.DurationScaleChangeListener { scale ->
onScaleChanged(scale.coerceAtLeast(0f))
}
ValueAnimator.registerDurationScaleChangeListener(listener)
onScaleChanged(currentScale())
return WearAnimatorScaleSubscription {
ValueAnimator.unregisterDurationScaleChangeListener(listener)
}
}
@Suppress("UnspecifiedRegisterReceiverFlag")
private fun subscribeToLegacyEffectiveScale(onScaleChanged: (Float) -> Unit): WearAnimatorScaleSubscription {
val refresh = { onScaleChanged(currentScale()) }
val receiver =
object : BroadcastReceiver() {
override fun onReceive(
context: Context?,
intent: Intent?,
) {
refresh()
}
}
val lifecycleObserver =
object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
refresh()
}
override fun onResume(owner: LifecycleOwner) {
refresh()
}
}
context.registerReceiver(receiver, IntentFilter(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED))
lifecycleOwner.lifecycle.addObserver(lifecycleObserver)
refresh()
return WearAnimatorScaleSubscription {
context.unregisterReceiver(receiver)
lifecycleOwner.lifecycle.removeObserver(lifecycleObserver)
}
}
}
internal fun interface WearAvatarFrameClock {
suspend fun awaitFrame(onFrame: (Long) -> Unit)
}
private val ComposeWearAvatarFrameClock = WearAvatarFrameClock { onFrame -> withFrameNanos(onFrame) }
internal data class WearAvatarAnimationState(
val durationScale: Float,
val animationSeconds: Float,
val mouthLevel: Float,
)
internal data class WearAvatarMotionInputs(
val animationSeconds: Float,
val mouthLevel: Float,
)
internal fun avatarMotionInputs(
animationsEnabled: Boolean,
animationSeconds: Float,
mouthLevel: Float,
): WearAvatarMotionInputs =
if (animationsEnabled) {
WearAvatarMotionInputs(
animationSeconds = animationSeconds,
mouthLevel = mouthLevel.coerceIn(0f, 1f),
)
} else {
WearAvatarMotionInputs(animationSeconds = 0f, mouthLevel = 0f)
}
private fun DrawScope.drawCanonicalAvatar(
pose: WearAvatarPose,
state: RealtimeVoiceButtonState,
animationSeconds: Float,
) {
val stretchX = (1f + ((1f - pose.bodyStretch) * 0.5f)).coerceIn(0.96f, 1.04f)
withTransform({ translate(top = pose.floatOffset) }) {
withTransform({
scale(stretchX, pose.bodyStretch, pivot = Offset(60f, 110f))
rotate(pose.bodyTilt, pivot = Offset(60f, 60f))
}) {
drawPath(
path = BodyPath,
brush =
Brush.linearGradient(
colors = listOf(CoralBright, CoralDark),
start = Offset(15f, 10f),
end = Offset(105f, 110f),
),
)
withTransform({ rotate(pose.leftClawDegrees, pivot = LeftClawPivot) }) {
drawPath(
path = LeftClawPath,
brush =
Brush.linearGradient(
colors = listOf(CoralBright, CoralDark),
start = Offset(3.125f, 43.67f),
end = Offset(26.197f, 65.451f),
),
)
}
withTransform({ rotate(pose.rightClawDegrees, pivot = RightClawPivot) }) {
drawPath(
path = RightClawPath,
brush =
Brush.linearGradient(
colors = listOf(CoralBright, CoralDark),
start = Offset(93.803f, 43.67f),
end = Offset(116.875f, 65.451f),
),
)
}
val antennaStroke = Stroke(width = 2f, cap = StrokeCap.Round)
val wiggle = pose.antennaDegrees * (1f - pose.antennaDroop)
withTransform({ rotate((-pose.antennaDroop * 40f), pivot = Offset(45f, 15f)) }) {
withTransform({ rotate(wiggle, pivot = LeftAntennaPivot) }) {
drawPath(LeftAntennaPath, CoralBright, style = antennaStroke)
}
}
withTransform({ rotate((pose.antennaDroop * 40f), pivot = Offset(75f, 15f)) }) {
withTransform({ rotate(wiggle, pivot = RightAntennaPivot) }) {
drawPath(RightAntennaPath, CoralBright, style = antennaStroke)
}
}
drawCanonicalEye(LeftEyeCenter, pose.eyeOpenness, pose.gaze)
drawCanonicalEye(RightEyeCenter, pose.eyeOpenness, pose.gaze)
drawCanonicalMouth(state, pose.mouthLevel, animationSeconds)
}
}
}
private fun DrawScope.drawCanonicalEye(
center: Offset,
openness: Float,
gaze: Offset,
) {
val eyeHeight = max(1.2f, 12f * openness)
val eyeCenterY = center.y - 6f + ((12f - eyeHeight) * 0.65f) + (eyeHeight / 2f)
drawOval(
color = EyeDark,
topLeft = Offset(center.x - 6f, eyeCenterY - (eyeHeight / 2f)),
size = Size(12f, eyeHeight),
)
if (openness <= 0.16f) return
val pupil =
Offset(
x = center.x + (gaze.x * 2.7f),
y = center.y - 1f + (gaze.y * 2.1f),
)
drawCircle(
color = EyeGlow,
radius = 2.1f,
center = pupil,
alpha = ((openness - 0.16f) / 0.84f).coerceIn(0f, 1f),
)
}
private fun DrawScope.drawCanonicalMouth(
state: RealtimeVoiceButtonState,
mouthLevel: Float,
animationSeconds: Float,
) {
if (state == RealtimeVoiceButtonState.ERROR) {
val frown =
Path().apply {
moveTo(52.5f, 54f)
quadraticTo(60f, 47f, 67.5f, 54f)
}
drawPath(frown, EyeDark, style = Stroke(width = 2.2f, cap = StrokeCap.Round))
return
}
if (state != RealtimeVoiceButtonState.SPEAKING || mouthLevel <= 0.025f) return
val vowelShape = 0.5f + (0.5f * sin(animationSeconds * 2f * PI.toFloat() / 0.31f))
val radiusX = 2.2f + (mouthLevel * (4.7f + (1.6f * vowelShape)))
val radiusY = 1.1f + (mouthLevel * (6.5f - (1.3f * vowelShape)))
drawOval(
color = EyeDark,
topLeft = Offset(60f - radiusX, 52f - radiusY),
size = Size(radiusX * 2f, radiusY * 2f),
)
if (mouthLevel > 0.48f) {
drawOval(
color = Tongue,
topLeft = Offset(60f - (radiusX * 0.55f), 52f + (radiusY * 0.24f)),
size = Size(radiusX * 1.1f, radiusY * 0.42f),
alpha = ((mouthLevel - 0.48f) / 0.52f).coerceIn(0f, 0.82f),
)
}
}
internal fun avatarPoseAt(
state: RealtimeVoiceButtonState,
animationSeconds: Float,
mouthLevel: Float,
): WearAvatarPose {
val tau = 2f * PI.toFloat()
val breathing = sin(animationSeconds * tau / 3.8f)
var floatOffset = -2.6f * (1f - cos(animationSeconds * tau / 4.2f))
var bodyTilt = 0.8f * sin(animationSeconds * tau / 6.4f)
var bodyStretch = 1f + (0.012f * breathing)
var antennaDegrees = -3f * sin(animationSeconds * tau / 2.1f)
var antennaDroop = 0f
var leftClawDegrees = 0f
var rightClawDegrees = 0f
var gaze = Offset(0.45f * sin(animationSeconds * tau / 7.5f), 0.2f * sin(animationSeconds * tau / 5.8f))
var eyeOpenness = 1f - (0.96f * avatarBlinkClosure(animationSeconds))
var haloPulse = 0.5f + (0.5f * sin(animationSeconds * tau / 2.4f))
when (state) {
RealtimeVoiceButtonState.IDLE -> Unit
RealtimeVoiceButtonState.CONNECTING -> {
val orbit = animationSeconds * tau / 1.65f
gaze = Offset(cos(orbit) * 1.05f, sin(orbit) * 0.82f)
bodyTilt = 2f * sin(animationSeconds * tau / 2.8f)
antennaDegrees = -7f * sin(animationSeconds * tau / 1.1f)
leftClawDegrees = 3f * sin(animationSeconds * tau / 1.4f)
rightClawDegrees = -leftClawDegrees
haloPulse = 0.5f + (0.5f * sin(animationSeconds * tau / 0.9f))
}
RealtimeVoiceButtonState.LISTENING -> {
val attentivePulse = 0.5f + (0.5f * sin(animationSeconds * tau / 1.25f))
gaze = Offset(0.2f * sin(animationSeconds * tau / 3.2f), 0.34f)
bodyStretch += 0.018f * attentivePulse
leftClawDegrees = 4f + (2f * attentivePulse)
rightClawDegrees = -leftClawDegrees
antennaDegrees = -4f * sin(animationSeconds * tau / 1.45f)
haloPulse = attentivePulse
}
RealtimeVoiceButtonState.THINKING -> {
val orbit = animationSeconds * tau / 2.15f
gaze = Offset(cos(orbit) * 1.15f, sin(orbit) * 0.92f)
bodyTilt = 2.8f * sin(animationSeconds * tau / 4.5f)
antennaDegrees = -7f * sin(animationSeconds * tau / 1.25f)
leftClawDegrees = 5f + (2f * sin(animationSeconds * tau / 2.7f))
rightClawDegrees = -10f - (3f * sin(animationSeconds * tau / 2.2f))
haloPulse = 0.5f + (0.5f * sin(animationSeconds * tau / 1.4f))
}
RealtimeVoiceButtonState.SPEAKING -> {
val speechBeat = sin(animationSeconds * tau / 0.72f)
floatOffset -= mouthLevel * 2.2f
bodyStretch += (mouthLevel * 0.055f) + (speechBeat * 0.008f)
bodyTilt = 1.5f * sin(animationSeconds * tau / 2.1f)
antennaDegrees = -5f * sin(animationSeconds * tau / 0.95f)
leftClawDegrees = 4f + (mouthLevel * 10f) + (speechBeat * 2f)
rightClawDegrees = -leftClawDegrees
gaze = Offset(0.18f * sin(animationSeconds * tau / 2.6f), 0.12f)
haloPulse = (0.25f + (mouthLevel * 0.75f)).coerceIn(0f, 1f)
}
RealtimeVoiceButtonState.ERROR -> {
bodyTilt = 2.2f * sin(animationSeconds * tau / 0.42f)
antennaDroop = 0.72f
leftClawDegrees = -5f
rightClawDegrees = 5f
gaze = Offset(0f, 0.7f)
eyeOpenness *= 0.72f
haloPulse = 0.72f + (0.28f * sin(animationSeconds * tau / 0.8f))
}
}
return WearAvatarPose(
floatOffset = floatOffset,
bodyTilt = bodyTilt,
bodyStretch = bodyStretch.coerceIn(0.94f, 1.08f),
antennaDegrees = antennaDegrees,
antennaDroop = antennaDroop,
leftClawDegrees = leftClawDegrees,
rightClawDegrees = rightClawDegrees,
eyeOpenness = eyeOpenness.coerceIn(0.04f, 1f),
gaze = gaze,
mouthLevel = mouthLevel.coerceIn(0f, 1f),
haloPulse = haloPulse.coerceIn(0f, 1f),
)
}
internal fun smoothAvatarMouth(
current: Float,
target: Float,
deltaSeconds: Float,
): Float {
val safeCurrent = current.coerceIn(0f, 1f)
val safeTarget = target.coerceIn(0f, 1f)
val safeDelta = deltaSeconds.coerceIn(0f, 0.05f)
if (safeDelta == 0f) return safeCurrent
val responseSeconds = if (safeTarget > safeCurrent) MOUTH_ATTACK_SECONDS else MOUTH_RELEASE_SECONDS
val blend = (1.0 - exp((-safeDelta / responseSeconds).toDouble())).toFloat()
return (safeCurrent + ((safeTarget - safeCurrent) * blend)).coerceIn(0f, 1f)
}
internal fun scaledAvatarDeltaSeconds(
deltaSeconds: Float,
durationScale: Float,
): Float {
if (durationScale <= 0f) return 0f
return (deltaSeconds / durationScale).coerceIn(0f, 0.05f)
}
private fun syntheticSpeechMouth(animationSeconds: Float): Float {
val tau = 2f * PI.toFloat()
val syllable = 0.5f + (0.5f * sin(animationSeconds * tau / 0.19f))
val phrase = 0.68f + (0.32f * sin(animationSeconds * tau / 0.83f))
return (0.1f + (0.72f * syllable * phrase)).coerceIn(0.08f, 0.86f)
}
private fun avatarBlinkClosure(animationSeconds: Float): Float {
val phase = animationSeconds % BLINK_CYCLE_SECONDS
return when {
phase in FIRST_BLINK_START..FIRST_BLINK_END ->
smoothBell((phase - FIRST_BLINK_START) / (FIRST_BLINK_END - FIRST_BLINK_START))
phase in SECOND_BLINK_START..SECOND_BLINK_END ->
smoothBell((phase - SECOND_BLINK_START) / (SECOND_BLINK_END - SECOND_BLINK_START))
else -> 0f
}
}
private fun smoothBell(value: Float): Float {
val mirrored = if (value < 0.5f) value * 2f else (1f - value) * 2f
val clamped = mirrored.coerceIn(0f, 1f)
return clamped * clamped * (3f - (2f * clamped))
}
private const val CANONICAL_ART_SIZE = 120f
private const val CANONICAL_ART_BOX = 126f
private const val AVATAR_ANIMATION_CYCLE_SECONDS = 60f
private const val MOUTH_ATTACK_SECONDS = 0.045f
private const val MOUTH_RELEASE_SECONDS = 0.11f
private const val BLINK_CYCLE_SECONDS = 5.4f
private const val FIRST_BLINK_START = 3.58f
private const val FIRST_BLINK_END = 3.76f
private const val SECOND_BLINK_START = 4.02f
private const val SECOND_BLINK_END = 4.17f

View file

@ -0,0 +1,179 @@
package ai.openclaw.wear
import android.content.Context
import android.content.SharedPreferences
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.core.content.edit
import androidx.wear.compose.material3.MaterialTheme
internal enum class WearThemeMode(
val rawValue: String,
) {
Dark(rawValue = "dark"),
Light(rawValue = "light"),
;
companion object {
fun fromRawValue(value: String?): WearThemeMode = entries.firstOrNull { mode -> mode.rawValue == value?.trim()?.lowercase() } ?: Dark
}
}
internal data class WearSettings(
val themeMode: WearThemeMode,
val autoSpeak: Boolean,
)
internal class WearSettingsStore internal constructor(
private val preferences: SharedPreferences,
) {
constructor(context: Context) :
this(
context.applicationContext.getSharedPreferences(
PREFERENCES_NAME,
Context.MODE_PRIVATE,
),
)
fun read(): WearSettings =
WearSettings(
themeMode = WearThemeMode.fromRawValue(preferences.getString(THEME_MODE_KEY, null)),
autoSpeak = preferences.getBoolean(AUTO_SPEAK_KEY, DEFAULT_AUTO_SPEAK),
)
fun writeThemeMode(mode: WearThemeMode) {
preferences.edit {
putString(THEME_MODE_KEY, mode.rawValue)
}
}
fun writeAutoSpeak(enabled: Boolean) {
preferences.edit {
putBoolean(AUTO_SPEAK_KEY, enabled)
}
}
private companion object {
// One Watch-owned store is the durable owner for local UI preferences. These
// keys have not shipped in a tagged release, so defaults are the only upgrade path.
const val DEFAULT_AUTO_SPEAK = false
const val PREFERENCES_NAME = "openclaw.wear.settings"
const val THEME_MODE_KEY = "appearance.themeMode"
const val AUTO_SPEAK_KEY = "conversation.autoSpeak"
}
}
@Immutable
internal data class WearColors(
val canvas: Color,
val surface: Color,
val surfaceRaised: Color,
val surfacePressed: Color,
val border: Color,
val borderStrong: Color,
val text: Color,
val textMuted: Color,
val primary: Color,
val primaryText: Color,
val voiceAccent: Color,
val voiceAccentSoft: Color,
val onVoiceAccent: Color,
val success: Color,
val warning: Color,
val danger: Color,
)
// Keep the companion surfaces aligned with the canonical Phone ClawTheme.
// Voice blue comes from the Phone MobileUiTokens and is intentionally not the
// general control or panel color.
private val DarkWearColors =
WearColors(
canvas = Color(0xFF030303),
surface = Color(0xFF0A0A0A),
surfaceRaised = Color(0xFF111111),
surfacePressed = Color(0xFF1A1A1A),
border = Color(0xFF242424),
borderStrong = Color(0xFF3A3A3A),
text = Color(0xFFF8F8F8),
textMuted = Color(0xFFA8A8A8),
primary = Color(0xFFFFFFFF),
primaryText = Color(0xFF050505),
voiceAccent = Color(0xFF6EA8FF),
voiceAccentSoft = Color(0xFF1A2A44),
onVoiceAccent = Color(0xFF050505),
success = Color(0xFF3EDB82),
warning = Color(0xFFE6B956),
danger = Color(0xFFFF6B6B),
)
private val LightWearColors =
WearColors(
canvas = Color(0xFFFAFBFC),
surface = Color(0xFFFFFEFB),
surfaceRaised = Color(0xFFFFFFFF),
surfacePressed = Color(0xFFE9EDF3),
border = Color(0xFFDDE3EC),
borderStrong = Color(0xFFC7D0DC),
text = Color(0xFF111318),
textMuted = Color(0xFF505865),
primary = Color(0xFF111827),
primaryText = Color(0xFFFFFFFF),
voiceAccent = Color(0xFF1B5ACB),
voiceAccentSoft = Color(0xFFEAF2FF),
onVoiceAccent = Color(0xFFFFFFFF),
success = Color(0xFF217747),
warning = Color(0xFFA56F17),
danger = Color(0xFFB82929),
)
internal fun wearColorsFor(themeMode: WearThemeMode): WearColors =
when (themeMode) {
WearThemeMode.Dark -> DarkWearColors
WearThemeMode.Light -> LightWearColors
}
private val LocalWearColors = staticCompositionLocalOf { DarkWearColors }
internal object OpenClawWearTheme {
val colors: WearColors
@Composable
@ReadOnlyComposable
get() = LocalWearColors.current
}
@Composable
internal fun OpenClawWearTheme(
themeMode: WearThemeMode,
content: @Composable () -> Unit,
) {
val colors = wearColorsFor(themeMode)
val colorScheme =
MaterialTheme.colorScheme.copy(
primary = colors.primary,
primaryContainer = colors.surfaceRaised,
onPrimary = colors.primaryText,
onPrimaryContainer = colors.text,
surfaceContainerLow = colors.surface,
surfaceContainer = colors.surface,
surfaceContainerHigh = colors.surfaceRaised,
onSurface = colors.text,
onSurfaceVariant = colors.textMuted,
outline = colors.borderStrong,
outlineVariant = colors.border,
background = colors.canvas,
onBackground = colors.text,
error = colors.danger,
onError = colors.primaryText,
)
MaterialTheme(colorScheme = colorScheme) {
CompositionLocalProvider(
LocalWearColors provides colors,
content = content,
)
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="oval">
<solid android:color="#030303" />
</shape>
</item>
<item
android:bottom="66dp"
android:left="20dp"
android:right="20dp"
android:top="42dp">
<shape android:shape="rectangle">
<solid android:color="#FFFFFF" />
<corners android:radius="30dp" />
</shape>
</item>
<item
android:width="50dp"
android:height="50dp"
android:drawable="@mipmap/ic_launcher_foreground"
android:gravity="left|center_vertical"
android:left="27dp" />
<item
android:bottom="14dp"
android:left="34dp"
android:right="34dp"
android:top="142dp">
<shape android:shape="rectangle">
<solid android:color="#111111" />
<stroke
android:width="1dp"
android:color="#3A3A3A" />
<corners android:radius="24dp" />
</shape>
</item>
</layer-list>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,2C7.6,2 4,5.2 4,9.5C4,13.8 7.2,17 12,17C16.8,17 20,13.8 20,9.5C20,5.2 16.4,2 12,2ZM8,8.5A1.5,1.5 0,1 1,11 8.5A1.5,1.5 0,1 1,8 8.5ZM13,8.5A1.5,1.5 0,1 1,16 8.5A1.5,1.5 0,1 1,13 8.5ZM8.5,12.5C10.5,14 13.5,14 15.5,12.5C14.8,16 9.2,16 8.5,12.5ZM9,17L7.5,22L12,19L16.5,22L15,17Z" />
</vector>

View file

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="#030303" />
<corners android:radius="24dp" />
</shape>
</item>
<item
android:bottom="66dp"
android:left="18dp"
android:right="18dp"
android:top="42dp">
<shape android:shape="rectangle">
<solid android:color="#FFFFFF" />
<corners android:radius="30dp" />
</shape>
</item>
<item
android:width="50dp"
android:height="50dp"
android:drawable="@mipmap/ic_launcher_foreground"
android:gravity="left|center_vertical"
android:left="25dp" />
<item
android:bottom="14dp"
android:left="32dp"
android:right="32dp"
android:top="142dp">
<shape android:shape="rectangle">
<solid android:color="#111111" />
<stroke
android:width="1dp"
android:color="#3A3A3A" />
<corners android:radius="24dp" />
</shape>
</item>
</layer-list>

View file

@ -0,0 +1,6 @@
<?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="@mipmap/ic_launcher_foreground" />
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,6 @@
<?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="@mipmap/ic_launcher_foreground" />
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View file

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@array/android_wear_capabilities" />

View file

@ -0,0 +1,89 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="chat" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"الدردشة"</string>
<string name="session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"الجلسة"</string>
<string name="model" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"النموذج"</string>
<string name="previous_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s السابق"</string>
<string name="next_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s التالي"</string>
<string name="controls" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"عناصر التحكم"</string>
<string name="talk" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"التحدث"</string>
<string name="real_time_audio_failed" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"فشل صوت الساعة"</string>
<string name="dictate" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"الإملاء"</string>
<string name="live" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"مباشر"</string>
<string name="thread" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"سلسلة المحادثة"</string>
<string name="open_thread" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"فتح سلسلة المحادثة"</string>
<string name="swipe_between_voice_modes" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"← اسحب →"</string>
<string name="hold" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"اضغط مطولًا"</string>
<string name="tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"انقر"</string>
<string name="double_tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"انقر نقرًا مزدوجًا"</string>
<string name="show_new_messages" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"إظهار الرسائل الجديدة"</string>
<string name="no_live_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ابدأ البث المباشر لرؤية المحادثة هنا."</string>
<string name="new_messages" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"جديد"</string>
<string name="type" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"اكتب"</string>
<string name="message" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"رسالة"</string>
<string name="message_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"مراسلة الوكيل"</string>
<string name="send" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"إرسال"</string>
<string name="speak_to_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"تحدث إلى وكيلك"</string>
<string name="stop_speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"إيقاف التحدث"</string>
<string name="speak_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"نطق أحدث رد"</string>
<string name="speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"جارٍ التحدث"</string>
<string name="listening" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"جارٍ الاستماع"</string>
<string name="connecting" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"جارٍ الاتصال"</string>
<string name="thinking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"جارٍ التفكير"</string>
<string name="typing" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"جارٍ الكتابة"</string>
<string name="sending" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"جارٍ الإرسال"</string>
<string name="agent_working" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"الوكيل يعمل"</string>
<string name="error" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"خطأ"</string>
<string name="ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"جاهز"</string>
<string name="you" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"أنت"</string>
<string name="agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"الوكيل"</string>
<string name="system" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"النظام"</string>
<string name="start_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"بدء محادثة"</string>
<string name="start_conversation_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"تحدّث أو اكتب على ساعتك. يرسل الهاتف المقترن الرسالة عبر جلسة OpenClaw المصادق عليها."</string>
<string name="current_session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"الجلسة الحالية"</string>
<string name="appearance" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"المظهر"</string>
<string name="theme_dark" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"داكن"</string>
<string name="theme_light" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"فاتح"</string>
<string name="auto_speak" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"نطق الردود تلقائيًا"</string>
<string name="reply_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"تنبيهات الردود"</string>
<string name="enable_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"تفعيل التنبيهات"</string>
<string name="abort_run" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"إيقاف التشغيل"</string>
<string name="open_notification_settings" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"فتح إعدادات الإشعارات"</string>
<string name="on" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"مفعّل"</string>
<string name="off" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"متوقف"</string>
<string name="connection" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"الاتصال"</string>
<string name="gateway" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway"</string>
<string name="security_boundary" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"الأمان"</string>
<string name="phone_controlled" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"يتحكم فيه الهاتف"</string>
<string name="phone_controlled_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"تبقى بيانات اعتماد Gateway وهويته على الهاتف المقترن. لا تستخدم الساعة سوى Wear Data Layer."</string>
<string name="checking_phone" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"جارٍ التحقق من الهاتف"</string>
<string name="reading_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"جارٍ قراءة الوكلاء والجلسات والدردشة"</string>
<string name="phone_ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"الهاتف جاهز"</string>
<string name="gateway_connected" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway متصل"</string>
<string name="gateway_offline" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway غير متصل"</string>
<string name="gateway_offline_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"أعِد توصيل Gateway في OpenClaw على الهاتف المقترن."</string>
<string name="open_phone_app" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"افتح OpenClaw على الهاتف"</string>
<string name="phone_not_ready_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"لا تبدأ الساعة تشغيل Gateway أو تصادق عليه بنفسها مطلقًا."</string>
<string name="phone_unavailable" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"يتعذر الوصول إلى الهاتف"</string>
<string name="phone_unavailable_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"أبقِ الهاتف المقترن قريبًا وثبّت تطبيق OpenClaw المطابق."</string>
<string name="selection_not_found" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"لم يعد التحديد متاحًا"</string>
<string name="message_not_sent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"لم يتم قبول الإجراء"</string>
<string name="something_went_wrong" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"حدث خطأ ما"</string>
<string name="refresh_and_try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"حدّث القوائم وحاول مرة أخرى."</string>
<string name="try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"حاول مرة أخرى من الساعة."</string>
<string name="update_required" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"التحديث مطلوب"</string>
<string name="update_required_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"حدّث OpenClaw على كلٍ من الهاتف والساعة."</string>
<string name="refresh" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"تحديث"</string>
<string name="retry" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"إعادة المحاولة"</string>
<string name="notification_channel_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ردود OpenClaw"</string>
<string name="notification_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"رد"</string>
<string name="notification_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"رد OpenClaw"</string>
<string name="notification_reply_failed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"لم يُرسل الرد"</string>
<string name="notification_reply_failed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"الهاتف غير متاح. اضغط على رد للمحاولة مرة أخرى."</string>
<string name="notification_phone_changed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"افتح OpenClaw للرد"</string>
<string name="notification_phone_changed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"تم تغيير هاتفك المفضل. افتح التطبيق لإعادة تحميل الجلسة قبل الرد."</string>
<string name="tile_label" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="tile_description" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"افتح الجلسات ورد عبر هاتفك المقترن"</string>
<string name="tile_open" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"فتح"</string>
<string name="tile_phone_proxy" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"وكيل الهاتف"</string>
</resources>

View file

@ -0,0 +1,89 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="chat" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Chat"</string>
<string name="session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sitzung"</string>
<string name="model" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Modell"</string>
<string name="previous_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Vorherige %1$s"</string>
<string name="next_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nächste %1$s"</string>
<string name="controls" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Steuerelemente"</string>
<string name="talk" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sprechen"</string>
<string name="real_time_audio_failed" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Watch-Audio fehlgeschlagen"</string>
<string name="dictate" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Diktieren"</string>
<string name="live" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Live"</string>
<string name="thread" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Thread"</string>
<string name="open_thread" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"Thread öffnen"</string>
<string name="swipe_between_voice_modes" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"← Wischen →"</string>
<string name="hold" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Halten"</string>
<string name="tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tippen"</string>
<string name="double_tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Doppeltippen"</string>
<string name="show_new_messages" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"Neue Nachrichten anzeigen"</string>
<string name="no_live_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Starte Live, um die Unterhaltung hier zu sehen."</string>
<string name="new_messages" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Neu"</string>
<string name="type" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Eingeben"</string>
<string name="message" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nachricht"</string>
<string name="message_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nachricht an den Agenten"</string>
<string name="send" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Senden"</string>
<string name="speak_to_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mit deinem Agenten sprechen"</string>
<string name="stop_speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sprechen beenden"</string>
<string name="speak_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Neueste Antwort vorlesen"</string>
<string name="speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Spricht"</string>
<string name="listening" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Hört zu"</string>
<string name="connecting" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Verbindung wird hergestellt"</string>
<string name="thinking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Denkt nach"</string>
<string name="typing" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Schreibt"</string>
<string name="sending" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wird gesendet"</string>
<string name="agent_working" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Agent arbeitet"</string>
<string name="error" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Fehler"</string>
<string name="ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bereit"</string>
<string name="you" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Du"</string>
<string name="agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Agent"</string>
<string name="system" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"System"</string>
<string name="start_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Unterhaltung beginnen"</string>
<string name="start_conversation_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sprechen oder tippen Sie auf Ihrer Uhr. Das gekoppelte Smartphone sendet die Nachricht über seine authentifizierte OpenClaw-Sitzung."</string>
<string name="current_session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aktuelle Sitzung"</string>
<string name="appearance" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Darstellung"</string>
<string name="theme_dark" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dunkel"</string>
<string name="theme_light" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Hell"</string>
<string name="auto_speak" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Antworten automatisch vorlesen"</string>
<string name="reply_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Antwortbenachrichtigungen"</string>
<string name="enable_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Benachrichtigungen aktivieren"</string>
<string name="abort_run" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ausführung abbrechen"</string>
<string name="open_notification_settings" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Benachrichtigungseinstellungen öffnen"</string>
<string name="on" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ein"</string>
<string name="off" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aus"</string>
<string name="connection" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Verbindung"</string>
<string name="gateway" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway"</string>
<string name="security_boundary" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sicherheit"</string>
<string name="phone_controlled" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Vom Smartphone gesteuert"</string>
<string name="phone_controlled_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway-Anmeldedaten und Identität verbleiben auf dem gekoppelten Smartphone. Die Uhr verwendet ausschließlich den Wear Data Layer."</string>
<string name="checking_phone" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Telefon wird überprüft"</string>
<string name="reading_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Agenten, Sitzungen und Chat werden gelesen"</string>
<string name="phone_ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Telefon bereit"</string>
<string name="gateway_connected" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway verbunden"</string>
<string name="gateway_offline" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway offline"</string>
<string name="gateway_offline_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Verbinden Sie das Gateway in OpenClaw auf dem gekoppelten Telefon erneut."</string>
<string name="open_phone_app" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw auf dem Telefon öffnen"</string>
<string name="phone_not_ready_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Die Smartwatch startet oder authentifiziert das Gateway niemals selbst."</string>
<string name="phone_unavailable" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Telefon nicht erreichbar"</string>
<string name="phone_unavailable_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Lassen Sie das gekoppelte Telefon in der Nähe und installieren Sie die passende OpenClaw-App."</string>
<string name="selection_not_found" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Auswahl nicht mehr verfügbar"</string>
<string name="message_not_sent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aktion nicht akzeptiert"</string>
<string name="something_went_wrong" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Etwas ist schiefgelaufen"</string>
<string name="refresh_and_try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aktualisieren Sie die Listen und versuchen Sie es erneut."</string>
<string name="try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Versuchen Sie es erneut über die Smartwatch."</string>
<string name="update_required" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aktualisierung erforderlich"</string>
<string name="update_required_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aktualisieren Sie OpenClaw auf dem Telefon und der Smartwatch."</string>
<string name="refresh" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aktualisieren"</string>
<string name="retry" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Erneut versuchen"</string>
<string name="notification_channel_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw-Antworten"</string>
<string name="notification_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Antworten"</string>
<string name="notification_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw-Antwort"</string>
<string name="notification_reply_failed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Antwort nicht gesendet"</string>
<string name="notification_reply_failed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Telefon nicht verfügbar. Tippe auf „Antworten“, um es erneut zu versuchen."</string>
<string name="notification_phone_changed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw öffnen, um zu antworten"</string>
<string name="notification_phone_changed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dein bevorzugtes Telefon hat sich geändert. Öffne die App, um die Sitzung vor dem Antworten neu zu laden."</string>
<string name="tile_label" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="tile_description" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sitzungen öffnen und über dein gekoppeltes Telefon antworten"</string>
<string name="tile_open" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ÖFFNEN"</string>
<string name="tile_phone_proxy" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"TELEFON-PROXY"</string>
</resources>

View file

@ -0,0 +1,89 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="chat" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Chat"</string>
<string name="session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sesión"</string>
<string name="model" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Modelo"</string>
<string name="previous_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s anterior"</string>
<string name="next_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s siguiente"</string>
<string name="controls" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Controles"</string>
<string name="talk" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Hablar"</string>
<string name="real_time_audio_failed" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Error en el audio del reloj"</string>
<string name="dictate" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dictar"</string>
<string name="live" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"En vivo"</string>
<string name="thread" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Hilo"</string>
<string name="open_thread" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"Abrir hilo"</string>
<string name="swipe_between_voice_modes" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"← Desliza →"</string>
<string name="hold" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mantén pulsado"</string>
<string name="tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Toca"</string>
<string name="double_tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Toca dos veces"</string>
<string name="show_new_messages" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"Mostrar mensajes nuevos"</string>
<string name="no_live_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Inicia Live para ver la conversación aquí."</string>
<string name="new_messages" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nuevo"</string>
<string name="type" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Escribir"</string>
<string name="message" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mensaje"</string>
<string name="message_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Enviar mensaje al agente"</string>
<string name="send" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Enviar"</string>
<string name="speak_to_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Habla con tu agente"</string>
<string name="stop_speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dejar de hablar"</string>
<string name="speak_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Leer en voz alta la última respuesta"</string>
<string name="speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Hablando"</string>
<string name="listening" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Escuchando"</string>
<string name="connecting" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Conectando"</string>
<string name="thinking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Pensando"</string>
<string name="typing" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Escribiendo"</string>
<string name="sending" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Enviando"</string>
<string name="agent_working" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"El agente está trabajando"</string>
<string name="error" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Error"</string>
<string name="ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Listo"</string>
<string name="you" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tú"</string>
<string name="agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Agente"</string>
<string name="system" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sistema"</string>
<string name="start_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Iniciar una conversación"</string>
<string name="start_conversation_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Habla o escribe en tu reloj. El teléfono vinculado envía el mensaje a través de su sesión autenticada de OpenClaw."</string>
<string name="current_session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sesión actual"</string>
<string name="appearance" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Apariencia"</string>
<string name="theme_dark" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Oscuro"</string>
<string name="theme_light" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Claro"</string>
<string name="auto_speak" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Leer las respuestas automáticamente"</string>
<string name="reply_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Alertas de respuesta"</string>
<string name="enable_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Activar alertas"</string>
<string name="abort_run" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Cancelar ejecución"</string>
<string name="open_notification_settings" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Abrir ajustes de notificaciones"</string>
<string name="on" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Activado"</string>
<string name="off" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Desactivado"</string>
<string name="connection" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Conexión"</string>
<string name="gateway" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway"</string>
<string name="security_boundary" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Seguridad"</string>
<string name="phone_controlled" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Controlado por el teléfono"</string>
<string name="phone_controlled_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Las credenciales y la identidad de Gateway permanecen en el teléfono vinculado. El reloj solo usa Wear Data Layer."</string>
<string name="checking_phone" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Comprobando el teléfono"</string>
<string name="reading_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Leyendo agentes, sesiones y chat"</string>
<string name="phone_ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Teléfono listo"</string>
<string name="gateway_connected" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway conectado"</string>
<string name="gateway_offline" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway sin conexión"</string>
<string name="gateway_offline_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Vuelve a conectar el Gateway en OpenClaw desde el teléfono emparejado."</string>
<string name="open_phone_app" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Abrir OpenClaw en el teléfono"</string>
<string name="phone_not_ready_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"El reloj nunca inicia ni autentica el Gateway por sí mismo."</string>
<string name="phone_unavailable" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"No se puede acceder al teléfono"</string>
<string name="phone_unavailable_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mantén cerca el teléfono emparejado e instala la aplicación de OpenClaw correspondiente."</string>
<string name="selection_not_found" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"La selección ya no está disponible"</string>
<string name="message_not_sent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Acción no aceptada"</string>
<string name="something_went_wrong" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Algo salió mal"</string>
<string name="refresh_and_try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Actualiza las listas e inténtalo de nuevo."</string>
<string name="try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Inténtalo de nuevo desde el reloj."</string>
<string name="update_required" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Actualización necesaria"</string>
<string name="update_required_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Actualiza OpenClaw tanto en el teléfono como en el reloj."</string>
<string name="refresh" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Actualizar"</string>
<string name="retry" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Reintentar"</string>
<string name="notification_channel_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Respuestas de OpenClaw"</string>
<string name="notification_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Responder"</string>
<string name="notification_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Respuesta de OpenClaw"</string>
<string name="notification_reply_failed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"No se envió la respuesta"</string>
<string name="notification_reply_failed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Teléfono no disponible. Toca Responder para volver a intentarlo."</string>
<string name="notification_phone_changed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Abrir OpenClaw para responder"</string>
<string name="notification_phone_changed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tu teléfono preferido cambió. Abre la aplicación para volver a cargar la sesión antes de responder."</string>
<string name="tile_label" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="tile_description" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Abre sesiones y responde mediante tu teléfono vinculado"</string>
<string name="tile_open" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ABRIR"</string>
<string name="tile_phone_proxy" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"PROXY DEL TELÉFONO"</string>
</resources>

View file

@ -0,0 +1,89 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="chat" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"گفتگو"</string>
<string name="session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"نشست"</string>
<string name="model" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"مدل"</string>
<string name="previous_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s قبلی"</string>
<string name="next_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s بعدی"</string>
<string name="controls" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"کنترل‌ها"</string>
<string name="talk" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"صحبت"</string>
<string name="real_time_audio_failed" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"پخش صدا در ساعت ناموفق بود"</string>
<string name="dictate" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"دیکته"</string>
<string name="live" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"زنده"</string>
<string name="thread" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"رشته گفتگو"</string>
<string name="open_thread" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"باز کردن رشته گفتگو"</string>
<string name="swipe_between_voice_modes" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"← بکشید →"</string>
<string name="hold" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"نگه دارید"</string>
<string name="tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ضربه بزنید"</string>
<string name="double_tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"دو بار ضربه بزنید"</string>
<string name="show_new_messages" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"نمایش پیام‌های جدید"</string>
<string name="no_live_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"برای مشاهده گفتگو در اینجا، Live را شروع کنید."</string>
<string name="new_messages" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"جدید"</string>
<string name="type" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"تایپ"</string>
<string name="message" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"پیام"</string>
<string name="message_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"پیام به عامل"</string>
<string name="send" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ارسال"</string>
<string name="speak_to_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"با عامل خود صحبت کنید"</string>
<string name="stop_speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"توقف صحبت"</string>
<string name="speak_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"خواندن آخرین پاسخ"</string>
<string name="speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"در حال صحبت"</string>
<string name="listening" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"در حال گوش‌دادن"</string>
<string name="connecting" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"در حال اتصال"</string>
<string name="thinking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"در حال فکرکردن"</string>
<string name="typing" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"در حال تایپ"</string>
<string name="sending" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"در حال ارسال"</string>
<string name="agent_working" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"عامل در حال کار است"</string>
<string name="error" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"خطا"</string>
<string name="ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"آماده"</string>
<string name="you" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"شما"</string>
<string name="agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"عامل"</string>
<string name="system" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"سیستم"</string>
<string name="start_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"شروع گفتگو"</string>
<string name="start_conversation_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"با ساعت خود صحبت کنید یا تایپ کنید. تلفن جفت‌شده پیام را از طریق نشست احراز هویت‌شده OpenClaw ارسال می‌کند."</string>
<string name="current_session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"نشست فعلی"</string>
<string name="appearance" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ظاهر"</string>
<string name="theme_dark" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"تیره"</string>
<string name="theme_light" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"روشن"</string>
<string name="auto_speak" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"خواندن خودکار پاسخ‌ها"</string>
<string name="reply_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"هشدارهای پاسخ"</string>
<string name="enable_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"فعال‌کردن هشدارها"</string>
<string name="abort_run" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"لغو اجرا"</string>
<string name="open_notification_settings" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"بازکردن تنظیمات اعلان‌ها"</string>
<string name="on" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"روشن"</string>
<string name="off" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"خاموش"</string>
<string name="connection" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"اتصال"</string>
<string name="gateway" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway"</string>
<string name="security_boundary" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"امنیت"</string>
<string name="phone_controlled" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"کنترل‌شده با تلفن"</string>
<string name="phone_controlled_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"اعتبارنامه‌ها و هویت Gateway روی تلفن جفت‌شده باقی می‌مانند. ساعت فقط از Wear Data Layer استفاده می‌کند."</string>
<string name="checking_phone" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"در حال بررسی تلفن"</string>
<string name="reading_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"در حال خواندن عامل‌ها، نشست‌ها و گفت‌وگو"</string>
<string name="phone_ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"تلفن آماده است"</string>
<string name="gateway_connected" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway متصل است"</string>
<string name="gateway_offline" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway آفلاین است"</string>
<string name="gateway_offline_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway را در OpenClaw روی تلفن جفت‌شده دوباره متصل کنید."</string>
<string name="open_phone_app" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw را روی تلفن باز کنید"</string>
<string name="phone_not_ready_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ساعت هیچ‌گاه Gateway را راه‌اندازی یا احراز هویت نمی‌کند."</string>
<string name="phone_unavailable" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"تلفن در دسترس نیست"</string>
<string name="phone_unavailable_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"تلفن جفت‌شده را نزدیک نگه دارید و برنامه سازگار OpenClaw را نصب کنید."</string>
<string name="selection_not_found" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"گزینه انتخاب‌شده دیگر در دسترس نیست"</string>
<string name="message_not_sent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"عملیات پذیرفته نشد"</string>
<string name="something_went_wrong" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"مشکلی پیش آمد"</string>
<string name="refresh_and_try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"فهرست‌ها را تازه‌سازی و دوباره تلاش کنید."</string>
<string name="try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"از ساعت دوباره تلاش کنید."</string>
<string name="update_required" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"به‌روزرسانی لازم است"</string>
<string name="update_required_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw را هم در تلفن و هم در ساعت به‌روزرسانی کنید."</string>
<string name="refresh" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"تازه‌سازی"</string>
<string name="retry" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"تلاش دوباره"</string>
<string name="notification_channel_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"پاسخ‌های OpenClaw"</string>
<string name="notification_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"پاسخ"</string>
<string name="notification_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"پاسخ OpenClaw"</string>
<string name="notification_reply_failed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"پاسخ ارسال نشد"</string>
<string name="notification_reply_failed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"تلفن در دسترس نیست. برای تلاش دوباره، روی پاسخ بزنید."</string>
<string name="notification_phone_changed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"برای پاسخ دادن، OpenClaw را باز کنید"</string>
<string name="notification_phone_changed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"تلفن ترجیحی شما تغییر کرده است. پیش از پاسخ دادن، برنامه را باز کنید تا نشست دوباره بارگیری شود."</string>
<string name="tile_label" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="tile_description" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"نشست‌ها را باز کنید و از طریق تلفن جفت‌شده خود پاسخ دهید"</string>
<string name="tile_open" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"باز کردن"</string>
<string name="tile_phone_proxy" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"پروکسی تلفن"</string>
</resources>

View file

@ -0,0 +1,89 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="chat" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Discussion"</string>
<string name="session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Session"</string>
<string name="model" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Modèle"</string>
<string name="previous_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s précédent"</string>
<string name="next_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s suivant"</string>
<string name="controls" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Commandes"</string>
<string name="talk" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Parler"</string>
<string name="real_time_audio_failed" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Échec de laudio de la montre"</string>
<string name="dictate" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dicter"</string>
<string name="live" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"En direct"</string>
<string name="thread" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Fil"</string>
<string name="open_thread" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"Ouvrir le fil"</string>
<string name="swipe_between_voice_modes" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"← Balayer →"</string>
<string name="hold" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Maintenir"</string>
<string name="tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Toucher"</string>
<string name="double_tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Appuyer deux fois"</string>
<string name="show_new_messages" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"Afficher les nouveaux messages"</string>
<string name="no_live_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Démarrez Live pour voir la conversation ici."</string>
<string name="new_messages" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nouveau"</string>
<string name="type" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Saisir"</string>
<string name="message" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Message"</string>
<string name="message_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Envoyer un message à lagent"</string>
<string name="send" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Envoyer"</string>
<string name="speak_to_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Parler à votre agent"</string>
<string name="stop_speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Arrêter de parler"</string>
<string name="speak_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Lire la dernière réponse"</string>
<string name="speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"En train de parler"</string>
<string name="listening" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Écoute en cours"</string>
<string name="connecting" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Connexion en cours"</string>
<string name="thinking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Réflexion en cours"</string>
<string name="typing" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Saisie en cours"</string>
<string name="sending" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Envoi en cours"</string>
<string name="agent_working" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Lagent travaille"</string>
<string name="error" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Erreur"</string>
<string name="ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Prêt"</string>
<string name="you" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Vous"</string>
<string name="agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Agent"</string>
<string name="system" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Système"</string>
<string name="start_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Démarrer une conversation"</string>
<string name="start_conversation_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Parlez ou saisissez du texte sur votre montre. Le téléphone associé envoie le message via sa session OpenClaw authentifiée."</string>
<string name="current_session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Session actuelle"</string>
<string name="appearance" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Apparence"</string>
<string name="theme_dark" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sombre"</string>
<string name="theme_light" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Clair"</string>
<string name="auto_speak" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Lire automatiquement les réponses"</string>
<string name="reply_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Alertes de réponse"</string>
<string name="enable_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Activer les alertes"</string>
<string name="abort_run" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Interrompre lexécution"</string>
<string name="open_notification_settings" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ouvrir les paramètres de notification"</string>
<string name="on" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Activé"</string>
<string name="off" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Désactivé"</string>
<string name="connection" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Connexion"</string>
<string name="gateway" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway"</string>
<string name="security_boundary" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sécurité"</string>
<string name="phone_controlled" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Contrôlé par le téléphone"</string>
<string name="phone_controlled_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Les identifiants et lidentité du Gateway restent sur le téléphone associé. La montre utilise uniquement la Wear Data Layer."</string>
<string name="checking_phone" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Vérification du téléphone"</string>
<string name="reading_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Lecture des agents, des sessions et du chat"</string>
<string name="phone_ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Téléphone prêt"</string>
<string name="gateway_connected" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway connecté"</string>
<string name="gateway_offline" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway hors ligne"</string>
<string name="gateway_offline_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Reconnectez le Gateway dans OpenClaw sur le téléphone associé."</string>
<string name="open_phone_app" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ouvrir OpenClaw sur le téléphone"</string>
<string name="phone_not_ready_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"La montre ne démarre ni nauthentifie jamais le Gateway elle-même."</string>
<string name="phone_unavailable" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Téléphone inaccessible"</string>
<string name="phone_unavailable_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gardez le téléphone associé à proximité et installez lapplication OpenClaw correspondante."</string>
<string name="selection_not_found" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sélection désormais indisponible"</string>
<string name="message_not_sent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Action non acceptée"</string>
<string name="something_went_wrong" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Une erreur sest produite"</string>
<string name="refresh_and_try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Actualisez les listes et réessayez."</string>
<string name="try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Réessayez depuis la montre."</string>
<string name="update_required" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mise à jour requise"</string>
<string name="update_required_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mettez à jour OpenClaw sur le téléphone et la montre."</string>
<string name="refresh" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Actualiser"</string>
<string name="retry" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Réessayer"</string>
<string name="notification_channel_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Réponses dOpenClaw"</string>
<string name="notification_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Répondre"</string>
<string name="notification_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Réponse OpenClaw"</string>
<string name="notification_reply_failed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Réponse non envoyée"</string>
<string name="notification_reply_failed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Téléphone indisponible. Touchez Répondre pour réessayer."</string>
<string name="notification_phone_changed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ouvrez OpenClaw pour répondre"</string>
<string name="notification_phone_changed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Votre téléphone préféré a changé. Ouvrez lapplication pour recharger la session avant de répondre."</string>
<string name="tile_label" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="tile_description" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ouvrez des sessions et répondez via votre téléphone jumelé"</string>
<string name="tile_open" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OUVRIR"</string>
<string name="tile_phone_proxy" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"RELAIS TÉLÉPHONIQUE"</string>
</resources>

View file

@ -0,0 +1,89 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="chat" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"चैट"</string>
<string name="session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"सत्र"</string>
<string name="model" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"मॉडल"</string>
<string name="previous_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"पिछला %1$s"</string>
<string name="next_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"अगला %1$s"</string>
<string name="controls" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"नियंत्रण"</string>
<string name="talk" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"बोलें"</string>
<string name="real_time_audio_failed" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"वॉच ऑडियो विफल रहा"</string>
<string name="dictate" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"बोलकर लिखें"</string>
<string name="live" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"लाइव"</string>
<string name="thread" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"थ्रेड"</string>
<string name="open_thread" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"थ्रेड खोलें"</string>
<string name="swipe_between_voice_modes" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"← स्वाइप करें →"</string>
<string name="hold" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"दबाकर रखें"</string>
<string name="tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"टैप करें"</string>
<string name="double_tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"दो बार टैप करें"</string>
<string name="show_new_messages" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"नए संदेश दिखाएँ"</string>
<string name="no_live_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"बातचीत यहाँ देखने के लिए Live शुरू करें।"</string>
<string name="new_messages" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"नया"</string>
<string name="type" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"टाइप करें"</string>
<string name="message" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"संदेश"</string>
<string name="message_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"एजेंट को संदेश भेजें"</string>
<string name="send" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"भेजें"</string>
<string name="speak_to_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"अपने एजेंट से बात करें"</string>
<string name="stop_speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"बोलना बंद करें"</string>
<string name="speak_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"नवीनतम उत्तर सुनें"</string>
<string name="speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"बोल रहा है"</string>
<string name="listening" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"सुन रहा है"</string>
<string name="connecting" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"कनेक्ट हो रहा है"</string>
<string name="thinking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"सोच रहा है"</string>
<string name="typing" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"टाइप कर रहा है"</string>
<string name="sending" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"भेज रहा है"</string>
<string name="agent_working" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"एजेंट काम कर रहा है"</string>
<string name="error" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"त्रुटि"</string>
<string name="ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"तैयार"</string>
<string name="you" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"आप"</string>
<string name="agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"एजेंट"</string>
<string name="system" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"सिस्टम"</string>
<string name="start_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"बातचीत शुरू करें"</string>
<string name="start_conversation_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"अपनी घड़ी पर बोलें या टाइप करें। युग्मित फ़ोन अपने प्रमाणित OpenClaw सत्र के माध्यम से संदेश भेजता है।"</string>
<string name="current_session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"वर्तमान सत्र"</string>
<string name="appearance" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"दिखावट"</string>
<string name="theme_dark" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"गहरा"</string>
<string name="theme_light" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"हल्का"</string>
<string name="auto_speak" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"जवाब अपने-आप बोलकर सुनाएँ"</string>
<string name="reply_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"जवाब के अलर्ट"</string>
<string name="enable_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"अलर्ट चालू करें"</string>
<string name="abort_run" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"रन रोकें"</string>
<string name="open_notification_settings" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"नोटिफ़िकेशन सेटिंग खोलें"</string>
<string name="on" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"चालू"</string>
<string name="off" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"बंद"</string>
<string name="connection" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"कनेक्शन"</string>
<string name="gateway" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway"</string>
<string name="security_boundary" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"सुरक्षा"</string>
<string name="phone_controlled" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"फ़ोन द्वारा नियंत्रित"</string>
<string name="phone_controlled_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway के क्रेडेंशियल और पहचान युग्मित फ़ोन पर ही रहते हैं। घड़ी केवल Wear Data Layer का उपयोग करती है।"</string>
<string name="checking_phone" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"फ़ोन की जाँच की जा रही है"</string>
<string name="reading_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"एजेंट, सत्र और चैट पढ़े जा रहे हैं"</string>
<string name="phone_ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"फ़ोन तैयार है"</string>
<string name="gateway_connected" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway कनेक्ट है"</string>
<string name="gateway_offline" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway ऑफ़लाइन है"</string>
<string name="gateway_offline_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"जोड़े गए फ़ोन पर OpenClaw में Gateway को फिर से कनेक्ट करें।"</string>
<string name="open_phone_app" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"फ़ोन पर OpenClaw खोलें"</string>
<string name="phone_not_ready_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"घड़ी कभी भी स्वयं Gateway को शुरू या प्रमाणित नहीं करती है।"</string>
<string name="phone_unavailable" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"फ़ोन से संपर्क नहीं हो पा रहा है"</string>
<string name="phone_unavailable_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"जोड़े गए फ़ोन को पास रखें और उससे मेल खाने वाला OpenClaw ऐप इंस्टॉल करें।"</string>
<string name="selection_not_found" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"चयन अब उपलब्ध नहीं है"</string>
<string name="message_not_sent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"कार्रवाई स्वीकार नहीं की गई"</string>
<string name="something_went_wrong" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"कुछ गड़बड़ी हुई"</string>
<string name="refresh_and_try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"सूचियाँ रीफ़्रेश करके फिर से प्रयास करें।"</string>
<string name="try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"घड़ी से फिर से प्रयास करें।"</string>
<string name="update_required" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"अपडेट आवश्यक है"</string>
<string name="update_required_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"फ़ोन और घड़ी, दोनों पर OpenClaw अपडेट करें।"</string>
<string name="refresh" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"रीफ़्रेश करें"</string>
<string name="retry" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"फिर से प्रयास करें"</string>
<string name="notification_channel_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw के जवाब"</string>
<string name="notification_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"जवाब दें"</string>
<string name="notification_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw का जवाब"</string>
<string name="notification_reply_failed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"जवाब नहीं भेजा गया"</string>
<string name="notification_reply_failed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"फ़ोन उपलब्ध नहीं है। फिर से कोशिश करने के लिए जवाब दें पर टैप करें।"</string>
<string name="notification_phone_changed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"जवाब देने के लिए OpenClaw खोलें"</string>
<string name="notification_phone_changed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"आपका पसंदीदा फ़ोन बदल गया है। जवाब देने से पहले सत्र को फिर से लोड करने के लिए ऐप खोलें।"</string>
<string name="tile_label" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="tile_description" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"सत्र खोलें और अपने युग्मित फ़ोन के ज़रिए जवाब दें"</string>
<string name="tile_open" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"खोलें"</string>
<string name="tile_phone_proxy" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"फ़ोन प्रॉक्सी"</string>
</resources>

View file

@ -0,0 +1,89 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="chat" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Obrolan"</string>
<string name="session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sesi"</string>
<string name="model" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Model"</string>
<string name="previous_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s sebelumnya"</string>
<string name="next_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s berikutnya"</string>
<string name="controls" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Kontrol"</string>
<string name="talk" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bicara"</string>
<string name="real_time_audio_failed" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Audio jam tangan gagal"</string>
<string name="dictate" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dikte"</string>
<string name="live" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Langsung"</string>
<string name="thread" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Utas"</string>
<string name="open_thread" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"Buka utas"</string>
<string name="swipe_between_voice_modes" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"← Geser →"</string>
<string name="hold" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tahan"</string>
<string name="tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ketuk"</string>
<string name="double_tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ketuk dua kali"</string>
<string name="show_new_messages" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"Tampilkan pesan baru"</string>
<string name="no_live_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mulai Live untuk melihat percakapan di sini."</string>
<string name="new_messages" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Baru"</string>
<string name="type" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ketik"</string>
<string name="message" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Pesan"</string>
<string name="message_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Kirim pesan kepada agen"</string>
<string name="send" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Kirim"</string>
<string name="speak_to_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bicara dengan agen Anda"</string>
<string name="stop_speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Berhenti berbicara"</string>
<string name="speak_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bacakan balasan terbaru"</string>
<string name="speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sedang berbicara"</string>
<string name="listening" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sedang mendengarkan"</string>
<string name="connecting" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sedang menghubungkan"</string>
<string name="thinking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sedang berpikir"</string>
<string name="typing" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sedang mengetik"</string>
<string name="sending" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sedang mengirim"</string>
<string name="agent_working" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Agen sedang bekerja"</string>
<string name="error" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Kesalahan"</string>
<string name="ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Siap"</string>
<string name="you" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Anda"</string>
<string name="agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Agen"</string>
<string name="system" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sistem"</string>
<string name="start_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mulai percakapan"</string>
<string name="start_conversation_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bicara atau ketik di jam tangan Anda. Ponsel yang disandingkan mengirim pesan melalui sesi OpenClaw yang telah diautentikasi."</string>
<string name="current_session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sesi saat ini"</string>
<string name="appearance" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tampilan"</string>
<string name="theme_dark" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gelap"</string>
<string name="theme_light" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Terang"</string>
<string name="auto_speak" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ucapkan balasan secara otomatis"</string>
<string name="reply_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Notifikasi balasan"</string>
<string name="enable_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aktifkan notifikasi"</string>
<string name="abort_run" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Batalkan proses"</string>
<string name="open_notification_settings" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Buka pengaturan notifikasi"</string>
<string name="on" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aktif"</string>
<string name="off" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nonaktif"</string>
<string name="connection" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Koneksi"</string>
<string name="gateway" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway"</string>
<string name="security_boundary" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Keamanan"</string>
<string name="phone_controlled" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dikendalikan ponsel"</string>
<string name="phone_controlled_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Kredensial dan identitas Gateway tetap berada di ponsel yang disandingkan. Jam tangan hanya menggunakan Wear Data Layer."</string>
<string name="checking_phone" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Memeriksa ponsel"</string>
<string name="reading_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Membaca agen, sesi, dan chat"</string>
<string name="phone_ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ponsel siap"</string>
<string name="gateway_connected" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway terhubung"</string>
<string name="gateway_offline" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway offline"</string>
<string name="gateway_offline_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Hubungkan kembali Gateway di OpenClaw pada ponsel yang disandingkan."</string>
<string name="open_phone_app" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Buka OpenClaw di ponsel"</string>
<string name="phone_not_ready_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Jam tangan tidak pernah memulai atau mengautentikasi Gateway itu sendiri."</string>
<string name="phone_unavailable" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ponsel tidak dapat dijangkau"</string>
<string name="phone_unavailable_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Letakkan ponsel yang disandingkan di dekat Anda dan instal aplikasi OpenClaw yang sesuai."</string>
<string name="selection_not_found" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Pilihan tidak lagi tersedia"</string>
<string name="message_not_sent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tindakan tidak diterima"</string>
<string name="something_went_wrong" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Terjadi kesalahan"</string>
<string name="refresh_and_try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Segarkan daftar dan coba lagi."</string>
<string name="try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Coba lagi dari jam tangan."</string>
<string name="update_required" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Pembaruan diperlukan"</string>
<string name="update_required_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Perbarui OpenClaw di ponsel dan jam tangan."</string>
<string name="refresh" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Segarkan"</string>
<string name="retry" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Coba lagi"</string>
<string name="notification_channel_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Balasan OpenClaw"</string>
<string name="notification_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Balas"</string>
<string name="notification_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Balasan OpenClaw"</string>
<string name="notification_reply_failed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Balasan tidak terkirim"</string>
<string name="notification_reply_failed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ponsel tidak tersedia. Ketuk Balas untuk mencoba lagi."</string>
<string name="notification_phone_changed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Buka OpenClaw untuk membalas"</string>
<string name="notification_phone_changed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ponsel pilihan Anda telah berubah. Buka aplikasi untuk memuat ulang sesi sebelum membalas."</string>
<string name="tile_label" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="tile_description" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Buka sesi dan balas melalui ponsel yang telah dipasangkan"</string>
<string name="tile_open" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"BUKA"</string>
<string name="tile_phone_proxy" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"PROKSI PONSEL"</string>
</resources>

View file

@ -0,0 +1,89 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="chat" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Chat"</string>
<string name="session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sessione"</string>
<string name="model" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Modello"</string>
<string name="previous_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s precedente"</string>
<string name="next_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s successivo"</string>
<string name="controls" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Controlli"</string>
<string name="talk" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Parla"</string>
<string name="real_time_audio_failed" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Audio dell\'orologio non riuscito"</string>
<string name="dictate" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Detta"</string>
<string name="live" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"In diretta"</string>
<string name="thread" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Conversazione"</string>
<string name="open_thread" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"Apri conversazione"</string>
<string name="swipe_between_voice_modes" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"← Scorri →"</string>
<string name="hold" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tieni premuto"</string>
<string name="tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tocca"</string>
<string name="double_tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tocca due volte"</string>
<string name="show_new_messages" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"Mostra nuovi messaggi"</string>
<string name="no_live_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Avvia Live per vedere qui la conversazione."</string>
<string name="new_messages" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nuovo"</string>
<string name="type" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Scrivi"</string>
<string name="message" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Messaggio"</string>
<string name="message_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Invia un messaggio all\'agente"</string>
<string name="send" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Invia"</string>
<string name="speak_to_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Parla con il tuo agente"</string>
<string name="stop_speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Interrompi la conversazione"</string>
<string name="speak_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Leggi l\'ultima risposta"</string>
<string name="speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Riproduzione vocale"</string>
<string name="listening" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"In ascolto"</string>
<string name="connecting" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Connessione"</string>
<string name="thinking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Elaborazione"</string>
<string name="typing" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Scrittura"</string>
<string name="sending" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Invio"</string>
<string name="agent_working" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Agente al lavoro"</string>
<string name="error" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Errore"</string>
<string name="ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Pronto"</string>
<string name="you" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tu"</string>
<string name="agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Agente"</string>
<string name="system" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sistema"</string>
<string name="start_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Avvia una conversazione"</string>
<string name="start_conversation_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Parla o digita sul tuo orologio. Il telefono associato invia il messaggio tramite la sua sessione OpenClaw autenticata."</string>
<string name="current_session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sessione corrente"</string>
<string name="appearance" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aspetto"</string>
<string name="theme_dark" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Scuro"</string>
<string name="theme_light" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Chiaro"</string>
<string name="auto_speak" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Leggi automaticamente le risposte"</string>
<string name="reply_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Avvisi per le risposte"</string>
<string name="enable_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Abilita avvisi"</string>
<string name="abort_run" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Interrompi esecuzione"</string>
<string name="open_notification_settings" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Apri le impostazioni delle notifiche"</string>
<string name="on" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Attivo"</string>
<string name="off" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Disattivo"</string>
<string name="connection" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Connessione"</string>
<string name="gateway" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway"</string>
<string name="security_boundary" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sicurezza"</string>
<string name="phone_controlled" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Controllato dal telefono"</string>
<string name="phone_controlled_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Le credenziali e l\'identità del Gateway rimangono sul telefono associato. L\'orologio utilizza solo Wear Data Layer."</string>
<string name="checking_phone" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Verifica del telefono"</string>
<string name="reading_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Lettura di agenti, sessioni e chat"</string>
<string name="phone_ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Telefono pronto"</string>
<string name="gateway_connected" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway connesso"</string>
<string name="gateway_offline" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway offline"</string>
<string name="gateway_offline_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Riconnetti il Gateway in OpenClaw sul telefono associato."</string>
<string name="open_phone_app" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Apri OpenClaw sul telefono"</string>
<string name="phone_not_ready_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"L\'orologio non avvia né autentica mai autonomamente il Gateway."</string>
<string name="phone_unavailable" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Telefono non raggiungibile"</string>
<string name="phone_unavailable_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tieni vicino il telefono associato e installa l\'app OpenClaw corrispondente."</string>
<string name="selection_not_found" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Selezione non più disponibile"</string>
<string name="message_not_sent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Azione non accettata"</string>
<string name="something_went_wrong" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Si è verificato un errore"</string>
<string name="refresh_and_try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aggiorna gli elenchi e riprova."</string>
<string name="try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Riprova dall\'orologio."</string>
<string name="update_required" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aggiornamento richiesto"</string>
<string name="update_required_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aggiorna OpenClaw sia sul telefono che sull\'orologio."</string>
<string name="refresh" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aggiorna"</string>
<string name="retry" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Riprova"</string>
<string name="notification_channel_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Risposte di OpenClaw"</string>
<string name="notification_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Rispondi"</string>
<string name="notification_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Risposta di OpenClaw"</string>
<string name="notification_reply_failed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Risposta non inviata"</string>
<string name="notification_reply_failed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Telefono non disponibile. Tocca Rispondi per riprovare."</string>
<string name="notification_phone_changed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Apri OpenClaw per rispondere"</string>
<string name="notification_phone_changed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Il telefono preferito è cambiato. Apri l\'app per ricaricare la sessione prima di rispondere."</string>
<string name="tile_label" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="tile_description" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Apri le sessioni e rispondi tramite il telefono associato"</string>
<string name="tile_open" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"APRI"</string>
<string name="tile_phone_proxy" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"PROXY TELEFONO"</string>
</resources>

View file

@ -0,0 +1,89 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="chat" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"チャット"</string>
<string name="session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"セッション"</string>
<string name="model" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"モデル"</string>
<string name="previous_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"前の%1$s"</string>
<string name="next_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"次の%1$s"</string>
<string name="controls" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"操作"</string>
<string name="talk" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"話す"</string>
<string name="real_time_audio_failed" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"音声の視聴に失敗しました"</string>
<string name="dictate" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"音声入力"</string>
<string name="live" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ライブ"</string>
<string name="thread" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"スレッド"</string>
<string name="open_thread" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"スレッドを開く"</string>
<string name="swipe_between_voice_modes" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"← スワイプ →"</string>
<string name="hold" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"長押し"</string>
<string name="tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"タップ"</string>
<string name="double_tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ダブルタップ"</string>
<string name="show_new_messages" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"新着メッセージを表示"</string>
<string name="no_live_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Liveを開始すると、ここに会話が表示されます。"</string>
<string name="new_messages" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"新規"</string>
<string name="type" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"入力"</string>
<string name="message" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"メッセージ"</string>
<string name="message_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"エージェントにメッセージを送信"</string>
<string name="send" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"送信"</string>
<string name="speak_to_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"エージェントに話しかける"</string>
<string name="stop_speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"読み上げを停止"</string>
<string name="speak_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"最新の返信を読み上げる"</string>
<string name="speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"読み上げ中"</string>
<string name="listening" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"聞き取り中"</string>
<string name="connecting" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"接続中"</string>
<string name="thinking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"思考中"</string>
<string name="typing" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"入力中"</string>
<string name="sending" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"送信中"</string>
<string name="agent_working" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"エージェントが作業中"</string>
<string name="error" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"エラー"</string>
<string name="ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"準備完了"</string>
<string name="you" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"あなた"</string>
<string name="agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"エージェント"</string>
<string name="system" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"システム"</string>
<string name="start_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"会話を開始"</string>
<string name="start_conversation_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ウォッチで話すか入力してください。ペアリング済みのスマートフォンが、認証済みのOpenClawセッションを通じてメッセージを送信します。"</string>
<string name="current_session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"現在のセッション"</string>
<string name="appearance" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"外観"</string>
<string name="theme_dark" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ダーク"</string>
<string name="theme_light" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ライト"</string>
<string name="auto_speak" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"返信を自動的に読み上げる"</string>
<string name="reply_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"返信通知"</string>
<string name="enable_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"通知を有効にする"</string>
<string name="abort_run" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"実行を中止"</string>
<string name="open_notification_settings" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"通知設定を開く"</string>
<string name="on" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"オン"</string>
<string name="off" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"オフ"</string>
<string name="connection" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"接続"</string>
<string name="gateway" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway"</string>
<string name="security_boundary" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"セキュリティ"</string>
<string name="phone_controlled" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"スマートフォンで管理"</string>
<string name="phone_controlled_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gatewayの認証情報とIDはペアリング済みのスマートフォンに保持されます。ウォッチはWear Data Layerのみを使用します。"</string>
<string name="checking_phone" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"スマートフォンを確認中"</string>
<string name="reading_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"エージェント、セッション、チャットを読み込み中"</string>
<string name="phone_ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"スマートフォンの準備ができました"</string>
<string name="gateway_connected" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gatewayに接続しました"</string>
<string name="gateway_offline" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gatewayはオフラインです"</string>
<string name="gateway_offline_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ペアリングしたスマートフォンのOpenClawでGatewayに再接続してください。"</string>
<string name="open_phone_app" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"スマートフォンでOpenClawを開く"</string>
<string name="phone_not_ready_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ウォッチ自体がGatewayを起動したり、認証したりすることはありません。"</string>
<string name="phone_unavailable" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"スマートフォンに接続できません"</string>
<string name="phone_unavailable_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ペアリングしたスマートフォンを近くに置き、対応するOpenClawアプリをインストールしてください。"</string>
<string name="selection_not_found" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"選択した項目は利用できなくなりました"</string>
<string name="message_not_sent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"操作を受け付けられませんでした"</string>
<string name="something_went_wrong" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"問題が発生しました"</string>
<string name="refresh_and_try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"リストを更新して、もう一度お試しください。"</string>
<string name="try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ウォッチからもう一度お試しください。"</string>
<string name="update_required" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"アップデートが必要です"</string>
<string name="update_required_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"スマートフォンとウォッチの両方でOpenClawをアップデートしてください。"</string>
<string name="refresh" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"更新"</string>
<string name="retry" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"再試行"</string>
<string name="notification_channel_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClawからの返信"</string>
<string name="notification_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"返信"</string>
<string name="notification_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClawの返信"</string>
<string name="notification_reply_failed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"返信を送信できませんでした"</string>
<string name="notification_reply_failed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"スマートフォンを利用できません。もう一度試すには「返信」をタップしてください。"</string>
<string name="notification_phone_changed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClawを開いて返信"</string>
<string name="notification_phone_changed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"優先するスマートフォンが変更されました。返信する前にアプリを開いてセッションを再読み込みしてください。"</string>
<string name="tile_label" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="tile_description" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"セッションを開き、ペアリング済みのスマートフォン経由で返信"</string>
<string name="tile_open" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"開く"</string>
<string name="tile_phone_proxy" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"スマートフォンプロキシ"</string>
</resources>

View file

@ -0,0 +1,89 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="chat" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"채팅"</string>
<string name="session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"세션"</string>
<string name="model" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"모델"</string>
<string name="previous_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"이전 %1$s"</string>
<string name="next_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"다음 %1$s"</string>
<string name="controls" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"제어"</string>
<string name="talk" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"말하기"</string>
<string name="real_time_audio_failed" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Watch 오디오 실패"</string>
<string name="dictate" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"받아쓰기"</string>
<string name="live" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"라이브"</string>
<string name="thread" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"스레드"</string>
<string name="open_thread" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"스레드 열기"</string>
<string name="swipe_between_voice_modes" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"← 스와이프 →"</string>
<string name="hold" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"길게 누르기"</string>
<string name="tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"탭"</string>
<string name="double_tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"두 번 탭"</string>
<string name="show_new_messages" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"새 메시지 표시"</string>
<string name="no_live_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"대화를 보려면 Live를 시작하세요."</string>
<string name="new_messages" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"새로 만들기"</string>
<string name="type" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"입력"</string>
<string name="message" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"메시지"</string>
<string name="message_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"에이전트에게 메시지 보내기"</string>
<string name="send" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"보내기"</string>
<string name="speak_to_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"에이전트에게 말하기"</string>
<string name="stop_speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"말하기 중지"</string>
<string name="speak_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"최근 답변 읽기"</string>
<string name="speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"말하는 중"</string>
<string name="listening" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"듣는 중"</string>
<string name="connecting" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"연결 중"</string>
<string name="thinking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"생각하는 중"</string>
<string name="typing" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"입력 중"</string>
<string name="sending" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"보내는 중"</string>
<string name="agent_working" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"에이전트 작업 중"</string>
<string name="error" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"오류"</string>
<string name="ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"준비됨"</string>
<string name="you" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"나"</string>
<string name="agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"에이전트"</string>
<string name="system" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"시스템"</string>
<string name="start_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"대화 시작"</string>
<string name="start_conversation_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"시계에서 말하거나 입력하세요. 페어링된 휴대전화가 인증된 OpenClaw 세션을 통해 메시지를 전송합니다."</string>
<string name="current_session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"현재 세션"</string>
<string name="appearance" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"화면 모드"</string>
<string name="theme_dark" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"다크"</string>
<string name="theme_light" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"라이트"</string>
<string name="auto_speak" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"답변 자동 음성 재생"</string>
<string name="reply_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"답변 알림"</string>
<string name="enable_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"알림 사용"</string>
<string name="abort_run" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"실행 중단"</string>
<string name="open_notification_settings" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"알림 설정 열기"</string>
<string name="on" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"켜짐"</string>
<string name="off" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"꺼짐"</string>
<string name="connection" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"연결"</string>
<string name="gateway" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway"</string>
<string name="security_boundary" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"보안"</string>
<string name="phone_controlled" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"휴대전화에서 제어"</string>
<string name="phone_controlled_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway 자격 증명과 ID는 페어링된 휴대전화에 유지됩니다. 시계는 Wear Data Layer만 사용합니다."</string>
<string name="checking_phone" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"휴대전화 확인 중"</string>
<string name="reading_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"에이전트, 세션 및 채팅을 불러오는 중"</string>
<string name="phone_ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"휴대전화 준비 완료"</string>
<string name="gateway_connected" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway 연결됨"</string>
<string name="gateway_offline" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway 오프라인"</string>
<string name="gateway_offline_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"페어링된 휴대전화의 OpenClaw에서 Gateway를 다시 연결하세요."</string>
<string name="open_phone_app" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"휴대전화에서 OpenClaw 열기"</string>
<string name="phone_not_ready_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"시계 자체에서는 Gateway를 시작하거나 인증하지 않습니다."</string>
<string name="phone_unavailable" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"휴대전화에 연결할 수 없음"</string>
<string name="phone_unavailable_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"페어링된 휴대전화를 가까이 두고 호환되는 OpenClaw 앱을 설치하세요."</string>
<string name="selection_not_found" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"선택 항목을 더 이상 사용할 수 없음"</string>
<string name="message_not_sent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"작업이 수락되지 않음"</string>
<string name="something_went_wrong" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"문제가 발생했습니다"</string>
<string name="refresh_and_try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"목록을 새로 고친 후 다시 시도하세요."</string>
<string name="try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"시계에서 다시 시도하세요."</string>
<string name="update_required" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"업데이트 필요"</string>
<string name="update_required_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"휴대전화와 시계 모두에서 OpenClaw를 업데이트하세요."</string>
<string name="refresh" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"새로 고침"</string>
<string name="retry" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"다시 시도"</string>
<string name="notification_channel_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw 답변"</string>
<string name="notification_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"답장"</string>
<string name="notification_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw 답장"</string>
<string name="notification_reply_failed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"답장을 보내지 못했습니다"</string>
<string name="notification_reply_failed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"휴대전화를 사용할 수 없습니다. 다시 시도하려면 답장을 탭하세요."</string>
<string name="notification_phone_changed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"답장하려면 OpenClaw 열기"</string>
<string name="notification_phone_changed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"기본 휴대전화가 변경되었습니다. 답장하기 전에 앱을 열어 세션을 다시 불러오세요."</string>
<string name="tile_label" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="tile_description" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"세션을 열고 페어링된 휴대전화를 통해 답장하세요"</string>
<string name="tile_open" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"열기"</string>
<string name="tile_phone_proxy" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"휴대전화 프록시"</string>
</resources>

View file

@ -0,0 +1,89 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="chat" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Chat"</string>
<string name="session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sessie"</string>
<string name="model" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Model"</string>
<string name="previous_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Vorige %1$s"</string>
<string name="next_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Volgende %1$s"</string>
<string name="controls" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bediening"</string>
<string name="talk" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Praten"</string>
<string name="real_time_audio_failed" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Watch-audio mislukt"</string>
<string name="dictate" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dicteren"</string>
<string name="live" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Live"</string>
<string name="thread" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gespreksdraad"</string>
<string name="open_thread" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"Discussie openen"</string>
<string name="swipe_between_voice_modes" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"← Veeg →"</string>
<string name="hold" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ingedrukt houden"</string>
<string name="tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tikken"</string>
<string name="double_tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dubbeltik"</string>
<string name="show_new_messages" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"Nieuwe berichten weergeven"</string>
<string name="no_live_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Start Live om het gesprek hier te zien."</string>
<string name="new_messages" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nieuw"</string>
<string name="type" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Typen"</string>
<string name="message" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bericht"</string>
<string name="message_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Stuur agent een bericht"</string>
<string name="send" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Verzenden"</string>
<string name="speak_to_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Praat met je agent"</string>
<string name="stop_speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Stop met spreken"</string>
<string name="speak_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Spreek het laatste antwoord uit"</string>
<string name="speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aan het spreken"</string>
<string name="listening" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aan het luisteren"</string>
<string name="connecting" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Verbinding maken"</string>
<string name="thinking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aan het nadenken"</string>
<string name="typing" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aan het typen"</string>
<string name="sending" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aan het verzenden"</string>
<string name="agent_working" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Agent is bezig"</string>
<string name="error" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Fout"</string>
<string name="ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gereed"</string>
<string name="you" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Jij"</string>
<string name="agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Agent"</string>
<string name="system" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Systeem"</string>
<string name="start_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Een gesprek starten"</string>
<string name="start_conversation_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Praat of typ op je horloge. De gekoppelde telefoon verstuurt het bericht via de geverifieerde OpenClaw-sessie."</string>
<string name="current_session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Huidige sessie"</string>
<string name="appearance" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Weergave"</string>
<string name="theme_dark" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Donker"</string>
<string name="theme_light" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Licht"</string>
<string name="auto_speak" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Antwoorden automatisch uitspreken"</string>
<string name="reply_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Meldingen bij antwoorden"</string>
<string name="enable_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Meldingen inschakelen"</string>
<string name="abort_run" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Uitvoering afbreken"</string>
<string name="open_notification_settings" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Meldingsinstellingen openen"</string>
<string name="on" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aan"</string>
<string name="off" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Uit"</string>
<string name="connection" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Verbinding"</string>
<string name="gateway" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway"</string>
<string name="security_boundary" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Beveiliging"</string>
<string name="phone_controlled" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Beheerd via telefoon"</string>
<string name="phone_controlled_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"De Gateway-inloggegevens en identiteit blijven op de gekoppelde telefoon. Het horloge gebruikt alleen de Wear Data Layer."</string>
<string name="checking_phone" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Telefoon controleren"</string>
<string name="reading_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Agents, sessies en chat lezen"</string>
<string name="phone_ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Telefoon gereed"</string>
<string name="gateway_connected" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway verbonden"</string>
<string name="gateway_offline" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway offline"</string>
<string name="gateway_offline_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Verbind de Gateway opnieuw via OpenClaw op de gekoppelde telefoon."</string>
<string name="open_phone_app" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Open OpenClaw op de telefoon"</string>
<string name="phone_not_ready_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Het horloge start of verifieert de Gateway nooit zelf."</string>
<string name="phone_unavailable" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Telefoon niet bereikbaar"</string>
<string name="phone_unavailable_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Houd de gekoppelde telefoon in de buurt en installeer de bijbehorende OpenClaw-app."</string>
<string name="selection_not_found" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Selectie niet meer beschikbaar"</string>
<string name="message_not_sent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Actie niet geaccepteerd"</string>
<string name="something_went_wrong" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Er is iets misgegaan"</string>
<string name="refresh_and_try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Vernieuw de lijsten en probeer het opnieuw."</string>
<string name="try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Probeer het opnieuw vanaf het horloge."</string>
<string name="update_required" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Update vereist"</string>
<string name="update_required_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Werk OpenClaw bij op zowel de telefoon als het horloge."</string>
<string name="refresh" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Vernieuwen"</string>
<string name="retry" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Opnieuw proberen"</string>
<string name="notification_channel_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Antwoorden van OpenClaw"</string>
<string name="notification_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Beantwoorden"</string>
<string name="notification_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Antwoord via OpenClaw"</string>
<string name="notification_reply_failed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Antwoord niet verzonden"</string>
<string name="notification_reply_failed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Telefoon niet beschikbaar. Tik op Beantwoorden om het opnieuw te proberen."</string>
<string name="notification_phone_changed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Open OpenClaw om te antwoorden"</string>
<string name="notification_phone_changed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Je voorkeurstelefoon is gewijzigd. Open de app om de sessie opnieuw te laden voordat je antwoordt."</string>
<string name="tile_label" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="tile_description" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Open sessies en antwoord via je gekoppelde telefoon"</string>
<string name="tile_open" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OPENEN"</string>
<string name="tile_phone_proxy" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"TELEFOONPROXY"</string>
</resources>

View file

@ -0,0 +1,89 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="chat" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Czat"</string>
<string name="session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sesja"</string>
<string name="model" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Model"</string>
<string name="previous_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Poprzednie: %1$s"</string>
<string name="next_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Następne: %1$s"</string>
<string name="controls" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sterowanie"</string>
<string name="talk" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Rozmawiaj"</string>
<string name="real_time_audio_failed" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nie udało się odtworzyć dźwięku na zegarku"</string>
<string name="dictate" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dyktuj"</string>
<string name="live" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Na żywo"</string>
<string name="thread" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wątek"</string>
<string name="open_thread" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"Otwórz wątek"</string>
<string name="swipe_between_voice_modes" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"← Przesuń →"</string>
<string name="hold" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Przytrzymaj"</string>
<string name="tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dotknij"</string>
<string name="double_tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Stuknij dwukrotnie"</string>
<string name="show_new_messages" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"Pokaż nowe wiadomości"</string>
<string name="no_live_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Uruchom Live, aby zobaczyć tutaj rozmowę."</string>
<string name="new_messages" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nowa"</string>
<string name="type" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wpisz"</string>
<string name="message" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wiadomość"</string>
<string name="message_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Napisz do agenta"</string>
<string name="send" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wyślij"</string>
<string name="speak_to_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Porozmawiaj ze swoim agentem"</string>
<string name="stop_speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Przestań mówić"</string>
<string name="speak_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Odczytaj najnowszą odpowiedź"</string>
<string name="speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mówi"</string>
<string name="listening" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Słucha"</string>
<string name="connecting" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Łączenie"</string>
<string name="thinking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Myśli"</string>
<string name="typing" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Pisze"</string>
<string name="sending" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wysyłanie"</string>
<string name="agent_working" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Agent pracuje"</string>
<string name="error" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Błąd"</string>
<string name="ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gotowe"</string>
<string name="you" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ty"</string>
<string name="agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Agent"</string>
<string name="system" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"System"</string>
<string name="start_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Rozpocznij rozmowę"</string>
<string name="start_conversation_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mów lub pisz na zegarku. Sparowany telefon wysyła wiadomość za pośrednictwem uwierzytelnionej sesji OpenClaw."</string>
<string name="current_session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bieżąca sesja"</string>
<string name="appearance" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wygląd"</string>
<string name="theme_dark" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ciemny"</string>
<string name="theme_light" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Jasny"</string>
<string name="auto_speak" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Automatycznie odczytuj odpowiedzi"</string>
<string name="reply_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Powiadomienia o odpowiedziach"</string>
<string name="enable_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Włącz powiadomienia"</string>
<string name="abort_run" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Przerwij działanie"</string>
<string name="open_notification_settings" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Otwórz ustawienia powiadomień"</string>
<string name="on" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wł."</string>
<string name="off" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wył."</string>
<string name="connection" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Połączenie"</string>
<string name="gateway" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway"</string>
<string name="security_boundary" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bezpieczeństwo"</string>
<string name="phone_controlled" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sterowane przez telefon"</string>
<string name="phone_controlled_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dane uwierzytelniające Gateway i informacje o tożsamości pozostają na sparowanym telefonie. Zegarek korzysta wyłącznie z Wear Data Layer."</string>
<string name="checking_phone" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sprawdzanie telefonu"</string>
<string name="reading_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Odczytywanie agentów, sesji i czatu"</string>
<string name="phone_ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Telefon jest gotowy"</string>
<string name="gateway_connected" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Połączono z Gateway"</string>
<string name="gateway_offline" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway jest offline"</string>
<string name="gateway_offline_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Połącz ponownie Gateway w OpenClaw na sparowanym telefonie."</string>
<string name="open_phone_app" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Otwórz OpenClaw na telefonie"</string>
<string name="phone_not_ready_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Zegarek nigdy samodzielnie nie uruchamia ani nie uwierzytelnia Gateway."</string>
<string name="phone_unavailable" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Telefon jest nieosiągalny"</string>
<string name="phone_unavailable_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Trzymaj sparowany telefon w pobliżu i zainstaluj na nim odpowiednią aplikację OpenClaw."</string>
<string name="selection_not_found" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wybrana opcja nie jest już dostępna"</string>
<string name="message_not_sent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Działanie nie zostało zaakceptowane"</string>
<string name="something_went_wrong" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Coś poszło nie tak"</string>
<string name="refresh_and_try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Odśwież listy i spróbuj ponownie."</string>
<string name="try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Spróbuj ponownie na zegarku."</string>
<string name="update_required" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wymagana aktualizacja"</string>
<string name="update_required_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Zaktualizuj OpenClaw na telefonie i zegarku."</string>
<string name="refresh" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Odśwież"</string>
<string name="retry" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Spróbuj ponownie"</string>
<string name="notification_channel_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Odpowiedzi OpenClaw"</string>
<string name="notification_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Odpowiedz"</string>
<string name="notification_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Odpowiedź OpenClaw"</string>
<string name="notification_reply_failed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nie wysłano odpowiedzi"</string>
<string name="notification_reply_failed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Telefon jest niedostępny. Dotknij opcji Odpowiedz, aby spróbować ponownie."</string>
<string name="notification_phone_changed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Otwórz OpenClaw, aby odpowiedzieć"</string>
<string name="notification_phone_changed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Preferowany telefon został zmieniony. Przed udzieleniem odpowiedzi otwórz aplikację, aby ponownie wczytać sesję."</string>
<string name="tile_label" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="tile_description" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Otwieraj sesje i odpowiadaj za pomocą sparowanego telefonu"</string>
<string name="tile_open" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OTWÓRZ"</string>
<string name="tile_phone_proxy" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"PROXY TELEFONU"</string>
</resources>

View file

@ -0,0 +1,89 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="chat" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Chat"</string>
<string name="session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sessão"</string>
<string name="model" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Modelo"</string>
<string name="previous_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s anterior"</string>
<string name="next_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Próximo %1$s"</string>
<string name="controls" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Controles"</string>
<string name="talk" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Falar"</string>
<string name="real_time_audio_failed" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Falha no áudio do relógio"</string>
<string name="dictate" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ditar"</string>
<string name="live" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ao vivo"</string>
<string name="thread" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Conversa"</string>
<string name="open_thread" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"Abrir conversa"</string>
<string name="swipe_between_voice_modes" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"← Deslize →"</string>
<string name="hold" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mantenha pressionado"</string>
<string name="tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Toque"</string>
<string name="double_tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Toque duas vezes"</string>
<string name="show_new_messages" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"Mostrar novas mensagens"</string>
<string name="no_live_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Inicie o Live para ver a conversa aqui."</string>
<string name="new_messages" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Novo"</string>
<string name="type" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Digitar"</string>
<string name="message" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mensagem"</string>
<string name="message_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Enviar mensagem ao agente"</string>
<string name="send" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Enviar"</string>
<string name="speak_to_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Fale com seu agente"</string>
<string name="stop_speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Parar de falar"</string>
<string name="speak_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Reproduzir a resposta mais recente"</string>
<string name="speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Falando"</string>
<string name="listening" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ouvindo"</string>
<string name="connecting" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Conectando"</string>
<string name="thinking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Pensando"</string>
<string name="typing" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Digitando"</string>
<string name="sending" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Enviando"</string>
<string name="agent_working" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Agente trabalhando"</string>
<string name="error" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Erro"</string>
<string name="ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Pronto"</string>
<string name="you" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Você"</string>
<string name="agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Agente"</string>
<string name="system" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sistema"</string>
<string name="start_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Iniciar uma conversa"</string>
<string name="start_conversation_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Fale ou digite no relógio. O telefone pareado envia a mensagem por meio da sessão autenticada do OpenClaw."</string>
<string name="current_session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sessão atual"</string>
<string name="appearance" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aparência"</string>
<string name="theme_dark" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Escuro"</string>
<string name="theme_light" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Claro"</string>
<string name="auto_speak" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Falar respostas automaticamente"</string>
<string name="reply_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Alertas de resposta"</string>
<string name="enable_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ativar alertas"</string>
<string name="abort_run" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Interromper execução"</string>
<string name="open_notification_settings" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Abrir configurações de notificações"</string>
<string name="on" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ativado"</string>
<string name="off" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Desativado"</string>
<string name="connection" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Conexão"</string>
<string name="gateway" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway"</string>
<string name="security_boundary" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Segurança"</string>
<string name="phone_controlled" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Controlado pelo telefone"</string>
<string name="phone_controlled_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"As credenciais e a identidade do Gateway permanecem no telefone pareado. O relógio usa apenas a Wear Data Layer."</string>
<string name="checking_phone" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Verificando o telefone"</string>
<string name="reading_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Lendo agentes, sessões e conversas"</string>
<string name="phone_ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Telefone pronto"</string>
<string name="gateway_connected" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway conectado"</string>
<string name="gateway_offline" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway offline"</string>
<string name="gateway_offline_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Reconecte o Gateway no OpenClaw no telefone pareado."</string>
<string name="open_phone_app" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Abra o OpenClaw no telefone"</string>
<string name="phone_not_ready_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"O relógio nunca inicia nem autentica o Gateway por conta própria."</string>
<string name="phone_unavailable" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Não foi possível acessar o telefone"</string>
<string name="phone_unavailable_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mantenha o telefone pareado por perto e instale o aplicativo OpenClaw correspondente."</string>
<string name="selection_not_found" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"A seleção não está mais disponível"</string>
<string name="message_not_sent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ação não aceita"</string>
<string name="something_went_wrong" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Algo deu errado"</string>
<string name="refresh_and_try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Atualize as listas e tente novamente."</string>
<string name="try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tente novamente pelo relógio."</string>
<string name="update_required" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Atualização necessária"</string>
<string name="update_required_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Atualize o OpenClaw no telefone e no relógio."</string>
<string name="refresh" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Atualizar"</string>
<string name="retry" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tentar novamente"</string>
<string name="notification_channel_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Respostas do OpenClaw"</string>
<string name="notification_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Responder"</string>
<string name="notification_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Resposta do OpenClaw"</string>
<string name="notification_reply_failed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Resposta não enviada"</string>
<string name="notification_reply_failed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Telefone indisponível. Toque em Responder para tentar novamente."</string>
<string name="notification_phone_changed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Abra o OpenClaw para responder"</string>
<string name="notification_phone_changed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Seu telefone preferido mudou. Abra o app para recarregar a sessão antes de responder."</string>
<string name="tile_label" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="tile_description" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Abra sessões e responda usando seu telefone pareado"</string>
<string name="tile_open" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ABRIR"</string>
<string name="tile_phone_proxy" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"PROXY DO TELEFONE"</string>
</resources>

View file

@ -0,0 +1,89 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="chat" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Чат"</string>
<string name="session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Сеанс"</string>
<string name="model" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Модель"</string>
<string name="previous_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Предыдущий %1$s"</string>
<string name="next_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Следующий %1$s"</string>
<string name="controls" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Управление"</string>
<string name="talk" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Говорить"</string>
<string name="real_time_audio_failed" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Сбой аудио на часах"</string>
<string name="dictate" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Диктовать"</string>
<string name="live" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"В прямом эфире"</string>
<string name="thread" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ветка"</string>
<string name="open_thread" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"Открыть ветку"</string>
<string name="swipe_between_voice_modes" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"← Проведите пальцем →"</string>
<string name="hold" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Удерживайте"</string>
<string name="tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Коснитесь"</string>
<string name="double_tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Двойное нажатие"</string>
<string name="show_new_messages" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"Показать новые сообщения"</string>
<string name="no_live_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Запустите Live, чтобы увидеть здесь переписку."</string>
<string name="new_messages" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Новое"</string>
<string name="type" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Введите текст"</string>
<string name="message" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Сообщение"</string>
<string name="message_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Сообщение агенту"</string>
<string name="send" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Отправить"</string>
<string name="speak_to_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Поговорить с агентом"</string>
<string name="stop_speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Перестать говорить"</string>
<string name="speak_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Озвучить последний ответ"</string>
<string name="speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Говорит"</string>
<string name="listening" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Слушает"</string>
<string name="connecting" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Подключение"</string>
<string name="thinking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Обдумывает"</string>
<string name="typing" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Печатает"</string>
<string name="sending" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Отправка"</string>
<string name="agent_working" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Агент работает"</string>
<string name="error" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ошибка"</string>
<string name="ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Готово"</string>
<string name="you" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Вы"</string>
<string name="agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Агент"</string>
<string name="system" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Система"</string>
<string name="start_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Начать разговор"</string>
<string name="start_conversation_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Говорите или вводите текст на часах. Сопряжённый телефон отправит сообщение через аутентифицированный сеанс OpenClaw."</string>
<string name="current_session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Текущий сеанс"</string>
<string name="appearance" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Оформление"</string>
<string name="theme_dark" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Тёмное"</string>
<string name="theme_light" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Светлое"</string>
<string name="auto_speak" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Автоматически озвучивать ответы"</string>
<string name="reply_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Уведомления об ответах"</string>
<string name="enable_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Включить уведомления"</string>
<string name="abort_run" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Прервать выполнение"</string>
<string name="open_notification_settings" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Открыть настройки уведомлений"</string>
<string name="on" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Вкл."</string>
<string name="off" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Выкл."</string>
<string name="connection" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Подключение"</string>
<string name="gateway" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway"</string>
<string name="security_boundary" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Безопасность"</string>
<string name="phone_controlled" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Управляется телефоном"</string>
<string name="phone_controlled_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Учётные данные и идентификационные данные Gateway хранятся на сопряжённом телефоне. Часы используют только Wear Data Layer."</string>
<string name="checking_phone" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Проверка телефона"</string>
<string name="reading_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Чтение агентов, сеансов и чата"</string>
<string name="phone_ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Телефон готов"</string>
<string name="gateway_connected" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway подключён"</string>
<string name="gateway_offline" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway не в сети"</string>
<string name="gateway_offline_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Повторно подключите Gateway в OpenClaw на сопряжённом телефоне."</string>
<string name="open_phone_app" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Открыть OpenClaw на телефоне"</string>
<string name="phone_not_ready_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Часы никогда не запускают и не аутентифицируют Gateway самостоятельно."</string>
<string name="phone_unavailable" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Телефон недоступен"</string>
<string name="phone_unavailable_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Держите сопряжённый телефон поблизости и установите соответствующее приложение OpenClaw."</string>
<string name="selection_not_found" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Выбранный элемент больше недоступен"</string>
<string name="message_not_sent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Действие не принято"</string>
<string name="something_went_wrong" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Что-то пошло не так"</string>
<string name="refresh_and_try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Обновите списки и повторите попытку."</string>
<string name="try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Повторите попытку с часов."</string>
<string name="update_required" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Требуется обновление"</string>
<string name="update_required_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Обновите OpenClaw на телефоне и часах."</string>
<string name="refresh" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Обновить"</string>
<string name="retry" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Повторить"</string>
<string name="notification_channel_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ответы OpenClaw"</string>
<string name="notification_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ответить"</string>
<string name="notification_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ответ OpenClaw"</string>
<string name="notification_reply_failed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ответ не отправлен"</string>
<string name="notification_reply_failed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Телефон недоступен. Нажмите «Ответить», чтобы повторить попытку."</string>
<string name="notification_phone_changed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Открыть OpenClaw, чтобы ответить"</string>
<string name="notification_phone_changed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Предпочитаемый телефон изменён. Прежде чем отвечать, откройте приложение, чтобы перезагрузить сеанс."</string>
<string name="tile_label" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="tile_description" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Открывайте сеансы и отвечайте через сопряжённый телефон"</string>
<string name="tile_open" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ОТКРЫТЬ"</string>
<string name="tile_phone_proxy" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ПРОКСИ-ТЕЛЕФОН"</string>
</resources>

View file

@ -0,0 +1,89 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="chat" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Chatt"</string>
<string name="session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Session"</string>
<string name="model" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Modell"</string>
<string name="previous_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Föregående %1$s"</string>
<string name="next_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nästa %1$s"</string>
<string name="controls" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Kontroller"</string>
<string name="talk" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Prata"</string>
<string name="real_time_audio_failed" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Watch-ljudet misslyckades"</string>
<string name="dictate" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Diktera"</string>
<string name="live" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Live"</string>
<string name="thread" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tråd"</string>
<string name="open_thread" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"Öppna tråd"</string>
<string name="swipe_between_voice_modes" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"← Svep →"</string>
<string name="hold" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Håll"</string>
<string name="tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tryck"</string>
<string name="double_tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dubbeltryck"</string>
<string name="show_new_messages" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"Visa nya meddelanden"</string>
<string name="no_live_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Starta Live för att se konversationen här."</string>
<string name="new_messages" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ny"</string>
<string name="type" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Skriv"</string>
<string name="message" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Meddelande"</string>
<string name="message_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Skicka meddelande till agenten"</string>
<string name="send" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Skicka"</string>
<string name="speak_to_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Prata med din agent"</string>
<string name="stop_speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sluta prata"</string>
<string name="speak_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Läs upp det senaste svaret"</string>
<string name="speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Pratar"</string>
<string name="listening" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Lyssnar"</string>
<string name="connecting" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ansluter"</string>
<string name="thinking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tänker"</string>
<string name="typing" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Skriver"</string>
<string name="sending" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Skickar"</string>
<string name="agent_working" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Agenten arbetar"</string>
<string name="error" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Fel"</string>
<string name="ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Redo"</string>
<string name="you" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Du"</string>
<string name="agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Agent"</string>
<string name="system" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"System"</string>
<string name="start_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Starta en konversation"</string>
<string name="start_conversation_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Prata eller skriv på din klocka. Den parkopplade telefonen skickar meddelandet via sin autentiserade OpenClaw-session."</string>
<string name="current_session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aktuell session"</string>
<string name="appearance" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Utseende"</string>
<string name="theme_dark" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mörkt"</string>
<string name="theme_light" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ljust"</string>
<string name="auto_speak" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Läs upp svar automatiskt"</string>
<string name="reply_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Svarsaviseringar"</string>
<string name="enable_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aktivera aviseringar"</string>
<string name="abort_run" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Avbryt körning"</string>
<string name="open_notification_settings" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Öppna aviseringsinställningar"</string>
<string name="on" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"På"</string>
<string name="off" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Av"</string>
<string name="connection" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Anslutning"</string>
<string name="gateway" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway"</string>
<string name="security_boundary" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Säkerhet"</string>
<string name="phone_controlled" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Styrs av telefonen"</string>
<string name="phone_controlled_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Inloggningsuppgifter och identitet för Gateway lagras på den parkopplade telefonen. Klockan använder endast Wear Data Layer."</string>
<string name="checking_phone" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Kontrollerar telefonen"</string>
<string name="reading_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Läser in agenter, sessioner och chatt"</string>
<string name="phone_ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Telefonen är redo"</string>
<string name="gateway_connected" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway är ansluten"</string>
<string name="gateway_offline" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway är offline"</string>
<string name="gateway_offline_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Återanslut Gateway i OpenClaw på den parkopplade telefonen."</string>
<string name="open_phone_app" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Öppna OpenClaw på telefonen"</string>
<string name="phone_not_ready_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Klockan startar eller autentiserar aldrig Gateway på egen hand."</string>
<string name="phone_unavailable" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Telefonen kan inte nås"</string>
<string name="phone_unavailable_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ha den parkopplade telefonen i närheten och installera motsvarande OpenClaw-app."</string>
<string name="selection_not_found" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Valet är inte längre tillgängligt"</string>
<string name="message_not_sent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Åtgärden godkändes inte"</string>
<string name="something_went_wrong" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Något gick fel"</string>
<string name="refresh_and_try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Uppdatera listorna och försök igen."</string>
<string name="try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Försök igen från klockan."</string>
<string name="update_required" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Uppdatering krävs"</string>
<string name="update_required_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Uppdatera OpenClaw på både telefonen och klockan."</string>
<string name="refresh" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Uppdatera"</string>
<string name="retry" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Försök igen"</string>
<string name="notification_channel_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Svar från OpenClaw"</string>
<string name="notification_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Svara"</string>
<string name="notification_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw-svar"</string>
<string name="notification_reply_failed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Svaret skickades inte"</string>
<string name="notification_reply_failed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Telefonen är inte tillgänglig. Tryck på Svara för att försöka igen."</string>
<string name="notification_phone_changed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Öppna OpenClaw för att svara"</string>
<string name="notification_phone_changed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Din föredragna telefon har ändrats. Öppna appen för att läsa in sessionen igen innan du svarar."</string>
<string name="tile_label" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="tile_description" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Öppna sessioner och svara via din parkopplade telefon"</string>
<string name="tile_open" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ÖPPNA"</string>
<string name="tile_phone_proxy" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"TELEFONPROXY"</string>
</resources>

View file

@ -0,0 +1,89 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="chat" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"แชท"</string>
<string name="session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"เซสชัน"</string>
<string name="model" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"โมเดล"</string>
<string name="previous_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s ก่อนหน้า"</string>
<string name="next_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s ถัดไป"</string>
<string name="controls" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"การควบคุม"</string>
<string name="talk" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"พูด"</string>
<string name="real_time_audio_failed" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"เสียงจากนาฬิกาล้มเหลว"</string>
<string name="dictate" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ป้อนตามคำบอก"</string>
<string name="live" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"สด"</string>
<string name="thread" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"เธรด"</string>
<string name="open_thread" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"เปิดเธรด"</string>
<string name="swipe_between_voice_modes" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"← ปัด →"</string>
<string name="hold" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"กดค้าง"</string>
<string name="tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"แตะ"</string>
<string name="double_tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"แตะสองครั้ง"</string>
<string name="show_new_messages" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"แสดงข้อความใหม่"</string>
<string name="no_live_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"เริ่ม Live เพื่อดูการสนทนาที่นี่"</string>
<string name="new_messages" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ใหม่"</string>
<string name="type" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"พิมพ์"</string>
<string name="message" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ข้อความ"</string>
<string name="message_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ส่งข้อความถึงเอเจนต์"</string>
<string name="send" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ส่ง"</string>
<string name="speak_to_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"พูดคุยกับเอเจนต์ของคุณ"</string>
<string name="stop_speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"หยุดพูด"</string>
<string name="speak_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"อ่านคำตอบล่าสุด"</string>
<string name="speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"กำลังพูด"</string>
<string name="listening" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"กำลังฟัง"</string>
<string name="connecting" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"กำลังเชื่อมต่อ"</string>
<string name="thinking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"กำลังคิด"</string>
<string name="typing" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"กำลังพิมพ์"</string>
<string name="sending" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"กำลังส่ง"</string>
<string name="agent_working" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"เอเจนต์กำลังทำงาน"</string>
<string name="error" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ข้อผิดพลาด"</string>
<string name="ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"พร้อม"</string>
<string name="you" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"คุณ"</string>
<string name="agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"เอเจนต์"</string>
<string name="system" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ระบบ"</string>
<string name="start_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"เริ่มการสนทนา"</string>
<string name="start_conversation_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"พูดหรือพิมพ์บนนาฬิกาของคุณ โทรศัพท์ที่จับคู่ไว้จะส่งข้อความผ่านเซสชัน OpenClaw ที่ผ่านการยืนยันตัวตน"</string>
<string name="current_session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"เซสชันปัจจุบัน"</string>
<string name="appearance" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"รูปลักษณ์"</string>
<string name="theme_dark" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"มืด"</string>
<string name="theme_light" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"สว่าง"</string>
<string name="auto_speak" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"พูดข้อความตอบกลับโดยอัตโนมัติ"</string>
<string name="reply_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"การแจ้งเตือนข้อความตอบกลับ"</string>
<string name="enable_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"เปิดใช้การแจ้งเตือน"</string>
<string name="abort_run" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ยกเลิกการทำงาน"</string>
<string name="open_notification_settings" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"เปิดการตั้งค่าการแจ้งเตือน"</string>
<string name="on" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"เปิด"</string>
<string name="off" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ปิด"</string>
<string name="connection" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"การเชื่อมต่อ"</string>
<string name="gateway" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway"</string>
<string name="security_boundary" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ความปลอดภัย"</string>
<string name="phone_controlled" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ควบคุมโดยโทรศัพท์"</string>
<string name="phone_controlled_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ข้อมูลประจำตัวและข้อมูลยืนยันตัวตนของ Gateway จะอยู่ในโทรศัพท์ที่จับคู่ไว้ นาฬิกาจะใช้เฉพาะ Wear Data Layer เท่านั้น"</string>
<string name="checking_phone" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"กำลังตรวจสอบโทรศัพท์"</string>
<string name="reading_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"กำลังอ่านเอเจนต์ เซสชัน และแชต"</string>
<string name="phone_ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"โทรศัพท์พร้อมใช้งาน"</string>
<string name="gateway_connected" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"เชื่อมต่อ Gateway แล้ว"</string>
<string name="gateway_offline" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway ออฟไลน์"</string>
<string name="gateway_offline_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"เชื่อมต่อ Gateway อีกครั้งใน OpenClaw บนโทรศัพท์ที่จับคู่ไว้"</string>
<string name="open_phone_app" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"เปิด OpenClaw บนโทรศัพท์"</string>
<string name="phone_not_ready_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"นาฬิกาจะไม่เริ่มต้นหรือตรวจสอบสิทธิ์ Gateway ด้วยตัวเอง"</string>
<string name="phone_unavailable" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ไม่สามารถติดต่อโทรศัพท์ได้"</string>
<string name="phone_unavailable_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"วางโทรศัพท์ที่จับคู่ไว้ใกล้ ๆ และติดตั้งแอป OpenClaw เวอร์ชันที่ตรงกัน"</string>
<string name="selection_not_found" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ตัวเลือกนี้ไม่พร้อมใช้งานแล้ว"</string>
<string name="message_not_sent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ไม่ยอมรับการดำเนินการ"</string>
<string name="something_went_wrong" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"เกิดข้อผิดพลาดบางอย่าง"</string>
<string name="refresh_and_try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"รีเฟรชรายการแล้วลองอีกครั้ง"</string>
<string name="try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ลองอีกครั้งจากนาฬิกา"</string>
<string name="update_required" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"จำเป็นต้องอัปเดต"</string>
<string name="update_required_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"อัปเดต OpenClaw ทั้งบนโทรศัพท์และนาฬิกา"</string>
<string name="refresh" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"รีเฟรช"</string>
<string name="retry" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ลองอีกครั้ง"</string>
<string name="notification_channel_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"การตอบกลับจาก OpenClaw"</string>
<string name="notification_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ตอบกลับ"</string>
<string name="notification_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"การตอบกลับจาก OpenClaw"</string>
<string name="notification_reply_failed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ไม่ได้ส่งการตอบกลับ"</string>
<string name="notification_reply_failed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"โทรศัพท์ไม่พร้อมใช้งาน แตะตอบกลับเพื่อลองอีกครั้ง"</string>
<string name="notification_phone_changed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"เปิด OpenClaw เพื่อตอบกลับ"</string>
<string name="notification_phone_changed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"โทรศัพท์ที่คุณเลือกใช้มีการเปลี่ยนแปลง เปิดแอปเพื่อโหลดเซสชันใหม่ก่อนตอบกลับ"</string>
<string name="tile_label" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="tile_description" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"เปิดเซสชันและตอบกลับผ่านโทรศัพท์ที่จับคู่ไว้"</string>
<string name="tile_open" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"เปิด"</string>
<string name="tile_phone_proxy" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"พร็อกซีโทรศัพท์"</string>
</resources>

View file

@ -0,0 +1,89 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="chat" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sohbet"</string>
<string name="session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Oturum"</string>
<string name="model" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Model"</string>
<string name="previous_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Önceki %1$s"</string>
<string name="next_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sonraki %1$s"</string>
<string name="controls" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Kontroller"</string>
<string name="talk" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Konuş"</string>
<string name="real_time_audio_failed" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Saatteki ses başarısız oldu"</string>
<string name="dictate" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dikte et"</string>
<string name="live" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Canlı"</string>
<string name="thread" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Konu"</string>
<string name="open_thread" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"Yazışmayı aç"</string>
<string name="swipe_between_voice_modes" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"← Kaydır →"</string>
<string name="hold" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Basılı tut"</string>
<string name="tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dokun"</string>
<string name="double_tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Çift dokunun"</string>
<string name="show_new_messages" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"Yeni mesajları göster"</string>
<string name="no_live_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Konuşmayı burada görmek için Canlı\'yı başlatın."</string>
<string name="new_messages" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Yeni"</string>
<string name="type" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Yazın"</string>
<string name="message" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mesaj"</string>
<string name="message_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Temsilciye mesaj gönderin"</string>
<string name="send" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gönder"</string>
<string name="speak_to_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Temsilcinizle konuşun"</string>
<string name="stop_speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Konuşmayı durdur"</string>
<string name="speak_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Son yanıtı seslendir"</string>
<string name="speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Konuşuyor"</string>
<string name="listening" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dinliyor"</string>
<string name="connecting" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bağlanıyor"</string>
<string name="thinking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Düşünüyor"</string>
<string name="typing" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Yazıyor"</string>
<string name="sending" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gönderiliyor"</string>
<string name="agent_working" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Temsilci çalışıyor"</string>
<string name="error" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Hata"</string>
<string name="ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Hazır"</string>
<string name="you" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Siz"</string>
<string name="agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aracı"</string>
<string name="system" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sistem"</string>
<string name="start_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bir sohbet başlatın"</string>
<string name="start_conversation_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Saatinizde konuşun veya yazın. Eşleştirilmiş telefon, mesajı kimliği doğrulanmış OpenClaw oturumu üzerinden gönderir."</string>
<string name="current_session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Geçerli oturum"</string>
<string name="appearance" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Görünüm"</string>
<string name="theme_dark" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Koyu"</string>
<string name="theme_light" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Açık"</string>
<string name="auto_speak" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Yanıtları otomatik olarak seslendir"</string>
<string name="reply_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Yanıt uyarıları"</string>
<string name="enable_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Uyarıları etkinleştir"</string>
<string name="abort_run" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Çalıştırmayı iptal et"</string>
<string name="open_notification_settings" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bildirim ayarlarını aç"</string>
<string name="on" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Açık"</string>
<string name="off" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Kapalı"</string>
<string name="connection" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bağlantı"</string>
<string name="gateway" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway"</string>
<string name="security_boundary" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Güvenlik"</string>
<string name="phone_controlled" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Telefon tarafından denetlenir"</string>
<string name="phone_controlled_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway kimlik bilgileri ve kimliği eşleştirilmiş telefonda kalır. Saat yalnızca Wear Data Layer\'ı kullanır."</string>
<string name="checking_phone" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Telefon kontrol ediliyor"</string>
<string name="reading_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aracılar, oturumlar ve sohbet okunuyor"</string>
<string name="phone_ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Telefon hazır"</string>
<string name="gateway_connected" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway bağlı"</string>
<string name="gateway_offline" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway çevrimdışı"</string>
<string name="gateway_offline_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Eşleştirilmiş telefondaki OpenClaw üzerinden Gateway\'i yeniden bağlayın."</string>
<string name="open_phone_app" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Telefonda OpenClaw\'u açın"</string>
<string name="phone_not_ready_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Saat, Gateway\'i hiçbir zaman kendisi başlatmaz veya doğrulamaz."</string>
<string name="phone_unavailable" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Telefona ulaşılamıyor"</string>
<string name="phone_unavailable_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Eşleştirilmiş telefonu yakında tutun ve uyumlu OpenClaw uygulamasını yükleyin."</string>
<string name="selection_not_found" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Seçim artık kullanılamıyor"</string>
<string name="message_not_sent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"İşlem kabul edilmedi"</string>
<string name="something_went_wrong" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bir sorun oluştu"</string>
<string name="refresh_and_try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Listeleri yenileyip tekrar deneyin."</string>
<string name="try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Saatten tekrar deneyin."</string>
<string name="update_required" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Güncelleme gerekli"</string>
<string name="update_required_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw\'u hem telefonda hem de saatte güncelleyin."</string>
<string name="refresh" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Yenile"</string>
<string name="retry" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tekrar dene"</string>
<string name="notification_channel_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw yanıtları"</string>
<string name="notification_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Yanıtla"</string>
<string name="notification_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw yanıtı"</string>
<string name="notification_reply_failed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Yanıt gönderilmedi"</string>
<string name="notification_reply_failed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Telefon kullanılamıyor. Tekrar denemek için Yanıtla\'ya dokunun."</string>
<string name="notification_phone_changed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Yanıtlamak için OpenClaw\'ıın"</string>
<string name="notification_phone_changed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tercih ettiğiniz telefon değişti. Yanıtlamadan önce oturumu yeniden yüklemek için uygulamayıın."</string>
<string name="tile_label" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="tile_description" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Oturumlarıın ve eşleştirilmiş telefonunuz üzerinden yanıtlayın"</string>
<string name="tile_open" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"AÇ"</string>
<string name="tile_phone_proxy" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"TELEFON PROXY\'Sİ"</string>
</resources>

View file

@ -0,0 +1,89 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="chat" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Чат"</string>
<string name="session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Сеанс"</string>
<string name="model" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Модель"</string>
<string name="previous_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Попередній %1$s"</string>
<string name="next_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Наступний %1$s"</string>
<string name="controls" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Елементи керування"</string>
<string name="talk" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Говорити"</string>
<string name="real_time_audio_failed" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Не вдалося відтворити аудіо на годиннику"</string>
<string name="dictate" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Диктувати"</string>
<string name="live" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Наживо"</string>
<string name="thread" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Гілка"</string>
<string name="open_thread" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"Відкрити гілку"</string>
<string name="swipe_between_voice_modes" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"← Проведіть →"</string>
<string name="hold" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Утримуйте"</string>
<string name="tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Торкніться"</string>
<string name="double_tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Двічі торкніться"</string>
<string name="show_new_messages" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"Показати нові повідомлення"</string>
<string name="no_live_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Запустіть Live, щоб побачити розмову тут."</string>
<string name="new_messages" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Нове"</string>
<string name="type" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Введіть"</string>
<string name="message" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Повідомлення"</string>
<string name="message_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Написати агенту"</string>
<string name="send" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Надіслати"</string>
<string name="speak_to_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Говорити зі своїм агентом"</string>
<string name="stop_speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Припинити говорити"</string>
<string name="speak_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Озвучити останню відповідь"</string>
<string name="speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Говорить"</string>
<string name="listening" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Слухає"</string>
<string name="connecting" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Підключення"</string>
<string name="thinking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Обмірковує"</string>
<string name="typing" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Вводить текст"</string>
<string name="sending" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Надсилання"</string>
<string name="agent_working" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Агент працює"</string>
<string name="error" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Помилка"</string>
<string name="ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Готово"</string>
<string name="you" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ви"</string>
<string name="agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Агент"</string>
<string name="system" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Система"</string>
<string name="start_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Почати розмову"</string>
<string name="start_conversation_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Говоріть або вводьте текст на годиннику. Спарений телефон надсилає повідомлення через автентифікований сеанс OpenClaw."</string>
<string name="current_session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Поточний сеанс"</string>
<string name="appearance" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Вигляд"</string>
<string name="theme_dark" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Темна"</string>
<string name="theme_light" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Світла"</string>
<string name="auto_speak" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Автоматично озвучувати відповіді"</string>
<string name="reply_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Сповіщення про відповіді"</string>
<string name="enable_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Увімкнути сповіщення"</string>
<string name="abort_run" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Перервати виконання"</string>
<string name="open_notification_settings" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Відкрити налаштування сповіщень"</string>
<string name="on" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Увімкнено"</string>
<string name="off" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Вимкнено"</string>
<string name="connection" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Підключення"</string>
<string name="gateway" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway"</string>
<string name="security_boundary" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Безпека"</string>
<string name="phone_controlled" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Керується телефоном"</string>
<string name="phone_controlled_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Облікові дані та ідентифікаційна інформація Gateway зберігаються на спареному телефоні. Годинник використовує лише Wear Data Layer."</string>
<string name="checking_phone" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Перевірка телефона"</string>
<string name="reading_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Зчитування агентів, сеансів і чату"</string>
<string name="phone_ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Телефон готовий"</string>
<string name="gateway_connected" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway підключено"</string>
<string name="gateway_offline" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway не в мережі"</string>
<string name="gateway_offline_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Повторно підключіть Gateway в OpenClaw на спареному телефоні."</string>
<string name="open_phone_app" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Відкрийте OpenClaw на телефоні"</string>
<string name="phone_not_ready_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Годинник ніколи не запускає та не автентифікує Gateway самостійно."</string>
<string name="phone_unavailable" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Телефон недоступний"</string>
<string name="phone_unavailable_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Тримайте спарений телефон поруч і встановіть відповідний застосунок OpenClaw."</string>
<string name="selection_not_found" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Вибраний елемент більше недоступний"</string>
<string name="message_not_sent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Дію не прийнято"</string>
<string name="something_went_wrong" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Щось пішло не так"</string>
<string name="refresh_and_try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Оновіть списки та спробуйте ще раз."</string>
<string name="try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Спробуйте ще раз із годинника."</string>
<string name="update_required" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Потрібне оновлення"</string>
<string name="update_required_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Оновіть OpenClaw на телефоні та годиннику."</string>
<string name="refresh" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Оновити"</string>
<string name="retry" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Повторити"</string>
<string name="notification_channel_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Відповіді OpenClaw"</string>
<string name="notification_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Відповісти"</string>
<string name="notification_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Відповідь OpenClaw"</string>
<string name="notification_reply_failed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Відповідь не надіслано"</string>
<string name="notification_reply_failed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Телефон недоступний. Натисніть «Відповісти», щоб повторити спробу."</string>
<string name="notification_phone_changed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Відкрийте OpenClaw, щоб відповісти"</string>
<string name="notification_phone_changed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ваш бажаний телефон змінено. Відкрийте застосунок, щоб перезавантажити сеанс перед відповіддю."</string>
<string name="tile_label" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="tile_description" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Відкривайте сеанси та відповідайте через спарений телефон"</string>
<string name="tile_open" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ВІДКРИТИ"</string>
<string name="tile_phone_proxy" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ПРОКСІ ТЕЛЕФОНУ"</string>
</resources>

View file

@ -0,0 +1,89 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="chat" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Trò chuyện"</string>
<string name="session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Phiên"</string>
<string name="model" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mô hình"</string>
<string name="previous_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s trước"</string>
<string name="next_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s tiếp theo"</string>
<string name="controls" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Điều khiển"</string>
<string name="talk" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nói"</string>
<string name="real_time_audio_failed" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Âm thanh trên đồng hồ bị lỗi"</string>
<string name="dictate" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Đọc chính tả"</string>
<string name="live" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Trực tiếp"</string>
<string name="thread" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Luồng"</string>
<string name="open_thread" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"Mở chuỗi hội thoại"</string>
<string name="swipe_between_voice_modes" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"← Vuốt →"</string>
<string name="hold" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Giữ"</string>
<string name="tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Chạm"</string>
<string name="double_tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nhấn đúp"</string>
<string name="show_new_messages" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"Hiển thị tin nhắn mới"</string>
<string name="no_live_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bắt đầu Live để xem cuộc trò chuyện tại đây."</string>
<string name="new_messages" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mới"</string>
<string name="type" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nhập"</string>
<string name="message" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tin nhắn"</string>
<string name="message_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nhắn tin cho tác nhân"</string>
<string name="send" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gửi"</string>
<string name="speak_to_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nói chuyện với tác nhân của bạn"</string>
<string name="stop_speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dừng nói"</string>
<string name="speak_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Đọc câu trả lời mới nhất"</string>
<string name="speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Đang nói"</string>
<string name="listening" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Đang nghe"</string>
<string name="connecting" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Đang kết nối"</string>
<string name="thinking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Đang suy nghĩ"</string>
<string name="typing" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Đang nhập"</string>
<string name="sending" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Đang gửi"</string>
<string name="agent_working" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tác nhân đang làm việc"</string>
<string name="error" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Lỗi"</string>
<string name="ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sẵn sàng"</string>
<string name="you" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bạn"</string>
<string name="agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tác nhân"</string>
<string name="system" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Hệ thống"</string>
<string name="start_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bắt đầu cuộc trò chuyện"</string>
<string name="start_conversation_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nói hoặc nhập trên đồng hồ. Điện thoại đã ghép đôi sẽ gửi tin nhắn qua phiên OpenClaw đã xác thực."</string>
<string name="current_session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Phiên hiện tại"</string>
<string name="appearance" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Giao diện"</string>
<string name="theme_dark" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tối"</string>
<string name="theme_light" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sáng"</string>
<string name="auto_speak" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tự động đọc câu trả lời"</string>
<string name="reply_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Thông báo trả lời"</string>
<string name="enable_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bật thông báo"</string>
<string name="abort_run" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Hủy lượt chạy"</string>
<string name="open_notification_settings" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mở cài đặt thông báo"</string>
<string name="on" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bật"</string>
<string name="off" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tắt"</string>
<string name="connection" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Kết nối"</string>
<string name="gateway" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway"</string>
<string name="security_boundary" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bảo mật"</string>
<string name="phone_controlled" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Do điện thoại kiểm soát"</string>
<string name="phone_controlled_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Thông tin đăng nhập Gateway và danh tính được lưu trên điện thoại đã ghép đôi. Đồng hồ chỉ sử dụng Wear Data Layer."</string>
<string name="checking_phone" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Đang kiểm tra điện thoại"</string>
<string name="reading_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Đang đọc tác nhân, phiên và cuộc trò chuyện"</string>
<string name="phone_ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Điện thoại đã sẵn sàng"</string>
<string name="gateway_connected" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway đã kết nối"</string>
<string name="gateway_offline" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway ngoại tuyến"</string>
<string name="gateway_offline_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Hãy kết nối lại Gateway trong OpenClaw trên điện thoại đã ghép đôi."</string>
<string name="open_phone_app" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mở OpenClaw trên điện thoại"</string>
<string name="phone_not_ready_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Đồng hồ không bao giờ tự khởi động hoặc xác thực Gateway."</string>
<string name="phone_unavailable" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Không thể kết nối với điện thoại"</string>
<string name="phone_unavailable_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Hãy để điện thoại đã ghép đôi ở gần và cài đặt ứng dụng OpenClaw tương ứng."</string>
<string name="selection_not_found" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Lựa chọn không còn khả dụng"</string>
<string name="message_not_sent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Thao tác không được chấp nhận"</string>
<string name="something_went_wrong" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Đã xảy ra lỗi"</string>
<string name="refresh_and_try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Hãy làm mới danh sách và thử lại."</string>
<string name="try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Hãy thử lại từ đồng hồ."</string>
<string name="update_required" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Cần cập nhật"</string>
<string name="update_required_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Hãy cập nhật OpenClaw trên cả điện thoại và đồng hồ."</string>
<string name="refresh" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Làm mới"</string>
<string name="retry" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Thử lại"</string>
<string name="notification_channel_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Phản hồi của OpenClaw"</string>
<string name="notification_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Trả lời"</string>
<string name="notification_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Trả lời qua OpenClaw"</string>
<string name="notification_reply_failed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Chưa gửi được câu trả lời"</string>
<string name="notification_reply_failed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Điện thoại không khả dụng. Nhấn Trả lời để thử lại."</string>
<string name="notification_phone_changed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mở OpenClaw để trả lời"</string>
<string name="notification_phone_changed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Điện thoại ưu tiên của bạn đã thay đổi. Hãy mở ứng dụng để tải lại phiên trước khi trả lời."</string>
<string name="tile_label" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="tile_description" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mở các phiên và trả lời qua điện thoại đã ghép đôi"</string>
<string name="tile_open" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"MỞ"</string>
<string name="tile_phone_proxy" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"PROXY ĐIỆN THOẠI"</string>
</resources>

View file

@ -0,0 +1,89 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="chat" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"聊天"</string>
<string name="session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"会话"</string>
<string name="model" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"模型"</string>
<string name="previous_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"上一个 %1$s"</string>
<string name="next_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"下一个 %1$s"</string>
<string name="controls" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"控制"</string>
<string name="talk" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"说话"</string>
<string name="real_time_audio_failed" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"手表音频失败"</string>
<string name="dictate" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"听写"</string>
<string name="live" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"实时"</string>
<string name="thread" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"对话串"</string>
<string name="open_thread" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"打开话题"</string>
<string name="swipe_between_voice_modes" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"← 滑动 →"</string>
<string name="hold" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"按住"</string>
<string name="tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"点按"</string>
<string name="double_tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"双击"</string>
<string name="show_new_messages" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"显示新消息"</string>
<string name="no_live_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"启动实时模式后,即可在此处查看对话。"</string>
<string name="new_messages" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"新建"</string>
<string name="type" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"输入"</string>
<string name="message" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"消息"</string>
<string name="message_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"向智能体发送消息"</string>
<string name="send" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"发送"</string>
<string name="speak_to_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"与智能体对话"</string>
<string name="stop_speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"停止朗读"</string>
<string name="speak_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"朗读最新回复"</string>
<string name="speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"正在朗读"</string>
<string name="listening" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"正在聆听"</string>
<string name="connecting" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"正在连接"</string>
<string name="thinking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"正在思考"</string>
<string name="typing" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"正在输入"</string>
<string name="sending" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"正在发送"</string>
<string name="agent_working" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"智能体正在工作"</string>
<string name="error" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"错误"</string>
<string name="ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"就绪"</string>
<string name="you" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"你"</string>
<string name="agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"智能体"</string>
<string name="system" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"系统"</string>
<string name="start_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"开始对话"</string>
<string name="start_conversation_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"在手表上说话或输入文字。已配对的手机会通过其已认证的 OpenClaw 会话发送消息。"</string>
<string name="current_session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"当前会话"</string>
<string name="appearance" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"外观"</string>
<string name="theme_dark" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"深色"</string>
<string name="theme_light" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"浅色"</string>
<string name="auto_speak" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"自动朗读回复"</string>
<string name="reply_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"回复提醒"</string>
<string name="enable_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"启用提醒"</string>
<string name="abort_run" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"中止运行"</string>
<string name="open_notification_settings" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"打开通知设置"</string>
<string name="on" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"开"</string>
<string name="off" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"关"</string>
<string name="connection" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"连接"</string>
<string name="gateway" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway"</string>
<string name="security_boundary" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"安全"</string>
<string name="phone_controlled" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"由手机控制"</string>
<string name="phone_controlled_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway 凭据和身份信息保留在已配对的手机上。手表仅使用 Wear Data Layer。"</string>
<string name="checking_phone" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"正在检查手机"</string>
<string name="reading_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"正在读取智能体、会话和聊天"</string>
<string name="phone_ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"手机已就绪"</string>
<string name="gateway_connected" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway 已连接"</string>
<string name="gateway_offline" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway 离线"</string>
<string name="gateway_offline_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"请在已配对手机的 OpenClaw 中重新连接 Gateway。"</string>
<string name="open_phone_app" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"在手机上打开 OpenClaw"</string>
<string name="phone_not_ready_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"手表本身不会启动 Gateway也不会对其进行身份验证。"</string>
<string name="phone_unavailable" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"无法连接手机"</string>
<string name="phone_unavailable_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"请将已配对的手机放在附近,并安装匹配的 OpenClaw 应用。"</string>
<string name="selection_not_found" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"所选项目已不可用"</string>
<string name="message_not_sent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"操作未被接受"</string>
<string name="something_went_wrong" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"出现错误"</string>
<string name="refresh_and_try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"请刷新列表后重试。"</string>
<string name="try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"请在手表上重试。"</string>
<string name="update_required" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"需要更新"</string>
<string name="update_required_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"请更新手机和手表上的 OpenClaw。"</string>
<string name="refresh" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"刷新"</string>
<string name="retry" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"重试"</string>
<string name="notification_channel_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw 回复"</string>
<string name="notification_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"回复"</string>
<string name="notification_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw 回复"</string>
<string name="notification_reply_failed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"回复未发送"</string>
<string name="notification_reply_failed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"手机不可用。点按“回复”重试。"</string>
<string name="notification_phone_changed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"打开 OpenClaw 进行回复"</string>
<string name="notification_phone_changed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"您的首选手机已更改。回复前请打开应用以重新加载会话。"</string>
<string name="tile_label" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="tile_description" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"打开会话并通过已配对的手机回复"</string>
<string name="tile_open" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"打开"</string>
<string name="tile_phone_proxy" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"手机代理"</string>
</resources>

View file

@ -0,0 +1,89 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="chat" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"聊天"</string>
<string name="session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"工作階段"</string>
<string name="model" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"模型"</string>
<string name="previous_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"上一個 %1$s"</string>
<string name="next_item" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"下一個 %1$s"</string>
<string name="controls" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"控制項"</string>
<string name="talk" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"說話"</string>
<string name="real_time_audio_failed" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"手錶音訊失敗"</string>
<string name="dictate" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"語音輸入"</string>
<string name="live" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"即時"</string>
<string name="thread" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"對話串"</string>
<string name="open_thread" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"開啟討論串"</string>
<string name="swipe_between_voice_modes" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"← 滑動 →"</string>
<string name="hold" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"按住"</string>
<string name="tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"點一下"</string>
<string name="double_tap" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"輕觸兩下"</string>
<string name="show_new_messages" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis">"顯示新訊息"</string>
<string name="no_live_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"啟動 Live 即可在此查看對話。"</string>
<string name="new_messages" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"新增"</string>
<string name="type" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"輸入"</string>
<string name="message" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"訊息"</string>
<string name="message_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"傳訊息給代理程式"</string>
<string name="send" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"傳送"</string>
<string name="speak_to_agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"與您的代理程式交談"</string>
<string name="stop_speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"停止說話"</string>
<string name="speak_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"朗讀最新回覆"</string>
<string name="speaking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"正在說話"</string>
<string name="listening" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"正在聆聽"</string>
<string name="connecting" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"正在連線"</string>
<string name="thinking" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"正在思考"</string>
<string name="typing" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"正在輸入"</string>
<string name="sending" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"正在傳送"</string>
<string name="agent_working" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"代理程式正在處理"</string>
<string name="error" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"錯誤"</string>
<string name="ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"就緒"</string>
<string name="you" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"您"</string>
<string name="agent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"代理程式"</string>
<string name="system" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"系統"</string>
<string name="start_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"開始對話"</string>
<string name="start_conversation_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"在手錶上說話或輸入文字。配對的手機會透過已驗證的 OpenClaw 工作階段傳送訊息。"</string>
<string name="current_session" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"目前的工作階段"</string>
<string name="appearance" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"外觀"</string>
<string name="theme_dark" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"深色"</string>
<string name="theme_light" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"淺色"</string>
<string name="auto_speak" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"自動朗讀回覆"</string>
<string name="reply_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"回覆通知"</string>
<string name="enable_alerts" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"啟用通知"</string>
<string name="abort_run" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"中止執行"</string>
<string name="open_notification_settings" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"開啟通知設定"</string>
<string name="on" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"開啟"</string>
<string name="off" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"關閉"</string>
<string name="connection" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"連線"</string>
<string name="gateway" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway"</string>
<string name="security_boundary" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"安全性"</string>
<string name="phone_controlled" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"由手機控制"</string>
<string name="phone_controlled_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway 憑證和身分資訊會保留在配對的手機上。手錶僅使用 Wear Data Layer。"</string>
<string name="checking_phone" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"正在檢查手機"</string>
<string name="reading_conversation" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"正在讀取代理程式、工作階段和聊天"</string>
<string name="phone_ready" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"手機已就緒"</string>
<string name="gateway_connected" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway 已連線"</string>
<string name="gateway_offline" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway 離線"</string>
<string name="gateway_offline_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"請在已配對手機上的 OpenClaw 中重新連線 Gateway。"</string>
<string name="open_phone_app" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"在手機上開啟 OpenClaw"</string>
<string name="phone_not_ready_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"手錶本身不會啟動 Gateway 或進行驗證。"</string>
<string name="phone_unavailable" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"無法連上手機"</string>
<string name="phone_unavailable_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"請將已配對的手機放在附近,並安裝相符的 OpenClaw 應用程式。"</string>
<string name="selection_not_found" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"所選項目已無法使用"</string>
<string name="message_not_sent" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"操作未被接受"</string>
<string name="something_went_wrong" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"發生錯誤"</string>
<string name="refresh_and_try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"請重新整理清單,然後再試一次。"</string>
<string name="try_again" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"請從手錶再試一次。"</string>
<string name="update_required" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"需要更新"</string>
<string name="update_required_detail" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"請更新手機和手錶上的 OpenClaw。"</string>
<string name="refresh" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"重新整理"</string>
<string name="retry" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"重試"</string>
<string name="notification_channel_name" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw 回覆"</string>
<string name="notification_reply" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"回覆"</string>
<string name="notification_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw 回覆"</string>
<string name="notification_reply_failed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"回覆未傳送"</string>
<string name="notification_reply_failed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"手機無法使用。點選「回覆」以重試。"</string>
<string name="notification_phone_changed_title" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"開啟 OpenClaw 以回覆"</string>
<string name="notification_phone_changed_text" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"您的偏好手機已變更。回覆前,請開啟應用程式以重新載入工作階段。"</string>
<string name="tile_label" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
<string name="tile_description" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"開啟工作階段,並透過已配對的手機回覆"</string>
<string name="tile_open" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"開啟"</string>
<string name="tile_phone_proxy" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"手機代理"</string>
</resources>

View file

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

View file

@ -0,0 +1,91 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name">OpenClaw</string>
<string name="chat">Chat</string>
<string name="session">Session</string>
<string name="model">Model</string>
<string name="previous_item">Previous %1$s</string>
<string name="next_item">Next %1$s</string>
<string name="controls">Controls</string>
<string name="talk">Talk</string>
<string name="real_time_audio_failed">Watch audio failed</string>
<string name="dictate">Dictate</string>
<string name="live">Live</string>
<string name="thread">Thread</string>
<!-- The serialized native-locale refresh owns translations for source-only changes. -->
<string name="open_thread" tools:ignore="MissingTranslation">Open thread</string>
<string name="swipe_between_voice_modes">← Swipe →</string>
<string name="hold">Hold</string>
<string name="tap">Tap</string>
<string name="double_tap">Double tap</string>
<string name="show_new_messages" tools:ignore="MissingTranslation">Show new messages</string>
<string name="no_live_conversation">Start Live to see the conversation here.</string>
<string name="new_messages">New</string>
<string name="type">Type</string>
<string name="message">Message</string>
<string name="message_agent">Message agent</string>
<string name="send">Send</string>
<string name="speak_to_agent">Speak to your agent</string>
<string name="stop_speaking">Stop speaking</string>
<string name="speak_reply">Speak latest reply</string>
<string name="speaking">Speaking</string>
<string name="listening">Listening</string>
<string name="connecting">Connecting</string>
<string name="thinking">Thinking</string>
<string name="typing">Typing</string>
<string name="sending">Sending</string>
<string name="agent_working">Agent working</string>
<string name="error">Error</string>
<string name="ready">Ready</string>
<string name="you">You</string>
<string name="agent">Agent</string>
<string name="system">System</string>
<string name="start_conversation">Start a conversation</string>
<string name="start_conversation_detail">Talk or type on your watch. The paired phone sends the message through its authenticated OpenClaw session.</string>
<string name="current_session">Current session</string>
<string name="appearance">Appearance</string>
<string name="theme_dark">Dark</string>
<string name="theme_light">Light</string>
<string name="auto_speak">Speak replies automatically</string>
<string name="reply_alerts">Reply alerts</string>
<string name="enable_alerts">Enable alerts</string>
<string name="abort_run">Abort run</string>
<string name="open_notification_settings">Open notification settings</string>
<string name="on">On</string>
<string name="off">Off</string>
<string name="connection">Connection</string>
<string name="gateway">Gateway</string>
<string name="security_boundary">Security</string>
<string name="phone_controlled">Phone-controlled</string>
<string name="phone_controlled_detail">Gateway credentials and identity stay on the paired phone. The watch only uses the Wear Data Layer.</string>
<string name="checking_phone">Checking phone</string>
<string name="reading_conversation">Reading agents, sessions, and chat</string>
<string name="phone_ready">Phone ready</string>
<string name="gateway_connected">Gateway connected</string>
<string name="gateway_offline">Gateway offline</string>
<string name="gateway_offline_detail">Reconnect the Gateway in OpenClaw on the paired phone.</string>
<string name="open_phone_app">Open OpenClaw on phone</string>
<string name="phone_not_ready_detail">The watch never starts or authenticates the Gateway itself.</string>
<string name="phone_unavailable">Phone not reachable</string>
<string name="phone_unavailable_detail">Keep the paired phone nearby and install the matching OpenClaw app.</string>
<string name="selection_not_found">Selection no longer available</string>
<string name="message_not_sent">Action not accepted</string>
<string name="something_went_wrong">Something went wrong</string>
<string name="refresh_and_try_again">Refresh the lists and try again.</string>
<string name="try_again">Try again from the watch.</string>
<string name="update_required">Update required</string>
<string name="update_required_detail">Update OpenClaw on both phone and watch.</string>
<string name="refresh">Refresh</string>
<string name="retry">Retry</string>
<string name="notification_channel_name">OpenClaw replies</string>
<string name="notification_reply">Reply</string>
<string name="notification_title">OpenClaw reply</string>
<string name="notification_reply_failed_title">Reply not sent</string>
<string name="notification_reply_failed_text">Phone unavailable. Tap Reply to try again.</string>
<string name="notification_phone_changed_title">Open OpenClaw to reply</string>
<string name="notification_phone_changed_text">Your preferred phone changed. Open the app to reload the session before replying.</string>
<string name="tile_label">OpenClaw</string>
<string name="tile_description">Open sessions and reply through your paired phone</string>
<string name="tile_open">OPEN</string>
<string name="tile_phone_proxy">PHONE PROXY</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.OpenClawWear" parent="@android:style/Theme.DeviceDefault.NoActionBar">
<item name="android:windowBackground">@android:color/black</item>
<item name="android:windowSplashScreenBackground">@android:color/black</item>
<item name="android:windowSplashScreenAnimatedIcon">@mipmap/ic_launcher_foreground</item>
<item name="android:windowSplashScreenIconBackgroundColor">@android:color/transparent</item>
</style>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string-array name="android_wear_capabilities" translatable="false">
<item tools:ignore="Typos">openclaw_wear_companion_v1</item>
</string-array>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<exclude domain="root" path="." />
</full-backup-content>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup disableIfNoEncryptionCapabilities="true">
<exclude domain="root" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="root" path="." />
</device-transfer>
</data-extraction-rules>

View file

@ -0,0 +1,261 @@
package ai.openclaw.wear
import ai.openclaw.wear.shared.WearRealtimeTalkEntry
import ai.openclaw.wear.shared.WearRealtimeTalkRole
import ai.openclaw.wear.shared.WearRealtimeTalkSnapshot
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class MainActivityTest {
@Test
fun assistantReplyMustBelongToOriginatingSession() {
val reply = WearChatMessage(id = "reply-2", role = "assistant", text = "Second", timestamp = 2L)
assertNull(
newAssistantReplyForSession(
awaitingSessionId = "session-a",
activeSessionId = "session-b",
expectedAssistantKey = "reply-1",
latestAssistantMessage = reply,
),
)
assertEquals(
reply,
newAssistantReplyForSession(
awaitingSessionId = "session-a",
activeSessionId = "session-a",
expectedAssistantKey = "reply-1",
latestAssistantMessage = reply,
),
)
}
@Test
fun realtimeThinkingOverrideSurvivesUnrelatedActiveUpdates() {
val streaming = realtimeSnapshot(entryStreaming = true)
val completed = realtimeSnapshot(entryStreaming = false)
val unrelatedUpdate =
completed.copy(
realtimeTalk = completed.realtimeTalk.copy(statusText = "Still active"),
)
val newTurnId = nextRealtimeThinkingTurnId(streaming, completed, currentTurnId = null)
assertEquals("user-1", newTurnId)
assertEquals("user-1", nextRealtimeThinkingTurnId(completed, unrelatedUpdate, newTurnId))
assertNull(
nextRealtimeThinkingTurnId(
unrelatedUpdate,
unrelatedUpdate.copy(realtimeTalk = unrelatedUpdate.realtimeTalk.copy(active = false)),
newTurnId,
),
)
}
@Test
fun threadFollowKeepsStreamingContentVisibleAtLatest() {
val first =
nextWearThreadFollowForContent(
state = WearThreadFollowState(),
contentRevision = threadRevision(text = "Hel", streaming = true),
)
val continued =
nextWearThreadFollowForContent(
state = first.state,
contentRevision = threadRevision(text = "Hello", streaming = true),
)
assertTrue(first.scrollToLatest)
assertTrue(continued.scrollToLatest)
assertTrue(continued.state.followingLatest)
assertFalse(continued.state.hasNewContent)
}
@Test
fun threadFollowTargetsTrailingAnchorAfterLatestContent() {
assertEquals(-1, wearThreadLatestAnchorIndex(entryCount = 0, thinking = false))
assertEquals(1, wearThreadLatestAnchorIndex(entryCount = 1, thinking = false))
assertEquals(3, wearThreadLatestAnchorIndex(entryCount = 2, thinking = true))
}
@Test
fun threadFollowPreservesManualScrollUntilLatestIsRequested() {
val initial =
nextWearThreadFollowForContent(
state = WearThreadFollowState(),
contentRevision = threadRevision(text = "First", streaming = false),
)
val scrolledBack =
nextWearThreadFollowForViewport(
state = initial.state,
atLatest = false,
scrollingBackward = true,
)
val newContent =
nextWearThreadFollowForContent(
state = scrolledBack,
contentRevision = threadRevision(text = "Second", streaming = false),
)
assertFalse(newContent.scrollToLatest)
assertFalse(newContent.state.followingLatest)
assertTrue(newContent.state.hasNewContent)
val latest = wearThreadFollowLatest(newContent.state)
assertTrue(latest.followingLatest)
assertFalse(latest.hasNewContent)
}
@Test
fun threadFollowClearsNewContentWhenUserScrollsToLatest() {
val away =
WearThreadFollowState(
followingLatest = false,
hasNewContent = true,
)
val latest =
nextWearThreadFollowForViewport(
state = away,
atLatest = true,
scrollingBackward = false,
)
assertTrue(latest.followingLatest)
assertFalse(latest.hasNewContent)
}
@Test
fun threadFollowResetsWhenRealtimeStops() {
val revision = threadRevision(text = "Old", streaming = false)
val away =
WearThreadFollowState(
contentRevision = revision,
followingLatest = false,
hasNewContent = true,
)
val stopped =
nextWearThreadFollowForContent(
state = away,
contentRevision = revision,
realtimeActive = false,
)
assertFalse(stopped.scrollToLatest)
assertTrue(stopped.state.followingLatest)
assertFalse(stopped.state.hasNewContent)
val restarted =
nextWearThreadFollowForContent(
state = stopped.state,
contentRevision = revision,
)
assertTrue(restarted.scrollToLatest)
}
@Test
fun proxyErrorsMapToLocalizedFailureCodes() {
assertEquals(
WearConversationFailure.PHONE_UNAVAILABLE,
WearProxyException("phone_unavailable", "Raw phone error").toWearConversationFailure(),
)
assertEquals(
WearConversationFailure.INCOMPATIBLE,
WearProxyException("unsupported_peer", "Raw compatibility error").toWearConversationFailure(),
)
assertEquals(
WearConversationFailure.INTERNAL_ERROR,
IllegalStateException("Raw internal error").toWearConversationFailure(),
)
}
@Test
fun conversationSnapshotCarriesSemanticFailureAndUntitledSession() {
val session =
WearSession(
key = "session-1",
title = null,
updatedAt = null,
hasActiveRun = false,
phoneNodeId = "phone-1",
)
val snapshot =
WearUiState(
connected = true,
phoneNodeId = "phone-1",
sessions = listOf(session),
selectedSession = session,
failure = WearConversationFailure.ACTION_REJECTED,
).toConversationSnapshot()
assertEquals(WearConversationFailure.ACTION_REJECTED, snapshot?.failure)
assertNull(snapshot?.sessions?.single()?.title)
}
@Test
fun connectionEventsPreserveTypedAndLegacyIncompatibilityReasons() {
assertEquals(
WearConversationFailure.INCOMPATIBLE,
wearConversationFailureForConnection(
buildJsonObject {
put("connected", false)
put("failure", "incompatible")
put("status", "Offline")
},
),
)
assertEquals(
WearConversationFailure.INCOMPATIBLE,
wearConversationFailureForConnection(
buildJsonObject {
put("connected", false)
put("status", "Update required")
},
),
)
assertEquals(
WearConversationFailure.GATEWAY_OFFLINE,
wearConversationFailureForConnection(
buildJsonObject {
put("connected", false)
put("status", "Offline")
},
),
)
}
private fun threadRevision(
text: String,
streaming: Boolean,
): WearThreadContentRevision =
WearThreadContentRevision(
entryCount = 1,
latestEntryId = "entry-1",
latestText = text,
latestStreaming = streaming,
thinking = false,
)
private fun realtimeSnapshot(entryStreaming: Boolean): WearConversationSnapshot =
WearConversationSnapshot(
gatewayState = WearGatewayState.CONNECTED,
realtimeTalk =
WearRealtimeTalkSnapshot(
active = true,
conversation =
listOf(
WearRealtimeTalkEntry(
id = "user-1",
role = WearRealtimeTalkRole.USER,
text = "Hello",
streaming = entryStreaming,
),
),
),
)
}

View file

@ -0,0 +1,439 @@
package ai.openclaw.wear
import ai.openclaw.wear.shared.WearProxyCapability
import ai.openclaw.wear.shared.WearRealtimeTalkSnapshot
import ai.openclaw.wear.shared.WearRpcMethod
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class WearGatewayRepositoryTest {
private val json = Json
@Test
fun talkEventsMatchOnlyTheirCurrentAttempt() {
val current = WearRealtimeTalkSnapshot(attemptId = "attempt-current", active = true)
val stale = WearRealtimeTalkSnapshot(attemptId = "attempt-stale")
assertTrue(shouldAcceptWearTalkSnapshot(current, "attempt-current"))
assertFalse(shouldAcceptWearTalkSnapshot(stale, "attempt-current"))
assertFalse(shouldAcceptWearTalkSnapshot(WearRealtimeTalkSnapshot(), "attempt-current"))
}
@Test
fun sessionsAndHistoryParseOnlyProjectedContract() =
runTest {
val requester =
RecordingRequester { method, _ ->
when (method) {
WearRpcMethod.SessionsList ->
json.parseToJsonElement(
"""{"sessions":[{"key":"agent:main","agentId":"main","displayName":"Main","updatedAt":7,"hasActiveRun":true,"modelRef":"openai/gpt-test"}],"activeAgentId":"main","selectedSessionValid":true}""",
)
WearRpcMethod.ChatHistory ->
json.parseToJsonElement(
"""{"sessionKey":"agent:main","selectedModelRef":"openai/gpt-test","messages":[{"id":"m1","role":"assistant","content":[{"type":"text","text":"hello 😀"}],"timestamp":9}],"inFlightRun":{"runId":"run-1","text":"working"}}""",
)
else -> error("unexpected $method")
}
}
val repository = WearGatewayRepository(requester)
val sessions =
repository.sessions(
selectedSessionKey = "agent:main",
capabilities = setOf(WearProxyCapability.SessionSelectionLookup),
)
val history = repository.history("agent:main", sessions.phoneNodeId)
assertEquals("Main", sessions.sessions.single().title)
assertTrue(sessions.sessions.single().hasActiveRun)
assertEquals(7L, sessions.eventSequence)
assertEquals("phone", sessions.phoneNodeId)
assertEquals("phone", sessions.sessions.single().phoneNodeId)
assertEquals("main", sessions.sessions.single().agentId)
assertEquals("openai/gpt-test", sessions.sessions.single().modelRef)
assertEquals("main", sessions.activeAgentId)
assertTrue(sessions.selectedSessionValid)
assertEquals("hello 😀", history.messages.single().text)
assertEquals("run-1", history.activeRunId)
assertEquals("working", history.activeText)
assertEquals("openai/gpt-test", history.selectedModelRef)
assertEquals(7L, history.eventSequence)
assertEquals(setOf("limit", "selectedSessionKey"), requester.calls[0].second.keys)
assertEquals(setOf("sessionKey", "limit", "maxChars"), requester.calls[1].second.keys)
}
@Test
fun agentsAndGatewayControlsRequireThePreferredPhone() =
runTest {
val capabilities = WearProxyCapability.entries.toSet()
val requester =
RecordingRequester { method, _ ->
when (method) {
WearRpcMethod.AgentsList ->
json.parseToJsonElement(
"""{"agents":[{"id":"main","name":"Main","emoji":"*","selected":true}]}""",
)
WearRpcMethod.AgentsSelect -> JsonObject(emptyMap())
WearRpcMethod.GatewayDisconnect ->
json.parseToJsonElement(
"""{"connected":false,"status":"Offline","activeAgentId":"main","selectedModelRef":"openai/gpt-test","capabilities":["agent-controls","gateway-controls","model-controls","session-selection-lookup","attempt-scoped-realtime-audio"]}""",
)
else -> error("unexpected $method")
}
}
val repository = WearGatewayRepository(requester)
val agents = repository.agents("phone-a", capabilities)
repository.selectAgent("main", "phone-a", capabilities)
val status =
repository.setGatewayEnabled(
enabled = false,
phoneNodeId = "phone-a",
capabilities = capabilities,
)
assertEquals("Main", agents.agents.single().name)
assertTrue(agents.agents.single().selected)
assertFalse(status.connected)
assertEquals("main", status.activeAgentId)
assertEquals("openai/gpt-test", status.selectedModelRef)
assertEquals(capabilities, status.capabilities)
assertEquals(
listOf(WearRpcMethod.AgentsList, WearRpcMethod.AgentsSelect, WearRpcMethod.GatewayDisconnect),
requester.calls.map(Pair<WearRpcMethod, JsonObject>::first),
)
assertEquals(setOf("agentId"), requester.calls[1].second.keys)
assertTrue(requester.expectedNodeIds.all { it == "phone-a" })
assertTrue(requester.requirePreferredNodes.all { it })
}
@Test
fun oldPhoneStatusBlocksUnsupportedControlsBeforeSendingTheirRpc() =
runTest {
val requester =
RecordingRequester { method, _ ->
assertEquals(WearRpcMethod.ProxyStatus, method)
json.parseToJsonElement(
"""{"connected":true,"status":"Connected","activeSessionKey":"agent:main"}""",
)
}
val repository = WearGatewayRepository(requester)
val status = repository.status()
val agentsFailure = runCatching { repository.agents(status.phoneNodeId, status.capabilities) }.exceptionOrNull()
val gatewayFailure =
runCatching {
repository.setGatewayEnabled(
enabled = false,
phoneNodeId = status.phoneNodeId,
capabilities = status.capabilities,
)
}.exceptionOrNull()
val modelsFailure = runCatching { repository.models(status.phoneNodeId, status.capabilities) }.exceptionOrNull()
assertTrue(status.capabilities.isEmpty())
assertEquals("unsupported_peer", (agentsFailure as? WearProxyException)?.code)
assertEquals("unsupported_peer", (gatewayFailure as? WearProxyException)?.code)
assertEquals("unsupported_peer", (modelsFailure as? WearProxyException)?.code)
assertEquals(listOf(WearRpcMethod.ProxyStatus), requester.calls.map(Pair<WearRpcMethod, JsonObject>::first))
}
@Test
fun newPhoneStatusNegotiatesKnownCapabilitiesAndIgnoresFutureOnes() =
runTest {
val requester =
RecordingRequester { _, _ ->
json.parseToJsonElement(
"""{"connected":true,"status":"Connected","capabilities":["agent-controls","future-capability","gateway-controls","model-controls","session-selection-lookup","attempt-scoped-realtime-audio"]}""",
)
}
val status = WearGatewayRepository(requester).status()
assertEquals(WearProxyCapability.entries.toSet(), status.capabilities)
}
@Test
fun modelSelectionKeepsTheSelectedSessionAndUsesThePreferredPhone() =
runTest {
val capabilities = setOf(WearProxyCapability.ModelControls)
val requester =
RecordingRequester { method, params ->
when (method) {
WearRpcMethod.ModelsList -> {
assertEquals("openai/gpt-a", params.getValue("selectedModelRef").jsonPrimitive.content)
json.parseToJsonElement(
"""{"models":[{"ref":"openai/gpt-a","name":"GPT A"},{"ref":"openai/gpt-b","name":"GPT B"}]}""",
)
}
WearRpcMethod.ModelsSelect -> {
assertEquals("agent:main:thread-7", params.getValue("sessionKey").jsonPrimitive.content)
assertEquals("openai/gpt-b", params.getValue("modelRef").jsonPrimitive.content)
json.parseToJsonElement(
"""{"sessionKey":"agent:main:thread-7","selectedModelRef":"openai/gpt-b"}""",
)
}
else -> error("unexpected $method")
}
}
val repository = WearGatewayRepository(requester)
val models = repository.models("phone-a", capabilities, selectedModelRef = "openai/gpt-a")
val selected =
repository.selectModel(
sessionKey = "agent:main:thread-7",
modelRef = "openai/gpt-b",
phoneNodeId = "phone-a",
capabilities = capabilities,
)
assertEquals(listOf("openai/gpt-a", "openai/gpt-b"), models.models.map(WearModel::ref))
assertEquals("openai/gpt-b", selected.selectedModelRef)
assertEquals(7L, selected.eventSequence)
assertEquals("phone-a", selected.phoneNodeId)
assertEquals(listOf(WearRpcMethod.ModelsList, WearRpcMethod.ModelsSelect), requester.calls.map { it.first })
assertTrue(requester.expectedNodeIds.all { it == "phone-a" })
assertTrue(requester.requirePreferredNodes.all { it })
}
@Test
fun chatEventPreservesReplaceAndTextOnlyMessage() {
val event =
parseWearChatEvent(
json.parseToJsonElement(
"""{"sessionKey":"main","runId":"run-1","state":"delta","deltaText":"new","replace":true,"streamText":"done","streamTextComplete":true,"message":{"role":"assistant","content":"done"}}""",
),
)
assertEquals("main", event?.sessionKey)
assertEquals("new", event?.deltaText)
assertTrue(event?.replace == true)
assertEquals("done", event?.streamText)
assertTrue(event?.streamTextComplete == true)
assertEquals("done", event?.message?.text)
}
@Test
fun nonTextOrEmptyMessagesAreDropped() {
val binaryOnly =
parseChatMessage(
json.parseToJsonElement(
"""{"role":"assistant","content":[{"type":"image"}]}""",
),
)
assertNull(binaryOnly)
}
@Test
fun ambiguousSendRetryReusesItsIdempotencyKeyUntilSuccess() =
runTest {
val generatedIds = ArrayDeque(listOf("first", "second"))
val tracker = WearSendAttemptTracker(newId = { generatedIds.removeFirst() })
val first = tracker.begin("session-1", "hello", "phone-1")
tracker.markAmbiguous(first)
val retry = tracker.begin("session-1", "hello", "phone-1")
assertEquals(first, retry)
val requester = RecordingRequester { _, _ -> JsonObject(emptyMap()) }
WearGatewayRepository(requester).send(retry)
assertEquals(
"wear-first",
requester.calls
.single()
.second
.getValue("idempotencyKey")
.jsonPrimitive
.content,
)
tracker.markSucceeded(retry)
assertEquals("wear-second", tracker.begin("session-1", "hello", "phone-1").idempotencyKey)
}
@Test
fun differentMessageExpiresAnAbandonedAmbiguousAttempt() {
val generatedIds = ArrayDeque(listOf("first", "second", "third"))
val tracker = WearSendAttemptTracker(newId = { generatedIds.removeFirst() })
val abandoned = tracker.begin("session-1", "hello", "phone-1")
tracker.markAmbiguous(abandoned)
val different = tracker.begin("session-1", "different", "phone-1")
tracker.markSucceeded(different)
val laterHello = tracker.begin("session-1", "hello", "phone-1")
assertEquals("wear-second", different.idempotencyKey)
assertEquals("wear-third", laterHello.idempotencyKey)
}
@Test
fun realtimeTalkStartCarriesTheSelectedSessionAndPhone() =
runTest {
val requester =
RecordingRequester { method, _ ->
assertEquals(WearRpcMethod.TalkStart, method)
json.parseToJsonElement("""{"active":true}""")
}
val snapshot =
WearGatewayRepository(requester).startRealtimeTalk(
sessionKey = "agent:main:thread-7",
attemptId = "attempt-7",
language = "de",
phoneNodeId = "phone-a",
attemptScopedAudio = true,
)
assertTrue(snapshot.active)
assertEquals(
json
.parseToJsonElement(
"""{"sessionKey":"agent:main:thread-7","attemptId":"attempt-7","language":"de","attemptScopedAudio":true}""",
).jsonObject,
requester.calls.single().second,
)
assertEquals("phone-a", requester.expectedNodeIds.single())
assertTrue(requester.requirePreferredNodes.single())
}
@Test
fun realtimeTalkStartOmitsAttemptScopedAudioForLegacyPhones() =
runTest {
val requester =
RecordingRequester { _, _ ->
json.parseToJsonElement("""{"active":true}""")
}
WearGatewayRepository(requester).startRealtimeTalk(
sessionKey = "agent:main:thread-7",
attemptId = "attempt-7",
language = null,
phoneNodeId = "phone-a",
attemptScopedAudio = false,
)
assertEquals(
json
.parseToJsonElement(
"""{"sessionKey":"agent:main:thread-7","attemptId":"attempt-7"}""",
).jsonObject,
requester.calls.single().second,
)
}
@Test
fun observedFinalMessageSurvivesAnOlderSnapshotWithoutDuplication() {
val older = WearChatMessage(id = "m1", role = "assistant", text = "older", timestamp = 1)
val final = WearChatMessage(id = "m2", role = "assistant", text = "done", timestamp = 2)
val merged = mergeEventMessage(listOf(older), final)
val deduplicated = mergeEventMessage(merged, final.copy(text = "done!"))
assertEquals(listOf(older, final.copy(text = "done!")), deduplicated)
}
@Test
fun eventMergeReplacesIdentifiedRowsInPlaceAndPreservesUnknownDuplicates() {
val identified = WearChatMessage(id = "m1", role = "assistant", text = "old", timestamp = 1)
val newer = WearChatMessage(id = "m2", role = "user", text = "later", timestamp = 2)
val unknown = WearChatMessage(id = null, role = "assistant", text = "same", timestamp = null)
val replaced = mergeEventMessage(listOf(identified, newer), identified.copy(text = "updated"))
val duplicates = mergeEventMessage(listOf(unknown), unknown)
assertEquals(listOf(identified.copy(text = "updated"), newer), replaced)
assertEquals(listOf(unknown, unknown), duplicates)
}
@Test
fun canonicalSnapshotDeduplicatesItsIdentityLessObservedFinal() {
val canonical = WearChatMessage(id = "m1", role = "assistant", text = "done", timestamp = 7)
val observed = WearChatMessage(id = null, role = "assistant", text = "done", timestamp = null)
assertEquals(listOf(canonical), mergeObservedMessageIntoSnapshot(listOf(canonical), observed))
}
@Test
fun canonicalSnapshotDeduplicatesObservedFinalThatOnlyHasTimestamp() {
val canonical = WearChatMessage(id = "m1", role = "assistant", text = "done", timestamp = 7)
val observed = WearChatMessage(id = null, role = "assistant", text = "done", timestamp = 7)
val other = observed.copy(timestamp = 8)
assertEquals(listOf(canonical), mergeObservedMessageIntoSnapshot(listOf(canonical), observed))
assertEquals(listOf(canonical, other), mergeObservedMessageIntoSnapshot(listOf(canonical), other))
}
@Test
fun historyLoadCarriesRacedCanonicalStreamIntoItsSnapshot() {
val tracker = WearHistoryLoadTracker()
val token = tracker.start("session-1")
tracker.observeDelta("other-session", text = "wrong", complete = true, runId = "other")
tracker.observeDelta("session-1", text = "Hello world", complete = true, runId = "run-1")
assertTrue(tracker.isCurrent(token))
assertEquals(
WearLiveStreamSnapshot(text = "Hello world", complete = true, runId = "run-1"),
tracker.finish(token).liveStream,
)
assertNull(tracker.finish(token).liveStream)
}
@Test
fun stableHistoryLoadCanApplyItsCanonicalSnapshot() {
val tracker = WearHistoryLoadTracker()
val token = tracker.start("session-1")
assertNull(tracker.finish(token).liveStream)
}
@Test
fun racedStreamReconcilesCanonicalPrefixWithoutDuplication() {
assertEquals("Hello world", reconcileWearStreamSnapshot("Hello", "Hello world", liveComplete = true))
assertEquals("Hello world", reconcileWearStreamSnapshot("Hello world", "Hello", liveComplete = true))
assertEquals("Hello", reconcileWearStreamSnapshot("Hello", "He", liveComplete = false))
assertEquals("Hello", reconcileWearStreamSnapshot("Hello", "Hel", liveComplete = false))
assertEquals("Hello world!", reconcileWearStreamSnapshot("Hello world", " world!", liveComplete = false))
}
@Test
fun liveStreamCapPreservesWholeUnicodeCodePoints() {
val oversized = "x".repeat(2_000) + "😀"
val bounded = updateWearStreamText(current = null, delta = oversized, replace = true)
assertEquals(2_000, bounded?.codePointCount(0, bounded.length))
assertTrue(bounded?.endsWith("😀") == true)
}
}
private class RecordingRequester(
private val handler: suspend (WearRpcMethod, JsonObject) -> JsonElement,
) : WearRpcRequester {
val calls = mutableListOf<Pair<WearRpcMethod, JsonObject>>()
val expectedNodeIds = mutableListOf<String?>()
val requirePreferredNodes = mutableListOf<Boolean>()
override suspend fun request(
method: WearRpcMethod,
params: JsonObject,
expectedNodeId: String?,
requirePreferredNode: Boolean,
): WearRpcResult {
calls += method to params
expectedNodeIds += expectedNodeId
requirePreferredNodes += requirePreferredNode
return WearRpcResult(payload = handler(method, params), eventSequence = 7, sourceNodeId = expectedNodeId ?: "phone")
}
}

View file

@ -0,0 +1,219 @@
package ai.openclaw.wear
import android.content.Intent
import android.os.Looper
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.wear.protolayout.ActionBuilders
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class WearLaunchIntentTest {
@Test
fun normalAndUnknownLaunchesStartOnChat() {
assertEquals(WearLaunchTarget.Chat, parseWearLaunchTarget(Intent(Intent.ACTION_MAIN)))
assertEquals(
WearLaunchTarget.Chat,
parseWearLaunchTarget(Intent().putExtra(extraWearLaunchTarget, "unknown")),
)
}
@Test
fun tileTalkLaunchStartsOnVoice() {
val target =
parseWearLaunchTarget(
Intent().putExtra(extraWearLaunchTarget, WearLaunchTarget.Voice.rawValue),
)
assertEquals(WearLaunchTarget.Voice, target)
assertEquals(WearHomePage.Voice, target.initialPage)
}
@Test
fun launchTargetsAreConsumedOnce() {
val intent = Intent().putExtra(extraWearLaunchTarget, WearLaunchTarget.Voice.rawValue)
val initial = WearLaunchState.initial(intent)
assertEquals(WearLaunchTarget.Voice, initial.initialTarget)
assertFalse(intent.hasExtra(extraWearLaunchTarget))
assertEquals(WearLaunchTarget.Chat, WearLaunchState.initial(intent).initialTarget)
}
@Test
fun warmLaunchesCreateUniquePagerRequestsForTalkOpenAndNotifications() {
val initial = WearLaunchState.initial(Intent(Intent.ACTION_MAIN))
val voice =
initial.next(
Intent().putExtra(extraWearLaunchTarget, WearLaunchTarget.Voice.rawValue),
)
val chat =
voice.next(
Intent().putExtra(extraWearLaunchTarget, WearLaunchTarget.Chat.rawValue),
)
val notification = chat.next(Intent())
assertEquals(WearLaunchTarget.Chat, initial.initialTarget)
assertEquals(WearNavigationRequest(1, WearLaunchTarget.Voice), voice.navigationRequest)
assertEquals(WearNavigationRequest(2, WearLaunchTarget.Chat), chat.navigationRequest)
assertEquals(WearNavigationRequest(3, WearLaunchTarget.Chat), notification.navigationRequest)
assertSame(notification, notification.handled(requestId = 2))
assertNull(notification.handled(requestId = 3).navigationRequest)
}
@Test
fun activeRealtimeTalkKeepsWarmRoutesOnVoice() {
assertEquals(
WearHomePage.Voice,
wearLaunchPage(WearLaunchTarget.Chat, realtimeActive = true),
)
assertEquals(
WearHomePage.Voice,
wearLaunchPage(WearLaunchTarget.Voice, realtimeActive = true),
)
assertEquals(
WearHomePage.Chat,
wearLaunchPage(WearLaunchTarget.Chat, realtimeActive = false),
)
}
@Test
fun warmPagerRequestsPreservePendingReplyAndRealtimeUiState() {
val controller = Robolectric.buildActivity(ComponentActivity::class.java).setup()
var launchState by mutableStateOf(WearLaunchState.initial(Intent(Intent.ACTION_MAIN)))
var retainedState: WarmLaunchRetentionProbe? = null
controller.get().setContent {
WearLaunchContent(launchState) { _, _ ->
retainedState =
remember {
WarmLaunchRetentionProbe(
awaitingReply = true,
realtimeStartedAtMillis = 4_200L,
)
}
}
}
idleMainLooper()
val initialRetainedState = retainedState
launchState =
launchState.next(
Intent().putExtra(extraWearLaunchTarget, WearLaunchTarget.Voice.rawValue),
)
idleMainLooper()
assertSame(initialRetainedState, retainedState)
assertTrue(retainedState?.awaitingReply == true)
assertEquals(4_200L, retainedState?.realtimeStartedAtMillis)
launchState =
launchState.next(
Intent().putExtra(extraWearLaunchTarget, WearLaunchTarget.Chat.rawValue),
)
idleMainLooper()
assertSame(initialRetainedState, retainedState)
assertTrue(retainedState?.awaitingReply == true)
assertEquals(4_200L, retainedState?.realtimeStartedAtMillis)
controller.pause().stop().destroy()
}
@Test
fun warmDebugLaunchesRecreateForScreenshotEntrySwitchAndExit() {
val voiceScreenshotIntent =
Intent(Intent.ACTION_MAIN)
.putExtra(extraWearScreenshotMode, true)
.putExtra(extraWearScreenshotScene, WearScreenshotScene.Voice.rawValue)
val controlsScreenshotIntent =
Intent(Intent.ACTION_MAIN)
.putExtra(extraWearScreenshotMode, true)
.putExtra(extraWearScreenshotScene, WearScreenshotScene.Controls.rawValue)
val normalIntent = Intent(Intent.ACTION_MAIN)
assertEquals(
true,
shouldRecreateForScreenshotMode(null, voiceScreenshotIntent, screenshotModeEnabled = true),
)
assertEquals(
true,
shouldRecreateForScreenshotMode(
WearScreenshotScene.Voice,
controlsScreenshotIntent,
screenshotModeEnabled = true,
),
)
assertEquals(
true,
shouldRecreateForScreenshotMode(
WearScreenshotScene.Controls,
normalIntent,
screenshotModeEnabled = true,
),
)
assertEquals(
false,
shouldRecreateForScreenshotMode(
null,
Intent(Intent.ACTION_MAIN)
.putExtra(extraWearLaunchTarget, WearLaunchTarget.Voice.rawValue),
screenshotModeEnabled = true,
),
)
}
@Test
fun releaseLaunchDoesNotRecreateForScreenshotExtras() {
val screenshotIntent =
Intent(Intent.ACTION_MAIN)
.putExtra(extraWearScreenshotMode, true)
.putExtra(extraWearScreenshotScene, WearScreenshotScene.Voice.rawValue)
assertEquals(
false,
shouldRecreateForScreenshotMode(null, screenshotIntent, screenshotModeEnabled = false),
)
}
@Test
fun tileActionsTargetMainActivityWithTheirRequestedPage() {
val context = RuntimeEnvironment.getApplication()
WearLaunchTarget.entries.forEach { target ->
val activity = wearLaunchAction(context, target).androidActivity
val pageExtra =
activity?.keyToExtraMapping?.get(extraWearLaunchTarget)
as? ActionBuilders.AndroidStringExtra
assertEquals(context.packageName, activity?.packageName)
assertEquals(MainActivity::class.java.name, activity?.className)
assertEquals(target.rawValue, pageExtra?.value)
}
}
private fun idleMainLooper() {
shadowOf(Looper.getMainLooper()).idle()
}
private data class WarmLaunchRetentionProbe(
val awaitingReply: Boolean,
val realtimeStartedAtMillis: Long,
)
}

View file

@ -0,0 +1,39 @@
package ai.openclaw.wear
import androidx.compose.ui.unit.dp
import org.junit.Assert.assertEquals
import org.junit.Test
class WearLayoutTest {
@Test
fun voiceLayoutFitsSmallAndLargeRoundScreens() {
assertEquals(
WearVoiceLayout(
horizontalPadding = 6.dp,
orbSize = 80.dp,
contentHeight = 144.dp,
),
wearVoiceLayout(maxWidth = 192.dp, fontScale = 1f),
)
assertEquals(
WearVoiceLayout(
horizontalPadding = 6.dp,
orbSize = 92.dp,
contentHeight = 156.dp,
),
wearVoiceLayout(maxWidth = 227.dp, fontScale = 1f),
)
}
@Test
fun voiceLayoutMakesRoomForLargeTextOnSmallRoundScreens() {
assertEquals(
WearVoiceLayout(
horizontalPadding = 4.dp,
orbSize = 68.dp,
contentHeight = 132.dp,
),
wearVoiceLayout(maxWidth = 192.dp, fontScale = 1.2f),
)
}
}

View file

@ -0,0 +1,12 @@
package ai.openclaw.wear
import org.junit.Assert.assertEquals
import org.junit.Test
import java.util.Locale
class WearLocaleTextTest {
@Test
fun `uppercases labels with the active locale`() {
assertEquals("İLETİŞİM", wearUppercase("iletişim", Locale.forLanguageTag("tr")))
}
}

View file

@ -0,0 +1,937 @@
package ai.openclaw.wear
import ai.openclaw.wear.shared.WearDecodeResult
import ai.openclaw.wear.shared.WearEventType
import ai.openclaw.wear.shared.WearMessage
import ai.openclaw.wear.shared.WearProtocol
import ai.openclaw.wear.shared.WearProtocolCodec
import ai.openclaw.wear.shared.WearProxyCapability
import ai.openclaw.wear.shared.WearRpcMethod
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class WearProxyClientTest {
@Test
fun requestUsesReachablePhoneAndCorrelatesResponse() =
runTest {
lateinit var client: WearProxyClient
var sentNode: String? = null
client =
WearProxyClient.createForTests(
nodeResolver = WearNodeResolver { "phone-nearby" },
transport =
WearMessageTransport { nodeId, path, data ->
sentNode = nodeId
assertEquals(WearProtocol.REQUEST_PATH, path)
val request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request
val response =
WearProtocolCodec.encode(
WearMessage.Response(
requestId = request.requestId,
ok = true,
result = buildJsonObject { put("connected", JsonPrimitive(true)) },
eventStreamId = "stream-1",
eventSequence = 12,
),
)
client.handleMessage(
sourceNodeId = "phone-nearby",
path = WearProtocol.RESPONSE_PATH,
data = response,
)
},
)
val response = client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, expectedNodeId = null)
assertEquals("phone-nearby", sentNode)
assertTrue(
response.payload
.jsonObject
.getValue("connected")
.jsonPrimitive
.content
.toBoolean(),
)
assertEquals(12L, response.eventSequence)
assertEquals("stream-1", response.eventStreamId)
assertEquals("phone-nearby", response.sourceNodeId)
}
@Test
fun responseWithoutWatermarkKeepsLegacyBaselineUnknown() =
runTest {
lateinit var client: WearProxyClient
client =
WearProxyClient.createForTests(
nodeResolver = WearNodeResolver { "phone-nearby" },
transport =
WearMessageTransport { _, _, data ->
val request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request
val response =
WearProtocolCodec.encode(
WearMessage.Response(
requestId = request.requestId,
ok = true,
result = buildJsonObject { put("connected", JsonPrimitive(true)) },
),
)
client.handleMessage(
sourceNodeId = "phone-nearby",
path = WearProtocol.RESPONSE_PATH,
data = response,
)
},
)
val response = client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, expectedNodeId = null)
assertEquals(null, response.eventSequence)
val tracker = WearEventSequenceTracker()
tracker.adoptSnapshot(response.eventStreamId, response.eventSequence)
assertEquals(WearSequenceDecision.Accepted, tracker.accept(null, 37))
assertEquals(WearSequenceDecision.Accepted, tracker.accept(null, 38))
}
@Test
fun inboundMessagesStayBoundToTheSelectedPhone() =
runTest {
var preferredNode = "phone-1"
lateinit var client: WearProxyClient
client =
WearProxyClient.createForTests(
nodeResolver = WearNodeResolver { preferredNode },
transport =
WearMessageTransport { nodeId, _, data ->
val request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request
val response = WearProtocolCodec.encode(WearMessage.Response(requestId = request.requestId, ok = true))
client.handleMessage("wrong-phone", WearProtocol.RESPONSE_PATH, response)
client.handleMessage(nodeId, WearProtocol.RESPONSE_PATH, response)
},
)
client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, expectedNodeId = null)
val event = WearProtocolCodec.encode(WearMessage.Event(sequence = 1, event = WearEventType.Connection))
assertEquals(null, client.handleMessage("phone-2", WearProtocol.EVENT_PATH, event))
assertEquals("phone-1", client.handleMessage("phone-1", WearProtocol.EVENT_PATH, event)?.sourceNodeId)
preferredNode = "phone-2"
client.updatePreferredPhoneNodeId("phone-2")
assertEquals(null, client.handleMessage("phone-1", WearProtocol.EVENT_PATH, event))
assertEquals("phone-2", client.handleMessage("phone-2", WearProtocol.EVENT_PATH, event)?.sourceNodeId)
}
@Test
fun snapshotRequestRejectsOldPhoneAfterPreferredPhoneChanges() =
runTest {
lateinit var client: WearProxyClient
lateinit var request: WearMessage.Request
client =
WearProxyClient.createForTests(
nodeResolver = WearNodeResolver { "phone-1" },
transport =
WearMessageTransport { _, _, data ->
request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request
},
)
client.updatePreferredPhoneNodeId("phone-1")
val pending =
async {
runCatching {
client.request(WearRpcMethod.SessionsList, buildJsonObject {}, expectedNodeId = "phone-1")
}
}
runCurrent()
client.updatePreferredPhoneNodeId("phone-2")
client.handleMessage(
sourceNodeId = "phone-1",
path = WearProtocol.RESPONSE_PATH,
data = WearProtocolCodec.encode(WearMessage.Response(requestId = request.requestId, ok = true)),
)
assertEquals("phone_changed", (pending.await().exceptionOrNull() as WearProxyException).code)
}
@Test
fun statefulRequestStaysOnItsExpectedPhoneWithoutRediscovery() =
runTest {
lateinit var client: WearProxyClient
var discoveries = 0
var sentNode: String? = null
client =
WearProxyClient.createForTests(
nodeResolver =
WearNodeResolver {
discoveries += 1
"different-phone"
},
transport =
WearMessageTransport { nodeId, _, data ->
sentNode = nodeId
val request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request
client.handleMessage(
sourceNodeId = nodeId,
path = WearProtocol.RESPONSE_PATH,
data = WearProtocolCodec.encode(WearMessage.Response(requestId = request.requestId, ok = true)),
)
},
)
val result = client.request(WearRpcMethod.ChatAbort, buildJsonObject {}, expectedNodeId = "state-phone")
assertEquals(0, discoveries)
assertEquals("state-phone", sentNode)
assertEquals("state-phone", result.sourceNodeId)
}
@Test
fun statefulRequestDoesNotReplaceResolverSelectedEventSource() =
runTest {
lateinit var client: WearProxyClient
client =
WearProxyClient.createForTests(
nodeResolver = WearNodeResolver { "preferred-phone" },
transport =
WearMessageTransport { nodeId, _, data ->
val request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request
client.handleMessage(
sourceNodeId = nodeId,
path = WearProtocol.RESPONSE_PATH,
data = WearProtocolCodec.encode(WearMessage.Response(requestId = request.requestId, ok = true)),
)
},
)
client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, expectedNodeId = null)
client.request(WearRpcMethod.ChatAbort, buildJsonObject {}, expectedNodeId = "notification-phone")
val event = WearProtocolCodec.encode(WearMessage.Event(sequence = 1, event = WearEventType.Connection))
assertEquals(null, client.handleMessage("notification-phone", WearProtocol.EVENT_PATH, event))
assertEquals("preferred-phone", client.handleMessage("preferred-phone", WearProtocol.EVENT_PATH, event)?.sourceNodeId)
}
@Test
fun capabilitySelectionRoutesSnapshotsAndRejectsOldStatefulActions() =
runTest {
lateinit var client: WearProxyClient
var discoveries = 0
val sentNodes = mutableListOf<String>()
client =
WearProxyClient.createForTests(
nodeResolver =
WearNodeResolver {
discoveries += 1
"resolver-phone"
},
transport =
WearMessageTransport { nodeId, _, data ->
sentNodes += nodeId
val request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request
client.handleMessage(
sourceNodeId = nodeId,
path = WearProtocol.RESPONSE_PATH,
data = WearProtocolCodec.encode(WearMessage.Response(requestId = request.requestId, ok = true)),
)
},
)
val changed = async { client.preferredPhoneChanges.first() }
runCurrent()
client.updatePreferredPhoneNodeId("phone-2")
val status = client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, expectedNodeId = null)
val stale =
runCatching {
client.request(
WearRpcMethod.ChatAbort,
buildJsonObject {},
expectedNodeId = "phone-1",
requirePreferredNode = true,
)
}.exceptionOrNull()
assertEquals("phone-2", changed.await())
assertEquals("phone-2", status.sourceNodeId)
assertEquals("phone_changed", (stale as WearProxyException).code)
assertEquals(0, discoveries)
assertEquals(listOf("phone-2"), sentNodes)
}
@Test
fun preferredActionResolvesCapabilityBeforeSendingToStoredPhone() =
runTest {
var sends = 0
val client =
WearProxyClient.createForTests(
nodeResolver = WearNodeResolver { "phone-current" },
transport = WearMessageTransport { _, _, _ -> sends += 1 },
)
val stale =
runCatching {
client.request(
WearRpcMethod.ChatSend,
buildJsonObject {},
expectedNodeId = "phone-old",
requirePreferredNode = true,
)
}.exceptionOrNull()
assertEquals("phone_changed", (stale as WearProxyException).code)
assertEquals(0, sends)
}
@Test
fun missingPhoneFailsWithoutSending() =
runTest {
var sends = 0
val client =
WearProxyClient.createForTests(
nodeResolver = WearNodeResolver { null },
transport = WearMessageTransport { _, _, _ -> sends += 1 },
)
var code: String? = null
try {
client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, expectedNodeId = null)
} catch (err: WearProxyException) {
code = err.code
}
assertEquals("phone_unavailable", code)
assertEquals(0, sends)
}
@Test
fun discoveryTaskCancellationUsesConnectivityError() =
runTest {
val client =
WearProxyClient.createForTests(
nodeResolver = WearNodeResolver { throw CancellationException("task canceled") },
transport = WearMessageTransport { _, _, _ -> error("must not send") },
)
val failure = runCatching { client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null) }.exceptionOrNull()
assertEquals("phone_unavailable", (failure as WearProxyException).code)
}
@Test
fun sendTaskCancellationUsesConnectivityError() =
runTest {
val client =
WearProxyClient.createForTests(
nodeResolver = WearNodeResolver { "phone-nearby" },
transport = WearMessageTransport { _, _, _ -> throw CancellationException("task canceled") },
)
val failure = runCatching { client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null) }.exceptionOrNull()
assertEquals("phone_unavailable", (failure as WearProxyException).code)
}
@Test
fun shorterCallerTimeoutPropagatesWithoutInvalidatingPhone() =
runTest {
var discoveries = 0
var respond = false
lateinit var client: WearProxyClient
client =
WearProxyClient.createForTests(
nodeResolver =
WearNodeResolver {
discoveries += 1
"resolver-phone"
},
transport =
WearMessageTransport { nodeId, _, data ->
if (!respond) awaitCancellation()
val request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request
client.handleMessage(
sourceNodeId = nodeId,
path = WearProtocol.RESPONSE_PATH,
data = WearProtocolCodec.encode(WearMessage.Response(requestId = request.requestId, ok = true)),
)
},
)
client.updatePreferredPhoneNodeId("phone-current")
val failure =
runCatching {
withTimeout(1_000L) {
client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null)
}
}.exceptionOrNull()
respond = true
assertTrue(failure is TimeoutCancellationException)
assertEquals("phone-current", client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null).sourceNodeId)
assertEquals(0, discoveries)
}
@Test
fun sendFailureInvalidatesCachedPhoneAndRediscoversNextRequest() =
runTest {
var resolvedNode = "phone-old"
var discoveries = 0
var sends = 0
val sentNodes = mutableListOf<String>()
lateinit var client: WearProxyClient
client =
WearProxyClient.createForTests(
nodeResolver =
WearNodeResolver {
discoveries += 1
resolvedNode
},
transport =
WearMessageTransport { nodeId, _, data ->
sends += 1
sentNodes += nodeId
if (sends == 1) error("stale node")
val request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request
client.handleMessage(
sourceNodeId = nodeId,
path = WearProtocol.RESPONSE_PATH,
data = WearProtocolCodec.encode(WearMessage.Response(requestId = request.requestId, ok = true)),
)
},
)
val first = runCatching { client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null) }.exceptionOrNull()
resolvedNode = "phone-new"
val second = client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null)
assertEquals("phone_unavailable", (first as WearProxyException).code)
assertEquals("phone-new", second.sourceNodeId)
assertEquals(2, discoveries)
assertEquals(listOf("phone-old", "phone-new"), sentNodes)
}
@Test
fun responseTimeoutInvalidatesCachedPhoneAndRediscoversNextRequest() =
runTest {
var resolvedNode = "phone-old"
var discoveries = 0
var sends = 0
lateinit var client: WearProxyClient
client =
WearProxyClient.createForTests(
nodeResolver =
WearNodeResolver {
discoveries += 1
resolvedNode
},
transport =
WearMessageTransport { nodeId, _, data ->
sends += 1
if (sends > 1) {
val request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request
client.handleMessage(
sourceNodeId = nodeId,
path = WearProtocol.RESPONSE_PATH,
data = WearProtocolCodec.encode(WearMessage.Response(requestId = request.requestId, ok = true)),
)
}
},
)
val first = runCatching { client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null) }.exceptionOrNull()
resolvedNode = "phone-new"
val second = client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null)
assertEquals("timeout", (first as WearProxyException).code)
assertEquals("phone-new", second.sourceNodeId)
assertEquals(2, discoveries)
}
@Test
fun staleSendFailureDoesNotInvalidateNewerSamePhoneGeneration() =
runTest {
var discoveries = 0
var sends = 0
lateinit var client: WearProxyClient
client =
WearProxyClient.createForTests(
nodeResolver =
WearNodeResolver {
discoveries += 1
"resolver-phone"
},
transport =
WearMessageTransport { nodeId, _, data ->
sends += 1
if (sends == 1) {
client.updatePreferredPhoneNodeId(nodeId)
error("stale send")
}
val request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request
client.handleMessage(
sourceNodeId = nodeId,
path = WearProtocol.RESPONSE_PATH,
data = WearProtocolCodec.encode(WearMessage.Response(requestId = request.requestId, ok = true)),
)
},
)
client.updatePreferredPhoneNodeId("phone-current")
val first = runCatching { client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null) }.exceptionOrNull()
val second = client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null)
assertEquals("phone_unavailable", (first as WearProxyException).code)
assertEquals("phone-current", second.sourceNodeId)
assertEquals(0, discoveries)
}
@Test
fun staleResponseTimeoutDoesNotInvalidateNewerSamePhoneGeneration() =
runTest {
var discoveries = 0
var sends = 0
lateinit var client: WearProxyClient
client =
WearProxyClient.createForTests(
nodeResolver =
WearNodeResolver {
discoveries += 1
"resolver-phone"
},
transport =
WearMessageTransport { nodeId, _, data ->
sends += 1
if (sends == 1) {
client.updatePreferredPhoneNodeId(nodeId)
} else {
val request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request
client.handleMessage(
sourceNodeId = nodeId,
path = WearProtocol.RESPONSE_PATH,
data = WearProtocolCodec.encode(WearMessage.Response(requestId = request.requestId, ok = true)),
)
}
},
)
client.updatePreferredPhoneNodeId("phone-current")
val first = runCatching { client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null) }.exceptionOrNull()
val second = client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null)
assertEquals("timeout", (first as WearProxyException).code)
assertEquals("phone-current", second.sourceNodeId)
assertEquals(0, discoveries)
}
@Test
fun successfulParallelResponseProtectsPhoneFromOlderTimeout() =
runTest {
var discoveries = 0
var sends = 0
lateinit var client: WearProxyClient
client =
WearProxyClient.createForTests(
nodeResolver =
WearNodeResolver {
discoveries += 1
"resolver-phone"
},
transport =
WearMessageTransport { nodeId, _, data ->
sends += 1
if (sends > 1) {
val request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request
client.handleMessage(
sourceNodeId = nodeId,
path = WearProtocol.RESPONSE_PATH,
data = WearProtocolCodec.encode(WearMessage.Response(requestId = request.requestId, ok = true)),
)
}
},
)
client.updatePreferredPhoneNodeId("phone-current")
val older = async { runCatching { client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null) } }
runCurrent()
val newer = async { client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null) }
runCurrent()
assertEquals("phone-current", newer.await().sourceNodeId)
assertEquals("timeout", (older.await().exceptionOrNull() as WearProxyException).code)
assertEquals("phone-current", client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null).sourceNodeId)
assertEquals(0, discoveries)
}
@Test
fun correlatedResponseProtectsPhoneBeforeRequesterResumes() =
runTest {
var discoveries = 0
var respond = false
val requests = mutableListOf<WearMessage.Request>()
lateinit var client: WearProxyClient
client =
WearProxyClient.createForTests(
nodeResolver =
WearNodeResolver {
discoveries += 1
"resolver-phone"
},
transport =
WearMessageTransport { nodeId, _, data ->
val request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request
requests += request
if (respond) {
client.handleMessage(
sourceNodeId = nodeId,
path = WearProtocol.RESPONSE_PATH,
data = WearProtocolCodec.encode(WearMessage.Response(requestId = request.requestId, ok = true)),
)
}
},
)
client.updatePreferredPhoneNodeId("phone-current")
val older = async { runCatching { client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null) } }
val newer = async { runCatching { client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null) } }
runCurrent()
assertEquals(2, requests.size)
client.handleMessage(
sourceNodeId = "phone-current",
path = WearProtocol.RESPONSE_PATH,
data = WearProtocolCodec.encode(WearMessage.Response(requestId = requests[1].requestId, ok = true)),
)
newer.cancel()
advanceUntilIdle()
assertTrue(newer.isCancelled)
assertEquals("timeout", (older.await().exceptionOrNull() as WearProxyException).code)
respond = true
assertEquals("phone-current", client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null).sourceNodeId)
assertEquals(0, discoveries)
}
@Test
fun ambiguousCapabilityCallbackForcesReachableRediscovery() =
runTest {
var discoveries = 0
lateinit var client: WearProxyClient
client =
WearProxyClient.createForTests(
nodeResolver =
WearNodeResolver {
discoveries += 1
"phone-reachable"
},
transport =
WearMessageTransport { nodeId, _, data ->
val request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request
client.handleMessage(
sourceNodeId = nodeId,
path = WearProtocol.RESPONSE_PATH,
data = WearProtocolCodec.encode(WearMessage.Response(requestId = request.requestId, ok = true)),
)
},
)
client.updatePreferredPhoneNodeId("phone-stale")
client.invalidatePreferredPhoneNode()
val result = client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null)
assertEquals("phone-reachable", result.sourceNodeId)
assertEquals(1, discoveries)
}
@Test
fun inFlightDiscoveryCannotRestoreInvalidatedPhone() =
runTest {
val discoveryStarted = CompletableDeferred<Unit>()
val releaseDiscovery = CompletableDeferred<Unit>()
var resolvedNode = "phone-old"
var discoveries = 0
val sentNodes = mutableListOf<String>()
lateinit var client: WearProxyClient
client =
WearProxyClient.createForTests(
nodeResolver =
WearNodeResolver {
val result = resolvedNode
discoveries += 1
if (discoveries == 1) {
discoveryStarted.complete(Unit)
releaseDiscovery.await()
}
result
},
transport =
WearMessageTransport { nodeId, _, data ->
sentNodes += nodeId
val request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request
client.handleMessage(
sourceNodeId = nodeId,
path = WearProtocol.RESPONSE_PATH,
data = WearProtocolCodec.encode(WearMessage.Response(requestId = request.requestId, ok = true)),
)
},
)
val first = async { runCatching { client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null) } }
discoveryStarted.await()
client.invalidatePreferredPhoneNode()
resolvedNode = "phone-new"
releaseDiscovery.complete(Unit)
assertEquals("phone_unavailable", (first.await().exceptionOrNull() as WearProxyException).code)
assertEquals("phone-new", client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null).sourceNodeId)
assertEquals(2, discoveries)
assertEquals(listOf("phone-new"), sentNodes)
}
@Test
fun reachablePhoneSelectionPrefersOneNearbyNodeAndRejectsAmbiguity() {
val nearby = WearReachablePhoneNode(id = "phone-nearby", isNearby = true)
val nearbyTwo = WearReachablePhoneNode(id = "phone-nearby-2", isNearby = true)
val cloud = WearReachablePhoneNode(id = "phone-cloud", isNearby = false)
val cloudTwo = WearReachablePhoneNode(id = "phone-cloud-2", isNearby = false)
assertEquals("phone-nearby", selectReachablePhoneNodeId(listOf(cloud, nearby)))
assertEquals("phone-cloud", selectReachablePhoneNodeId(listOf(cloud)))
assertEquals(null, selectReachablePhoneNodeId(listOf(nearby, nearbyTwo, cloud)))
assertEquals(null, selectReachablePhoneNodeId(listOf(cloud, cloudTwo)))
}
@Test
fun eventGapAndResetRequireCanonicalRefresh() {
val tracker = WearEventSequenceTracker()
assertEquals(WearSequenceDecision.Accepted, tracker.accept(null, 5))
assertEquals(WearSequenceDecision.Accepted, tracker.accept(null, 6))
assertEquals(WearSequenceDecision.GapOrReset, tracker.accept(null, 9))
assertEquals(WearSequenceDecision.AwaitingSnapshot, tracker.accept(null, 10))
tracker.adoptSnapshot(null, 9)
assertEquals(WearSequenceDecision.GapOrReset, tracker.accept(null, 8))
assertEquals(WearSequenceDecision.AwaitingSnapshot, tracker.accept(null, 8))
tracker.adoptSnapshot(null, 8)
assertEquals(WearSequenceDecision.GapOrReset, tracker.accept(null, 1))
tracker.adoptSnapshot(null, 1)
assertEquals(WearSequenceDecision.Accepted, tracker.accept(null, 2))
}
@Test
fun phoneProcessEpochForcesRefreshEvenWhenSequenceLooksContiguous() {
val tracker = WearEventSequenceTracker()
tracker.adoptSnapshot("old-process", 5)
assertEquals(WearSequenceDecision.GapOrReset, tracker.accept("new-process", 6))
assertEquals(WearSequenceDecision.AwaitingSnapshot, tracker.accept("new-process", 7))
tracker.adoptSnapshot("new-process", 7)
assertEquals(WearSequenceDecision.Accepted, tracker.accept("new-process", 8))
}
@Test
fun snapshotWatermarkMakesTheFirstMissingEventVisible() {
val tracker = WearEventSequenceTracker()
tracker.adoptSnapshot("stream", 10)
assertEquals(WearSequenceDecision.GapOrReset, tracker.accept("stream", 12))
tracker.adoptSnapshot("stream", 12)
assertEquals(WearSequenceDecision.Accepted, tracker.accept("stream", 13))
}
@Test
fun rpcResponseMustMatchTheCurrentStreamAndWatermark() {
val tracker = WearEventSequenceTracker()
tracker.adoptSnapshot("stream", 10)
assertTrue(tracker.isResponseCurrent(tracker.beginResponseRequest(), "stream", 10))
assertFalse(tracker.isResponseCurrent(tracker.beginResponseRequest(), "stream", 11))
assertFalse(tracker.isResponseCurrent(tracker.beginResponseRequest(), "stream", 9))
assertFalse(tracker.isResponseCurrent(tracker.beginResponseRequest(), "new-stream", 11))
val pendingSnapshot = tracker.beginResponseRequest()
tracker.requireSnapshot()
assertFalse(tracker.isResponseCurrent(pendingSnapshot, "stream", 11))
}
@Test
fun legacyRpcResponseWithoutWatermarkRequiresUnchangedEvents() {
val tracker = WearEventSequenceTracker()
tracker.adoptSnapshot(null, 10)
assertTrue(tracker.isResponseCurrent(tracker.beginResponseRequest(), null, null))
val staleRequest = tracker.beginResponseRequest()
assertEquals(WearSequenceDecision.Accepted, tracker.accept(null, 11))
assertFalse(tracker.isResponseCurrent(staleRequest, null, null))
}
@Test
fun newerRpcRequestInvalidatesAnOlderCompletion() {
val tracker = WearEventSequenceTracker()
tracker.adoptSnapshot("stream", 10)
val olderRequest = tracker.beginResponseRequest()
val newerRequest = tracker.beginResponseRequest()
assertFalse(tracker.isResponseCurrent(olderRequest, "stream", 12))
assertTrue(tracker.isResponseCurrent(newerRequest, "stream", 10))
}
@Test
fun resyncBufferReplaysEventsNewerThanTheSnapshotWatermark() {
val tracker = WearEventSequenceTracker()
val buffer = WearEventResyncBuffer()
val missed =
WearInboundEvent(sourceNodeId = "phone", sequence = 5, event = WearEventType.Chat, payload = null)
val raced =
WearInboundEvent(sourceNodeId = "phone", sequence = 6, event = WearEventType.Chat, payload = null)
tracker.adoptSnapshot(null, 3)
assertEquals(WearSequenceDecision.GapOrReset, tracker.accept(missed.streamId, missed.sequence))
buffer.start(missed)
assertEquals(WearSequenceDecision.AwaitingSnapshot, tracker.accept(raced.streamId, raced.sequence))
buffer.append(raced)
val replay = buffer.drainAfterSnapshot(null, 4)
tracker.adoptSnapshot(null, 4)
assertEquals(listOf(5L, 6L), replay.map(WearInboundEvent::sequence))
assertTrue(replay.all { tracker.accept(it.streamId, it.sequence) == WearSequenceDecision.Accepted })
}
@Test
fun resyncBufferDiscardsEventsAlreadyCoveredByTheSnapshot() {
val buffer = WearEventResyncBuffer()
buffer.start(
WearInboundEvent(sourceNodeId = "phone", sequence = 5, event = WearEventType.Chat, payload = null),
)
buffer.append(
WearInboundEvent(sourceNodeId = "phone", sequence = 6, event = WearEventType.Chat, payload = null),
)
assertTrue(buffer.drainAfterSnapshot(null, 6).isEmpty())
}
@Test
fun resyncBufferDoesNotReplayAnOldProcessEpoch() {
val buffer = WearEventResyncBuffer()
buffer.start(
WearInboundEvent(sourceNodeId = "phone", streamId = "old", sequence = 6, event = WearEventType.Chat, payload = null),
)
buffer.append(
WearInboundEvent(sourceNodeId = "phone", streamId = "new", sequence = 1, event = WearEventType.Chat, payload = null),
)
val replay = buffer.drainAfterSnapshot("new", 0)
assertEquals(listOf(1L), replay.map(WearInboundEvent::sequence))
}
@Test
fun legacySnapshotWithoutWatermarkDoesNotReplayAmbiguousEvents() {
val buffer = WearEventResyncBuffer()
buffer.start(
WearInboundEvent(sourceNodeId = "phone", sequence = 5, event = WearEventType.Chat, payload = null),
)
buffer.append(
WearInboundEvent(sourceNodeId = "phone", sequence = 6, event = WearEventType.Chat, payload = null),
)
assertTrue(buffer.drainAfterSnapshot(null, null).isEmpty())
}
@Test
fun eventSourceTrackerRequiresResyncOnlyWhenThePhoneChanges() {
val tracker = WearEventSourceTracker()
assertTrue(!tracker.changed("phone-1"))
assertTrue(!tracker.changed("phone-1"))
assertTrue(tracker.changed("phone-2"))
tracker.adopt("snapshot-phone")
assertTrue(!tracker.changed("snapshot-phone"))
assertTrue(tracker.changed("other-phone"))
}
@Test
fun phoneChangeClearsPhoneLocalSessionState() {
val selected = WearSession(key = "main", title = "Main", updatedAt = null, hasActiveRun = true, phoneNodeId = "phone-1")
val state =
WearUiState(
loading = false,
connected = true,
proxyCapabilities = WearProxyCapability.entries.toSet(),
sessions = listOf(selected),
selectedSession = selected,
messages = listOf(WearChatMessage(id = "m1", role = "assistant", text = "old", timestamp = 1)),
streamText = "typing",
activeRunId = "run-1",
sending = true,
failure = WearConversationFailure.INTERNAL_ERROR,
)
val reset = state.resetForPhoneChange()
assertTrue(reset.loading)
assertTrue(!reset.connected)
assertTrue(reset.proxyCapabilities.isEmpty())
assertTrue(reset.sessions.isEmpty())
assertEquals(null, reset.selectedSession)
assertTrue(reset.messages.isEmpty())
assertEquals(null, reset.streamText)
assertEquals(null, reset.activeRunId)
assertTrue(!reset.sending)
assertEquals(null, reset.failure)
}
@Test
fun requestDeadlineIncludesPhoneDiscovery() =
runTest {
val client =
WearProxyClient.createForTests(
nodeResolver = WearNodeResolver { awaitCancellation() },
transport = WearMessageTransport { _, _, _ -> error("must not send") },
)
val failure =
async {
runCatching { client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null) }.exceptionOrNull()
}
advanceUntilIdle()
assertEquals("timeout", (failure.await() as WearProxyException).code)
}
@Test
fun discoveryFailureUsesConnectivityError() =
runTest {
val client =
WearProxyClient.createForTests(
nodeResolver = WearNodeResolver { error("Play services failed") },
transport = WearMessageTransport { _, _, _ -> error("must not send") },
)
val failure = runCatching { client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null) }.exceptionOrNull()
assertEquals("phone_unavailable", (failure as WearProxyException).code)
}
}

View file

@ -0,0 +1,90 @@
package ai.openclaw.wear
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class WearReplyNotifierTest {
@Test
fun visibilityTracksOverlappingActivityLifecycles() {
val tracker = VisibleActivityTracker()
tracker.onStarted()
tracker.onStarted()
tracker.onStopped()
assertTrue(tracker.isVisible())
tracker.onStopped()
assertTrue(!tracker.isVisible())
}
@Test
fun pendingIntentIdentityDoesNotUseCollidingStringHashCodes() {
check("Aa".hashCode() == "BB".hashCode())
val first = replyPendingIntentAction("Aa", "notification-1")
val second = replyPendingIntentAction("BB", "notification-1")
assertNotEquals(first, second)
assertTrue(first.startsWith("ai.openclaw.wear.REPLY."))
}
@Test
fun distinctFinalMessagesUseDistinctNotificationAndReplyIdentities() {
val firstMessage = WearChatMessage(id = "m1", role = "assistant", text = "first", timestamp = 1)
val secondMessage = WearChatMessage(id = "m2", role = "assistant", text = "second", timestamp = 2)
val firstTag = replyNotificationTag("session", firstMessage, "run-1")
val secondTag = replyNotificationTag("session", secondMessage, "run-2")
assertNotEquals(firstTag, secondTag)
assertNotEquals(
replyPendingIntentAction("session", firstTag),
replyPendingIntentAction("session", secondTag),
)
}
@Test
fun missingMessageIdentityUsesStableEventFallback() {
val message = WearChatMessage(id = null, role = "assistant", text = "same", timestamp = null)
val first = replyNotificationTag("session", message, "run-1")
val retry = replyNotificationTag("session", message, "run-1")
val distinct = replyNotificationTag("session", message, "run-2")
assertEquals(first, retry)
assertNotEquals(first, distinct)
}
@Test
fun fallbackIdentitySeparatesPhoneProcessEpochs() {
val message = WearChatMessage(id = null, role = "assistant", text = "same", timestamp = null)
val first = replyNotificationTag("session", message, "source:phone\u0000stream:epoch-1\u0000sequence:1")
val restarted = replyNotificationTag("session", message, "source:phone\u0000stream:epoch-2\u0000sequence:1")
assertNotEquals(first, restarted)
}
@Test
fun notificationRetryIdentityIsStableForTheSameLogicalReply() {
val first = notificationReplyIdempotencyKey("session", "notification", "reply")
val retry = notificationReplyIdempotencyKey("session", "notification", "reply")
val edited = notificationReplyIdempotencyKey("session", "notification", "edited")
assertEquals(first, retry)
assertNotEquals(first, edited)
}
@Test
fun preferredPhoneChangeRequiresAppRecoveryInsteadOfAStaleRetry() {
assertEquals(
NotificationReplyFailureAction.OpenApp,
notificationReplyFailureAction(WearProxyException("phone_changed", "preferred phone changed")),
)
assertEquals(
NotificationReplyFailureAction.RetrySamePhone,
notificationReplyFailureAction(WearProxyException("phone_unavailable", "offline")),
)
}
}

View file

@ -0,0 +1,59 @@
package ai.openclaw.wear
import android.content.Intent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class WearScreenshotModeTest {
@Test
fun ignoresNormalLaunches() {
assertNull(parseWearScreenshotModeIntent(Intent(Intent.ACTION_MAIN)))
}
@Test
fun parsesRequestedScene() {
val parsed =
parseWearScreenshotModeIntent(
Intent(Intent.ACTION_MAIN)
.putExtra(extraWearScreenshotMode, true)
.putExtra(extraWearScreenshotScene, "voice"),
)
assertEquals(WearScreenshotScene.Voice, parsed)
}
@Test
fun defaultsUnknownScenesToChat() {
val parsed =
parseWearScreenshotModeIntent(
Intent(Intent.ACTION_MAIN)
.putExtra(extraWearScreenshotMode, true)
.putExtra(extraWearScreenshotScene, "unknown"),
)
assertEquals(WearScreenshotScene.Chat, parsed)
}
@Test
fun mapsScenesToProductionPages() {
assertEquals(WearHomePage.Chat, WearScreenshotScene.Chat.initialPage)
assertEquals(WearHomePage.Voice, WearScreenshotScene.Voice.initialPage)
assertEquals(WearHomePage.Controls, WearScreenshotScene.Controls.initialPage)
}
@Test
fun fixtureRepresentsAConnectedConversation() {
val snapshot = WearScreenshotFixture.snapshot
assertEquals(WearGatewayState.CONNECTED, snapshot.gatewayState)
assertEquals("release-planning", snapshot.activeSessionId)
assertTrue(snapshot.messages.any { message -> message.chatRole == WearChatRole.ASSISTANT })
}
}

View file

@ -0,0 +1,585 @@
package ai.openclaw.wear
import ai.openclaw.wear.shared.WearProxyCapability
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class WearSessionScopeTest {
@Test
fun discardsStatusSessionWhenLaterListReportsDifferentAgent() {
assertNull(
coherentWearActiveSessionKey(
statusAgentId = "agent-a",
statusSessionKey = "agent:agent-a:main",
sessionListAgentId = "agent-b",
),
)
}
@Test
fun keepsStatusSessionForMatchingAndLegacyPhoneSnapshots() {
val sessionKey = "agent:agent-a:main"
assertEquals(sessionKey, coherentWearActiveSessionKey("agent-a", sessionKey, "agent-a"))
assertEquals(sessionKey, coherentWearActiveSessionKey("agent-a", sessionKey, null))
}
@Test
fun exposesModelOnlyForPhoneActiveSession() {
assertEquals("openai/model", wearSelectedModelRef("agent:main", "agent:main", "openai/model"))
assertNull(wearSelectedModelRef("agent:other", "agent:main", "openai/model"))
assertNull(wearSelectedModelRef(null, "agent:main", "openai/model"))
}
@Test
fun modelCatalogScopeTracksBothPhoneAndModel() {
val requested =
WearSession(
key = "agent:main",
title = "Main",
updatedAt = null,
hasActiveRun = false,
phoneNodeId = "phone-a",
modelRef = "openai/model-a",
)
assertEquals(false, wearModelCatalogScopeChanged(requested, requested.copy()))
assertEquals(true, wearModelCatalogScopeChanged(requested, requested.copy(phoneNodeId = "phone-b")))
assertEquals(true, wearModelCatalogScopeChanged(requested, requested.copy(modelRef = "openai/model-b")))
}
@Test
fun modelCatalogResultRequiresTheFullRequestedScope() {
val requested =
WearSession(
key = "agent:main",
title = "Main",
updatedAt = null,
hasActiveRun = false,
phoneNodeId = "phone-a",
modelRef = "openai/model-a",
)
assertEquals(true, wearSessionRequestIsCurrent(requested, requested.copy(), "phone-a"))
assertEquals(
false,
wearSessionRequestIsCurrent(requested, requested.copy(key = "agent:other"), "phone-a"),
)
assertEquals(
false,
wearSessionRequestIsCurrent(requested, requested.copy(modelRef = "openai/model-b"), "phone-a"),
)
assertEquals(false, wearSessionRequestIsCurrent(requested, requested.copy(), "phone-b"))
assertEquals(
false,
wearSessionRequestIsCurrent(requested, requested.copy(phoneNodeId = "phone-b"), "phone-b"),
)
}
@Test
fun transcriptResultPreservesNewerModelWithinTheSamePhoneSession() {
val requested =
WearSession(
key = "agent:main",
title = "Main",
updatedAt = null,
hasActiveRun = false,
phoneNodeId = "phone-a",
modelRef = "openai/model-a",
)
assertEquals(
true,
wearTranscriptRequestIsCurrent(requested, requested.copy(modelRef = "openai/model-b"), "phone-a"),
)
assertEquals(false, wearTranscriptRequestIsCurrent(requested, requested.copy(), "phone-b"))
assertEquals(
false,
wearTranscriptRequestIsCurrent(requested, requested.copy(phoneNodeId = "phone-b"), "phone-b"),
)
}
@Test
fun delayedSessionActionsRequireTheOriginalPhoneAndSession() {
val requested =
WearSession(
key = "agent:main",
title = "Main",
updatedAt = null,
hasActiveRun = false,
phoneNodeId = "phone-a",
modelRef = "openai/model-a",
)
assertTrue(
wearSessionActionIsCurrent(
requested,
WearUiState(phoneNodeId = "phone-a", selectedSession = requested),
requestedRouteGeneration = 3,
currentRouteGeneration = 3,
),
)
assertFalse(
wearSessionActionIsCurrent(
requested,
WearUiState(
phoneNodeId = "phone-b",
selectedSession = requested.copy(phoneNodeId = "phone-b"),
),
requestedRouteGeneration = 3,
currentRouteGeneration = 4,
),
)
assertFalse(
wearSessionActionIsCurrent(
requested,
WearUiState(
phoneNodeId = "phone-a",
selectedSession = requested.copy(key = "agent:other"),
),
requestedRouteGeneration = 3,
currentRouteGeneration = 3,
),
)
assertFalse(
wearSessionActionIsCurrent(
requested,
WearUiState(phoneNodeId = "phone-a", selectedSession = requested),
requestedRouteGeneration = 3,
currentRouteGeneration = 5,
),
)
}
@Test
fun delayedControlsRequireTheOriginalPhoneRouteGeneration() {
val phoneA = WearUiState(phoneNodeId = "phone-a", controlBusy = true)
assertTrue(
wearControlRouteIsCurrent(
requestedPhoneNodeId = "phone-a",
currentState = phoneA,
requestedRouteGeneration = 3,
currentRouteGeneration = 3,
),
)
assertFalse(
wearControlRouteIsCurrent(
requestedPhoneNodeId = "phone-a",
currentState = WearUiState(phoneNodeId = "phone-b", controlBusy = true),
requestedRouteGeneration = 3,
currentRouteGeneration = 4,
),
)
assertFalse(
wearControlRouteIsCurrent(
requestedPhoneNodeId = "phone-a",
currentState = phoneA,
requestedRouteGeneration = 3,
currentRouteGeneration = 5,
),
)
}
@Test
fun staleControlCompletionCannotClearReplacementBusyOwner() {
val owners = WearControlBusyOwner()
val staleOwner = checkNotNull(owners.claim())
owners.reset()
val replacementOwner = checkNotNull(owners.claim())
assertFalse(owners.release(staleOwner))
assertTrue(owners.release(replacementOwner))
}
@Test
fun abandonedControlActionReleasesItsOwnBusyOwner() {
val owners = WearControlBusyOwner()
val owner = checkNotNull(owners.claim())
assertTrue(owners.release(owner))
assertTrue(owners.claim() != null)
}
@Test
fun gatewayControlResponseKeepsBusyUntilItsOwnerFinalizes() {
val updated =
applyWearGatewayControlStatus(
state =
WearUiState(
phoneNodeId = "phone-a",
controlBusy = true,
activeAgentId = "agent-a",
),
status =
WearProxyStatus(
connected = true,
activeAgentId = "agent-b",
activeSessionKey = null,
selectedModelRef = null,
capabilities = setOf(WearProxyCapability.GatewayControls),
eventStreamId = null,
eventSequence = null,
phoneNodeId = "phone-b",
),
enabled = true,
)
assertTrue(updated.controlBusy)
assertEquals("phone-b", updated.phoneNodeId)
assertEquals("agent-b", updated.activeAgentId)
}
@Test
fun snapshotResponsesRequireTheSamePhoneAndEventStream() {
assertEquals(true, wearSnapshotSourcesMatch("phone-a", "stream-a", "phone-a", "stream-a"))
assertEquals(false, wearSnapshotSourcesMatch("phone-a", "stream-a", "phone-b", "stream-a"))
assertEquals(false, wearSnapshotSourcesMatch("phone-a", "stream-a", "phone-a", "stream-b"))
assertEquals(true, wearSnapshotSourcesMatch("phone-a", null, "phone-a", null))
}
@Test
fun agentSwitchDropsThePreviousSessionModelAndStreamTogether() {
val previousSession =
WearSession(
key = "agent:old:thread-1",
title = "Old",
updatedAt = null,
hasActiveRun = true,
phoneNodeId = "phone-a",
modelRef = "openai/old",
)
val state =
WearUiState(
activeAgentId = "old",
sessions = listOf(previousSession),
selectedSession = previousSession,
selectedModelRef = "openai/old",
models = listOf(WearModel("openai/old", "Old")),
messages = listOf(WearChatMessage("m1", "assistant", "old reply", 1)),
streamText = "old stream",
activeRunId = "run-old",
)
val switched = state.switchAgentContext("new")
assertEquals("new", switched.activeAgentId)
assertNull(switched.selectedSession)
assertNull(switched.selectedModelRef)
assertNull(switched.streamText)
assertNull(switched.activeRunId)
assertEquals(emptyList<WearSession>(), switched.sessions)
assertEquals(emptyList<WearModel>(), switched.models)
assertEquals(emptyList<WearChatMessage>(), switched.messages)
}
@Test
fun sessionSwitchMovesModelAndClearsThePreviousCatalogAndTranscript() {
val nextSession =
WearSession(
key = "agent:main:thread-2",
title = "Next",
updatedAt = null,
hasActiveRun = false,
phoneNodeId = "phone-a",
modelRef = "openai/new",
)
val state =
WearUiState(
activeAgentId = "main",
selectedModelRef = "openai/old",
models = listOf(WearModel("openai/new", "New")),
messages = listOf(WearChatMessage("m1", "assistant", "old reply", 1)),
streamText = "old stream",
activeRunId = "run-old",
)
val switched = state.switchSessionContext(nextSession)
assertEquals(nextSession, switched.selectedSession)
assertEquals("openai/new", switched.selectedModelRef)
assertEquals("main", switched.activeAgentId)
assertEquals(emptyList<WearModel>(), switched.models)
assertEquals(emptyList<WearChatMessage>(), switched.messages)
assertNull(switched.streamText)
assertNull(switched.activeRunId)
}
@Test
fun modelSwitchClearsTheSelectedModelScopedCatalog() {
val selectedSession =
WearSession(
key = "agent:main:thread-2",
title = "Selected",
updatedAt = null,
hasActiveRun = false,
phoneNodeId = "phone-a",
modelRef = "openai/model-59",
)
val state =
WearUiState(
sessions = listOf(selectedSession),
selectedSession = selectedSession,
selectedModelRef = "openai/model-59",
models =
listOf(
WearModel("openai/model-0", "Model 0"),
WearModel("openai/model-59", "Model 59"),
),
)
val switched = state.switchModelContext("openai/model-0")
assertEquals("openai/model-0", switched.selectedModelRef)
assertEquals("openai/model-0", switched.selectedSession?.modelRef)
assertEquals("openai/model-0", switched.sessions.single().modelRef)
assertEquals(emptyList<WearModel>(), switched.models)
}
@Test
fun olderRunFinalMergesItsMessageWithoutEndingTheActiveReply() {
val previous = WearChatMessage("previous", "assistant", "Earlier", 1)
val completed = WearChatMessage("older", "assistant", "Finished older reply", 2)
val current = activeTerminalState(messages = listOf(previous))
val transition =
reduceWearTerminalChatEvent(
current,
terminalEvent(state = "final", runId = "older-run", message = completed),
)
assertEquals(listOf(previous, completed), transition.state.messages)
assertEquals("active-run", transition.state.activeRunId)
assertEquals("Hello", transition.state.streamText)
assertFalse(transition.reloadHistory)
assertNull(transition.observedMessage)
}
@Test
fun olderRunAbortCannotEndTheActiveReply() {
assertForeignTerminalPreservesLiveReply("aborted")
}
@Test
fun olderRunErrorCannotEndTheActiveReply() {
assertForeignTerminalPreservesLiveReply("error")
}
@Test
fun identifiedOlderFinalCannotEndAnAnonymousReply() {
assertForeignTerminalPreservesLiveReply("final", activeRunId = null)
}
@Test
fun identifiedOlderAbortCannotEndAnAnonymousReply() {
assertForeignTerminalPreservesLiveReply("aborted", activeRunId = null)
}
@Test
fun identifiedOlderErrorCannotEndAnAnonymousReply() {
assertForeignTerminalPreservesLiveReply("error", activeRunId = null)
}
@Test
fun anonymousReplyFinalReconcilesWhenTheRunIsFirstIdentified() {
val completed = WearChatMessage("completed", "assistant", "Finished reply", 2)
val current = activeTerminalState(activeRunId = null)
val transition =
reduceWearTerminalChatEvent(
current,
terminalEvent(state = "final", runId = "revealed-run", message = completed),
)
assertEquals(listOf(completed), transition.state.messages)
assertEquals("Hello", transition.state.streamText)
assertNull(transition.state.activeRunId)
assertTrue(transition.reloadHistory)
assertEquals(completed, transition.observedMessage)
}
@Test
fun anonymousReplyAbortReconcilesWhenTheRunIsFirstIdentified() {
assertUncertainTerminalPreservesReplyAndReloadsHistory(
state = "aborted",
activeRunId = null,
eventRunId = "revealed-run",
)
}
@Test
fun anonymousReplyErrorReconcilesWhenTheRunIsFirstIdentified() {
assertUncertainTerminalPreservesReplyAndReloadsHistory(
state = "error",
activeRunId = null,
eventRunId = "revealed-run",
)
}
@Test
fun matchingRunFinalEndsTheReplyAndReloadsItsFinalMessage() {
val completed = WearChatMessage("completed", "assistant", "Finished reply", 2)
val transition =
reduceWearTerminalChatEvent(
activeTerminalState(),
terminalEvent(state = "final", runId = "active-run", message = completed),
)
assertEquals(listOf(completed), transition.state.messages)
assertNull(transition.state.activeRunId)
assertNull(transition.state.streamText)
assertTrue(transition.reloadHistory)
assertEquals(completed, transition.observedMessage)
}
@Test
fun matchingRunAbortEndsTheReplyAndReloadsHistory() {
assertOwnTerminalEndsLiveReply("aborted", runId = "active-run")
}
@Test
fun matchingRunErrorEndsTheReplyAndReloadsHistory() {
assertOwnTerminalEndsLiveReply("error", runId = "active-run")
}
@Test
fun unidentifiedAbortReconcilesWithoutClearingAnIdentifiedReply() {
assertUncertainTerminalPreservesReplyAndReloadsHistory(
state = "aborted",
activeRunId = "active-run",
eventRunId = null,
)
}
@Test
fun unidentifiedErrorReconcilesWithoutClearingAnIdentifiedReply() {
assertUncertainTerminalPreservesReplyAndReloadsHistory(
state = "error",
activeRunId = "active-run",
eventRunId = null,
)
}
@Test
fun unidentifiedFinalMergesWithoutClearingAnIdentifiedReply() {
val completed = WearChatMessage("completed", "assistant", "Finished reply", 2)
val transition =
reduceWearTerminalChatEvent(
activeTerminalState(),
terminalEvent(state = "final", runId = null, message = completed),
)
assertEquals(listOf(completed), transition.state.messages)
assertEquals("active-run", transition.state.activeRunId)
assertEquals("Hello", transition.state.streamText)
assertTrue(transition.reloadHistory)
assertEquals(completed, transition.observedMessage)
}
@Test
fun otherSessionTerminalNeverChangesTheSelectedReply() {
val current = activeTerminalState()
val transition =
reduceWearTerminalChatEvent(
current,
terminalEvent(state = "final", runId = "active-run", sessionKey = "agent:other"),
)
assertEquals(current, transition.state)
assertFalse(transition.reloadHistory)
assertNull(transition.observedMessage)
}
private fun assertUncertainTerminalPreservesReplyAndReloadsHistory(
state: String,
activeRunId: String?,
eventRunId: String?,
) {
val current = activeTerminalState(activeRunId = activeRunId)
val transition =
reduceWearTerminalChatEvent(
current,
terminalEvent(state = state, runId = eventRunId),
)
assertEquals(current, transition.state)
assertTrue(transition.reloadHistory)
assertNull(transition.observedMessage)
}
private fun assertForeignTerminalPreservesLiveReply(
state: String,
activeRunId: String? = "active-run",
) {
val current = activeTerminalState(activeRunId = activeRunId)
val transition =
reduceWearTerminalChatEvent(
current,
terminalEvent(state = state, runId = "older-run"),
)
assertEquals(current, transition.state)
if (activeRunId == null) {
assertTrue(transition.reloadHistory)
} else {
assertFalse(transition.reloadHistory)
}
assertNull(transition.observedMessage)
}
private fun assertOwnTerminalEndsLiveReply(
state: String,
runId: String?,
) {
val transition =
reduceWearTerminalChatEvent(
activeTerminalState(),
terminalEvent(state = state, runId = runId),
)
assertNull(transition.state.activeRunId)
assertNull(transition.state.streamText)
assertTrue(transition.reloadHistory)
assertNull(transition.observedMessage)
}
private fun activeTerminalState(
activeRunId: String? = "active-run",
messages: List<WearChatMessage> = emptyList(),
): WearUiState {
val selected =
WearSession(
key = "agent:main",
title = "Main",
updatedAt = null,
hasActiveRun = true,
phoneNodeId = "phone-a",
)
return WearUiState(
selectedSession = selected,
messages = messages,
streamText = "Hello",
activeRunId = activeRunId,
)
}
private fun terminalEvent(
state: String,
runId: String?,
sessionKey: String = "agent:main",
message: WearChatMessage? = null,
): WearChatEvent =
WearChatEvent(
sessionKey = sessionKey,
runId = runId,
state = state,
deltaText = null,
replace = false,
streamText = null,
streamTextComplete = false,
message = message,
)
}

View file

@ -0,0 +1,60 @@
package ai.openclaw.wear
import android.content.Context
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.util.UUID
@RunWith(RobolectricTestRunner::class)
class WearSettingsStoreTest {
@Test
fun defaultsAreStableWithoutWritingPreferenceRows() {
val preferences = freshPreferences()
val settings = WearSettingsStore(preferences).read()
assertEquals(WearThemeMode.Dark, settings.themeMode)
assertFalse(settings.autoSpeak)
assertTrue(preferences.all.isEmpty())
}
@Test
fun oneStoreOwnsThemeAndAutoSpeakAcrossProcessRestart() {
val preferences = freshPreferences()
WearSettingsStore(preferences).apply {
writeThemeMode(WearThemeMode.Light)
writeAutoSpeak(true)
}
val restored = WearSettingsStore(preferences).read()
assertEquals(WearThemeMode.Light, restored.themeMode)
assertTrue(restored.autoSpeak)
assertEquals(setOf("appearance.themeMode", "conversation.autoSpeak"), preferences.all.keys)
}
@Test
fun unknownThemeFallsBackWithoutResettingOtherSettings() {
val preferences = freshPreferences()
preferences
.edit()
.putString("appearance.themeMode", "future-theme")
.putBoolean("conversation.autoSpeak", true)
.commit()
val restored = WearSettingsStore(preferences).read()
assertEquals(WearThemeMode.Dark, restored.themeMode)
assertTrue(restored.autoSpeak)
}
private fun freshPreferences() =
RuntimeEnvironment
.getApplication()
.getSharedPreferences("wear-settings-${UUID.randomUUID()}", Context.MODE_PRIVATE)
}

View file

@ -0,0 +1,556 @@
package ai.openclaw.wear
import ai.openclaw.wear.shared.WearProtocol
import ai.openclaw.wear.shared.WearRpcMethod
import android.animation.ValueAnimator
import android.content.Intent
import android.os.Looper
import android.os.Parcel
import android.os.PowerManager
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.MotionDurationScale
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import com.google.android.gms.wearable.ChannelClient
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.serialization.json.JsonObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.shadows.ShadowSystemClock
import org.robolectric.shadows.ShadowValueAnimator
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.time.Duration
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class WearTalkAvatarTest {
@Test
fun silenceKeepsTheAvatarMouthClosed() {
val pcm = ByteArray(samplesForFrames(2) * 2)
assertEquals(listOf(0f, 0f), pcm16LeMouthLevels(pcm))
}
@Test
fun outputPcmProducesOneBoundedMouthLevelPerPlaybackFrame() {
val pcm = pcm16Le(samplesForFrames(2), sample = 24_000)
val levels = pcm16LeMouthLevels(pcm)
assertEquals(2, levels.size)
assertTrue(levels.all { level -> level in 0f..1f })
assertTrue(levels.all { level -> level > 0.9f })
}
@Test
fun finalPartialPlaybackFrameStillMovesTheMouth() {
val pcm = pcm16Le(samplesForFrames(1) + 12, sample = 12_000)
val levels = pcm16LeMouthLevels(pcm)
assertEquals(2, levels.size)
assertTrue(levels.last() > 0f)
}
@Test
fun consecutiveMaximumSizeChunksPreserveCumulativeWindowsAndFlushTheFinalPartial() {
val chunks =
listOf(
pcm16Le(WearProtocol.MAX_REALTIME_AUDIO_FRAME_BYTES / 2, sample = 4_000),
pcm16Le(WearProtocol.MAX_REALTIME_AUDIO_FRAME_BYTES / 2, sample = 20_000),
pcm16Le(100, sample = 12_000),
)
val client = realtimeTalkClient()
val queuedLevels = Channel<Float>(Channel.UNLIMITED)
val attempt = realtimeAttempt(generation = 1L)
client.setPrivateField("activeAttempt", attempt)
client.setPrivateField("mouthFrames", queuedLevels)
try {
val writeOutput =
WearRealtimeTalkClient::class.java.getDeclaredMethod(
"writeOutput",
WearRealtimeTalkClient.ActiveAttempt::class.java,
ByteArray::class.java,
)
writeOutput.isAccessible = true
chunks.forEach { chunk -> writeOutput.invoke(client, attempt, chunk) }
awaitPlaybackTeardown(client)
val actualLevels =
buildList {
while (true) add(queuedLevels.tryReceive().getOrNull() ?: break)
}
assertEquals(pcm16LeMouthLevels(chunks.reduce(ByteArray::plus)), actualLevels)
} finally {
client.shutdown()
}
}
@Test
fun staleAttemptCallbacksCannotMutateReplacement() {
val client = realtimeTalkClient()
val stale = realtimeAttempt(generation = 1L)
val replacement = realtimeAttempt(generation = 2L)
try {
client.invokePrivate("activate", stale)
client.invokePrivate("handleChannelFailure", stale)
assertTrue(client.channelFailed.value)
assertEquals(null, client.privateField("activeAttempt"))
client.invokePrivate("activate", replacement)
val replacementReader = Job()
client.setPrivateField("readJob", replacementReader)
assertFalse(client.channelFailed.value)
client.invokePrivate("writeOutput", stale, pcm16Le(samplesForFrames(1), sample = 20_000))
client.invokePrivate("handleChannelFailure", stale)
client.invokePrivate("closeLocal", stale, false)
assertFalse(client.isPlaying.value)
assertFalse(client.channelFailed.value)
assertTrue(replacementReader.isActive)
assertSame(replacement, client.privateField("activeAttempt"))
} finally {
client.shutdown()
}
}
@Test
fun realtimeAudioPathUsesNegotiatedAttemptScope() {
assertEquals(
WearProtocol.LEGACY_REALTIME_AUDIO_CHANNEL_PATH,
wearRealtimeAudioChannelPath("attempt-7", attemptScopedAudio = false),
)
assertEquals(
WearProtocol.realtimeAudioChannelPath("attempt-7"),
wearRealtimeAudioChannelPath("attempt-7", attemptScopedAudio = true),
)
}
@Test
fun mouthEnvelopeUsesFastAttackAndSoftReleaseWithoutOvershoot() {
val attack = smoothAvatarMouth(current = 0f, target = 1f, deltaSeconds = 0.02f)
val release = smoothAvatarMouth(current = 1f, target = 0f, deltaSeconds = 0.02f)
assertTrue(attack in 0f..1f)
assertTrue(release in 0f..1f)
assertTrue(attack > 0f)
assertTrue(release > attack)
}
@Test
fun mouthEnvelopeConvergesAcrossDisplayFrames() {
var level = 0f
repeat(30) { level = smoothAvatarMouth(level, target = 1f, deltaSeconds = 1f / 60f) }
assertTrue(level > 0.99f)
repeat(60) { level = smoothAvatarMouth(level, target = 0f, deltaSeconds = 1f / 60f) }
assertTrue(level < 0.001f)
}
@Test
fun avatarFrameDeltaHonorsAnimatorDurationScale() {
val frameDelta = 1f / 60f
assertEquals(1f / 30f, scaledAvatarDeltaSeconds(frameDelta, durationScale = 0.5f), 0.000_001f)
assertEquals(frameDelta, scaledAvatarDeltaSeconds(frameDelta, durationScale = 1f), 0.000_001f)
assertEquals(1f / 120f, scaledAvatarDeltaSeconds(frameDelta, durationScale = 2f), 0.000_001f)
}
@Test
fun zeroAnimatorDurationScaleStopsAvatarTime() {
assertEquals(0f, scaledAvatarDeltaSeconds(deltaSeconds = 1f / 60f, durationScale = 0f), 0f)
}
@Test
fun effectiveScaleTransitionsStopAndRestartTheClockWhileComposed() {
val controller = Robolectric.buildActivity(ComponentActivity::class.java).setup()
val scaleSource = FakeWearAnimatorScaleSource(initialScale = 1f)
val motionDurationScale = FakeMotionDurationScale(initialScale = 1f)
val frameClock = FakeWearAvatarFrameClock()
val observedStates = mutableListOf<WearAvatarAnimationState>()
controller.get().setContent {
WearTalkAvatar(
state = RealtimeVoiceButtonState.SPEAKING,
mouthLevel = 1f,
syntheticSpeech = false,
accent = Color.Cyan,
danger = Color.Red,
animatorScaleSource = scaleSource,
motionDurationScale = motionDurationScale,
frameClock = frameClock,
onAnimationStateChanged = observedStates::add,
)
}
idleMainLooper()
assertEquals(1, scaleSource.subscriptionCount)
assertEquals(1f, observedStates.last().durationScale, 0f)
frameClock.sendFrame(1_000_000_000L)
idleMainLooper()
frameClock.sendFrame(1_016_666_667L)
idleMainLooper()
assertTrue(observedStates.last().animationSeconds > 0f)
scaleSource.emit(0f)
idleMainLooper()
assertEquals(0f, observedStates.last().durationScale, 0f)
assertEquals(0f, observedStates.last().animationSeconds, 0f)
assertEquals(0f, observedStates.last().mouthLevel, 0f)
val frameRequestsAtZero = frameClock.awaitCount
idleMainLooper(Duration.ofMillis(100))
assertEquals(frameRequestsAtZero, frameClock.awaitCount)
scaleSource.emit(1f)
idleMainLooper()
assertEquals(1f, observedStates.last().durationScale, 0f)
assertTrue(frameClock.awaitCount > frameRequestsAtZero)
motionDurationScale.scaleFactor = 2f
idleMainLooper()
assertEquals(2f, observedStates.last().durationScale, 0f)
controller.pause().stop().destroy()
idleMainLooper()
assertEquals(1, scaleSource.disposeCount)
assertEquals(0, scaleSource.activeSubscriptionCount)
}
@Test
@Config(sdk = [32])
fun api31And32CanonicalScaleRestartsClockWithoutEffectiveScaleCallback() {
val controller = Robolectric.buildActivity(ComponentActivity::class.java).setup()
val lifecycleOwner = TestLifecycleOwner()
val scaleSource = AndroidWearAnimatorScaleSource(RuntimeEnvironment.getApplication(), lifecycleOwner)
val motionDurationScale = FakeMotionDurationScale(initialScale = 0f)
val frameClock = FakeWearAvatarFrameClock()
val observedStates = mutableListOf<WearAvatarAnimationState>()
setRobolectricAnimatorDurationScale(0f)
try {
assertEquals(false, ValueAnimator.areAnimatorsEnabled())
controller.get().setContent {
WearTalkAvatar(
state = RealtimeVoiceButtonState.IDLE,
mouthLevel = 0f,
syntheticSpeech = false,
accent = Color.Cyan,
danger = Color.Red,
animatorScaleSource = scaleSource,
motionDurationScale = motionDurationScale,
frameClock = frameClock,
onAnimationStateChanged = observedStates::add,
)
}
idleMainLooper()
assertEquals(0f, observedStates.last().durationScale, 0f)
assertEquals(0, frameClock.awaitCount)
motionDurationScale.scaleFactor = 1f
idleMainLooper()
assertEquals(1f, observedStates.last().durationScale, 0f)
assertTrue(frameClock.awaitCount > 0)
} finally {
controller.pause().stop().destroy()
idleMainLooper()
setRobolectricAnimatorDurationScale(1f)
}
}
@Test
@Config(sdk = [33])
fun zeroSystemScaleColdStartUsesTheComposeMotionScaleWithoutCrashing() {
val context = RuntimeEnvironment.getApplication()
val originalScale =
Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f)
val controller = Robolectric.buildActivity(ComponentActivity::class.java)
val observedStates = mutableListOf<WearAvatarAnimationState>()
Settings.Global.putFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 0f)
setRobolectricAnimatorDurationScale(0f)
try {
controller.setup()
controller.get().setContent {
WearTalkAvatar(
state = RealtimeVoiceButtonState.LISTENING,
mouthLevel = 0f,
syntheticSpeech = false,
accent = Color.Cyan,
danger = Color.Red,
animatorScaleSource = FakeWearAnimatorScaleSource(initialScale = 1f),
onAnimationStateChanged = observedStates::add,
)
}
idleMainLooper()
assertEquals(0f, observedStates.last().durationScale, 0f)
assertEquals(0f, observedStates.last().animationSeconds, 0f)
} finally {
controller.pause().stop().destroy()
idleMainLooper()
Settings.Global.putFloat(
context.contentResolver,
Settings.Global.ANIMATOR_DURATION_SCALE,
originalScale,
)
setRobolectricAnimatorDurationScale(originalScale)
}
}
@Test
@Config(sdk = [32])
fun api31And32RefreshEffectiveScaleOnLifecycleAndPowerChangesAndCleanUp() {
val context = RuntimeEnvironment.getApplication()
val lifecycleOwner = TestLifecycleOwner()
val source = AndroidWearAnimatorScaleSource(context, lifecycleOwner)
val observedScales = mutableListOf<Float>()
val subscription = source.subscribe(observedScales::add)
val countAfterSubscribe = observedScales.size
lifecycleOwner.registry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
lifecycleOwner.registry.handleLifecycleEvent(Lifecycle.Event.ON_START)
assertTrue(observedScales.size > countAfterSubscribe)
val countAfterStart = observedScales.size
context.sendBroadcast(Intent(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED))
idleMainLooper()
assertTrue(observedScales.size > countAfterStart)
subscription.dispose()
val countAfterDispose = observedScales.size
lifecycleOwner.registry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
context.sendBroadcast(Intent(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED))
idleMainLooper()
assertEquals(countAfterDispose, observedScales.size)
}
@Test
fun zeroMotionKeepsEveryVoiceStateStaticAndVisuallyDistinct() {
val poses =
RealtimeVoiceButtonState.entries.map { state ->
avatarPoseAt(
state = state,
animationSeconds = 0f,
mouthLevel = 0f,
)
}
assertEquals(RealtimeVoiceButtonState.entries.size, poses.distinct().size)
assertEquals(0f, poses.first().floatOffset, 0f)
assertTrue(poses.last().antennaDroop > 0f)
}
@Test
fun disabledAnimationsSuppressClockAndAudioMotionInputs() {
val inputs =
avatarMotionInputs(
animationsEnabled = false,
animationSeconds = 12.5f,
mouthLevel = 1f,
)
assertEquals(WearAvatarMotionInputs(animationSeconds = 0f, mouthLevel = 0f), inputs)
}
@Test
fun enabledAnimationsPreserveClockAndBoundAudioMotionInput() {
val inputs =
avatarMotionInputs(
animationsEnabled = true,
animationSeconds = 12.5f,
mouthLevel = 1.5f,
)
assertEquals(WearAvatarMotionInputs(animationSeconds = 12.5f, mouthLevel = 1f), inputs)
}
private fun samplesForFrames(frameCount: Int): Int = WEAR_REALTIME_SAMPLE_RATE_HZ * MOUTH_FRAME_MILLIS / 1_000 * frameCount
private fun pcm16Le(
sampleCount: Int,
sample: Int,
): ByteArray =
ByteArray(sampleCount * 2).also { bytes ->
repeat(sampleCount) { index ->
bytes[index * 2] = (sample and 0xff).toByte()
bytes[(index * 2) + 1] = ((sample shr 8) and 0xff).toByte()
}
}
private fun realtimeTalkClient(): WearRealtimeTalkClient {
val requester =
object : WearRpcRequester {
override suspend fun request(
method: WearRpcMethod,
params: JsonObject,
expectedNodeId: String?,
requirePreferredNode: Boolean,
): WearRpcResult = error("Unexpected request: $method $params $expectedNodeId $requirePreferredNode")
}
return WearRealtimeTalkClient(RuntimeEnvironment.getApplication(), WearGatewayRepository(requester))
}
private fun realtimeAttempt(generation: Long): WearRealtimeTalkClient.ActiveAttempt =
WearRealtimeTalkClient.ActiveAttempt(
nodeId = "watch-a",
attemptId = "attempt-$generation",
generation = generation,
resources =
WearRealtimeTalkClient.ChannelResources(
channel = FakeRealtimeChannel("watch-a", "channel-$generation"),
input = ByteArrayInputStream(byteArrayOf()),
output = ByteArrayOutputStream(),
),
)
private fun awaitPlaybackTeardown(client: WearRealtimeTalkClient) {
ShadowSystemClock.advanceBy(Duration.ofSeconds(1L))
val deadlineNanos = System.nanoTime() + 2_000_000_000L
while (client.isPlaying.value && System.nanoTime() < deadlineNanos) Thread.sleep(10L)
assertEquals(false, client.isPlaying.value)
}
private fun idleMainLooper(duration: Duration = Duration.ZERO) {
shadowOf(Looper.getMainLooper()).idleFor(duration)
}
private fun setRobolectricAnimatorDurationScale(scale: Float) {
ShadowValueAnimator::class.java
.getDeclaredMethod("setDurationScale", java.lang.Float.TYPE)
.apply { isAccessible = true }
.invoke(null, scale)
}
private fun Any.setPrivateField(
name: String,
value: Any,
) {
javaClass.getDeclaredField(name).apply {
isAccessible = true
set(this@setPrivateField, value)
}
}
private fun Any.privateField(name: String): Any? =
javaClass.getDeclaredField(name).run {
isAccessible = true
get(this@privateField)
}
private fun WearRealtimeTalkClient.invokePrivate(
name: String,
vararg args: Any,
) {
javaClass.declaredMethods
.single { method ->
method.name == name &&
method.parameterTypes.size == args.size &&
method.parameterTypes.zip(args).all { (type, arg) ->
type.isAssignableFrom(arg.javaClass) ||
(type == Boolean::class.javaPrimitiveType && arg is Boolean)
}
}.apply { isAccessible = true }
.invoke(this, *args)
}
private companion object {
const val WEAR_REALTIME_SAMPLE_RATE_HZ = 24_000
}
private class FakeMotionDurationScale(
initialScale: Float,
) : MotionDurationScale {
override var scaleFactor by mutableFloatStateOf(initialScale)
}
private class FakeWearAnimatorScaleSource(
initialScale: Float,
) : WearAnimatorScaleSource {
private var scale = initialScale
private var listener: ((Float) -> Unit)? = null
var subscriptionCount = 0
private set
var disposeCount = 0
private set
val activeSubscriptionCount: Int
get() = if (listener == null) 0 else 1
override fun currentScale(): Float = scale
override fun subscribe(onScaleChanged: (Float) -> Unit): WearAnimatorScaleSubscription {
subscriptionCount += 1
listener = onScaleChanged
return WearAnimatorScaleSubscription {
if (listener === onScaleChanged) listener = null
disposeCount += 1
}
}
fun emit(newScale: Float) {
scale = newScale
listener?.invoke(newScale)
}
}
private class FakeWearAvatarFrameClock : WearAvatarFrameClock {
private val frames = Channel<Long>(Channel.UNLIMITED)
var awaitCount = 0
private set
override suspend fun awaitFrame(onFrame: (Long) -> Unit) {
awaitCount += 1
onFrame(frames.receive())
}
fun sendFrame(frameNanos: Long) {
assertTrue(frames.trySend(frameNanos).isSuccess)
}
}
private class TestLifecycleOwner : LifecycleOwner {
val registry = LifecycleRegistry(this)
override val lifecycle: Lifecycle = registry
}
}
private data class FakeRealtimeChannel(
private val nodeId: String,
private val label: String,
) : ChannelClient.Channel {
override fun getNodeId(): String = nodeId
override fun getPath(): String = WearProtocol.realtimeAudioChannelPath("attempt-$label")
override fun describeContents(): Int = 0
override fun writeToParcel(
dest: Parcel,
flags: Int,
) {
dest.writeString(label)
}
}

View file

@ -0,0 +1,115 @@
package ai.openclaw.wear
import androidx.compose.ui.graphics.Color
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
class WearThemeTest {
@Test
fun `wear palettes mirror the canonical phone surfaces and voice accents`() {
val dark = wearColorsFor(WearThemeMode.Dark)
assertEquals(Color(0xFF030303), dark.canvas)
assertEquals(Color(0xFF0A0A0A), dark.surface)
assertEquals(Color(0xFF111111), dark.surfaceRaised)
assertEquals(Color(0xFF1A1A1A), dark.surfacePressed)
assertEquals(Color(0xFF242424), dark.border)
assertEquals(Color(0xFF3A3A3A), dark.borderStrong)
assertEquals(Color(0xFFF8F8F8), dark.text)
assertEquals(Color(0xFFA8A8A8), dark.textMuted)
assertEquals(Color(0xFFFFFFFF), dark.primary)
assertEquals(Color(0xFF050505), dark.primaryText)
assertEquals(Color(0xFF6EA8FF), dark.voiceAccent)
assertEquals(Color(0xFF1A2A44), dark.voiceAccentSoft)
val light = wearColorsFor(WearThemeMode.Light)
assertEquals(Color(0xFFFAFBFC), light.canvas)
assertEquals(Color(0xFFFFFEFB), light.surface)
assertEquals(Color(0xFFFFFFFF), light.surfaceRaised)
assertEquals(Color(0xFFE9EDF3), light.surfacePressed)
assertEquals(Color(0xFFDDE3EC), light.border)
assertEquals(Color(0xFFC7D0DC), light.borderStrong)
assertEquals(Color(0xFF111318), light.text)
assertEquals(Color(0xFF505865), light.textMuted)
assertEquals(Color(0xFF111827), light.primary)
assertEquals(Color(0xFFFFFFFF), light.primaryText)
assertEquals(Color(0xFF1B5ACB), light.voiceAccent)
assertEquals(Color(0xFFEAF2FF), light.voiceAccentSoft)
}
@Test
fun `dark and light palettes keep panels distinct from the canvas`() {
WearThemeMode.entries.forEach { mode ->
val colors = wearColorsFor(mode)
assertNotEquals("$mode canvas and panel must differ", colors.canvas, colors.surfaceRaised)
assertTrue(
"$mode panel outline must remain visible",
contrastRatio(colors.borderStrong, colors.surfaceRaised) >= MIN_OUTLINE_CONTRAST,
)
}
}
@Test
fun `dark and light palettes keep text readable on panels`() {
WearThemeMode.entries.forEach { mode ->
val colors = wearColorsFor(mode)
assertTrue(
"$mode text must remain readable",
contrastRatio(colors.text, colors.surfaceRaised) >= MIN_TEXT_CONTRAST,
)
assertTrue(
"$mode muted text must remain readable",
contrastRatio(colors.textMuted, colors.surfaceRaised) >= MIN_TEXT_CONTRAST,
)
}
}
@Test
fun `dark and light primary and voice accents keep their content readable`() {
WearThemeMode.entries.forEach { mode ->
val colors = wearColorsFor(mode)
assertTrue(
"$mode primary content must remain readable",
contrastRatio(colors.primaryText, colors.primary) >= MIN_TEXT_CONTRAST,
)
assertTrue(
"$mode voice accent content must remain readable",
contrastRatio(colors.onVoiceAccent, colors.voiceAccent) >= MIN_TEXT_CONTRAST,
)
}
}
private fun contrastRatio(
foreground: Color,
background: Color,
): Double {
val foregroundLuminance = relativeLuminance(foreground)
val backgroundLuminance = relativeLuminance(background)
return (max(foregroundLuminance, backgroundLuminance) + 0.05) /
(min(foregroundLuminance, backgroundLuminance) + 0.05)
}
private fun relativeLuminance(color: Color): Double =
0.2126 * linearize(color.red.toDouble()) +
0.7152 * linearize(color.green.toDouble()) +
0.0722 * linearize(color.blue.toDouble())
private fun linearize(channel: Double): Double =
if (channel <= 0.03928) {
channel / 12.92
} else {
((channel + 0.055) / 1.055).pow(2.4)
}
private companion object {
const val MIN_OUTLINE_CONTRAST = 1.5
const val MIN_TEXT_CONTRAST = 4.5
}
}

View file

@ -0,0 +1,57 @@
package ai.openclaw.wear
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotSame
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(application = WearApplication::class, sdk = [35])
class WearViewModelLifecycleTest {
@Test
fun recreatedViewModelGetsALiveTalkClientAfterThePreviousOneClears() {
val app = RuntimeEnvironment.getApplication() as WearApplication
val factory = ViewModelProvider.AndroidViewModelFactory.getInstance(app)
val firstOwner = TestViewModelStoreOwner()
val firstViewModel = ViewModelProvider(firstOwner, factory)[WearViewModel::class.java]
val firstClient = firstViewModel.realtimeTalkClientForTest()
firstOwner.viewModelStore.clear()
val reopenedOwner = TestViewModelStoreOwner()
val reopenedViewModel = ViewModelProvider(reopenedOwner, factory)[WearViewModel::class.java]
val reopenedClient = reopenedViewModel.realtimeTalkClientForTest()
try {
assertFalse(firstClient.scopeForTest().coroutineContext[Job]?.isActive == true)
assertNotSame(firstClient, reopenedClient)
assertTrue(reopenedClient.scopeForTest().coroutineContext[Job]?.isActive == true)
} finally {
reopenedOwner.viewModelStore.clear()
}
}
private class TestViewModelStoreOwner : ViewModelStoreOwner {
override val viewModelStore = ViewModelStore()
}
private fun WearViewModel.realtimeTalkClientForTest(): WearRealtimeTalkClient =
javaClass.getDeclaredField("realtimeTalkClient").run {
isAccessible = true
get(this@realtimeTalkClientForTest) as WearRealtimeTalkClient
}
private fun WearRealtimeTalkClient.scopeForTest(): CoroutineScope =
javaClass.getDeclaredField("scope").run {
isAccessible = true
get(this@scopeForTest) as CoroutineScope
}
}