mirror of
https://github.com/BrendanGreenlee/openclaw-android-heartbeat.git
synced 2026-08-17 16:49:14 +00:00
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:
commit
7a380c40ed
656 changed files with 209982 additions and 0 deletions
15
wear/BEHAVIOR.md
Normal file
15
wear/BEHAVIOR.md
Normal 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
126
wear/build.gradle.kts
Normal 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
2
wear/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
-keepattributes *Annotation*
|
||||
-keepclassmembers class ai.openclaw.wear.** { *; }
|
||||
82
wear/src/main/AndroidManifest.xml
Normal file
82
wear/src/main/AndroidManifest.xml
Normal 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>
|
||||
574
wear/src/main/java/ai/openclaw/wear/MainActivity.kt
Normal file
574
wear/src/main/java/ai/openclaw/wear/MainActivity.kt
Normal 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
|
||||
104
wear/src/main/java/ai/openclaw/wear/OpenClawTileService.kt
Normal file
104
wear/src/main/java/ai/openclaw/wear/OpenClawTileService.kt
Normal 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)),
|
||||
)
|
||||
41
wear/src/main/java/ai/openclaw/wear/WearApplication.kt
Normal file
41
wear/src/main/java/ai/openclaw/wear/WearApplication.kt
Normal 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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
124
wear/src/main/java/ai/openclaw/wear/WearCompanionUiModels.kt
Normal file
124
wear/src/main/java/ai/openclaw/wear/WearCompanionUiModels.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
520
wear/src/main/java/ai/openclaw/wear/WearGatewayRepository.kt
Normal file
520
wear/src/main/java/ai/openclaw/wear/WearGatewayRepository.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
13
wear/src/main/java/ai/openclaw/wear/WearLocaleText.kt
Normal file
13
wear/src/main/java/ai/openclaw/wear/WearLocaleText.kt
Normal 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)
|
||||
573
wear/src/main/java/ai/openclaw/wear/WearProxyClient.kt
Normal file
573
wear/src/main/java/ai/openclaw/wear/WearProxyClient.kt
Normal 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() }
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
613
wear/src/main/java/ai/openclaw/wear/WearRealtimeTalkClient.kt
Normal file
613
wear/src/main/java/ai/openclaw/wear/WearRealtimeTalkClient.kt
Normal 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() }
|
||||
}
|
||||
286
wear/src/main/java/ai/openclaw/wear/WearReplyNotifier.kt
Normal file
286
wear/src/main/java/ai/openclaw/wear/WearReplyNotifier.kt
Normal 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
|
||||
117
wear/src/main/java/ai/openclaw/wear/WearReplySpeaker.kt
Normal file
117
wear/src/main/java/ai/openclaw/wear/WearReplySpeaker.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
2066
wear/src/main/java/ai/openclaw/wear/WearScreens.kt
Normal file
2066
wear/src/main/java/ai/openclaw/wear/WearScreens.kt
Normal file
File diff suppressed because it is too large
Load diff
124
wear/src/main/java/ai/openclaw/wear/WearScreenshotMode.kt
Normal file
124
wear/src/main/java/ai/openclaw/wear/WearScreenshotMode.kt
Normal 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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
606
wear/src/main/java/ai/openclaw/wear/WearTalkAvatar.kt
Normal file
606
wear/src/main/java/ai/openclaw/wear/WearTalkAvatar.kt
Normal 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
|
||||
179
wear/src/main/java/ai/openclaw/wear/WearTheme.kt
Normal file
179
wear/src/main/java/ai/openclaw/wear/WearTheme.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
1424
wear/src/main/java/ai/openclaw/wear/WearViewModel.kt
Normal file
1424
wear/src/main/java/ai/openclaw/wear/WearViewModel.kt
Normal file
File diff suppressed because it is too large
Load diff
37
wear/src/main/res/drawable-round/tile_preview.xml
Normal file
37
wear/src/main/res/drawable-round/tile_preview.xml
Normal 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>
|
||||
10
wear/src/main/res/drawable/ic_notification.xml
Normal file
10
wear/src/main/res/drawable/ic_notification.xml
Normal 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>
|
||||
38
wear/src/main/res/drawable/tile_preview.xml
Normal file
38
wear/src/main/res/drawable/tile_preview.xml
Normal 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>
|
||||
6
wear/src/main/res/mipmap-anydpi/ic_launcher.xml
Normal file
6
wear/src/main/res/mipmap-anydpi/ic_launcher.xml
Normal 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>
|
||||
6
wear/src/main/res/mipmap-anydpi/ic_launcher_round.xml
Normal file
6
wear/src/main/res/mipmap-anydpi/ic_launcher_round.xml
Normal 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>
|
||||
BIN
wear/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
BIN
wear/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
wear/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
BIN
wear/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
BIN
wear/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
BIN
wear/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
wear/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
BIN
wear/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
BIN
wear/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
Normal file
BIN
wear/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
3
wear/src/main/res/raw/ai_openclaw_app_wear_keep.xml
Normal file
3
wear/src/main/res/raw/ai_openclaw_app_wear_keep.xml
Normal 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" />
|
||||
89
wear/src/main/res/values-ar/strings.xml
Normal file
89
wear/src/main/res/values-ar/strings.xml
Normal 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>
|
||||
89
wear/src/main/res/values-de/strings.xml
Normal file
89
wear/src/main/res/values-de/strings.xml
Normal 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>
|
||||
89
wear/src/main/res/values-es/strings.xml
Normal file
89
wear/src/main/res/values-es/strings.xml
Normal 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>
|
||||
89
wear/src/main/res/values-fa/strings.xml
Normal file
89
wear/src/main/res/values-fa/strings.xml
Normal 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>
|
||||
89
wear/src/main/res/values-fr/strings.xml
Normal file
89
wear/src/main/res/values-fr/strings.xml
Normal 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 l’audio 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 à l’agent"</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">"L’agent 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 l’exé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 l’identité 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 n’authentifie 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 l’application 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 s’est 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 d’OpenClaw"</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 l’application 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>
|
||||
89
wear/src/main/res/values-hi/strings.xml
Normal file
89
wear/src/main/res/values-hi/strings.xml
Normal 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>
|
||||
89
wear/src/main/res/values-in/strings.xml
Normal file
89
wear/src/main/res/values-in/strings.xml
Normal 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>
|
||||
89
wear/src/main/res/values-it/strings.xml
Normal file
89
wear/src/main/res/values-it/strings.xml
Normal 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>
|
||||
89
wear/src/main/res/values-ja/strings.xml
Normal file
89
wear/src/main/res/values-ja/strings.xml
Normal 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>
|
||||
89
wear/src/main/res/values-ko/strings.xml
Normal file
89
wear/src/main/res/values-ko/strings.xml
Normal 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>
|
||||
89
wear/src/main/res/values-nl/strings.xml
Normal file
89
wear/src/main/res/values-nl/strings.xml
Normal 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>
|
||||
89
wear/src/main/res/values-pl/strings.xml
Normal file
89
wear/src/main/res/values-pl/strings.xml
Normal 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>
|
||||
89
wear/src/main/res/values-pt-rBR/strings.xml
Normal file
89
wear/src/main/res/values-pt-rBR/strings.xml
Normal 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>
|
||||
89
wear/src/main/res/values-ru/strings.xml
Normal file
89
wear/src/main/res/values-ru/strings.xml
Normal 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>
|
||||
89
wear/src/main/res/values-sv/strings.xml
Normal file
89
wear/src/main/res/values-sv/strings.xml
Normal 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>
|
||||
89
wear/src/main/res/values-th/strings.xml
Normal file
89
wear/src/main/res/values-th/strings.xml
Normal 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>
|
||||
89
wear/src/main/res/values-tr/strings.xml
Normal file
89
wear/src/main/res/values-tr/strings.xml
Normal 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\'ı açı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ı açın."</string>
|
||||
<string name="tile_label" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw"</string>
|
||||
<string name="tile_description" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Oturumları açı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>
|
||||
89
wear/src/main/res/values-uk/strings.xml
Normal file
89
wear/src/main/res/values-uk/strings.xml
Normal 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>
|
||||
89
wear/src/main/res/values-vi/strings.xml
Normal file
89
wear/src/main/res/values-vi/strings.xml
Normal 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>
|
||||
89
wear/src/main/res/values-zh-rCN/strings.xml
Normal file
89
wear/src/main/res/values-zh-rCN/strings.xml
Normal 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>
|
||||
89
wear/src/main/res/values-zh-rTW/strings.xml
Normal file
89
wear/src/main/res/values-zh-rTW/strings.xml
Normal 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>
|
||||
4
wear/src/main/res/values/colors.xml
Normal file
4
wear/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
91
wear/src/main/res/values/strings.xml
Normal file
91
wear/src/main/res/values/strings.xml
Normal 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>
|
||||
9
wear/src/main/res/values/themes.xml
Normal file
9
wear/src/main/res/values/themes.xml
Normal 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>
|
||||
6
wear/src/main/res/values/wear.xml
Normal file
6
wear/src/main/res/values/wear.xml
Normal 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>
|
||||
4
wear/src/main/res/xml/backup_rules.xml
Normal file
4
wear/src/main/res/xml/backup_rules.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<full-backup-content>
|
||||
<exclude domain="root" path="." />
|
||||
</full-backup-content>
|
||||
9
wear/src/main/res/xml/data_extraction_rules.xml
Normal file
9
wear/src/main/res/xml/data_extraction_rules.xml
Normal 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>
|
||||
261
wear/src/test/java/ai/openclaw/wear/MainActivityTest.kt
Normal file
261
wear/src/test/java/ai/openclaw/wear/MainActivityTest.kt
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
439
wear/src/test/java/ai/openclaw/wear/WearGatewayRepositoryTest.kt
Normal file
439
wear/src/test/java/ai/openclaw/wear/WearGatewayRepositoryTest.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
219
wear/src/test/java/ai/openclaw/wear/WearLaunchIntentTest.kt
Normal file
219
wear/src/test/java/ai/openclaw/wear/WearLaunchIntentTest.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
39
wear/src/test/java/ai/openclaw/wear/WearLayoutTest.kt
Normal file
39
wear/src/test/java/ai/openclaw/wear/WearLayoutTest.kt
Normal 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
12
wear/src/test/java/ai/openclaw/wear/WearLocaleTextTest.kt
Normal file
12
wear/src/test/java/ai/openclaw/wear/WearLocaleTextTest.kt
Normal 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")))
|
||||
}
|
||||
}
|
||||
937
wear/src/test/java/ai/openclaw/wear/WearProxyClientTest.kt
Normal file
937
wear/src/test/java/ai/openclaw/wear/WearProxyClientTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
90
wear/src/test/java/ai/openclaw/wear/WearReplyNotifierTest.kt
Normal file
90
wear/src/test/java/ai/openclaw/wear/WearReplyNotifierTest.kt
Normal 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")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
585
wear/src/test/java/ai/openclaw/wear/WearSessionScopeTest.kt
Normal file
585
wear/src/test/java/ai/openclaw/wear/WearSessionScopeTest.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
60
wear/src/test/java/ai/openclaw/wear/WearSettingsStoreTest.kt
Normal file
60
wear/src/test/java/ai/openclaw/wear/WearSettingsStoreTest.kt
Normal 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)
|
||||
}
|
||||
556
wear/src/test/java/ai/openclaw/wear/WearTalkAvatarTest.kt
Normal file
556
wear/src/test/java/ai/openclaw/wear/WearTalkAvatarTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
115
wear/src/test/java/ai/openclaw/wear/WearThemeTest.kt
Normal file
115
wear/src/test/java/ai/openclaw/wear/WearThemeTest.kt
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue