From 9f39f7f713d386574b8c88de441cca658d9ab35c Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 10 May 2026 09:58:10 +0200 Subject: [PATCH 1/3] Bump androidx.work to 2.11.2 Picks up bug fixes since 2.9.1. The SystemForegroundService manifest override stays in the SDK manifest because 2.11 still doesn't declare foregroundServiceType in its bundled manifest. --- app/build.gradle.kts | 2 +- sdk/build.gradle.kts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e12cf50..7169337 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -56,6 +56,6 @@ dependencies { implementation("androidx.appcompat:appcompat:1.7.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.9.1") + implementation("androidx.work:work-runtime-ktx:2.11.2") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") } diff --git a/sdk/build.gradle.kts b/sdk/build.gradle.kts index 362f4eb..a86a9da 100644 --- a/sdk/build.gradle.kts +++ b/sdk/build.gradle.kts @@ -105,7 +105,7 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("androidx.annotation:annotation:1.8.2") - implementation("androidx.work:work-runtime-ktx:2.9.1") + implementation("androidx.work:work-runtime-ktx:2.11.2") implementation("androidx.core:core-ktx:1.13.1") testImplementation("junit:junit:4.13.2") @@ -115,7 +115,7 @@ dependencies { testImplementation("androidx.test:core:1.6.1") testImplementation("androidx.test.ext:junit:1.2.1") testImplementation("io.mockk:mockk:1.13.13") - testImplementation("androidx.work:work-testing:2.9.1") + testImplementation("androidx.work:work-testing:2.11.2") androidTestImplementation("androidx.test.ext:junit:1.2.1") androidTestImplementation("androidx.test:runner:1.6.2") From 990f21539fba5d98001c3e4615152dce9559a5b8 Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 10 May 2026 09:59:31 +0200 Subject: [PATCH 2/3] Preserve .tmp files in ModelManager so downloads resume across worker retries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two existing deletions were destroying the partial-download state we want to keep: 1. ensureModels() opens by walking the models dir and deleting every .tmp. The intent was to clean up after process crashes, but it also nukes the in-progress .tmp from a previous ModelDownloadWorker invocation that returned Result.retry() — meaning every WorkManager retry restarted that file from byte 0. Range resume can't help if there's nothing on disk to resume from. Replace with a comment explaining why we keep them; stale .tmp from an old MODEL_VERSION is still wiped by the version-mismatch path above. 2. downloadFile() deleted the .tmp file when its 5-attempt retry loop was exhausted before throwing. Same problem: a transient network failure that outlasts those 5 in-loop retries should still leave resumable bytes for the worker's next try. Drop the deletion. Net effect: a 1.2 GB download that hits a flaky network now keeps every byte that made it to disk — observed during emulator verification of PR #22 where a worker retry dropped ~2.5 MB of partial parakeet-encoder bytes for no good reason. Test: invert 'cleans up tmp file after all retries fail' to assert the .tmp persists after exhaustion, using DISCONNECT_DURING_RESPONSE_BODY to get a realistic mid-stream failure path. --- .../kotlin/com/soniqo/speech/ModelManager.kt | 13 +++++++---- .../soniqo/speech/ModelManagerDownloadTest.kt | 23 ++++++++++++++----- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/sdk/src/main/kotlin/com/soniqo/speech/ModelManager.kt b/sdk/src/main/kotlin/com/soniqo/speech/ModelManager.kt index 53ab5f5..4c1f66a 100644 --- a/sdk/src/main/kotlin/com/soniqo/speech/ModelManager.kt +++ b/sdk/src/main/kotlin/com/soniqo/speech/ModelManager.kt @@ -120,8 +120,10 @@ object ModelManager { dir.resolve("voices").listFiles()?.forEach { it.delete() } } - // Clean up leftover partial downloads from previous crashes - dir.walk().filter { it.extension == "tmp" }.forEach { it.delete() } + // Note: leftover .tmp files are intentionally preserved here. If a + // previous run was interrupted, downloadFile resumes via Range: + // bytes=N- on the next attempt. Stale .tmp from an old MODEL_VERSION + // is already wiped above. val fileList = models(precision) // FP32 encoder needs the external data file @@ -235,8 +237,11 @@ object ModelManager { } } - // All retries exhausted — clean up partial file and throw - tmp.delete() + // All retries exhausted — preserve the partial .tmp so the next + // ensureModels() call can pick up where this one left off via the + // Range: header. Particularly important when called from + // ModelDownloadWorker, where Result.retry() spins up a fresh + // ensureModels() invocation after WorkManager's backoff window. throw IOException("Download failed after $MAX_RETRIES attempts: ${lastException?.message}", lastException) } diff --git a/sdk/src/test/kotlin/audio/soniqo/speech/ModelManagerDownloadTest.kt b/sdk/src/test/kotlin/audio/soniqo/speech/ModelManagerDownloadTest.kt index 0bd6b69..af18ec0 100644 --- a/sdk/src/test/kotlin/audio/soniqo/speech/ModelManagerDownloadTest.kt +++ b/sdk/src/test/kotlin/audio/soniqo/speech/ModelManagerDownloadTest.kt @@ -99,7 +99,7 @@ class ModelManagerDownloadTest { } } - tmp.delete() + // Preserve tmp for resume on next attempt — mirrors production. throw IOException("Failed after $maxRetries attempts: ${lastException?.message}", lastException) } @@ -155,17 +155,28 @@ class ModelManagerDownloadTest { } @Test - fun `cleans up tmp file after all retries fail`() { - server.enqueue(MockResponse().setResponseCode(500)) - server.enqueue(MockResponse().setResponseCode(500)) + fun `preserves tmp file after all retries fail so next attempt can resume`() { + // Simulate every retry attempt failing mid-stream: server promises 16 + // bytes via Content-Length but disconnects during the body. OkHttp + // throws, triggering retry. After all retries exhaust, we expect the + // .tmp file to persist (with whatever partial bytes made it to disk) + // so the worker can resume via Range: bytes=N- on a future invocation. + repeat(2) { + server.enqueue( + MockResponse() + .setBody("ABCDEFGHIJKLMNOP") + .setSocketPolicy(okhttp3.mockwebserver.SocketPolicy.DISCONNECT_DURING_RESPONSE_BODY) + ) + } val dest = File(tmpDir.root, "model.onnx") try { downloadFile(server.url("/model.onnx").toString(), dest, maxRetries = 2) } catch (_: IOException) {} - assertFalse(dest.exists()) - assertFalse(File(tmpDir.root, "model.onnx.tmp").exists()) + assertFalse("final file should not exist on failure", dest.exists()) + val tmp = File(tmpDir.root, "model.onnx.tmp") + assertTrue("partial .tmp should be preserved for resume", tmp.exists()) } @Test From 680fdf38c2b60fceb1ade8415e1698b1f2eb2ea6 Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 10 May 2026 10:00:03 +0200 Subject: [PATCH 3/3] Route SpeechRecognitionService model download through ModelDownloadWorker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Gboard binds to our service for the first time on a fresh install, resolveModelDir() used to call ensureModels() inline — blocking the recognition request on a 1.2 GB download tied to the bind's lifecycle. If Gboard times out (which it will, well before the download finishes), the coroutine cancels and the bytes that made it to disk are at the mercy of the user re-tapping the mic. Now: 1. Fast path — ModelManager.areModelsReady(ctx, INT8) checks file validity without touching the network. If true, return modelDir immediately and the service starts in milliseconds. 2. Slow path — enqueue ModelDownloadWorker (idempotent via ExistingWorkPolicy.KEEP) and await its terminal state via getWorkInfoByIdFlow().filterNotNull().first { it.state.isFinished }. The worker runs as a foreground service so the download keeps progressing even when Gboard's bind times out and the user puts the phone in their pocket. The next mic tap takes the fast path. ModelManager: add public areModelsReady() + modelDir(). The validity check duplicates the per-file validation that ensureModels() already does, but exposed without the side effect of starting a download. The protected resolveModelDir() seam stays in place; tests still override it to bypass the worker entirely. --- .../kotlin/com/soniqo/speech/ModelManager.kt | 11 +++-- .../service/SpeechRecognitionService.kt | 42 +++++++++++++++++-- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/sdk/src/main/kotlin/com/soniqo/speech/ModelManager.kt b/sdk/src/main/kotlin/com/soniqo/speech/ModelManager.kt index 4c1f66a..773173a 100644 --- a/sdk/src/main/kotlin/com/soniqo/speech/ModelManager.kt +++ b/sdk/src/main/kotlin/com/soniqo/speech/ModelManager.kt @@ -75,9 +75,10 @@ object ModelManager { * and passes [isValidModel] (right ONNX magic, above the per-file size * floor) and the cached version matches [MODEL_VERSION]. * - * Cheap and side-effect free — does not start a download. Used by paths - * that must answer "are we ready?" without blocking, e.g. - * `SpeechRecognitionService.onCheckRecognitionSupport()`. + * Cheap and side-effect free — does not start a download. Use this from + * `SpeechRecognitionService.onCheckRecognitionSupport()` (or any path + * that must not block) to decide whether to invoke [ensureModels] / + * `ModelDownloadWorker` first. */ fun areModelsReady( context: Context, @@ -102,6 +103,10 @@ object ModelManager { } } + /** Path to the model directory for [precision], without downloading. */ + fun modelDir(context: Context): String = + File(context.filesDir, "models").absolutePath + /** Returns the model directory path, downloading models if needed. */ suspend fun ensureModels( context: Context, diff --git a/sdk/src/main/kotlin/com/soniqo/speech/service/SpeechRecognitionService.kt b/sdk/src/main/kotlin/com/soniqo/speech/service/SpeechRecognitionService.kt index ec08969..116cdd6 100644 --- a/sdk/src/main/kotlin/com/soniqo/speech/service/SpeechRecognitionService.kt +++ b/sdk/src/main/kotlin/com/soniqo/speech/service/SpeechRecognitionService.kt @@ -17,6 +17,9 @@ import android.speech.RecognizerIntent import android.speech.SpeechRecognizer import android.util.Log import androidx.annotation.RequiresApi +import androidx.work.WorkInfo +import androidx.work.WorkManager +import audio.soniqo.speech.ModelDownloadWorker import audio.soniqo.speech.ModelManager import audio.soniqo.speech.ModelPrecision import audio.soniqo.speech.SpeechConfig @@ -29,8 +32,11 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import java.io.IOException import java.util.concurrent.atomic.AtomicBoolean /** @@ -256,9 +262,39 @@ open class SpeechRecognitionService : RecognitionService() { protected open fun createPipeline(config: SpeechConfig): SpeechPipeline = SpeechPipeline(config) - /** Resolve the model directory. Overridden in tests to skip the download. */ - protected open suspend fun resolveModelDir(): String = - ModelManager.ensureModels(this, ModelPrecision.INT8) + /** + * Resolve the model directory. If models aren't on disk yet we delegate + * to [ModelDownloadWorker] (which runs as a foreground service so the + * download survives the bind from Gboard timing out) and suspend until + * it reports a terminal state. Suspension is bound to this session's + * coroutine — if the framework cancels the request, the worker keeps + * running on its own and serves the *next* invocation immediately. + * + * Overridden in tests to skip the download. + */ + protected open suspend fun resolveModelDir(): String { + val ctx = applicationContext + if (ModelManager.areModelsReady(ctx, ModelPrecision.INT8)) { + return ModelManager.modelDir(ctx) + } + Log.i(TAG, "models not ready — delegating to ModelDownloadWorker") + val workId = ModelDownloadWorker.enqueue(ctx, ModelPrecision.INT8) + val info = WorkManager.getInstance(ctx) + .getWorkInfoByIdFlow(workId) + .filterNotNull() + .first { it.state.isFinished } + return when (info.state) { + WorkInfo.State.SUCCEEDED -> info.outputData + .getString(ModelDownloadWorker.KEY_MODEL_DIR) + ?: throw IllegalStateException("worker succeeded but no model dir") + WorkInfo.State.FAILED -> throw IOException( + info.outputData.getString(ModelDownloadWorker.KEY_ERROR) + ?: "model download failed", + ) + WorkInfo.State.CANCELLED -> throw IllegalStateException("model download cancelled") + else -> throw IllegalStateException("unexpected worker state: ${info.state}") + } + } /** * Open the microphone. Returns null when the format is unsupported on this