Skip to content

Commit fc688af

Browse files
authored
Merge branch 'main' into meow/webchat-transcript-run-state-truth
2 parents 0d7559e + 9db04a2 commit fc688af

19 files changed

Lines changed: 1027 additions & 141 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
2+
<application>
3+
<receiver
4+
android:name=".VoiceE2eReceiver"
5+
android:exported="true">
6+
<intent-filter>
7+
<action android:name="ai.openclaw.app.debug.RUN_VOICE_E2E" />
8+
</intent-filter>
9+
</receiver>
10+
<service
11+
android:name=".VoiceE2eService"
12+
android:exported="false" />
13+
</application>
14+
</manifest>
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
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+
if (mode == "connect") {
96+
val resultJson = """{"ok":true,"mode":"connect","connected":true}"""
97+
writeResult(resultJson)
98+
Log.i(tag, "PASS $resultJson")
99+
return
100+
}
101+
102+
val transcript =
103+
intent
104+
.getDecodedStringExtra("transcript")
105+
?.trim()
106+
.orEmpty()
107+
.ifEmpty { "Reply exactly: Android voice e2e normal path ok." }
108+
val realtimeReply =
109+
intent
110+
.getDecodedStringExtra("realtimeAssistant")
111+
?.trim()
112+
.orEmpty()
113+
.ifEmpty { "Android realtime voice e2e relay path ok." }
114+
val timeoutMs = intent.getLongExtra("timeoutMs", 60_000L)
115+
val result =
116+
runtime.runVoiceE2e(
117+
mode = mode,
118+
transcript = transcript,
119+
realtimeAssistantText = realtimeReply,
120+
timeoutMs = timeoutMs,
121+
)
122+
val resultJson = encodeResult(result)
123+
writeResult(resultJson)
124+
Log.i(tag, "PASS $resultJson")
125+
} catch (err: Throwable) {
126+
val resultJson =
127+
buildJsonObject {
128+
put("ok", JsonPrimitive(false))
129+
put("error", JsonPrimitive(err.message ?: err::class.java.simpleName))
130+
}.toString()
131+
writeResult(resultJson)
132+
Log.e(tag, "FAIL $resultJson", err)
133+
}
134+
}
135+
136+
private fun configureGateway(
137+
runtime: NodeRuntime,
138+
intent: Intent,
139+
) {
140+
val host =
141+
intent
142+
.getDecodedStringExtra("host")
143+
?.trim()
144+
.orEmpty()
145+
.ifEmpty { "127.0.0.1" }
146+
val port = intent.getIntExtra("port", 18789)
147+
runtime.setManualEnabled(true)
148+
runtime.setManualHost(host)
149+
runtime.setManualPort(port)
150+
runtime.setManualTls(intent.getBooleanExtra("tls", false))
151+
runtime.setGatewayToken(intent.getDecodedStringExtra("token").orEmpty())
152+
runtime.setGatewayBootstrapToken(intent.getDecodedStringExtra("bootstrapToken").orEmpty())
153+
runtime.setGatewayPassword(intent.getDecodedStringExtra("password").orEmpty())
154+
runtime.setOnboardingCompleted(true)
155+
runtime.connectManual()
156+
}
157+
158+
private suspend fun awaitGateway(
159+
runtime: NodeRuntime,
160+
timeoutMs: Long,
161+
) {
162+
withTimeout(timeoutMs) {
163+
while (!runtime.isConnected.value) {
164+
delay(100L)
165+
}
166+
}
167+
}
168+
169+
private fun encodeResult(result: NodeRuntime.VoiceE2eResult): String =
170+
buildJsonObject {
171+
put("ok", JsonPrimitive(true))
172+
put("normal", result.normal?.let(::encodeSlice) ?: JsonNull)
173+
put("realtime", result.realtime?.let(::encodeSlice) ?: JsonNull)
174+
}.toString()
175+
176+
private fun encodeSlice(slice: NodeRuntime.VoiceE2eSliceResult) =
177+
buildJsonObject {
178+
put("mode", JsonPrimitive(slice.mode))
179+
put("status", JsonPrimitive(slice.status))
180+
put("userText", slice.userText?.let(::JsonPrimitive) ?: JsonNull)
181+
put("assistantText", slice.assistantText?.let(::JsonPrimitive) ?: JsonNull)
182+
}
183+
184+
private fun writeResult(json: String) {
185+
File(cacheDir, resultFileName).writeText(json)
186+
}
187+
}
188+
189+
private fun Intent.getDecodedStringExtra(name: String): String? {
190+
val encoded = getStringExtra("${name}Base64")
191+
if (!encoded.isNullOrBlank()) {
192+
return String(Base64.decode(encoded, Base64.NO_WRAP), Charsets.UTF_8)
193+
}
194+
return getStringExtra(name)
195+
}

apps/android/app/src/main/java/ai/openclaw/app/AssistantLaunch.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package ai.openclaw.app
33
import android.content.Intent
44

55
const val actionAskOpenClaw = "ai.openclaw.app.action.ASK_OPENCLAW"
6+
const val actionOpenVoiceE2e = "ai.openclaw.app.debug.OPEN_VOICE_E2E"
67
const val extraAssistantPrompt = "prompt"
78

89
enum class HomeDestination {
@@ -19,6 +20,14 @@ data class AssistantLaunchRequest(
1920
val autoSend: Boolean,
2021
)
2122

23+
fun parseHomeDestinationIntent(intent: Intent?): HomeDestination? {
24+
val action = intent?.action ?: return null
25+
return when {
26+
BuildConfig.DEBUG && action == actionOpenVoiceE2e -> HomeDestination.Voice
27+
else -> null
28+
}
29+
}
30+
2231
fun parseAssistantLaunchIntent(intent: Intent?): AssistantLaunchRequest? {
2332
val action = intent?.action ?: return null
2433
return when (action) {

apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ class MainActivity : ComponentActivity() {
7979
}
8080

8181
private fun handleAssistantIntent(intent: android.content.Intent?) {
82+
parseHomeDestinationIntent(intent)?.let { destination ->
83+
viewModel.requestHomeDestination(destination)
84+
return
85+
}
8286
val request = parseAssistantLaunchIntent(intent) ?: return
8387
viewModel.handleAssistantLaunch(request)
8488
}

apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,10 @@ class MainViewModel(
330330
_requestedHomeDestination.value = null
331331
}
332332

333+
fun requestHomeDestination(destination: HomeDestination) {
334+
_requestedHomeDestination.value = destination
335+
}
336+
333337
fun clearChatDraft() {
334338
_chatDraft.value = null
335339
}

0 commit comments

Comments
 (0)