




















@@ -0,0 +1,188 @@
1+package ai.openclaw.app
2+3+import android.app.Service
4+import android.content.BroadcastReceiver
5+import android.content.Context
6+import android.content.Intent
7+import android.os.IBinder
8+import android.util.Base64
9+import android.util.Log
10+import kotlinx.coroutines.CoroutineScope
11+import kotlinx.coroutines.Dispatchers
12+import kotlinx.coroutines.SupervisorJob
13+import kotlinx.coroutines.cancel
14+import kotlinx.coroutines.delay
15+import kotlinx.coroutines.launch
16+import kotlinx.coroutines.withTimeout
17+import kotlinx.serialization.json.JsonNull
18+import kotlinx.serialization.json.JsonPrimitive
19+import kotlinx.serialization.json.buildJsonObject
20+import java.io.File
21+22+private const val tag = "VoiceE2E"
23+private const val resultFileName = "voice_e2e_result.json"
24+25+class VoiceE2eReceiver : BroadcastReceiver() {
26+override fun onReceive(
27+context: Context,
28+intent: Intent,
29+ ) {
30+ context.startService(
31+Intent(context, VoiceE2eService::class.java)
32+ .putExtras(intent),
33+ )
34+ }
35+}
36+37+class VoiceE2eService : Service() {
38+private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
39+40+override fun onBind(intent: Intent?): IBinder? = null
41+42+override fun onStartCommand(
43+intent: Intent?,
44+flags: Int,
45+startId: Int,
46+ ): Int {
47+val command = intent ?: return START_NOT_STICKY
48+ serviceScope.launch {
49+try {
50+ runCommand(command)
51+ } finally {
52+ stopSelf(startId)
53+ }
54+ }
55+return START_NOT_STICKY
56+ }
57+58+override fun onDestroy() {
59+ serviceScope.cancel()
60+super.onDestroy()
61+ }
62+63+private suspend fun runCommand(intent: Intent) {
64+try {
65+val app = applicationContext as NodeApp
66+val runtime = app.ensureRuntime()
67+val mode =
68+ intent
69+ .getDecodedStringExtra("mode")
70+ ?.trim()
71+ .orEmpty()
72+ .ifEmpty { "both" }
73+if (mode == "stop") {
74+ runtime.cancelMicCapture()
75+ runtime.setTalkModeEnabled(false)
76+ writeResult("""{"ok":true,"mode":"stop"}""")
77+return
78+ }
79+80+val connect = !intent.getBooleanExtra("noConnect", false)
81+val connectTimeoutMs = intent.getLongExtra("connectTimeoutMs", 20_000L)
82+if (connect) {
83+ configureGateway(runtime = runtime, intent = intent)
84+ }
85+if (connect || !runtime.isConnected.value) {
86+ awaitGateway(runtime = runtime, timeoutMs = connectTimeoutMs)
87+ }
88+89+ startActivity(
90+Intent(actionOpenVoiceE2e)
91+ .setClass(this, MainActivity::class.java)
92+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP),
93+ )
94+95+val transcript =
96+ intent
97+ .getDecodedStringExtra("transcript")
98+ ?.trim()
99+ .orEmpty()
100+ .ifEmpty { "Reply exactly: Android voice e2e normal path ok." }
101+val realtimeReply =
102+ intent
103+ .getDecodedStringExtra("realtimeAssistant")
104+ ?.trim()
105+ .orEmpty()
106+ .ifEmpty { "Android realtime voice e2e relay path ok." }
107+val timeoutMs = intent.getLongExtra("timeoutMs", 60_000L)
108+val result =
109+ runtime.runVoiceE2e(
110+ mode = mode,
111+ transcript = transcript,
112+ realtimeAssistantText = realtimeReply,
113+ timeoutMs = timeoutMs,
114+ )
115+val resultJson = encodeResult(result)
116+ writeResult(resultJson)
117+Log.i(tag, "PASS $resultJson")
118+ } catch (err: Throwable) {
119+val resultJson =
120+ buildJsonObject {
121+ put("ok", JsonPrimitive(false))
122+ put("error", JsonPrimitive(err.message ?: err::class.java.simpleName))
123+ }.toString()
124+ writeResult(resultJson)
125+Log.e(tag, "FAIL $resultJson", err)
126+ }
127+ }
128+129+private fun configureGateway(
130+runtime: NodeRuntime,
131+intent: Intent,
132+ ) {
133+val host =
134+ intent
135+ .getDecodedStringExtra("host")
136+ ?.trim()
137+ .orEmpty()
138+ .ifEmpty { "127.0.0.1" }
139+val port = intent.getIntExtra("port", 18789)
140+ runtime.setManualEnabled(true)
141+ runtime.setManualHost(host)
142+ runtime.setManualPort(port)
143+ runtime.setManualTls(intent.getBooleanExtra("tls", false))
144+ runtime.setGatewayToken(intent.getDecodedStringExtra("token").orEmpty())
145+ runtime.setGatewayBootstrapToken(intent.getDecodedStringExtra("bootstrapToken").orEmpty())
146+ runtime.setGatewayPassword(intent.getDecodedStringExtra("password").orEmpty())
147+ runtime.setOnboardingCompleted(true)
148+ runtime.connectManual()
149+ }
150+151+private suspend fun awaitGateway(
152+runtime: NodeRuntime,
153+timeoutMs: Long,
154+ ) {
155+ withTimeout(timeoutMs) {
156+while (!runtime.isConnected.value) {
157+ delay(100L)
158+ }
159+ }
160+ }
161+162+private fun encodeResult(result: NodeRuntime.VoiceE2eResult): String =
163+ buildJsonObject {
164+ put("ok", JsonPrimitive(true))
165+ put("normal", result.normal?.let(::encodeSlice) ?: JsonNull)
166+ put("realtime", result.realtime?.let(::encodeSlice) ?: JsonNull)
167+ }.toString()
168+169+private fun encodeSlice(slice: NodeRuntime.VoiceE2eSliceResult) =
170+ buildJsonObject {
171+ put("mode", JsonPrimitive(slice.mode))
172+ put("status", JsonPrimitive(slice.status))
173+ put("userText", slice.userText?.let(::JsonPrimitive) ?: JsonNull)
174+ put("assistantText", slice.assistantText?.let(::JsonPrimitive) ?: JsonNull)
175+ }
176+177+private fun writeResult(json: String) {
178+File(cacheDir, resultFileName).writeText(json)
179+ }
180+}
181+182+private fun Intent.getDecodedStringExtra(name: String): String? {
183+val encoded = getStringExtra("${name}Base64")
184+if (!encoded.isNullOrBlank()) {
185+return String(Base64.decode(encoded, Base64.NO_WRAP), Charsets.UTF_8)
186+ }
187+return getStringExtra(name)
188+}
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。