From 3eb4befaa0b8687a1959c50e8d00b4331dd6c8df Mon Sep 17 00:00:00 2001 From: Ivan Date: Sat, 9 May 2026 16:22:20 +0200 Subject: [PATCH 1/3] Move model download to a foreground WorkManager worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ModelManager.ensureModels was called from lifecycleScope in the demo activities, so the 1.2 GB initial download was bound to the foreground Activity. Backgrounding the app cancelled the coroutine mid-stream; partial files were retained (the existing Range: resume logic in ModelManager handles that), but progress visibly stalled and any fresh utterance had to start from the byte where the user left off. Add ModelDownloadWorker — a CoroutineWorker that wraps ensureModels, calls setForeground() with a progress notification (FOREGROUND_SERVICE_ TYPE_DATA_SYNC on API 34+), reports per-file progress via setProgress, and returns the resolved model directory in outputData. MainActivity / DictationActivity now enqueue the worker via WorkManager.enqueueUniqueWork(KEEP) and observe state through getWorkInfoByIdLiveData. Existing Activity-driven UI updates (status text, progress bar) wire up to the worker's progress instead. Also pulls in androidx.work:work-runtime-ktx and androidx.core:core-ktx in :sdk, the same work-runtime in :app, and POST_NOTIFICATIONS in the demo manifest. Verified locally: ./gradlew :sdk:testDebugUnitTest — 20/20 pass (no behavioral change to ModelManager itself). --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 2 + .../soniqo/speech/demo/DictationActivity.kt | 48 ++++- .../com/soniqo/speech/demo/MainActivity.kt | 62 ++++-- sdk/build.gradle.kts | 2 + sdk/src/main/AndroidManifest.xml | 4 + .../com/soniqo/speech/ModelDownloadWorker.kt | 176 ++++++++++++++++++ 7 files changed, 270 insertions(+), 25 deletions(-) create mode 100644 sdk/src/main/kotlin/com/soniqo/speech/ModelDownloadWorker.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6c333e7..e12cf50 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -56,5 +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("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f518a1b..5126f4c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + - runOnUiThread { - statusView.text = "${progress.file} ${progress.completed}/${progress.totalFiles}" + // Models download in a foreground worker so the transfer survives + // backgrounding the app. Activity just observes progress. + val workId = ModelDownloadWorker.enqueue(applicationContext, ModelPrecision.INT8) + WorkManager.getInstance(applicationContext) + .getWorkInfoByIdLiveData(workId) + .observe(this) { info -> + if (info == null) return@observe + when (info.state) { + WorkInfo.State.ENQUEUED, + WorkInfo.State.BLOCKED, + WorkInfo.State.RUNNING -> { + val total = info.progress.getInt(ModelDownloadWorker.KEY_TOTAL, 0) + if (total > 0) { + val file = info.progress.getString(ModelDownloadWorker.KEY_FILE) ?: "" + val done = info.progress.getInt(ModelDownloadWorker.KEY_COMPLETED, 0) + statusView.text = "$file $done/$total" + } + } + WorkInfo.State.SUCCEEDED -> { + val modelDir = info.outputData.getString(ModelDownloadWorker.KEY_MODEL_DIR) + if (modelDir == null) { + statusView.text = "worker succeeded but no model dir" + return@observe + } + initPipeline(modelDir) } + WorkInfo.State.FAILED -> { + val err = info.outputData.getString(ModelDownloadWorker.KEY_ERROR) + ?: "unknown" + statusView.text = "download failed: $err" + } + WorkInfo.State.CANCELLED -> { statusView.text = "cancelled" } } + } + } + private fun initPipeline(modelDir: String) { + lifecycleScope.launch { + try { val config = SpeechConfig( modelDir = modelDir, useNnapi = false, diff --git a/app/src/main/kotlin/com/soniqo/speech/demo/MainActivity.kt b/app/src/main/kotlin/com/soniqo/speech/demo/MainActivity.kt index 41d654e..6dab0ce 100644 --- a/app/src/main/kotlin/com/soniqo/speech/demo/MainActivity.kt +++ b/app/src/main/kotlin/com/soniqo/speech/demo/MainActivity.kt @@ -25,7 +25,9 @@ import androidx.core.app.ActivityCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.lifecycle.lifecycleScope -import audio.soniqo.speech.ModelManager +import androidx.work.WorkInfo +import androidx.work.WorkManager +import audio.soniqo.speech.ModelDownloadWorker import audio.soniqo.speech.ModelPrecision import audio.soniqo.speech.SpeechConfig import audio.soniqo.speech.SpeechEvent @@ -218,24 +220,52 @@ class MainActivity : ComponentActivity() { private fun loadPipeline() { setStatus("initializing...") - lifecycleScope.launch { - try { - val modelDir = ModelManager.ensureModels( - this@MainActivity, - precision = ModelPrecision.INT8, - ) { progress -> - val mb = progress.bytesDownloaded / 1_000_000 - setStatus("${progress.file} ${progress.completed}/${progress.totalFiles} (${mb} MB)") - runOnUiThread { - downloadProgress.progress = - (progress.completed * 100 / progress.totalFiles).coerceIn(0, 100) + // Models download in a foreground worker so the transfer survives + // backgrounding the app. Activity just observes progress. + val workId = ModelDownloadWorker.enqueue(applicationContext, ModelPrecision.INT8) + WorkManager.getInstance(applicationContext) + .getWorkInfoByIdLiveData(workId) + .observe(this) { info -> + if (info == null) return@observe + when (info.state) { + WorkInfo.State.ENQUEUED, + WorkInfo.State.BLOCKED, + WorkInfo.State.RUNNING -> { + val total = info.progress.getInt(ModelDownloadWorker.KEY_TOTAL, 0) + if (total > 0) { + val file = info.progress.getString(ModelDownloadWorker.KEY_FILE) ?: "" + val done = info.progress.getInt(ModelDownloadWorker.KEY_COMPLETED, 0) + val pct = info.progress.getInt(ModelDownloadWorker.KEY_PERCENT, 0) + setStatus("$file $done/$total") + downloadProgress.progress = pct + } } + WorkInfo.State.SUCCEEDED -> { + val modelDir = info.outputData.getString(ModelDownloadWorker.KEY_MODEL_DIR) + if (modelDir == null) { + addSystemLine("worker succeeded but no model dir") + setStatus("error") + return@observe + } + downloadProgress.progress = 100 + downloadProgress.visibility = View.GONE + initPipeline(modelDir) + } + WorkInfo.State.FAILED -> { + val err = info.outputData.getString(ModelDownloadWorker.KEY_ERROR) + ?: "unknown" + addSystemLine("download failed: $err") + setStatus("error — tap to retry") + statusView.setOnClickListener { retryInit() } + } + WorkInfo.State.CANCELLED -> setStatus("cancelled") } - runOnUiThread { - downloadProgress.progress = 100 - downloadProgress.visibility = View.GONE - } + } + } + private fun initPipeline(modelDir: String) { + lifecycleScope.launch { + try { val config = SpeechConfig( modelDir = modelDir, useNnapi = !isEmulator, diff --git a/sdk/build.gradle.kts b/sdk/build.gradle.kts index a7d81a6..36746dc 100644 --- a/sdk/build.gradle.kts +++ b/sdk/build.gradle.kts @@ -105,6 +105,8 @@ 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.core:core-ktx:1.13.1") testImplementation("junit:junit:4.13.2") testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") diff --git a/sdk/src/main/AndroidManifest.xml b/sdk/src/main/AndroidManifest.xml index fbb2ef8..72d5917 100644 --- a/sdk/src/main/AndroidManifest.xml +++ b/sdk/src/main/AndroidManifest.xml @@ -1,3 +1,7 @@ + + + diff --git a/sdk/src/main/kotlin/com/soniqo/speech/ModelDownloadWorker.kt b/sdk/src/main/kotlin/com/soniqo/speech/ModelDownloadWorker.kt new file mode 100644 index 0000000..bdb53c1 --- /dev/null +++ b/sdk/src/main/kotlin/com/soniqo/speech/ModelDownloadWorker.kt @@ -0,0 +1,176 @@ +package audio.soniqo.speech + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.ForegroundInfo +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import java.io.IOException + +/** + * Downloads the speech models in a foreground worker so the transfer survives + * app backgrounding and process death. Wraps [ModelManager.ensureModels] — + * resumes partial downloads via the same on-disk `.tmp` files, retries on + * `IOException`, and reports progress via [setProgress]. + * + * ### Usage + * + * ``` + * WorkManager.getInstance(context).enqueueUniqueWork( + * ModelDownloadWorker.UNIQUE_NAME, + * ExistingWorkPolicy.KEEP, + * ModelDownloadWorker.request(ModelPrecision.INT8), + * ) + * + * WorkManager.getInstance(context) + * .getWorkInfosForUniqueWorkLiveData(ModelDownloadWorker.UNIQUE_NAME) + * .observe(this) { infos -> + * val info = infos.firstOrNull() ?: return@observe + * when (info.state) { + * WorkInfo.State.RUNNING -> { + * val pct = info.progress.getInt(ModelDownloadWorker.KEY_PERCENT, 0) + * ... + * } + * WorkInfo.State.SUCCEEDED -> { + * val dir = info.outputData.getString(ModelDownloadWorker.KEY_MODEL_DIR) + * ... + * } + * else -> Unit + * } + * } + * ``` + * + * Requires the host app to declare `POST_NOTIFICATIONS` (API 33+) for the + * progress notification to appear; the worker still runs without it. + */ +class ModelDownloadWorker( + context: Context, + params: WorkerParameters, +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + val precision = inputData.getString(KEY_PRECISION) + ?.let { runCatching { ModelPrecision.valueOf(it) }.getOrNull() } + ?: ModelPrecision.INT8 + + runCatching { setForeground(buildForegroundInfo(0, 0, "Preparing speech models…")) } + + return try { + val modelDir = ModelManager.ensureModels(applicationContext, precision) { p -> + val pct = if (p.totalFiles > 0) { + (p.completed * 100 / p.totalFiles).coerceIn(0, 100) + } else 0 + setProgressAsync(workDataOf( + KEY_FILE to p.file, + KEY_COMPLETED to p.completed, + KEY_TOTAL to p.totalFiles, + KEY_BYTES_DOWNLOADED to p.bytesDownloaded, + KEY_PERCENT to pct, + )) + runCatching { + setForegroundAsync(buildForegroundInfo( + completed = p.completed, + total = p.totalFiles, + text = "${p.file} ${p.completed}/${p.totalFiles}", + )) + } + } + Result.success(workDataOf(KEY_MODEL_DIR to modelDir)) + } catch (e: IOException) { + // Network / disk hiccup — let WorkManager retry with backoff. + Result.retry() + } catch (t: Throwable) { + Result.failure(workDataOf(KEY_ERROR to (t.message ?: t::class.java.simpleName))) + } + } + + private fun buildForegroundInfo(completed: Int, total: Int, text: String): ForegroundInfo { + ensureChannel() + val indeterminate = total <= 0 + val notif = NotificationCompat.Builder(applicationContext, CHANNEL_ID) + .setContentTitle("Speech models") + .setContentText(text) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setProgress(if (indeterminate) 100 else total, completed, indeterminate) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + ForegroundInfo(NOTIFICATION_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + ForegroundInfo(NOTIFICATION_ID, notif) + } + } + + private fun ensureChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val nm = applicationContext.getSystemService(NotificationManager::class.java) ?: return + if (nm.getNotificationChannel(CHANNEL_ID) != null) return + nm.createNotificationChannel(NotificationChannel( + CHANNEL_ID, + "Speech model downloads", + NotificationManager.IMPORTANCE_LOW, + ).apply { description = "Progress for downloading on-device speech models" }) + } + + companion object { + /** Pass to [WorkManager.enqueueUniqueWork] to dedupe concurrent downloads. */ + const val UNIQUE_NAME = "audio.soniqo.speech.modelDownload" + + // Input keys + const val KEY_PRECISION = "precision" + + // Output keys + const val KEY_MODEL_DIR = "modelDir" + const val KEY_ERROR = "error" + + // Progress keys + const val KEY_FILE = "file" + const val KEY_COMPLETED = "completed" + const val KEY_TOTAL = "totalFiles" + const val KEY_BYTES_DOWNLOADED = "bytesDownloaded" + const val KEY_PERCENT = "percent" + + private const val CHANNEL_ID = "audio.soniqo.speech.models" + // Stable, unlikely-to-collide id (decimal of 0xC0FFEE). + private const val NOTIFICATION_ID = 12648430 + + /** Build a one-shot download request. Requires a network connection. */ + fun request(precision: ModelPrecision = ModelPrecision.INT8) = + OneTimeWorkRequestBuilder() + .setInputData(workDataOf(KEY_PRECISION to precision.name)) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .build() + + /** + * Convenience: enqueue under the standard unique name with + * [ExistingWorkPolicy.KEEP] (a running download is reused; otherwise a + * new one starts). Returns the request id so callers can observe it. + */ + fun enqueue( + context: Context, + precision: ModelPrecision = ModelPrecision.INT8, + ): java.util.UUID { + val req = request(precision) + WorkManager.getInstance(context).enqueueUniqueWork( + UNIQUE_NAME, ExistingWorkPolicy.KEEP, req, + ) + return req.id + } + } +} From 5e0ee5dc44ea5f5bcab514a0a48a8d14d6f92ea1 Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 10 May 2026 09:33:28 +0200 Subject: [PATCH 2/3] Fix ModelDownloadWorker runtime: manifest type + drop network constraint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues surfaced during on-emulator verification of the worker: 1. WorkManager 2.9.x's bundled manifest declares SystemForegroundService without a foregroundServiceType. On API 34+, startForeground() with FOREGROUND_SERVICE_TYPE_DATA_SYNC then fails with IllegalArgumentException 'foregroundServiceType 0x00000001 is not a subset of foregroundServiceType attribute 0x00000000 in service element of manifest file'. Override the service entry in the SDK manifest with android:foregroundServiceType="dataSync" + tools:replace so the worker is compatible everywhere without forcing consumers to bump WorkManager. 2. NetworkType.CONNECTED maps to a JobInfo network request that requires the VALIDATED capability — which JobScheduler may hold unsatisfied for long periods on flaky networks (captive portal probe failure, transient DNS), even when the device has working internet. Drop the constraint entirely; OkHttp surfaces network failures as IOException which the worker already translates into Result.retry(). --- sdk/src/main/AndroidManifest.xml | 17 ++++++++++++++++- .../com/soniqo/speech/ModelDownloadWorker.kt | 17 +++++++++-------- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/sdk/src/main/AndroidManifest.xml b/sdk/src/main/AndroidManifest.xml index 72d5917..cbeb09d 100644 --- a/sdk/src/main/AndroidManifest.xml +++ b/sdk/src/main/AndroidManifest.xml @@ -1,7 +1,22 @@ - + + + + + + + diff --git a/sdk/src/main/kotlin/com/soniqo/speech/ModelDownloadWorker.kt b/sdk/src/main/kotlin/com/soniqo/speech/ModelDownloadWorker.kt index bdb53c1..ffc903b 100644 --- a/sdk/src/main/kotlin/com/soniqo/speech/ModelDownloadWorker.kt +++ b/sdk/src/main/kotlin/com/soniqo/speech/ModelDownloadWorker.kt @@ -6,11 +6,9 @@ import android.content.Context import android.content.pm.ServiceInfo import android.os.Build import androidx.core.app.NotificationCompat -import androidx.work.Constraints import androidx.work.CoroutineWorker import androidx.work.ExistingWorkPolicy import androidx.work.ForegroundInfo -import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters @@ -146,15 +144,18 @@ class ModelDownloadWorker( // Stable, unlikely-to-collide id (decimal of 0xC0FFEE). private const val NOTIFICATION_ID = 12648430 - /** Build a one-shot download request. Requires a network connection. */ + /** + * Build a one-shot download request. No JobScheduler network + * constraint — the underlying OkHttp client surfaces network failures + * as `IOException`, which the worker translates into `Result.retry()`. + * Avoids JobScheduler's `CONSTRAINT_CONNECTIVITY` waiting on a + * `VALIDATED` capability, which can sit unsatisfied for a long time + * on flaky or captive networks even when the device has working + * internet. + */ fun request(precision: ModelPrecision = ModelPrecision.INT8) = OneTimeWorkRequestBuilder() .setInputData(workDataOf(KEY_PRECISION to precision.name)) - .setConstraints( - Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build() - ) .build() /** From 9b44fd49dc5a52043bab313355d48da94227a283 Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 10 May 2026 09:33:42 +0200 Subject: [PATCH 3/3] Add Robolectric tests for ModelDownloadWorker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six tests using TestListenableWorkerBuilder with mockkObject(ModelManager) so the worker contract can be exercised on the JVM without touching the network or the file system: - doWork_success_returnsModelDirInOutputData - doWork_ioException_returnsRetry — pins the transient-failure path - doWork_genericThrowable_returnsFailureWithMessage — pins KEY_ERROR shape - doWork_invalidPrecisionInput_defaultsToInt8 — guards against bad input - doWork_missingPrecisionInput_defaultsToInt8 - request_buildsRequestWithPrecisionInputDataAndNoNetworkConstraint — pins the no-CONSTRAINT_CONNECTIVITY decision Adds androidx.work:work-testing:2.9.1 to :sdk testImplementation. Local: ./gradlew :sdk:testDebugUnitTest — 26/26 pass (6 new + 5 service + 15 ModelManager). --- sdk/build.gradle.kts | 1 + .../soniqo/speech/ModelDownloadWorkerTest.kt | 143 ++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 sdk/src/test/kotlin/audio/soniqo/speech/ModelDownloadWorkerTest.kt diff --git a/sdk/build.gradle.kts b/sdk/build.gradle.kts index 36746dc..362f4eb 100644 --- a/sdk/build.gradle.kts +++ b/sdk/build.gradle.kts @@ -115,6 +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") androidTestImplementation("androidx.test.ext:junit:1.2.1") androidTestImplementation("androidx.test:runner:1.6.2") diff --git a/sdk/src/test/kotlin/audio/soniqo/speech/ModelDownloadWorkerTest.kt b/sdk/src/test/kotlin/audio/soniqo/speech/ModelDownloadWorkerTest.kt new file mode 100644 index 0000000..24dd080 --- /dev/null +++ b/sdk/src/test/kotlin/audio/soniqo/speech/ModelDownloadWorkerTest.kt @@ -0,0 +1,143 @@ +package audio.soniqo.speech + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.work.ListenableWorker +import androidx.work.testing.TestListenableWorkerBuilder +import androidx.work.workDataOf +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockkObject +import io.mockk.unmockkAll +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.IOException + +/** + * Robolectric tests for [ModelDownloadWorker]. + * + * Mocks the [ModelManager] singleton via mockk so the worker can be exercised + * without touching the network or the file system. Uses + * [TestListenableWorkerBuilder] which gives the worker a real `Context` and + * stubs out the `setForeground` / `setProgress` plumbing — sufficient to + * assert the doWork() result contract. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class ModelDownloadWorkerTest { + + private lateinit var context: Context + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + mockkObject(ModelManager) + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun doWork_success_returnsModelDirInOutputData() = runBlocking { + coEvery { + ModelManager.ensureModels(any(), any(), any()) + } returns "/fake/model/dir" + + val worker = TestListenableWorkerBuilder(context) + .setInputData(workDataOf(ModelDownloadWorker.KEY_PRECISION to "INT8")) + .build() + + val result = worker.doWork() + + assertTrue("expected Success, got $result", result is ListenableWorker.Result.Success) + val output = (result as ListenableWorker.Result.Success).outputData + assertEquals("/fake/model/dir", output.getString(ModelDownloadWorker.KEY_MODEL_DIR)) + } + + @Test + fun doWork_ioException_returnsRetry() = runBlocking { + // The worker should bubble transient network/disk failures up to + // WorkManager so it reschedules with exponential backoff. + coEvery { + ModelManager.ensureModels(any(), any(), any()) + } throws IOException("network down") + + val worker = TestListenableWorkerBuilder(context).build() + val result = worker.doWork() + + assertTrue("expected Retry, got $result", result is ListenableWorker.Result.Retry) + } + + @Test + fun doWork_genericThrowable_returnsFailureWithMessage() = runBlocking { + // Non-IO exceptions are not transient (e.g. corrupt manifest, OOM) + // — emit Failure with the message in outputData so the host activity + // can surface a useful error. + coEvery { + ModelManager.ensureModels(any(), any(), any()) + } throws IllegalStateException("models corrupt") + + val worker = TestListenableWorkerBuilder(context).build() + val result = worker.doWork() + + assertTrue("expected Failure, got $result", result is ListenableWorker.Result.Failure) + val output = (result as ListenableWorker.Result.Failure).outputData + assertEquals("models corrupt", output.getString(ModelDownloadWorker.KEY_ERROR)) + } + + @Test + fun doWork_invalidPrecisionInput_defaultsToInt8() = runBlocking { + coEvery { + ModelManager.ensureModels(any(), any(), any()) + } returns "/fake" + + val worker = TestListenableWorkerBuilder(context) + .setInputData(workDataOf(ModelDownloadWorker.KEY_PRECISION to "NOT_A_PRECISION")) + .build() + + worker.doWork() + + coVerify(exactly = 1) { + ModelManager.ensureModels(any(), ModelPrecision.INT8, any()) + } + } + + @Test + fun doWork_missingPrecisionInput_defaultsToInt8() = runBlocking { + coEvery { + ModelManager.ensureModels(any(), any(), any()) + } returns "/fake" + + val worker = TestListenableWorkerBuilder(context).build() + worker.doWork() + + coVerify(exactly = 1) { + ModelManager.ensureModels(any(), ModelPrecision.INT8, any()) + } + } + + @Test + fun request_buildsRequestWithPrecisionInputDataAndNoNetworkConstraint() { + val req = ModelDownloadWorker.request(ModelPrecision.INT8) + + assertEquals( + "INT8", + req.workSpec.input.getString(ModelDownloadWorker.KEY_PRECISION), + ) + // No JobScheduler network constraint — the worker handles network + // failures itself via IOException → retry. See KDoc on `request()`. + assertEquals( + androidx.work.NetworkType.NOT_REQUIRED, + req.workSpec.constraints.requiredNetworkType, + ) + } +}