Skip to content

Commit e3f46d0

Browse files
NianJiuZststeipete
andauthored
[codex] Gate Android Talk capture starts in background (#98055)
* fix: gate Android talk capture starts * refactor(android): make Talk capture policy explicit --------- Co-authored-by: NianJiuZst <180004567+users.noreply.github.com> Co-authored-by: Peter Steinberger <[email protected]>
1 parent b60e8c4 commit e3f46d0

9 files changed

Lines changed: 103 additions & 15 deletions

File tree

apps/.i18n/native-source.json

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,23 +75,23 @@
7575
},
7676
{
7777
"kind": "conditional-branch",
78-
"line": 1686,
78+
"line": 1695,
7979
"path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt",
8080
"source": "Failed: this host requires wss:// or Tailscale Serve. No TLS endpoint detected.",
8181
"surface": "android",
8282
"id": "native.android.81009c40eed2216d"
8383
},
8484
{
8585
"kind": "conditional-branch",
86-
"line": 1688,
86+
"line": 1697,
8787
"path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt",
8888
"source": "Failed: secure endpoint reached, but TLS fingerprint verification timed out. Check Tailscale Serve or gateway TLS and retry.",
8989
"surface": "android",
9090
"id": "native.android.467899bb510b8e34"
9191
},
9292
{
9393
"kind": "conditional-branch",
94-
"line": 1690,
94+
"line": 1699,
9595
"path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt",
9696
"source": "Failed: couldn't reach the secure gateway endpoint for this host.",
9797
"surface": "android",
@@ -4931,31 +4931,31 @@
49314931
},
49324932
{
49334933
"kind": "conditional-branch",
4934-
"line": 336,
4934+
"line": 342,
49354935
"path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt",
49364936
"source": "Listening",
49374937
"surface": "android",
49384938
"id": "native.android.b4f67e96603515bd"
49394939
},
49404940
{
49414941
"kind": "conditional-branch",
4942-
"line": 336,
4942+
"line": 342,
49434943
"path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt",
49444944
"source": "Ready",
49454945
"surface": "android",
49464946
"id": "native.android.b88e4278ead724ba"
49474947
},
49484948
{
49494949
"kind": "conditional-branch",
4950-
"line": 1623,
4950+
"line": 1629,
49514951
"path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt",
49524952
"source": "Aborted",
49534953
"surface": "android",
49544954
"id": "native.android.13531fe081a45711"
49554955
},
49564956
{
49574957
"kind": "conditional-branch",
4958-
"line": 1623,
4958+
"line": 1629,
49594959
"path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt",
49604960
"source": "Chat error",
49614961
"surface": "android",

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

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1242,9 +1242,15 @@ class NodeRuntime(
12421242
}
12431243

12441244
private suspend fun handleTalkPttStart(): GatewaySession.InvokeResult =
1245-
runPreparedTalkPttCommand {
1246-
val payload = talkMode.beginPushToTalk()
1247-
GatewaySession.InvokeResult.ok(payload.toJson())
1245+
runTalkPttCommand {
1246+
if (!_isForeground.value) {
1247+
val payload = talkMode.beginPushToTalk(allowNewCapture = false)
1248+
return@runTalkPttCommand GatewaySession.InvokeResult.ok(payload.toJson())
1249+
}
1250+
runPreparedTalkPttCommand {
1251+
val payload = talkMode.beginPushToTalk(allowNewCapture = true)
1252+
GatewaySession.InvokeResult.ok(payload.toJson())
1253+
}
12481254
}
12491255

12501256
private suspend fun handleTalkPttStop(): GatewaySession.InvokeResult =
@@ -1288,6 +1294,9 @@ class NodeRuntime(
12881294
}
12891295

12901296
private suspend fun prepareTalkCapture() {
1297+
if (!_isForeground.value) {
1298+
throw IllegalStateException("NODE_BACKGROUND_UNAVAILABLE: command requires foreground")
1299+
}
12911300
if (!hasRecordAudioPermission()) {
12921301
throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
12931302
}

apps/android/app/src/main/java/ai/openclaw/app/node/InvokeCommandRegistry.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ object InvokeCommandRegistry {
163163
),
164164
InvokeCommandSpec(
165165
name = OpenClawTalkCommand.PttOnce.rawValue,
166+
requiresForeground = true,
166167
),
167168
InvokeCommandSpec(
168169
name = OpenClawCameraCommand.List.rawValue,

apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,10 @@ class InvokeDispatcher(
104104
message = "INVALID_REQUEST: unknown command",
105105
)
106106
if (spec.requiresForeground && !isForeground()) {
107-
// Canvas, camera, and screen-backed commands need an active Activity/WebView surface.
107+
// Foreground-only commands need an active Activity surface before touching UI or capture APIs.
108108
return GatewaySession.InvokeResult.error(
109109
code = "NODE_BACKGROUND_UNAVAILABLE",
110-
message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground",
110+
message = "NODE_BACKGROUND_UNAVAILABLE: command requires foreground",
111111
)
112112
}
113113
availabilityError(spec.availability)?.let { return it }

apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,13 @@ class TalkModeManager internal constructor(
243243
}
244244

245245
/** Starts a push-to-talk capture session for gateway node.invoke callers. */
246-
suspend fun beginPushToTalk(): TalkPttStartPayload {
246+
suspend fun beginPushToTalk(allowNewCapture: Boolean): TalkPttStartPayload {
247+
if (!allowNewCapture) {
248+
// A background retry may reconcile an existing capture, but must never create one.
249+
return activePttCaptureId
250+
?.let(::TalkPttStartPayload)
251+
?: throw IllegalStateException("NODE_BACKGROUND_UNAVAILABLE: command requires foreground")
252+
}
247253
if (!isConnected()) {
248254
_statusText.value = "Gateway not connected"
249255
throw IllegalStateException("UNAVAILABLE: Gateway not connected")
@@ -353,7 +359,7 @@ class TalkModeManager internal constructor(
353359
)
354360
}
355361

356-
beginPushToTalk()
362+
beginPushToTalk(allowNewCapture = true)
357363
val completion = CompletableDeferred<TalkPttStopPayload>()
358364
pttCompletion = completion
359365
pttAutoStopEnabled = true

apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,23 @@ class GatewayBootstrapAuthTest {
435435
assertFalse(talkMode.ttsOnAllResponses)
436436
}
437437

438+
@Test
439+
fun talkPttStart_rejectsNewCaptureWhenBackgrounded() =
440+
runBlocking {
441+
val app = RuntimeEnvironment.getApplication()
442+
shadowOf(app).grantPermissions(Manifest.permission.RECORD_AUDIO)
443+
val runtime = NodeRuntime(app)
444+
runtime.setForeground(false)
445+
val dispatcher = readField<InvokeDispatcher>(runtime, "invokeDispatcher")
446+
447+
val result = dispatcher.handleInvoke(OpenClawTalkCommand.PttStart.rawValue, null)
448+
449+
assertEquals("NODE_BACKGROUND_UNAVAILABLE", result.error?.code)
450+
assertEquals("NODE_BACKGROUND_UNAVAILABLE: command requires foreground", result.error?.message)
451+
assertEquals(VoiceCaptureMode.Off, runtime.voiceCaptureMode.value)
452+
assertFalse(readField<MutableStateFlow<Boolean>>(runtime, "externalAudioCaptureActive").value)
453+
}
454+
438455
private fun waitForGatewayTrustPrompt(runtime: NodeRuntime): NodeRuntime.GatewayTrustPrompt {
439456
repeat(50) {
440457
runtime.pendingGatewayTrust.value?.let { return it }

apps/android/app/src/test/java/ai/openclaw/app/node/InvokeCommandRegistryTest.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,11 +249,23 @@ class InvokeCommandRegistryTest {
249249
fun find_returnsForegroundMetadataForCameraCommands() {
250250
val list = InvokeCommandRegistry.find(OpenClawCameraCommand.List.rawValue)
251251
val location = InvokeCommandRegistry.find(OpenClawLocationCommand.Get.rawValue)
252+
val pttStart = InvokeCommandRegistry.find(OpenClawTalkCommand.PttStart.rawValue)
253+
val pttStop = InvokeCommandRegistry.find(OpenClawTalkCommand.PttStop.rawValue)
254+
val pttCancel = InvokeCommandRegistry.find(OpenClawTalkCommand.PttCancel.rawValue)
255+
val pttOnce = InvokeCommandRegistry.find(OpenClawTalkCommand.PttOnce.rawValue)
252256

253257
assertNotNull(list)
254258
assertEquals(true, list?.requiresForeground)
255259
assertNotNull(location)
256260
assertEquals(false, location?.requiresForeground)
261+
assertNotNull(pttStart)
262+
assertEquals(false, pttStart?.requiresForeground)
263+
assertNotNull(pttStop)
264+
assertEquals(false, pttStop?.requiresForeground)
265+
assertNotNull(pttCancel)
266+
assertEquals(false, pttCancel?.requiresForeground)
267+
assertNotNull(pttOnce)
268+
assertEquals(true, pttOnce?.requiresForeground)
257269
}
258270

259271
@Test

apps/android/app/src/test/java/ai/openclaw/app/node/InvokeDispatcherTest.kt

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,27 @@ class InvokeDispatcherTest {
256256
)
257257
}
258258

259+
@Test
260+
fun handleInvoke_blocksTalkOnceButLeavesPttStartToRuntimeStateGateWhenBackgrounded() =
261+
runTest {
262+
val talk = InvokeDispatcherFakeTalkHandler()
263+
val dispatcher = newDispatcher(isForeground = false, talkHandler = talk)
264+
265+
val start = dispatcher.handleInvoke(OpenClawTalkCommand.PttStart.rawValue, null)
266+
val once = dispatcher.handleInvoke(OpenClawTalkCommand.PttOnce.rawValue, null)
267+
val stop = dispatcher.handleInvoke(OpenClawTalkCommand.PttStop.rawValue, null)
268+
val cancel = dispatcher.handleInvoke(OpenClawTalkCommand.PttCancel.rawValue, null)
269+
270+
assertEquals("""{"captureId":"start"}""", start.payloadJson)
271+
assertEquals("NODE_BACKGROUND_UNAVAILABLE", once.error?.code)
272+
assertEquals("NODE_BACKGROUND_UNAVAILABLE: command requires foreground", once.error?.message)
273+
assertEquals("""{"status":"stop"}""", stop.payloadJson)
274+
assertEquals("""{"status":"cancel"}""", cancel.payloadJson)
275+
assertEquals(listOf("start", "stop", "cancel"), talk.calls)
276+
}
277+
259278
private fun newDispatcher(
279+
isForeground: Boolean = true,
260280
cameraEnabled: Boolean = false,
261281
locationEnabled: Boolean = false,
262282
sendSmsAvailable: Boolean = false,
@@ -302,7 +322,7 @@ class InvokeDispatcherTest {
302322
),
303323
debugHandler = DebugHandler(appContext, DeviceIdentityStore(appContext)),
304324
callLogHandler = CallLogHandler.forTesting(appContext, InvokeDispatcherFakeCallLogDataSource()),
305-
isForeground = { true },
325+
isForeground = { isForeground },
306326
cameraEnabled = { cameraEnabled },
307327
locationEnabled = { locationEnabled },
308328
sendSmsAvailable = { sendSmsAvailable },

apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeManagerTest.kt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,29 @@ class TalkModeManagerTest {
5959
assertEquals(12L, playbackGeneration(manager).get())
6060
}
6161

62+
@Test
63+
fun beginPushToTalkRejectsNewCaptureWhenNewCaptureIsDisallowed() =
64+
runTest {
65+
val manager = createManager()
66+
67+
val error =
68+
runCatching { manager.beginPushToTalk(allowNewCapture = false) }
69+
.exceptionOrNull()
70+
71+
assertEquals("NODE_BACKGROUND_UNAVAILABLE: command requires foreground", error?.message)
72+
}
73+
74+
@Test
75+
fun beginPushToTalkReturnsActiveCaptureWhenNewCaptureIsDisallowed() =
76+
runTest {
77+
val manager = createManager()
78+
setPrivateField(manager, "activePttCaptureId", "capture-1")
79+
80+
val payload = manager.beginPushToTalk(allowNewCapture = false)
81+
82+
assertEquals("capture-1", payload.captureId)
83+
}
84+
6285
@Test
6386
fun duplicateFinalForPendingTalkRunDoesNotStartAllResponseTts() {
6487
val manager = createManager()

0 commit comments

Comments
 (0)