Run conversions as durable foreground work

Conversions now go through WorkManager instead of a viewModelScope coroutine,
so a job outlives the ViewModel, survives process death, and keeps running
when the user leaves the app.

The foreground service type needs three branches across the supported range,
which is why ConversionForegroundType exists rather than a constant:

  API 33  no type is required at all
  API 34  a type is mandatory, but mediaProcessing does not exist yet, so
          dataSync is the only sensible fit
  API 35+ mediaProcessing, whose own documentation describes it as
          "converting media to different formats"

The manifest declares both types on WorkManager's SystemForegroundService
via tools:node="merge" -- setForeground runs *that* service, not one of
ours, so declaring the type on an app-owned service would have no effect.
The manifest cannot branch on API level, so the runtime picks which type is
actually passed.

Both types share a budget of six hours per twenty-four across the whole app.
When it runs out WorkManager reports STOP_REASON_FOREGROUND_SERVICE_TIMEOUT,
which the worker translates into Result.retry() rather than a failure: the
work is still valid, there is simply no budget right now. The UI surfaces
that as a distinct Waiting state that explains the pause instead of showing
an error.

Expedited work is deliberately not used. It maps to JobScheduler expedited
jobs with a short quota, which is the wrong shape for a multi-minute
transcode.

POST_NOTIFICATIONS is requested when the user taps Convert, not on first
launch, so the ask arrives with visible justification. The conversion starts
either way -- without the permission the foreground service still runs, but
its progress notification is confined to the Task Manager rather than the
shade. Notification updates are throttled to roughly one per second because
progress updates arrive far faster than the system UI can absorb.

