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