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
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
30 changes: 16 additions & 14 deletions app/src/main/java/me/maxistar/voiceinbox/SpeechModelImportWorker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
}
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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)
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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"))
}
}
Loading