Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,45 @@ jobs:

# Compiles the JNI bridge against the pinned speech-core, so a submodule
# that does not build with the current bridge fails here rather than at
# release time.
# release time. The minified control APK is intentional: its LiteRT-LM
# JNI surface differs from debug after R8 has run.
- name: Assemble
run: ./gradlew :sdk:assembleRelease :app:assembleDebug
run: ./gradlew :sdk:assembleRelease :app:assembleDebug :control-demo:assembleRelease

# LiteRT-LM's native conversation constructor resolves these Kotlin
# getters by name. Its AAR has no consumer keep rules, and a release APK
# still assembles if R8 removes them; the resulting process abort happens
# only when the first command reaches Thinking.
- name: Verify LiteRT-LM JNI bindings survive R8
shell: bash
run: |
set -euo pipefail
mapping=control-demo/build/outputs/mapping/release/mapping.txt

assert_kept_member() {
local class_name="$1"
local member_name="$2"
if ! awk -v class_name="$class_name" -v member_name="$member_name" '
$0 == class_name " -> " class_name ":" { inside = 1; next }
inside && $0 ~ /^[^#[:space:]].* -> .*:$/ { exit }
inside && index($0, " " member_name "(") &&
$0 ~ (" -> " member_name "$") { found = 1; exit }
END { if (!found) exit 1 }
' "$mapping"; then
echo "::error file=$mapping::R8 removed or renamed $class_name.$member_name"
exit 1
fi
}

sampler=com.google.ai.edge.litertlm.SamplerConfig
assert_kept_member "$sampler" getTopK
assert_kept_member "$sampler" getTopP
assert_kept_member "$sampler" getTemperature
assert_kept_member "$sampler" getSeed

thinking=com.google.ai.edge.litertlm.ThinkingConfig
assert_kept_member "$thinking" getEnableThinking
assert_kept_member "$thinking" getThinkingTokenBudget

- name: Upload test reports
if: always()
Expand Down
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
plugins {
id("com.android.application") version "8.13.2" apply false
id("com.android.library") version "8.13.2" apply false
// LiteRT-LM 0.14.0 publishes Kotlin 2.3 metadata, which requires the
// LiteRT-LM 0.16.0 publishes Kotlin 2.3 metadata, which requires the
// Kotlin 2.2 compiler used by the full-pipeline Compose demo.
id("org.jetbrains.kotlin.android") version "2.2.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.2.21" apply false
Expand Down
28 changes: 28 additions & 0 deletions control-demo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,34 @@ The same data is available for device benchmarks with:
adb logcat -s SpeechControl | grep 'TURN'
```

## Crash diagnostics

On Android 11 and newer, the demo records only its coarse execution phase
(`loading_llm`, `thinking`, `speaking`, and so on) in Android's process-exit
metadata. It never puts an utterance, contact, or media title there. After an
unexpected Java/native crash, ANR, signal, or low-memory kill, the next launch
shows the previous exit reason, phase, signal where available, and Android's
last sampled PSS. Open **ⓘ → Share diagnostics** to copy the full device/app
summary into a bug report.

For the stack trace, reproduce once while collecting logcat:

```bash
adb logcat -c
adb logcat -v threadtime > soniqo-control-crash.txt
# Reproduce the crash, then stop logcat with Ctrl+C.
```

Immediately after a crash, Android's dedicated crash buffer is a shorter
alternative:

```bash
adb logcat -b crash -d -v threadtime > soniqo-control-crash.txt
```

Review or redact logs before sharing them; system logs can contain unrelated
device information.

## Validate

```bash
Expand Down
14 changes: 11 additions & 3 deletions control-demo/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ android {
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
if (System.getenv("SIGNING_KEYSTORE") != null) {
signingConfig = signingConfigs.getByName("release")
}
Expand All @@ -50,6 +53,7 @@ android {

buildFeatures {
compose = true
buildConfig = true
}
}

Expand All @@ -60,12 +64,16 @@ dependencies {

// FunctionGemma runs in the app rather than the SDK, keeping LiteRT-LM
// out of the published speech artifact.
implementation("com.google.ai.edge.litertlm:litertlm-android:0.14.0")
implementation("com.google.ai.edge.litertlm:litertlm-android:0.16.0")
implementation("androidx.core:core-ktx:1.15.0")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.activity:activity-ktx:1.9.0")
implementation("androidx.work:work-runtime-ktx:2.11.2")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
// LiteRT-LM's Kotlin bindings are compiled against the interface-default
// layout introduced in coroutines 1.11.0. Its published POM still asks
// for 1.9.0, which can terminate the app with NoSuchMethodError when a
// binding callback completes (google-ai-edge/LiteRT-LM#2812).
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")

implementation(platform("androidx.compose:compose-bom:2024.12.01"))
implementation("androidx.activity:activity-compose:1.9.3")
Expand Down
5 changes: 5 additions & 0 deletions control-demo/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# LiteRT-LM's JNI implementation looks up Kotlin binding methods by their
# original names. The published AAR does not include consumer keep rules, so
# R8 can remove those methods and make nativeCreateConversation abort with
# "JNI DETECTED ERROR IN APPLICATION: mid == null" in minified builds.
-keep class com.google.ai.edge.litertlm.** { *; }
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ class ControlAgentActivity : ComponentActivity() {
private val store = ControlStore()
private val device = AndroidDeviceActions()
private val memory = MemoryMonitor()
private val exitDiagnostics by lazy { ProcessExitDiagnostics(this) }

private var pipelineStarted = false
private var observingDownload = false
Expand Down Expand Up @@ -185,6 +186,18 @@ class ControlAgentActivity : ComponentActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val previousExit = exitDiagnostics.consumePreviousExit()
store.setDiagnosticsReport(
buildDiagnosticsReport(
"${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
previousExit,
)
)
previousExit?.summaryLine()?.let { summary ->
Log.e(TAG, summary)
store.addNote("$summary · open ⓘ to share diagnostics")
}
exitDiagnostics.markPhase("initializing")
if (acceptsDebugIntents) {
pendingCommand = intent?.getStringExtra("command")
pendingReplay = if (pendingCommand == null) {
Expand All @@ -211,6 +224,7 @@ class ControlAgentActivity : ComponentActivity() {
onDismissType = { store.setTypeDialog(false) },
onOpenInfo = { store.setInfoDialog(true) },
onDismissInfo = { store.setInfoDialog(false) },
onShareDiagnostics = ::shareDiagnostics,
),
)
}
Expand Down Expand Up @@ -282,6 +296,21 @@ class ControlAgentActivity : ComponentActivity() {
store.setMemory(memory.currentMb(), memory.peakMb)
}

private fun shareDiagnostics() {
val report = store.state.value.diagnosticsReport ?: return
val share = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_SUBJECT, "Soniqo Control diagnostics")
putExtra(Intent.EXTRA_TEXT, report)
}
runCatching {
startActivity(Intent.createChooser(share, "Share diagnostics"))
}.onFailure {
Log.e(TAG, "Unable to share diagnostics", it)
store.addNote("could not open Android's share sheet")
}
}

/** Friendly stage name for a model file, so the status reads "downloading
* transcription model" instead of "downloading parakeet-eou-encoder.onnx". */
private fun modelLabel(file: String): String = when {
Expand All @@ -301,6 +330,7 @@ class ControlAgentActivity : ComponentActivity() {

/** Rest state after a turn: keep listening if the mic is live, else idle. */
private fun returnToRest() {
exitDiagnostics.markPhase(if (recording) "listening" else "idle")
store.setMic(if (recording) MicState.LISTENING else MicState.IDLE)
store.setStatus(if (recording) "listening" else "tap to talk")
}
Expand All @@ -310,6 +340,7 @@ class ControlAgentActivity : ComponentActivity() {
// -----------------------------------------------------------------------

private fun loadModels() {
exitDiagnostics.markPhase("downloading_models")
store.setStatus("downloading models")
store.setDownload(0)
// includeLlm = true: the FunctionGemma bundle downloads in this same
Expand Down Expand Up @@ -378,6 +409,7 @@ class ControlAgentActivity : ComponentActivity() {
private fun initEverything(modelDir: String) {
lifecycleScope.launch(Dispatchers.Default) {
try {
exitDiagnostics.markPhase("loading_pipeline")
store.setStatus("loading pipeline")
store.setDownloadStage(null)
val p = SpeechPipeline(SpeechConfig(
Expand Down Expand Up @@ -435,6 +467,7 @@ class ControlAgentActivity : ComponentActivity() {
ModelManager.llmAdapterFile(applicationContext, llmProfile),
) { "Control LLM profile is missing its adapter" }
store.setStatus("loading LLM engine")
exitDiagnostics.markPhase("loading_llm")
val runtime = LiteRtLmRuntime(llmPath, adapterPath)
runtime.initialize()
llmRuntime = runtime
Expand All @@ -447,6 +480,7 @@ class ControlAgentActivity : ComponentActivity() {
}

ready = true
exitDiagnostics.markPhase("ready")
store.setDownload(null)
store.setDownloadDetail(null)
store.setMic(MicState.IDLE)
Expand All @@ -461,6 +495,7 @@ class ControlAgentActivity : ComponentActivity() {
runReplay(name)
}
} catch (e: Throwable) {
e.rethrowIfUnrecoverable()
Log.e(TAG, "init failed", e)
store.addNote("init error: ${e.message}")
store.setStatus("error")
Expand Down Expand Up @@ -513,7 +548,8 @@ class ControlAgentActivity : ComponentActivity() {
runAgentTurn(turnId, text, sttMs, voiceAnchored)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
} catch (e: Throwable) {
e.rethrowIfUnrecoverable()
Log.e(TAG, "agent turn failed", e)
store.updateTurn(turnId) {
it.copy(toolLabel = "turn error: ${e.message}", failed = true)
Expand Down Expand Up @@ -546,6 +582,7 @@ class ControlAgentActivity : ComponentActivity() {

store.setMic(MicState.THINKING)
store.setStatus("thinking")
exitDiagnostics.markPhase("thinking")
val llmStart = System.currentTimeMillis()
val turnAnchorMs = if (voiceAnchored && speechEndMs > 0) speechEndMs else llmStart

Expand All @@ -563,13 +600,17 @@ class ControlAgentActivity : ComponentActivity() {
// state-filtered function names and current music state.
val runtime = llmRuntime ?: error("LLM runtime not ready")
runtime.generate(CompactPrompt.format(tools, musicPlaying, prompt), 128)
} catch (e: Exception) {
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
e.rethrowIfUnrecoverable()
Log.e(TAG, "LLM generation failed", e)
store.updateTurn(turnId) { it.copy(toolLabel = "llm error: ${e.message}", failed = true) }
returnToRest(); return
}
llmMs = System.currentTimeMillis() - llmStart
rawLength = raw.length
exitDiagnostics.markPhase("executing_tool")
val calls = llm.parseToolCalls(raw)
val selectedCall = ControlTools.selectSingleCall(calls)
// Routing telemetry for analysis: input, what the model emitted, and
Expand All @@ -581,7 +622,11 @@ class ControlAgentActivity : ComponentActivity() {
"raw='${raw.replace("\n", " ").take(160)}'")
val outcome = selectedCall?.let { call ->
try { ControlTools.execute(call, device) }
catch (e: Exception) { Log.e(TAG, "tool execution failed", e); null }
catch (e: Throwable) {
e.rethrowIfUnrecoverable()
Log.e(TAG, "tool execution failed", e)
null
}
} ?: run {
val label = when {
calls.isEmpty() -> "no tool call"
Expand All @@ -600,6 +645,7 @@ class ControlAgentActivity : ComponentActivity() {

store.setMic(MicState.SPEAKING)
store.setStatus("speaking")
exitDiagnostics.markPhase("speaking")
micPaused = true
// Pocket is genuinely recurrent, so send it the whole response and
// play its 80 ms callbacks through one continuous AudioTrack. Kokoro
Expand Down Expand Up @@ -710,7 +756,10 @@ class ControlAgentActivity : ComponentActivity() {
if (!playbackCompleted) stopPlayback()
}
}
} catch (e: Exception) {
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
e.rethrowIfUnrecoverable()
Log.e(TAG, "TTS failed", e)
store.addNote("tts error: ${e.message}")
micPaused = false
Expand All @@ -737,11 +786,15 @@ class ControlAgentActivity : ComponentActivity() {
/** Launch actions that intentionally leave this Activity only after TTS. */
private suspend fun executeDeferredAction(outcome: ToolOutcome?, turnId: Long) {
if (outcome?.deferredAction == null) return
exitDiagnostics.markPhase("deferred_action")
try {
withContext(Dispatchers.Main.immediate) {
ControlTools.executeDeferredAction(outcome, device)
}
} catch (e: Exception) {
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
e.rethrowIfUnrecoverable()
Log.e(TAG, "deferred tool execution failed", e)
store.updateTurn(turnId) { it.copy(failed = true) }
store.addNote("action error: ${e.message}")
Expand Down Expand Up @@ -1179,6 +1232,7 @@ class ControlAgentActivity : ComponentActivity() {

override fun onStop() {
super.onStop()
exitDiagnostics.markPhase("background")
if (recording) {
stopMicrophone()
store.setMic(MicState.IDLE)
Expand All @@ -1189,6 +1243,7 @@ class ControlAgentActivity : ComponentActivity() {
}

override fun onDestroy() {
exitDiagnostics.markPhase("destroying")
stopMicrophone()
stopPlayback()
device.stopMusic()
Expand All @@ -1198,4 +1253,13 @@ class ControlAgentActivity : ComponentActivity() {
llmRuntime?.close()
super.onDestroy()
}

/** Recover ordinary/linkage failures while preserving cancellation and fatal semantics. */
private fun Throwable.rethrowIfUnrecoverable() {
when (this) {
is CancellationException -> throw this
is Exception, is LinkageError -> Unit
else -> throw this
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ data class ControlUiState(
val memPeakMb: Int = 0,
/** Latencies of the most recent completed turn, for the status line. */
val lastMetrics: TurnMetrics? = null,
/** Device/app details plus the most recent unexpected process exit, for sharing. */
val diagnosticsReport: String? = null,
val feed: List<FeedItem> = emptyList(),
val showTypeDialog: Boolean = false,
val showInfoDialog: Boolean = false,
Expand Down Expand Up @@ -72,6 +74,9 @@ class ControlStore {
fun setLastMetrics(metrics: TurnMetrics) =
_state.update { it.copy(lastMetrics = metrics) }

fun setDiagnosticsReport(report: String) =
_state.update { it.copy(diagnosticsReport = report) }

fun setTypeDialog(visible: Boolean) = _state.update { it.copy(showTypeDialog = visible) }

fun setInfoDialog(visible: Boolean) = _state.update { it.copy(showInfoDialog = visible) }
Expand Down
Loading
Loading