diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4e3f1f0..404e3fd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -24,8 +24,8 @@ android { applicationId = "me.maxistar.voiceinbox" minSdk = 24 targetSdk = 36 - versionCode = 3 - versionName = "0.2.1" + versionCode = 4 + versionName = "0.2.2" ndk { abiFilters += "arm64-v8a" } diff --git a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelDownloadWorker.kt b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelDownloadWorker.kt index 2e298ea..18cb1a5 100644 --- a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelDownloadWorker.kt +++ b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelDownloadWorker.kt @@ -31,7 +31,21 @@ class SpeechModelDownloadWorker( .build() override suspend fun doWork(): Result { - setForeground(SpeechModelInstallationWork.foregroundInfo(applicationContext, 0, "Preparing model download")) + return try { + installModel() + } catch (error: ForegroundPromotionException) { + failure(error.userMessage) + } + } + + private suspend fun installModel(): Result { + SpeechModelInstallationWork.promote( + worker = this, + context = applicationContext, + progress = 0, + message = "Preparing model download", + source = SpeechModelInstallationWork.Source.NETWORK_DOWNLOAD, + ) repository.prepareForInstall().getOrElse { return failure(it.message ?: "Model installation preflight failed") } @@ -60,6 +74,9 @@ class SpeechModelDownloadWorker( publishProgress(completedBytes, "Verified ${entry.name}") lastFailure = null break + } catch (error: ForegroundPromotionException) { + repository.cleanupFailedCurrentFile(entry) + throw error } catch (error: Throwable) { repository.cleanupFailedCurrentFile(entry) currentCoroutineContext().ensureActive() @@ -129,11 +146,17 @@ class SpeechModelDownloadWorker( KEY_MESSAGE to message, ), ) - setForeground(SpeechModelInstallationWork.foregroundInfo(applicationContext, percent, message)) + SpeechModelInstallationWork.promote( + worker = this, + context = applicationContext, + progress = percent, + message = message, + source = SpeechModelInstallationWork.Source.NETWORK_DOWNLOAD, + ) } private fun failure(message: String): Result = - Result.failure(workDataOf(KEY_ERROR to message)) + Result.failure(SpeechModelInstallationWork.failureData(message)) companion object { const val UNIQUE_WORK_NAME = SpeechModelInstallationWork.UNIQUE_WORK_NAME diff --git a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelImportWorker.kt b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelImportWorker.kt index b28af2c..7ee62c6 100644 --- a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelImportWorker.kt +++ b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelImportWorker.kt @@ -20,14 +20,14 @@ class SpeechModelImportWorker( override suspend fun doWork(): Result { val treeUri = inputData.getString(KEY_TREE_URI)?.let(Uri::parse) ?: return failure("No model folder was selected") - setForeground( - SpeechModelInstallationWork.foregroundInfo( - applicationContext, - 0, - "Preparing local speech model", - ), - ) return try { + SpeechModelInstallationWork.promote( + worker = this, + context = applicationContext, + progress = 0, + message = "Preparing local speech model", + source = SpeechModelInstallationWork.Source.LOCAL_IMPORT, + ) val installed = SpeechModelLocalImporter( resolver = applicationContext.contentResolver, repository = repository, @@ -38,6 +38,8 @@ class SpeechModelImportWorker( Result.success( workDataOf(SpeechModelInstallationWork.KEY_MODEL_PATH to installed.absolutePath), ) + } catch (error: ForegroundPromotionException) { + failure(error.userMessage) } finally { SpeechModelImportPermission.releaseOwnedIfUnused(applicationContext) } @@ -53,17 +55,17 @@ class SpeechModelImportWorker( SpeechModelInstallationWork.KEY_MESSAGE to progress.message, ), ) - setForeground( - SpeechModelInstallationWork.foregroundInfo( - applicationContext, - percent, - progress.message, - ), + SpeechModelInstallationWork.promote( + worker = this, + context = applicationContext, + progress = percent, + message = progress.message, + source = SpeechModelInstallationWork.Source.LOCAL_IMPORT, ) } private fun failure(message: String): Result = Result.failure( - workDataOf(SpeechModelInstallationWork.KEY_ERROR to message), + SpeechModelInstallationWork.failureData(message), ) companion object { diff --git a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelInstallationWork.kt b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelInstallationWork.kt index 7ed8975..108dee2 100644 --- a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelInstallationWork.kt +++ b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelInstallationWork.kt @@ -3,9 +3,15 @@ package me.maxistar.voiceinbox import android.app.NotificationChannel import android.app.NotificationManager import android.content.Context +import android.content.pm.ServiceInfo import android.os.Build +import android.util.Log import androidx.core.app.NotificationCompat +import androidx.work.CoroutineWorker +import androidx.work.Data import androidx.work.ForegroundInfo +import androidx.work.workDataOf +import kotlinx.coroutines.CancellationException object SpeechModelInstallationWork { const val UNIQUE_WORK_NAME = "speech-model-installation" @@ -17,6 +23,15 @@ object SpeechModelInstallationWork { private const val NOTIFICATION_CHANNEL = "speech-model-download" private const val NOTIFICATION_ID = 1907 + private const val LOG_TAG = "ModelInstallation" + + enum class Source( + val diagnosticName: String, + val userFacingName: String, + ) { + NETWORK_DOWNLOAD("network-download", "model download"), + LOCAL_IMPORT("local-import", "local model import"), + } fun foregroundInfo(context: Context, progress: Int, message: String): ForegroundInfo { val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager @@ -37,6 +52,66 @@ object SpeechModelInstallationWork { .setOngoing(true) .setProgress(100, progress, false) .build() - return ForegroundInfo(NOTIFICATION_ID, notification) + val serviceType = foregroundServiceTypeForSdk(Build.VERSION.SDK_INT) + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ForegroundInfo(NOTIFICATION_ID, notification, serviceType) + } else { + ForegroundInfo(NOTIFICATION_ID, notification) + } + } + + suspend fun promote( + worker: CoroutineWorker, + context: Context, + progress: Int, + message: String, + source: Source, + ) { + try { + worker.setForeground(foregroundInfo(context, progress, message)) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Exception) { + Log.e( + LOG_TAG, + foregroundFailureDiagnostic( + source = source, + sdkInt = Build.VERSION.SDK_INT, + manufacturer = Build.MANUFACTURER, + model = Build.MODEL, + error = error, + ), + error, + ) + throw ForegroundPromotionException( + userMessage = "Could not keep ${source.userFacingName} running in the foreground. Please try again.", + cause = error, + ) + } } + + fun failureData(message: String): Data = workDataOf(KEY_ERROR to message) + + internal fun foregroundServiceTypeForSdk(sdkInt: Int): Int = + if (sdkInt >= Build.VERSION_CODES.Q) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + } else { + 0 + } + + internal fun foregroundFailureDiagnostic( + source: Source, + sdkInt: Int, + manufacturer: String, + model: String, + error: Exception, + ): String = + "Foreground promotion failed: source=${source.diagnosticName}, " + + "sdk=$sdkInt, device=$manufacturer $model, " + + "exception=${error.javaClass.name}: ${error.message ?: "no message"}" } + +class ForegroundPromotionException( + val userMessage: String, + cause: Throwable, +) : RuntimeException(userMessage, cause) diff --git a/app/src/test/java/me/maxistar/voiceinbox/SpeechModelInstallationWorkTest.kt b/app/src/test/java/me/maxistar/voiceinbox/SpeechModelInstallationWorkTest.kt index fd752c3..facea93 100644 --- a/app/src/test/java/me/maxistar/voiceinbox/SpeechModelInstallationWorkTest.kt +++ b/app/src/test/java/me/maxistar/voiceinbox/SpeechModelInstallationWorkTest.kt @@ -1,6 +1,8 @@ package me.maxistar.voiceinbox +import android.content.pm.ServiceInfo import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Test class SpeechModelInstallationWorkTest { @@ -14,4 +16,48 @@ class SpeechModelInstallationWorkTest { assertEquals(SpeechModelInstallationWork.KEY_ERROR, SpeechModelDownloadWorker.KEY_ERROR) assertEquals(SpeechModelInstallationWork.KEY_MODEL_PATH, SpeechModelDownloadWorker.KEY_MODEL_PATH) } + + @Test + fun typedForegroundInfoUsesDataSyncFromApi29() { + assertEquals( + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + SpeechModelInstallationWork.foregroundServiceTypeForSdk(29), + ) + assertEquals( + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + SpeechModelInstallationWork.foregroundServiceTypeForSdk(36), + ) + } + + @Test + fun legacyForegroundInfoDoesNotRequestAnUnavailableType() { + assertEquals(0, SpeechModelInstallationWork.foregroundServiceTypeForSdk(24)) + assertEquals(0, SpeechModelInstallationWork.foregroundServiceTypeForSdk(28)) + } + + @Test + fun foregroundFailureUsesExistingWorkerErrorContract() { + val message = "Could not keep model download running in the foreground. Please try again." + + val data = SpeechModelInstallationWork.failureData(message) + + assertEquals(message, data.getString(SpeechModelInstallationWork.KEY_ERROR)) + } + + @Test + fun foregroundFailureDiagnosticIdentifiesDeviceSourceAndException() { + val diagnostic = SpeechModelInstallationWork.foregroundFailureDiagnostic( + source = SpeechModelInstallationWork.Source.LOCAL_IMPORT, + sdkInt = 34, + manufacturer = "samsung", + model = "SM-G990B", + error = SecurityException("foreground type rejected"), + ) + + assertTrue(diagnostic.contains("source=local-import")) + assertTrue(diagnostic.contains("sdk=34")) + assertTrue(diagnostic.contains("device=samsung SM-G990B")) + assertTrue(diagnostic.contains("SecurityException")) + assertTrue(diagnostic.contains("foreground type rejected")) + } }