From 0e144f3abeab8cfe8efe884b792aa8862878cbd8 Mon Sep 17 00:00:00 2001 From: ivan-digital <42473865+ivan-digital@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:51:05 +0200 Subject: [PATCH 1/2] fix(control-demo): preserve LiteRT-LM JNI bindings --- build.gradle.kts | 2 +- control-demo/README.md | 28 ++++ control-demo/build.gradle.kts | 14 +- control-demo/proguard-rules.pro | 5 + .../speech/control/ControlAgentActivity.kt | 70 ++++++++- .../soniqo/speech/control/ControlStore.kt | 5 + .../soniqo/speech/control/LiteRtLmRuntime.kt | 8 +- .../speech/control/ProcessExitDiagnostics.kt | 145 ++++++++++++++++++ .../soniqo/speech/control/ui/ControlScreen.kt | 32 +++- .../control/ProcessExitDiagnosticsTest.kt | 44 ++++++ 10 files changed, 339 insertions(+), 14 deletions(-) create mode 100644 control-demo/proguard-rules.pro create mode 100644 control-demo/src/main/kotlin/audio/soniqo/speech/control/ProcessExitDiagnostics.kt create mode 100644 control-demo/src/test/kotlin/audio/soniqo/speech/control/ProcessExitDiagnosticsTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 7666de4..89f1460 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 diff --git a/control-demo/README.md b/control-demo/README.md index ea436ef..7837a94 100644 --- a/control-demo/README.md +++ b/control-demo/README.md @@ -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 diff --git a/control-demo/build.gradle.kts b/control-demo/build.gradle.kts index fd0b80a..bf85247 100644 --- a/control-demo/build.gradle.kts +++ b/control-demo/build.gradle.kts @@ -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") } @@ -50,6 +53,7 @@ android { buildFeatures { compose = true + buildConfig = true } } @@ -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") diff --git a/control-demo/proguard-rules.pro b/control-demo/proguard-rules.pro new file mode 100644 index 0000000..ade8a16 --- /dev/null +++ b/control-demo/proguard-rules.pro @@ -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.** { *; } diff --git a/control-demo/src/main/kotlin/audio/soniqo/speech/control/ControlAgentActivity.kt b/control-demo/src/main/kotlin/audio/soniqo/speech/control/ControlAgentActivity.kt index ba2f85d..7519d04 100644 --- a/control-demo/src/main/kotlin/audio/soniqo/speech/control/ControlAgentActivity.kt +++ b/control-demo/src/main/kotlin/audio/soniqo/speech/control/ControlAgentActivity.kt @@ -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 @@ -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) { @@ -211,6 +224,7 @@ class ControlAgentActivity : ComponentActivity() { onDismissType = { store.setTypeDialog(false) }, onOpenInfo = { store.setInfoDialog(true) }, onDismissInfo = { store.setInfoDialog(false) }, + onShareDiagnostics = ::shareDiagnostics, ), ) } @@ -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 { @@ -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") } @@ -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 @@ -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( @@ -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 @@ -447,6 +480,7 @@ class ControlAgentActivity : ComponentActivity() { } ready = true + exitDiagnostics.markPhase("ready") store.setDownload(null) store.setDownloadDetail(null) store.setMic(MicState.IDLE) @@ -461,6 +495,7 @@ class ControlAgentActivity : ComponentActivity() { runReplay(name) } } catch (e: Throwable) { + e.rethrowIfProcessFatal() Log.e(TAG, "init failed", e) store.addNote("init error: ${e.message}") store.setStatus("error") @@ -513,7 +548,8 @@ class ControlAgentActivity : ComponentActivity() { runAgentTurn(turnId, text, sttMs, voiceAnchored) } catch (e: CancellationException) { throw e - } catch (e: Exception) { + } catch (e: Throwable) { + e.rethrowIfProcessFatal() Log.e(TAG, "agent turn failed", e) store.updateTurn(turnId) { it.copy(toolLabel = "turn error: ${e.message}", failed = true) @@ -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 @@ -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.rethrowIfProcessFatal() 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 @@ -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.rethrowIfProcessFatal() + Log.e(TAG, "tool execution failed", e) + null + } } ?: run { val label = when { calls.isEmpty() -> "no tool call" @@ -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 @@ -710,7 +756,10 @@ class ControlAgentActivity : ComponentActivity() { if (!playbackCompleted) stopPlayback() } } - } catch (e: Exception) { + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + e.rethrowIfProcessFatal() Log.e(TAG, "TTS failed", e) store.addNote("tts error: ${e.message}") micPaused = false @@ -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.rethrowIfProcessFatal() Log.e(TAG, "deferred tool execution failed", e) store.updateTurn(turnId) { it.copy(failed = true) } store.addNote("action error: ${e.message}") @@ -1179,6 +1232,7 @@ class ControlAgentActivity : ComponentActivity() { override fun onStop() { super.onStop() + exitDiagnostics.markPhase("background") if (recording) { stopMicrophone() store.setMic(MicState.IDLE) @@ -1189,6 +1243,7 @@ class ControlAgentActivity : ComponentActivity() { } override fun onDestroy() { + exitDiagnostics.markPhase("destroying") stopMicrophone() stopPlayback() device.stopMusic() @@ -1198,4 +1253,9 @@ class ControlAgentActivity : ComponentActivity() { llmRuntime?.close() super.onDestroy() } + + /** Linkage/assertion errors are reportable; VM failures must retain normal fatal semantics. */ + private fun Throwable.rethrowIfProcessFatal() { + if (this is VirtualMachineError || this is ThreadDeath) throw this + } } diff --git a/control-demo/src/main/kotlin/audio/soniqo/speech/control/ControlStore.kt b/control-demo/src/main/kotlin/audio/soniqo/speech/control/ControlStore.kt index 650b3a4..0262c18 100644 --- a/control-demo/src/main/kotlin/audio/soniqo/speech/control/ControlStore.kt +++ b/control-demo/src/main/kotlin/audio/soniqo/speech/control/ControlStore.kt @@ -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 = emptyList(), val showTypeDialog: Boolean = false, val showInfoDialog: Boolean = false, @@ -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) } diff --git a/control-demo/src/main/kotlin/audio/soniqo/speech/control/LiteRtLmRuntime.kt b/control-demo/src/main/kotlin/audio/soniqo/speech/control/LiteRtLmRuntime.kt index a72b53d..e2c3b38 100644 --- a/control-demo/src/main/kotlin/audio/soniqo/speech/control/LiteRtLmRuntime.kt +++ b/control-demo/src/main/kotlin/audio/soniqo/speech/control/LiteRtLmRuntime.kt @@ -22,7 +22,7 @@ class LiteRtLmRuntime( private var engine: Engine? = null - // Verified on-emulator against 0.14.0: without SDK-side templating the + // Verified on-emulator against 0.16.0: without SDK-side templating the // model emits degraded, non-canonical call syntax (`name:...` instead of // `call:NAME{...}`), so the SDK applies the Gemma chat template. override val appliesChatTemplate: Boolean get() = false @@ -39,11 +39,13 @@ class LiteRtLmRuntime( // Greedy decoding: sampled decoding makes the 270M model's call // syntax fall apart every few turns (verified on-emulator), while // tool emission wants the single strongest path anyway. maxNewTokens - // is unused: released 0.14.0 has no maxOutputToken yet and valid - // FunctionGemma calls stop at on their own. + // is also enforced by LiteRT-LM so malformed output cannot run until + // the model-wide context limit. Valid FunctionGemma calls stop at + // on their own. val config = ConversationConfig( samplerConfig = SamplerConfig(topK = 1, topP = 1.0, temperature = 0.0), loraConfig = loraPath?.let { LoraConfig(loraPath = it) }, + maxOutputToken = maxNewTokens, ) return e.createConversation(config).use { conversation -> conversation.sendMessage(prompt).toString() diff --git a/control-demo/src/main/kotlin/audio/soniqo/speech/control/ProcessExitDiagnostics.kt b/control-demo/src/main/kotlin/audio/soniqo/speech/control/ProcessExitDiagnostics.kt new file mode 100644 index 0000000..aa23176 --- /dev/null +++ b/control-demo/src/main/kotlin/audio/soniqo/speech/control/ProcessExitDiagnostics.kt @@ -0,0 +1,145 @@ +package audio.soniqo.speech.control + +import android.app.ActivityManager +import android.app.ApplicationExitInfo +import android.content.Context +import android.os.Build +import android.util.Log +import java.time.Instant + +/** Actionable reason for an unexpected death of the previous app process. */ +internal enum class ProcessExitKind(val label: String) { + JAVA_CRASH("Java/Kotlin crash"), + NATIVE_CRASH("native crash"), + ANR("app not responding"), + LOW_MEMORY("system low-memory kill"), + SIGNAL("signal termination"), + EXCESSIVE_RESOURCES("excessive-resource kill"), + INITIALIZATION_FAILURE("initialization failure"), + DEPENDENCY_DIED("dependency process died"), +} + +internal data class PreviousProcessExit( + val kind: ProcessExitKind, + val status: Int, + val timestampMs: Long, + val phase: String?, + val pssKb: Long, + val rssKb: Long, + val description: String?, +) { + fun summaryLine(): String = buildString { + append("Previous run ended unexpectedly: ") + append(kind.label) + if (status != 0 && kind in setOf(ProcessExitKind.NATIVE_CRASH, ProcessExitKind.SIGNAL)) { + append(" (signal ").append(status).append(')') + } + phase?.let { append(" during ").append(it) } + if (pssKb > 0) append(" · last PSS ").append(kbToMb(pssKb)).append(" MB") + } +} + +internal fun buildDiagnosticsReport( + appVersion: String, + previousExit: PreviousProcessExit?, +): String = buildString { + appendLine("Soniqo Control diagnostics") + appendLine("App: $appVersion") + appendLine("Device: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE})") + appendLine( + "Android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT}; ${Build.DISPLAY})" + ) + if (previousExit == null) { + append("Previous unexpected exit: none recorded") + } else { + appendLine("Previous exit: ${previousExit.kind.label}") + appendLine("Timestamp: ${Instant.ofEpochMilli(previousExit.timestampMs)}") + appendLine("Status/signal: ${previousExit.status}") + appendLine("Recorded phase: ${previousExit.phase ?: "unknown"}") + appendLine("Last sampled PSS: ${formatMemory(previousExit.pssKb)}") + appendLine("Last sampled RSS: ${formatMemory(previousExit.rssKb)}") + append("System description: ${previousExit.description ?: "unavailable"}") + } +} + +private fun kbToMb(kb: Long): Long = (kb + 512L) / 1024L + +private fun formatMemory(kb: Long): String = + if (kb > 0) "${kbToMb(kb)} MB" else "unavailable" + +/** + * Records the current coarse pipeline phase and consumes Android's most recent + * process-exit record on the next launch. No utterance, contact, or media data + * is stored in the 128-byte process summary. + */ +internal class ProcessExitDiagnostics(context: Context) { + private val appContext = context.applicationContext + private val activityManager = appContext.getSystemService(ActivityManager::class.java) + private val preferences = appContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) + + @Volatile private var lastPhase: String? = null + + fun markPhase(phase: String) { + if (Build.VERSION.SDK_INT < 30) return + val safePhase = phase + .lowercase() + .replace(Regex("[^a-z0-9_-]+"), "_") + .trim('_') + .take(64) + .ifEmpty { "unknown" } + if (lastPhase == safePhase) return + lastPhase = safePhase + runCatching { + activityManager.setProcessStateSummary("phase=$safePhase".toByteArray(Charsets.UTF_8)) + }.onFailure { Log.w(TAG, "Unable to record process phase", it) } + } + + fun consumePreviousExit(): PreviousProcessExit? { + if (Build.VERSION.SDK_INT < 30) return null + val info = runCatching { + activityManager.getHistoricalProcessExitReasons(null, 0, 1).firstOrNull() + }.onFailure { Log.w(TAG, "Unable to read previous process exit", it) } + .getOrNull() ?: return null + + val lastSeen = preferences.getLong(KEY_LAST_SEEN_EXIT, 0L) + if (info.timestamp <= lastSeen) return null + preferences.edit().putLong(KEY_LAST_SEEN_EXIT, info.timestamp).apply() + + val kind = unexpectedKind(info.reason) ?: return null + return PreviousProcessExit( + kind = kind, + status = info.status, + timestampMs = info.timestamp, + phase = info.processStateSummary + ?.toString(Charsets.UTF_8) + ?.substringAfter("phase=", missingDelimiterValue = "") + ?.takeIf { it.isNotBlank() }, + pssKb = info.pss, + rssKb = info.rss, + description = info.description + ?.replace(Regex("\\s+"), " ") + ?.trim() + ?.take(240) + ?.takeIf { it.isNotEmpty() }, + ) + } + + private fun unexpectedKind(reason: Int): ProcessExitKind? = when (reason) { + ApplicationExitInfo.REASON_CRASH -> ProcessExitKind.JAVA_CRASH + ApplicationExitInfo.REASON_CRASH_NATIVE -> ProcessExitKind.NATIVE_CRASH + ApplicationExitInfo.REASON_ANR -> ProcessExitKind.ANR + ApplicationExitInfo.REASON_LOW_MEMORY -> ProcessExitKind.LOW_MEMORY + ApplicationExitInfo.REASON_SIGNALED -> ProcessExitKind.SIGNAL + ApplicationExitInfo.REASON_EXCESSIVE_RESOURCE_USAGE -> ProcessExitKind.EXCESSIVE_RESOURCES + ApplicationExitInfo.REASON_INITIALIZATION_FAILURE -> + ProcessExitKind.INITIALIZATION_FAILURE + ApplicationExitInfo.REASON_DEPENDENCY_DIED -> ProcessExitKind.DEPENDENCY_DIED + else -> null + } + + private companion object { + const val TAG = "SpeechControl" + const val PREFERENCES = "process_exit_diagnostics" + const val KEY_LAST_SEEN_EXIT = "last_seen_exit_timestamp" + } +} diff --git a/control-demo/src/main/kotlin/audio/soniqo/speech/control/ui/ControlScreen.kt b/control-demo/src/main/kotlin/audio/soniqo/speech/control/ui/ControlScreen.kt index 3fb3632..dcb32f4 100644 --- a/control-demo/src/main/kotlin/audio/soniqo/speech/control/ui/ControlScreen.kt +++ b/control-demo/src/main/kotlin/audio/soniqo/speech/control/ui/ControlScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.AlertDialog import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.OutlinedTextField @@ -68,6 +69,7 @@ data class ControlActions( val onDismissType: () -> Unit, val onOpenInfo: () -> Unit, val onDismissInfo: () -> Unit, + val onShareDiagnostics: () -> Unit, ) @Composable @@ -102,7 +104,11 @@ fun ControlScreen( TypeCommandDialog(actions.onSubmitTyped, actions.onDismissType) } if (state.showInfoDialog) { - InfoDialog(actions.onDismissInfo) + InfoDialog( + diagnosticsReport = state.diagnosticsReport, + onShareDiagnostics = actions.onShareDiagnostics, + onDismiss = actions.onDismissInfo, + ) } } @@ -370,7 +376,11 @@ private fun OrbDock(state: ControlUiState, actions: ControlActions, reduceMotion } @Composable -private fun InfoDialog(onDismiss: () -> Unit) { +private fun InfoDialog( + diagnosticsReport: String?, + onShareDiagnostics: () -> Unit, + onDismiss: () -> Unit, +) { AlertDialog( onDismissRequest = onDismiss, containerColor = Card, @@ -398,11 +408,29 @@ private fun InfoDialog(onDismiss: () -> Unit) { audio.soniqo.speech.control.ControlTools.declarations.forEach { tool -> CapabilityRow(tool) } + + diagnosticsReport?.let { report -> + Spacer(Modifier.height(10.dp)) + Text("Diagnostics", color = Foreground, fontFamily = Grotesk, + fontWeight = FontWeight.Bold, fontSize = 14.sp) + Spacer(Modifier.height(6.dp)) + SelectionContainer { + Text(report, color = FaintFg, fontFamily = Plex, + fontSize = 10.5.sp, lineHeight = 15.sp) + } + } } }, confirmButton = { TextButton(onClick = onDismiss) { Text("Close", color = Primary, fontFamily = Grotesk) } }, + dismissButton = { + if (diagnosticsReport != null) { + TextButton(onClick = onShareDiagnostics) { + Text("Share diagnostics", color = MutedFg, fontFamily = Grotesk) + } + } + }, ) } diff --git a/control-demo/src/test/kotlin/audio/soniqo/speech/control/ProcessExitDiagnosticsTest.kt b/control-demo/src/test/kotlin/audio/soniqo/speech/control/ProcessExitDiagnosticsTest.kt new file mode 100644 index 0000000..aa76190 --- /dev/null +++ b/control-demo/src/test/kotlin/audio/soniqo/speech/control/ProcessExitDiagnosticsTest.kt @@ -0,0 +1,44 @@ +package audio.soniqo.speech.control + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ProcessExitDiagnosticsTest { + + @Test + fun `native crash summary includes signal phase and sampled memory`() { + val exit = PreviousProcessExit( + kind = ProcessExitKind.NATIVE_CRASH, + status = 11, + timestampMs = 1L, + phase = "thinking", + pssKb = 903L * 1024L, + rssKb = 0L, + description = null, + ) + + assertEquals( + "Previous run ended unexpectedly: native crash (signal 11) during thinking" + + " · last PSS 903 MB", + exit.summaryLine(), + ) + } + + @Test + fun `java crash summary omits meaningless exit status and unavailable fields`() { + val exit = PreviousProcessExit( + kind = ProcessExitKind.JAVA_CRASH, + status = 1, + timestampMs = 1L, + phase = null, + pssKb = 0L, + rssKb = 0L, + description = "java.lang.IllegalStateException", + ) + + assertEquals( + "Previous run ended unexpectedly: Java/Kotlin crash", + exit.summaryLine(), + ) + } +} From 6709f6aa44568b655a29b95da9682ed30076488f Mon Sep 17 00:00:00 2001 From: ivan-digital <42473865+ivan-digital@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:09:32 +0200 Subject: [PATCH 2/2] fix(control-demo): harden release crash handling --- .github/workflows/ci.yml | 40 ++++++++++++++++++- .../speech/control/ControlAgentActivity.kt | 22 +++++----- 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70f7f20..bbef40b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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() diff --git a/control-demo/src/main/kotlin/audio/soniqo/speech/control/ControlAgentActivity.kt b/control-demo/src/main/kotlin/audio/soniqo/speech/control/ControlAgentActivity.kt index 7519d04..888e3c3 100644 --- a/control-demo/src/main/kotlin/audio/soniqo/speech/control/ControlAgentActivity.kt +++ b/control-demo/src/main/kotlin/audio/soniqo/speech/control/ControlAgentActivity.kt @@ -495,7 +495,7 @@ class ControlAgentActivity : ComponentActivity() { runReplay(name) } } catch (e: Throwable) { - e.rethrowIfProcessFatal() + e.rethrowIfUnrecoverable() Log.e(TAG, "init failed", e) store.addNote("init error: ${e.message}") store.setStatus("error") @@ -549,7 +549,7 @@ class ControlAgentActivity : ComponentActivity() { } catch (e: CancellationException) { throw e } catch (e: Throwable) { - e.rethrowIfProcessFatal() + e.rethrowIfUnrecoverable() Log.e(TAG, "agent turn failed", e) store.updateTurn(turnId) { it.copy(toolLabel = "turn error: ${e.message}", failed = true) @@ -603,7 +603,7 @@ class ControlAgentActivity : ComponentActivity() { } catch (e: CancellationException) { throw e } catch (e: Throwable) { - e.rethrowIfProcessFatal() + e.rethrowIfUnrecoverable() Log.e(TAG, "LLM generation failed", e) store.updateTurn(turnId) { it.copy(toolLabel = "llm error: ${e.message}", failed = true) } returnToRest(); return @@ -623,7 +623,7 @@ class ControlAgentActivity : ComponentActivity() { val outcome = selectedCall?.let { call -> try { ControlTools.execute(call, device) } catch (e: Throwable) { - e.rethrowIfProcessFatal() + e.rethrowIfUnrecoverable() Log.e(TAG, "tool execution failed", e) null } @@ -759,7 +759,7 @@ class ControlAgentActivity : ComponentActivity() { } catch (e: CancellationException) { throw e } catch (e: Throwable) { - e.rethrowIfProcessFatal() + e.rethrowIfUnrecoverable() Log.e(TAG, "TTS failed", e) store.addNote("tts error: ${e.message}") micPaused = false @@ -794,7 +794,7 @@ class ControlAgentActivity : ComponentActivity() { } catch (e: CancellationException) { throw e } catch (e: Throwable) { - e.rethrowIfProcessFatal() + e.rethrowIfUnrecoverable() Log.e(TAG, "deferred tool execution failed", e) store.updateTurn(turnId) { it.copy(failed = true) } store.addNote("action error: ${e.message}") @@ -1254,8 +1254,12 @@ class ControlAgentActivity : ComponentActivity() { super.onDestroy() } - /** Linkage/assertion errors are reportable; VM failures must retain normal fatal semantics. */ - private fun Throwable.rethrowIfProcessFatal() { - if (this is VirtualMachineError || this is ThreadDeath) throw this + /** 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 + } } }