From cee2f3e8b4c0e7d4eb69f554fa51ea635ed9c49f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 16:26:35 +0000 Subject: [PATCH] refactor: dedupe android talk config parsing --- .../app/voice/TalkModeGatewayConfig.kt | 55 ++++++++++++++++++- .../ai/openclaw/app/voice/TalkModeManager.kt | 48 ---------------- .../app/voice/TalkModeConfigContractTest.kt | 2 +- .../app/voice/TalkModeConfigParsingTest.kt | 27 ++++++--- 4 files changed, 72 insertions(+), 60 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeGatewayConfig.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeGatewayConfig.kt index 4293c113896..58208acc0bb 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeGatewayConfig.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeGatewayConfig.kt @@ -4,9 +4,16 @@ import ai.openclaw.app.normalizeMainKey import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.contentOrNull +internal data class TalkProviderConfigSelection( + val provider: String, + val config: JsonObject, + val normalizedPayload: Boolean, +) + internal data class TalkModeGatewayConfigState( val activeProvider: String, val normalizedPayload: Boolean, @@ -22,6 +29,8 @@ internal data class TalkModeGatewayConfigState( ) internal object TalkModeGatewayConfigParser { + private const val defaultTalkProvider = "elevenlabs" + fun parse( config: JsonObject?, defaultProvider: String, @@ -32,7 +41,7 @@ internal object TalkModeGatewayConfigParser { envKey: String?, ): TalkModeGatewayConfigState { val talk = config?.get("talk").asObjectOrNull() - val selection = TalkModeManager.selectTalkProviderConfig(talk) + val selection = selectTalkProviderConfig(talk) val activeProvider = selection?.provider ?: defaultProvider val activeConfig = selection?.config val sessionCfg = config?.get("session").asObjectOrNull() @@ -48,7 +57,7 @@ internal object TalkModeGatewayConfigParser { activeConfig?.get("outputFormat")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } val key = activeConfig?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull() - val silenceTimeoutMs = TalkModeManager.resolvedSilenceTimeoutMs(talk) + val silenceTimeoutMs = resolvedSilenceTimeoutMs(talk) return TalkModeGatewayConfigState( activeProvider = activeProvider, @@ -91,6 +100,48 @@ internal object TalkModeGatewayConfigParser { interruptOnSpeech = null, silenceTimeoutMs = TalkDefaults.defaultSilenceTimeoutMs, ) + + fun selectTalkProviderConfig(talk: JsonObject?): TalkProviderConfigSelection? { + if (talk == null) return null + selectResolvedTalkProviderConfig(talk)?.let { return it } + val rawProvider = talk["provider"].asStringOrNull() + val rawProviders = talk["providers"].asObjectOrNull() + val hasNormalizedPayload = rawProvider != null || rawProviders != null + if (hasNormalizedPayload) { + return null + } + return TalkProviderConfigSelection( + provider = defaultTalkProvider, + config = talk, + normalizedPayload = false, + ) + } + + fun resolvedSilenceTimeoutMs(talk: JsonObject?): Long { + val fallback = TalkDefaults.defaultSilenceTimeoutMs + val primitive = talk?.get("silenceTimeoutMs") as? JsonPrimitive ?: return fallback + if (primitive.isString) return fallback + val timeout = primitive.content.toDoubleOrNull() ?: return fallback + if (timeout <= 0 || timeout % 1.0 != 0.0 || timeout > Long.MAX_VALUE.toDouble()) { + return fallback + } + return timeout.toLong() + } + + private fun selectResolvedTalkProviderConfig(talk: JsonObject): TalkProviderConfigSelection? { + val resolved = talk["resolved"].asObjectOrNull() ?: return null + val providerId = normalizeTalkProviderId(resolved["provider"].asStringOrNull()) ?: return null + return TalkProviderConfigSelection( + provider = providerId, + config = resolved["config"].asObjectOrNull() ?: buildJsonObject {}, + normalizedPayload = true, + ) + } + + private fun normalizeTalkProviderId(raw: String?): String? { + val trimmed = raw?.trim()?.lowercase().orEmpty() + return trimmed.takeIf { it.isNotEmpty() } + } } private fun normalizeTalkAliasKey(value: String): String = diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt index 8a3b6fd948d..07bd8a346f0 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt @@ -64,54 +64,6 @@ class TalkModeManager( private const val chatFinalWaitWithSubscribeMs = 45_000L private const val chatFinalWaitWithoutSubscribeMs = 6_000L private const val maxCachedRunCompletions = 128 - - internal data class TalkProviderConfigSelection( - val provider: String, - val config: JsonObject, - val normalizedPayload: Boolean, - ) - - private fun normalizeTalkProviderId(raw: String?): String? { - val trimmed = raw?.trim()?.lowercase().orEmpty() - return trimmed.takeIf { it.isNotEmpty() } - } - - private fun selectResolvedTalkProviderConfig(talk: JsonObject): TalkProviderConfigSelection? { - val resolved = talk["resolved"].asObjectOrNull() ?: return null - val providerId = normalizeTalkProviderId(resolved["provider"].asStringOrNull()) ?: return null - return TalkProviderConfigSelection( - provider = providerId, - config = resolved["config"].asObjectOrNull() ?: buildJsonObject {}, - normalizedPayload = true, - ) - } - - internal fun selectTalkProviderConfig(talk: JsonObject?): TalkProviderConfigSelection? { - if (talk == null) return null - selectResolvedTalkProviderConfig(talk)?.let { return it } - val rawProvider = talk["provider"].asStringOrNull() - val rawProviders = talk["providers"].asObjectOrNull() - val hasNormalizedPayload = rawProvider != null || rawProviders != null - if (hasNormalizedPayload) { - return null - } - return TalkProviderConfigSelection( - provider = defaultTalkProvider, - config = talk, - normalizedPayload = false, - ) - } - - internal fun resolvedSilenceTimeoutMs(talk: JsonObject?): Long { - val fallback = TalkDefaults.defaultSilenceTimeoutMs - val primitive = talk?.get("silenceTimeoutMs") as? JsonPrimitive ?: return fallback - if (primitive.isString) return fallback - val timeout = primitive.content.toDoubleOrNull() ?: return fallback - if (timeout <= 0 || timeout % 1.0 != 0.0 || timeout > Long.MAX_VALUE.toDouble()) { - return fallback - } - return timeout.toLong() - } } private val mainHandler = Handler(Looper.getMainLooper()) diff --git a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigContractTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigContractTest.kt index 16336d65706..d2df33793e2 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigContractTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigContractTest.kt @@ -38,7 +38,7 @@ class TalkModeConfigContractTest { @Test fun selectionFixtures() { for (fixture in loadFixtures().selectionCases) { - val selection = TalkModeManager.selectTalkProviderConfig(fixture.talk) + val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(fixture.talk) val expected = fixture.expectedSelection if (expected == null) { assertNull(fixture.id, selection) diff --git a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt index 4ccee1cdf69..e9c46231961 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt @@ -36,7 +36,7 @@ class TalkModeConfigParsingTest { ) .jsonObject - val selection = TalkModeManager.selectTalkProviderConfig(talk) + val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk) assertNotNull(selection) assertEquals("elevenlabs", selection?.provider) assertTrue(selection?.normalizedPayload == true) @@ -61,7 +61,7 @@ class TalkModeConfigParsingTest { ) .jsonObject - val selection = TalkModeManager.selectTalkProviderConfig(talk) + val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk) assertEquals(null, selection) } @@ -82,7 +82,7 @@ class TalkModeConfigParsingTest { ) .jsonObject - val selection = TalkModeManager.selectTalkProviderConfig(talk) + val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk) assertEquals(null, selection) } @@ -105,7 +105,7 @@ class TalkModeConfigParsingTest { ) .jsonObject - val selection = TalkModeManager.selectTalkProviderConfig(talk) + val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk) assertEquals(null, selection) } @@ -118,7 +118,7 @@ class TalkModeConfigParsingTest { put("apiKey", legacyApiKey) // pragma: allowlist secret } - val selection = TalkModeManager.selectTalkProviderConfig(talk) + val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk) assertNotNull(selection) assertEquals("elevenlabs", selection?.provider) assertTrue(selection?.normalizedPayload == false) @@ -130,25 +130,34 @@ class TalkModeConfigParsingTest { fun readsConfiguredSilenceTimeoutMs() { val talk = buildJsonObject { put("silenceTimeoutMs", 1500) } - assertEquals(1500L, TalkModeManager.resolvedSilenceTimeoutMs(talk)) + assertEquals(1500L, TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(talk)) } @Test fun defaultsSilenceTimeoutMsWhenMissing() { - assertEquals(TalkDefaults.defaultSilenceTimeoutMs, TalkModeManager.resolvedSilenceTimeoutMs(null)) + assertEquals( + TalkDefaults.defaultSilenceTimeoutMs, + TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(null), + ) } @Test fun defaultsSilenceTimeoutMsWhenInvalid() { val talk = buildJsonObject { put("silenceTimeoutMs", 0) } - assertEquals(TalkDefaults.defaultSilenceTimeoutMs, TalkModeManager.resolvedSilenceTimeoutMs(talk)) + assertEquals( + TalkDefaults.defaultSilenceTimeoutMs, + TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(talk), + ) } @Test fun defaultsSilenceTimeoutMsWhenString() { val talk = buildJsonObject { put("silenceTimeoutMs", "1500") } - assertEquals(TalkDefaults.defaultSilenceTimeoutMs, TalkModeManager.resolvedSilenceTimeoutMs(talk)) + assertEquals( + TalkDefaults.defaultSilenceTimeoutMs, + TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(talk), + ) } }