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:
@@ -74,6 +74,10 @@ dependencies {
|
||||
implementation(libs.media3.common)
|
||||
implementation(libs.media3.muxer)
|
||||
|
||||
// Durable job queue. WorkManager survives process death, which is what makes the
|
||||
// queue resumable after the foreground-service timeout fires.
|
||||
implementation(libs.androidx.work.runtime.ktx)
|
||||
|
||||
implementation(platform(libs.compose.bom))
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.compose.ui.graphics)
|
||||
@@ -88,5 +92,6 @@ dependencies {
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(libs.compose.ui.test.junit4)
|
||||
androidTestImplementation(libs.androidx.work.testing)
|
||||
debugImplementation(libs.compose.ui.test.manifest)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package dev.jasonmross.mediaconverter.work
|
||||
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
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 java.io.File
|
||||
|
||||
/**
|
||||
* Exercises the real WorkManager path, not a test double.
|
||||
*
|
||||
* The point is to cover what a fake worker runner would skip: `setForeground` with a
|
||||
* foreground service type, on a device whose API level actually enforces the rules.
|
||||
* Declaring the wrong type — or forgetting to declare it on WorkManager's
|
||||
* SystemForegroundService in the manifest — fails here rather than in production.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ConversionWorkerTest {
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val workManager = WorkManager.getInstance(context)
|
||||
private lateinit var input: File
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
input = File(context.cacheDir, "worker_sample.mp4")
|
||||
InstrumentationRegistry.getInstrumentation().context.assets
|
||||
.open("sample_h264.mp4")
|
||||
.use { asset -> input.outputStream().use { asset.copyTo(it) } }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
input.delete()
|
||||
File(context.cacheDir, "conversions").listFiles()?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun foregroundTypeMatchesTheRunningApiLevel() {
|
||||
val expected = 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
|
||||
}
|
||||
assertEquals(expected, ConversionForegroundType.current())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runsAConversionThroughWorkManager() = runBlocking {
|
||||
val request = ConversionWorker.request(
|
||||
inputUri = Uri.fromFile(input),
|
||||
displayName = "worker_sample.mp4",
|
||||
sizeBytes = input.length(),
|
||||
)
|
||||
workManager.enqueue(request).result.get()
|
||||
|
||||
val terminal = withTimeout(TIMEOUT_MS) {
|
||||
workManager.getWorkInfoByIdFlow(request.id).first { info ->
|
||||
info != null && info.state.isFinished
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
"worker did not succeed: ${terminal?.outputData?.getString(ConversionWorker.KEY_ERROR)}",
|
||||
WorkInfo.State.SUCCEEDED,
|
||||
terminal?.state,
|
||||
)
|
||||
|
||||
val path = terminal?.outputData?.getString(ConversionWorker.KEY_OUTPUT_PATH)
|
||||
assertTrue("no output path in result", path != null)
|
||||
val output = File(path!!)
|
||||
assertTrue("output file missing", output.exists())
|
||||
assertTrue("output file empty", output.length() > 0)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TIMEOUT_MS = 180_000L
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
|
||||
<!--
|
||||
mediaProcessing is API 35+. Foreground service types became mandatory at API 34,
|
||||
but this type did not exist yet, so API 34 has to fall back to dataSync. minSdk is
|
||||
33, where no type is required at all. See ConversionForegroundType.
|
||||
-->
|
||||
<uses-permission
|
||||
android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROCESSING"
|
||||
android:minSdkVersion="35" />
|
||||
<uses-permission
|
||||
android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"
|
||||
android:maxSdkVersion="34" />
|
||||
|
||||
<!-- Runtime permission since API 33, so it always applies here. Notifications are
|
||||
off by default on new installs. -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
@@ -20,6 +39,17 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!--
|
||||
setForeground() runs WorkManager's own SystemForegroundService, so the type
|
||||
must be declared on THAT service, not on one of ours. Both types are listed
|
||||
because the manifest cannot branch on API level; the type actually passed at
|
||||
runtime is chosen in ConversionForegroundType.
|
||||
-->
|
||||
<service
|
||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||
android:foregroundServiceType="mediaProcessing|dataSync"
|
||||
tools:node="merge" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
<resources>
|
||||
<string name="app_name">Media Converter</string>
|
||||
|
||||
<string name="channel_conversions">Conversions</string>
|
||||
<string name="channel_conversions_description">Progress for running conversions</string>
|
||||
<string name="notification_progress">Converting… %1$d%%</string>
|
||||
<string name="notification_preparing">Preparing…</string>
|
||||
<string name="action_cancel">Cancel</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user