From e84ab6a89d773df775d2a26bb301612423fc724c Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Wed, 19 Aug 2026 21:27:03 -0500 Subject: [PATCH] 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) --- app/build.gradle.kts | 5 + .../work/ConversionWorkerTest.kt | 93 +++++++++++ app/src/main/AndroidManifest.xml | 32 +++- .../convert/ConversionViewModel.kt | 135 +++++++++------- .../mediaconverter/convert/ConverterScreen.kt | 36 ++++- .../work/ConversionForegroundType.kt | 37 +++++ .../work/ConversionNotifications.kt | 69 ++++++++ .../mediaconverter/work/ConversionWorker.kt | 149 ++++++++++++++++++ app/src/main/res/values/strings.xml | 6 + 9 files changed, 499 insertions(+), 63 deletions(-) create mode 100644 app/src/androidTest/java/dev/jasonmross/mediaconverter/work/ConversionWorkerTest.kt create mode 100644 app/src/main/java/dev/jasonmross/mediaconverter/work/ConversionForegroundType.kt create mode 100644 app/src/main/java/dev/jasonmross/mediaconverter/work/ConversionNotifications.kt create mode 100644 app/src/main/java/dev/jasonmross/mediaconverter/work/ConversionWorker.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5646484..65b69c3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) } diff --git a/app/src/androidTest/java/dev/jasonmross/mediaconverter/work/ConversionWorkerTest.kt b/app/src/androidTest/java/dev/jasonmross/mediaconverter/work/ConversionWorkerTest.kt new file mode 100644 index 0000000..2660305 --- /dev/null +++ b/app/src/androidTest/java/dev/jasonmross/mediaconverter/work/ConversionWorkerTest.kt @@ -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 + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2117410..0d0ee33 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,5 +1,24 @@ - + + + + + + + + + + + + + diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/convert/ConversionViewModel.kt b/app/src/main/java/dev/jasonmross/mediaconverter/convert/ConversionViewModel.kt index 0253b2e..821707a 100644 --- a/app/src/main/java/dev/jasonmross/mediaconverter/convert/ConversionViewModel.kt +++ b/app/src/main/java/dev/jasonmross/mediaconverter/convert/ConversionViewModel.kt @@ -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.Idle) val state: StateFlow = _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() - } } diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/convert/ConverterScreen.kt b/app/src/main/java/dev/jasonmross/mediaconverter/convert/ConverterScreen.kt index 72757ee..b85d2ea 100644 --- a/app/src/main/java/dev/jasonmross/mediaconverter/convert/ConverterScreen.kt +++ b/app/src/main/java/dev/jasonmross/mediaconverter/convert/ConverterScreen.kt @@ -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) diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/work/ConversionForegroundType.kt b/app/src/main/java/dev/jasonmross/mediaconverter/work/ConversionForegroundType.kt new file mode 100644 index 0000000..b8ae8e9 --- /dev/null +++ b/app/src/main/java/dev/jasonmross/mediaconverter/work/ConversionForegroundType.kt @@ -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 + } +} diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/work/ConversionNotifications.kt b/app/src/main/java/dev/jasonmross/mediaconverter/work/ConversionNotifications.kt new file mode 100644 index 0000000..8d159f3 --- /dev/null +++ b/app/src/main/java/dev/jasonmross/mediaconverter/work/ConversionNotifications.kt @@ -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" + } +} diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/work/ConversionWorker.kt b/app/src/main/java/dev/jasonmross/mediaconverter/work/ConversionWorker.kt new file mode 100644 index 0000000..0d43207 --- /dev/null +++ b/app/src/main/java/dev/jasonmross/mediaconverter/work/ConversionWorker.kt @@ -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() + .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() + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 12cf0d2..8640ea3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,9 @@ Media Converter + + Conversions + Progress for running conversions + Converting… %1$d%% + Preparing… + Cancel