Tests run against the real WorkManager rather than a test double,
specifically so setForeground and the declared service type are exercised on
a device that enforces them. Verified on an API 37 emulator: the service
starts, and logcat shows no type or permission exceptions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-19 21:27:03 -05:00
co-authored by Claude Opus 5
parent b082cea889
commit e84ab6a89d
9 changed files with 499 additions and 63 deletions
@@ -6,14 +6,18 @@ import android.provider.OpenableColumns
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import androidx.media3.common.util.UnstableApi
import androidx.work.WorkInfo
import androidx.work.WorkManager
import dev.jasonmross.mediaconverter.work.ConversionWorker
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.util.UUID
data class InputFile(
val uri: Uri,
@@ -25,11 +29,9 @@ sealed interface ConversionState {
data object Idle : ConversionState
data class Ready(val input: InputFile) : ConversionState
data class Converting(val input: InputFile, val percent: Int) : ConversionState
data class Converted(
val input: InputFile,
val staged: File,
val elapsedMs: Long,
) : ConversionState
/** Budget for foreground work ran out; WorkManager will retry when it can. */
data class Waiting(val input: InputFile) : ConversionState
data class Converted(val input: InputFile, val staged: File) : ConversionState
data class Saved(val displayName: String) : ConversionState
data class Failed(val message: String) : ConversionState
}
@@ -37,12 +39,15 @@ sealed interface ConversionState {
@UnstableApi
class ConversionViewModel(app: Application) : AndroidViewModel(app) {
private val engine = Media3Engine(app)
private val workManager = WorkManager.getInstance(app)
private val publisher = OutputPublisher(app)
private val _state = MutableStateFlow<ConversionState>(ConversionState.Idle)
val state: StateFlow<ConversionState> = _state.asStateFlow()
private var observer: Job? = null
private var activeWorkId: UUID? = null
fun onInputPicked(uri: Uri) {
viewModelScope.launch {
val info = withContext(Dispatchers.IO) { queryFile(uri) }
@@ -50,50 +55,72 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) {
}
}
/**
* Enqueues the conversion rather than running it inline.
*
* Going through WorkManager means the job outlives this ViewModel, survives the
* process being killed, and keeps running when the user leaves the app — none of
* which a viewModelScope coroutine would do.
*/
fun convert() {
val input = when (val s = _state.value) {
is ConversionState.Ready -> s.input
is ConversionState.Converted -> s.input
else -> return
}
val input = currentInput() ?: return
viewModelScope.launch {
// Staging plus the source means peak usage is roughly both at once.
if (!publisher.hasSpaceFor(input.sizeBytes)) {
_state.value = ConversionState.Failed(
"Not enough free space to convert this file."
)
return@launch
}
val request = ConversionWorker.request(
inputUri = input.uri,
displayName = input.displayName,
sizeBytes = input.sizeBytes,
)
activeWorkId = request.id
workManager.enqueue(request)
_state.value = ConversionState.Converting(input, 0)
observe(request.id, input)
}
_state.value = ConversionState.Converting(input, 0)
val staged = publisher.createStagingFile(outputNameFor(input.displayName))
val startedAt = System.currentTimeMillis()
private fun observe(id: UUID, input: InputFile) {
observer?.cancel()
observer = viewModelScope.launch {
workManager.getWorkInfoByIdFlow(id).collect { info ->
if (info == null) return@collect
_state.value = when (info.state) {
WorkInfo.State.RUNNING -> ConversionState.Converting(
input,
info.progress.getInt(ConversionWorker.KEY_PROGRESS, 0),
)
runCatching {
engine.transcode(input.uri, staged) { percent ->
_state.update { current ->
if (current is ConversionState.Converting) {
current.copy(percent = percent)
// ENQUEUED after a run means a retry is pending — most likely the
// six-hour foreground budget was exhausted mid-job.
WorkInfo.State.ENQUEUED ->
if (info.runAttemptCount > 0) {
ConversionState.Waiting(input)
} else {
current
ConversionState.Converting(input, 0)
}
WorkInfo.State.SUCCEEDED -> {
val path = info.outputData.getString(ConversionWorker.KEY_OUTPUT_PATH)
if (path == null) {
ConversionState.Failed("Conversion reported success but produced no file.")
} else {
ConversionState.Converted(input, File(path))
}
}
WorkInfo.State.FAILED -> ConversionState.Failed(
info.outputData.getString(ConversionWorker.KEY_ERROR)
?: "Conversion failed."
)
WorkInfo.State.CANCELLED -> ConversionState.Ready(input)
WorkInfo.State.BLOCKED -> ConversionState.Converting(input, 0)
}
}.onSuccess {
_state.value = ConversionState.Converted(
input = input,
staged = staged,
elapsedMs = System.currentTimeMillis() - startedAt,
)
}.onFailure { e ->
staged.delete()
_state.value = ConversionState.Failed(e.message ?: "Conversion failed.")
}
}
}
/** Copies the staged result out to the destination the user chose. */
fun cancel() {
activeWorkId?.let(workManager::cancelWorkById)
}
fun save(destination: Uri) {
val converted = _state.value as? ConversionState.Converted ?: return
viewModelScope.launch {
@@ -104,7 +131,7 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) {
}
}.onSuccess {
_state.value = ConversionState.Saved(
outputNameFor(converted.input.displayName)
ConversionWorker.outputNameFor(converted.input.displayName)
)
}.onFailure { e ->
_state.value = ConversionState.Failed(e.message ?: "Could not save the file.")
@@ -113,17 +140,21 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) {
}
fun reset() {
observer?.cancel()
observer = null
activeWorkId = null
_state.value = ConversionState.Idle
}
fun suggestedOutputName(): String {
val s = _state.value
val base = when (s) {
is ConversionState.Converted -> s.input.displayName
is ConversionState.Ready -> s.input.displayName
else -> "output"
}
return outputNameFor(base)
fun suggestedOutputName(): String =
ConversionWorker.outputNameFor(currentInput()?.displayName ?: "output")
private fun currentInput(): InputFile? = when (val s = _state.value) {
is ConversionState.Ready -> s.input
is ConversionState.Converting -> s.input
is ConversionState.Waiting -> s.input
is ConversionState.Converted -> s.input
else -> null
}
private fun queryFile(uri: Uri): InputFile {
@@ -143,12 +174,4 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) {
}
return InputFile(uri, name, size)
}
private fun outputNameFor(inputName: String): String =
inputName.substringBeforeLast('.', inputName) + "_converted.mp4"
override fun onCleared() {
engine.close()
super.onCleared()
}
}
@@ -1,5 +1,6 @@
package dev.jasonmross.mediaconverter.convert
import android.Manifest
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
@@ -45,6 +46,18 @@ fun ConverterScreen(
ActivityResultContracts.CreateDocument("video/mp4")
) { uri -> uri?.let(viewModel::save) }
// Requested at the point of use rather than on first launch, so the ask carries
// its own justification. The conversion starts either way: without the permission
// the foreground service still runs, but its progress notification is confined to
// the Task Manager instead of the shade.
val requestNotifications = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { viewModel.convert() }
fun startConversion() {
requestNotifications.launch(Manifest.permission.POST_NOTIFICATIONS)
}
Column(
modifier = modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
@@ -64,7 +77,11 @@ fun ConverterScreen(
is ConversionState.Ready -> {
FileCard(s.input)
Button(onClick = viewModel::convert) { Text("Convert") }
Text(
"We'll show progress in a notification so you can leave the app.",
style = MaterialTheme.typography.bodySmall,
)
Button(onClick = ::startConversion) { Text("Convert") }
OutlinedButton(onClick = { pickInput.launch(arrayOf("video/*")) }) {
Text("Choose a different file")
}
@@ -77,13 +94,23 @@ fun ConverterScreen(
progress = { s.percent / 100f },
modifier = Modifier.fillMaxWidth(),
)
OutlinedButton(onClick = viewModel::cancel) { Text("Cancel") }
}
is ConversionState.Waiting -> {
FileCard(s.input)
Text(
"Paused. The system limits background media processing to six " +
"hours a day, so this will resume automatically.",
style = MaterialTheme.typography.bodyMedium,
)
OutlinedButton(onClick = viewModel::cancel) { Text("Cancel") }
}
is ConversionState.Converted -> {
FileCard(s.input)
Text(
"Done in ${formatSeconds(s.elapsedMs)} — " +
"${formatBytes(s.staged.length())} output.",
"Done — ${formatBytes(s.staged.length())} output.",
style = MaterialTheme.typography.bodyMedium,
)
Button(onClick = { chooseDestination.launch(viewModel.suggestedOutputName()) }) {
@@ -124,6 +151,3 @@ private fun formatBytes(bytes: Long): String = when {
bytes >= 1_000 -> String.format(Locale.US, "%.0f kB", bytes / 1e3)
else -> "$bytes B"
}
private fun formatSeconds(ms: Long): String =
String.format(Locale.US, "%.1f s", ms / 1000.0)
@@ -0,0 +1,37 @@
package dev.jasonmross.mediaconverter.work
import android.content.pm.ServiceInfo
import android.os.Build
/**
* Picks the foreground service type for a conversion job.
*
* There are three regimes across the supported range, which is why this is not a
* single constant:
*
* | API | Regime |
* |------------|-----------------------------------------------------------------|
* | 33 | Foreground service types are not required at all. |
* | 34 | A type is mandatory, but `mediaProcessing` does not exist yet, |
* | | so `dataSync` is the only sensible fit. |
* | 35+ | `mediaProcessing` exists and is the correct type. Its own docs |
* | | describe it as "converting media to different formats". |
*
* Both types carry the same budget: **six hours out of every twenty-four**, shared
* across all of the app's foreground services. On expiry the system calls
* `Service.onTimeout` and the app has seconds to stop before taking an ANR.
*/
object ConversionForegroundType {
/**
* The `foregroundServiceType` to pass to `ForegroundInfo`.
*
* Returns 0 on API 33, where passing a type is unnecessary — and where the
* `mediaProcessing` constant does not exist to pass in the first place.
*/
fun current(): Int = when {
Build.VERSION.SDK_INT >= 35 -> ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING
Build.VERSION.SDK_INT >= 34 -> ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
else -> 0
}
}
@@ -0,0 +1,69 @@
package dev.jasonmross.mediaconverter.work
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.work.WorkManager
import dev.jasonmross.mediaconverter.R
import java.util.UUID
/** Progress notification for a running conversion. */
class ConversionNotifications(private val context: Context) {
init {
val channel = NotificationChannel(
CHANNEL_ID,
context.getString(R.string.channel_conversions),
// Low importance: a long-running progress bar should not make noise or
// push a heads-up card on every update.
NotificationManager.IMPORTANCE_LOW,
).apply {
description = context.getString(R.string.channel_conversions_description)
setShowBadge(false)
}
context.getSystemService(NotificationManager::class.java)
.createNotificationChannel(channel)
}
fun build(id: UUID, title: String, percent: Int, indeterminate: Boolean = false) =
NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(title)
.setContentText(
if (indeterminate) {
context.getString(R.string.notification_preparing)
} else {
context.getString(R.string.notification_progress, percent)
}
)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setOngoing(true)
// Progress updates far outpace what the UI can use; alerting once keeps
// the system UI from being hammered.
.setOnlyAlertOnce(true)
.setProgress(100, percent, indeterminate)
.addAction(
android.R.drawable.ic_menu_close_clear_cancel,
context.getString(R.string.action_cancel),
WorkManager.getInstance(context).createCancelPendingIntent(id),
)
.build()
/**
* True when notifications can actually be shown.
*
* A foreground service still starts without POST_NOTIFICATIONS, but its
* notification appears only in the Task Manager rather than the shade — so
* progress silently vanishes from the user's point of view.
*/
fun areEnabled(): Boolean =
context.getSystemService(NotificationManager::class.java)
.areNotificationsEnabled()
.also { if (!it) Log.i(TAG, "Notifications disabled; progress will not be visible.") }
companion object {
const val CHANNEL_ID = "conversions"
private const val TAG = "ConversionNotifications"
}
}
@@ -0,0 +1,149 @@
package dev.jasonmross.mediaconverter.work
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ForegroundInfo
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import dev.jasonmross.mediaconverter.convert.Media3Engine
import dev.jasonmross.mediaconverter.convert.OutputPublisher
/**
* Runs one conversion as durable, cancellable background work.
*
* WorkManager rather than a bare foreground service: the queue survives process
* death, cancellation and progress are already modelled, and `WorkInfo` gives the UI
* a Flow to observe. That durability is what makes the six-hour foreground-service
* timeout recoverable instead of fatal — see [handleTimeoutIfNeeded].
*
* Expedited work is deliberately *not* used. It maps to JobScheduler expedited jobs
* with a short quota, which is the wrong shape for a multi-minute transcode.
*/
@UnstableApi
class ConversionWorker(
context: Context,
params: WorkerParameters,
) : CoroutineWorker(context, params) {
private val notifications = ConversionNotifications(applicationContext)
private val publisher = OutputPublisher(applicationContext)
override suspend fun doWork(): Result {
val inputUri = inputData.getString(KEY_INPUT_URI)?.toUri() ?: return Result.failure()
val displayName = inputData.getString(KEY_DISPLAY_NAME) ?: "input"
val sizeBytes = inputData.getLong(KEY_SIZE_BYTES, 0L)
val mimeType = inputData.getString(KEY_VIDEO_MIME) ?: MimeTypes.VIDEO_H265
if (!publisher.hasSpaceFor(sizeBytes)) {
return Result.failure(workDataOf(KEY_ERROR to "Not enough free space to convert."))
}
setForeground(foregroundInfo(displayName, percent = 0, indeterminate = true))
val staged = publisher.createStagingFile(outputNameFor(displayName))
// The engine owns its own Looper thread, so it is safe to call from this
// Looper-less worker thread. See Media3Engine.
val engine = Media3Engine(applicationContext)
return try {
var lastPublished = 0L
engine.transcode(inputUri, staged, mimeType) { percent ->
setProgressAsync(workDataOf(KEY_PROGRESS to percent))
// Throttle the notification to ~1/sec. The underlying progress updates
// several times a second, and pushing every one janks the system UI.
val now = System.currentTimeMillis()
if (now - lastPublished >= NOTIFICATION_INTERVAL_MS) {
lastPublished = now
notifications
.build(id, displayName, percent)
.let { notificationManager().notify(NOTIFICATION_ID, it) }
}
}
Result.success(workDataOf(KEY_OUTPUT_PATH to staged.absolutePath))
} catch (e: Throwable) {
staged.delete()
handleTimeoutIfNeeded(e)
} finally {
engine.close()
}
}
/**
* Distinguishes a genuine failure from the foreground-service budget expiring.
*
* `mediaProcessing` allows six hours out of every twenty-four, shared across the
* app. When that runs out WorkManager reports
* `STOP_REASON_FOREGROUND_SERVICE_TIMEOUT`, and the correct response is to retry
* later rather than tell the user the conversion failed — the work is still valid,
* there is simply no budget right now.
*/
private fun handleTimeoutIfNeeded(cause: Throwable): Result {
val timedOut = stopReason == WorkInfo.STOP_REASON_FOREGROUND_SERVICE_TIMEOUT
return if (timedOut) {
Log.w(TAG, "Foreground service budget exhausted; will retry.", cause)
Result.retry()
} else {
Log.e(TAG, "Conversion failed.", cause)
Result.failure(workDataOf(KEY_ERROR to (cause.message ?: "Conversion failed.")))
}
}
override suspend fun getForegroundInfo(): ForegroundInfo =
foregroundInfo(
inputData.getString(KEY_DISPLAY_NAME) ?: "input",
percent = 0,
indeterminate = true,
)
private fun foregroundInfo(title: String, percent: Int, indeterminate: Boolean) =
ForegroundInfo(
NOTIFICATION_ID,
notifications.build(id, title, percent, indeterminate),
ConversionForegroundType.current(),
)
private fun notificationManager() =
applicationContext.getSystemService(android.app.NotificationManager::class.java)
private fun String.toUri(): Uri? = runCatching { Uri.parse(this) }.getOrNull()
companion object {
const val KEY_INPUT_URI = "input_uri"
const val KEY_DISPLAY_NAME = "display_name"
const val KEY_SIZE_BYTES = "size_bytes"
const val KEY_VIDEO_MIME = "video_mime"
const val KEY_PROGRESS = "progress"
const val KEY_OUTPUT_PATH = "output_path"
const val KEY_ERROR = "error"
private const val NOTIFICATION_ID = 1001
private const val NOTIFICATION_INTERVAL_MS = 1_000L
private const val TAG = "ConversionWorker"
fun outputNameFor(inputName: String): String =
inputName.substringBeforeLast('.', inputName) + "_converted.mp4"
fun request(
inputUri: Uri,
displayName: String,
sizeBytes: Long,
videoMimeType: String = MimeTypes.VIDEO_H265,
) = OneTimeWorkRequestBuilder<ConversionWorker>()
.setInputData(
Data.Builder()
.putString(KEY_INPUT_URI, inputUri.toString())
.putString(KEY_DISPLAY_NAME, displayName)
.putLong(KEY_SIZE_BYTES, sizeBytes)
.putString(KEY_VIDEO_MIME, videoMimeType)
.build()
)
.build()
}
}