Merge branch 'fix/reattach-unfinished-work' into scratch/integrate-d2-d3

# Conflicts:
#	app/src/main/java/org/libremediaconverter/join/JoinViewModel.kt
This commit is contained in:
2026-08-22 18:45:03 -05:00
10 changed files with 1068 additions and 5 deletions
@@ -0,0 +1,330 @@
package org.libremediaconverter.convert
import android.app.Application
import android.content.Context
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.Data
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.Worker
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.join.JoinState
import org.libremediaconverter.join.JoinViewModel
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.model.Engine
import org.libremediaconverter.work.ConcatWorker
import org.libremediaconverter.work.ConversionWorker
import org.libremediaconverter.work.JobTags
import java.io.File
import java.util.UUID
import java.util.concurrent.TimeUnit
/**
* Stands in for a worker whose job is already over.
*
* Reattachment is defined entirely by what WorkManager can hand back — the worker class name
* as a tag, the tags the request carried, and the output `Data` — so a job with that shape is
* all the ViewModel needs to see. Producing one by running a real transcode would take minutes
* and would test the engines, which have their own suites. This echoes its input as its result,
* which lets a test state any finished job in a line.
*/
class EchoWorker(context: Context, params: WorkerParameters) : Worker(context, params) {
override fun doWork(): Result = Result.success(inputData)
}
/**
* The defect: a conversion that outlives the process becomes unreachable.
*
* WorkManager's queue survives process death — that is why the app uses it — but the ViewModel
* held its job id in a plain field, so the next launch started at Idle while the finished output
* sat in `cacheDir` with no route to it from the UI. The realistic window is after the transcode
* finishes and before the user taps Save: the foreground service is gone and the process is an
* ordinary background one that may be reclaimed hours before the user comes back.
*
* A ViewModel constructed here *is* that next launch: it is a fresh instance with no memory of
* the work, exactly as after `am kill`. The real [WorkManager] is used rather than
* `WorkManagerTestInitHelper`, whose `setDelegate` replaces the singleton for the whole process
* and would silently turn `ConversionWorkerTest` — which exists to exercise the real WorkManager
* path, foreground service included — into a synchronous test double, depending on class order.
*/
@UnstableApi
@RunWith(AndroidJUnit4::class)
class ReattachOnLaunchTest {
private val app = InstrumentationRegistry.getInstrumentation()
.targetContext.applicationContext as Application
private val workManager = WorkManager.getInstance(app)
@Before
fun clearTheQueue() = emptyQueueAndStaging()
@After
fun leaveNothingBehind() = emptyQueueAndStaging()
/**
* The claim the whole fix rests on, checked against the production request builder rather
* than assumed: `WorkRequest.Builder` seeds every request's tags with its worker class name,
* so the app's own work is findable with nothing persisted anywhere.
*/
@Test
fun aConversionRequestIsFindableByItsWorkerClassName() {
val request = ConversionWorker.request(
// A file that does not exist, so the job fails within seconds instead of transcoding.
// What is under test is the request's tags, which are written when it is enqueued.
inputUri = Uri.fromFile(File(app.cacheDir, "no_such_input.mp4")),
displayName = "holiday.mp4",
sizeBytes = 4_096L,
)
workManager.enqueue(request).result.get()
workManager.cancelWorkById(request.id).result.get()
val info = awaitFinished(request.id)
assertTrue(
"no worker class name in ${info.tags}",
info.tags.contains(ConversionWorker::class.java.name),
)
assertEquals("holiday.mp4", JobTags.displayNameOf(info.tags))
assertEquals(4_096L, JobTags.sizeBytesOf(info.tags))
}
@Test
fun reattachesToAConversionThatFinishedWhileTheViewModelWasGone() {
val staged = stage("holiday_converted.mp4")
finishedJob(
tags = listOf(
ConversionWorker::class.java.name,
JobTags.displayName("holiday.mp4"),
JobTags.sizeBytes(4_096L),
),
output = workDataOf(
ConversionWorker.KEY_OUTPUT_PATH to staged.absolutePath,
ConversionWorker.KEY_ENGINE_USED to Engine.FFMPEG.name,
ConversionWorker.KEY_ROUTE_REASON to "test route",
),
)
val converted = awaitConversion<ConversionState.Converted>()
assertEquals(staged.absolutePath, converted.staged.absolutePath)
assertEquals("holiday.mp4", converted.input.displayName)
assertEquals(4_096L, converted.input.sizeBytes)
assertEquals(Engine.FFMPEG.name, converted.engineUsed)
}
/**
* The Save button has to be reachable *and* mean something. A staged file the OS reclaimed
* out of the cache — or one a previous save already published and deleted — would otherwise
* be offered and fail on tap.
*/
@Test
fun ignoresAFinishedConversionWhoseStagedFileIsGone() {
val missing = File(File(app.cacheDir, "conversions"), "vanished_converted.mp4")
missing.delete()
finishedJob(
tags = listOf(ConversionWorker::class.java.name, JobTags.displayName("vanished.mp4")),
output = workDataOf(ConversionWorker.KEY_OUTPUT_PATH to missing.absolutePath),
)
assertStaysIdle(conversionViewModel())
}
/**
* The shape a device actually produced: two SUCCEEDED jobs reporting the same output path,
* with one file on disk, because the staging name is derived from the input's display name.
* The file has to stay reachable — losing it is the defect — while the card must not claim
* an input that may belong to the other job.
*/
@Test
fun offersAFileTwoJobsClaimWithoutAttributingItToEither() {
val staged = stage("input_converted.mp4")
val output = workDataOf(ConversionWorker.KEY_OUTPUT_PATH to staged.absolutePath)
finishedJob(
tags = listOf(ConversionWorker::class.java.name, JobTags.displayName("input.mp4")),
output = output,
)
finishedJob(
tags = listOf(ConversionWorker::class.java.name, JobTags.displayName("input.mkv")),
output = output,
)
val converted = awaitConversion<ConversionState.Converted>()
assertEquals(staged.absolutePath, converted.staged.absolutePath)
assertTrue(
"attributed an aliased file to one of the jobs: ${converted.input.displayName}",
converted.input.displayName !in setOf("input.mp4", "input.mkv"),
)
}
@Test
fun reattachesToAConversionStillWaitingInTheQueue() {
queuedJob(
tags = listOf(
ConversionWorker::class.java.name,
JobTags.displayName("queued.mp4"),
JobTags.sizeBytes(2_048L),
),
)
val converting = awaitConversion<ConversionState.Converting>()
assertEquals("queued.mp4", converting.input.displayName)
assertEquals(2_048L, converting.input.sizeBytes)
}
/**
* The pair with the test above: same job, same tags, and the only difference is that the
* user cancelled it. Reattaching to it would undo their decision.
*/
@Test
fun doesNotResurrectAConversionTheUserCancelled() {
val id = queuedJob(
tags = listOf(ConversionWorker::class.java.name, JobTags.displayName("queued.mp4")),
)
workManager.cancelWorkById(id).result.get()
assertEquals(WorkInfo.State.CANCELLED, awaitFinished(id).state)
assertStaysIdle(conversionViewModel())
}
/** A pick the user has already made owns the screen; a job found afterwards must not take it. */
@Test
fun doesNotOverwriteAPickTheUserHasAlreadyMade() {
val staged = stage("holiday_converted.mp4")
finishedJob(
tags = listOf(ConversionWorker::class.java.name, JobTags.displayName("holiday.mp4")),
output = workDataOf(ConversionWorker.KEY_OUTPUT_PATH to staged.absolutePath),
)
val picked = Uri.fromFile(stage("picked.mp4"))
val viewModel = conversionViewModel()
onMainThread { viewModel.onInputPicked(picked) }
runBlocking {
val ready = withTimeout(TIMEOUT_MS) {
viewModel.state.first { it is ConversionState.Ready }
} as ConversionState.Ready
assertEquals(picked, ready.input.uri)
// And it stays the user's pick rather than being replaced a moment later.
val stolen = withTimeoutOrNull(SETTLE_MS) {
viewModel.state.first { it !is ConversionState.Ready }
}
assertNull("reattachment took the screen from the user: $stolen", stolen)
}
}
@Test
fun reattachesToAJoinThatFinishedWhileTheViewModelWasGone() {
val staged = stage("joined.mp4")
finishedJob(
tags = listOf(ConcatWorker::class.java.name, JobTags.inputCount(3)),
output = workDataOf(
ConcatWorker.KEY_OUTPUT_PATH to staged.absolutePath,
ConcatWorker.KEY_STRATEGY to ConcatStrategy.STREAM_COPY.name,
),
)
val viewModel = joinViewModel()
val joined = runBlocking {
withTimeout(TIMEOUT_MS) { viewModel.state.first { it is JoinState.Joined } }
} as JoinState.Joined
assertEquals(staged.absolutePath, joined.staged.absolutePath)
assertEquals(ConcatStrategy.STREAM_COPY, joined.strategy)
}
// --- staging the situation --------------------------------------------------------------
/** Enqueues a job that runs immediately and finishes with [output] as its result. */
private fun finishedJob(tags: List<String>, output: Data): UUID {
val builder = OneTimeWorkRequestBuilder<EchoWorker>().setInputData(output)
tags.forEach(builder::addTag)
val request = builder.build()
workManager.enqueue(request).result.get()
assertEquals(WorkInfo.State.SUCCEEDED, awaitFinished(request.id).state)
return request.id
}
/**
* Enqueues a job that stays [WorkInfo.State.ENQUEUED]. The delay is what holds it there: it
* is long enough that nothing can run it during a test, and it is cancelled either way.
*/
private fun queuedJob(tags: List<String>): UUID {
val builder = OneTimeWorkRequestBuilder<EchoWorker>()
.setInitialDelay(1, TimeUnit.HOURS)
tags.forEach(builder::addTag)
val request = builder.build()
workManager.enqueue(request).result.get()
return request.id
}
private fun stage(name: String): File {
val dir = File(app.cacheDir, "conversions").apply { mkdirs() }
return File(dir, name).apply { writeBytes(ByteArray(1_024)) }
}
private fun emptyQueueAndStaging() {
workManager.cancelAllWorkByTag(ConversionWorker::class.java.name).result.get()
workManager.cancelAllWorkByTag(ConcatWorker::class.java.name).result.get()
workManager.cancelAllWorkByTag(EchoWorker::class.java.name).result.get()
workManager.pruneWork().result.get()
File(app.cacheDir, "conversions").listFiles()?.forEach { it.delete() }
}
// --- reading the result -----------------------------------------------------------------
/** A ViewModel built now is the next launch: no memory of the work, only what it can query. */
private fun conversionViewModel(): ConversionViewModel = onMainThread { ConversionViewModel(app) }
private fun joinViewModel(): JoinViewModel = onMainThread { JoinViewModel(app) }
private inline fun <reified T : ConversionState> awaitConversion(): T = runBlocking {
val viewModel = conversionViewModel()
withTimeout(TIMEOUT_MS) { viewModel.state.first { it is T } } as T
}
private fun assertStaysIdle(viewModel: ConversionViewModel) = runBlocking {
val moved = withTimeoutOrNull(SETTLE_MS) {
viewModel.state.first { it !is ConversionState.Idle }
}
assertNull("reattached to work it should have left alone: $moved", moved)
}
private fun awaitFinished(id: UUID): WorkInfo = runBlocking {
withTimeout(TIMEOUT_MS) {
workManager.getWorkInfoByIdFlow(id).first { it != null && it.state.isFinished }
}!!
}
private fun <T : Any> onMainThread(block: () -> T): T {
lateinit var result: T
InstrumentationRegistry.getInstrumentation().runOnMainSync { result = block() }
return result
}
private companion object {
const val TIMEOUT_MS = 30_000L
/**
* How long "nothing happened" is given to happen. Reattachment is one indexed query
* against WorkManager's database, so this is generous rather than tuned.
*/
const val SETTLE_MS = 5_000L
}
}
@@ -31,6 +31,9 @@ import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.model.Validation
import org.libremediaconverter.model.VideoCodec
import org.libremediaconverter.work.ConversionWorker
import org.libremediaconverter.work.JobTags
import org.libremediaconverter.work.Reattachment
import org.libremediaconverter.work.jobSnapshots
import java.io.File
import java.util.UUID
@@ -132,6 +135,69 @@ class ConversionViewModel @JvmOverloads constructor(
ContainerCapabilities.validate(settings.spec, state.probe() ?: InputProbe())
}.stateIn(viewModelScope, SharingStarted.Eagerly, Validation.Valid)
init {
reattach()
}
/**
* Picks up a conversion this ViewModel did not start.
*
* The queue outliving the process is the entire reason [ConversionWorker] exists, but the
* ViewModel used to be where that stopped: its `activeWorkId` is a plain field, so a process
* reclaimed after a conversion finished came back to an empty screen while the output sat in
* `cacheDir` with nothing in the UI able to reach it. The realistic case is not a crash
* mid-transcode — it is the job finishing, the user not saving yet, and the process being
* reclaimed hours later as an ordinary background one.
*
* Nothing is persisted for this. The query is by worker class name, which WorkManager tags
* every request with on its own, so it finds work enqueued by an earlier run of the app —
* and by an earlier *version* of it — which an id saved in a `SavedStateHandle` would not.
*
* One known wart, not fixed here because it is a different defect: the save dialog's
* suggested name and MIME type come from the current picker rather than from the job that
* ran, so a reattached job converting to something other than the default format is offered
* the default extension. That derivation is wrong on its own terms and is left to the change
* that fixes it properly.
*/
private fun reattach() {
viewModelScope.launch {
val reattachment = Reattachment.choose(
workManager.jobSnapshots(
tag = ConversionWorker::class.java.name,
outputPathKey = ConversionWorker.KEY_OUTPUT_PATH,
),
) ?: return@launch
// The query suspends, so by now the user may have picked a file or started a
// conversion of their own. Either owns the screen; reattaching over it would throw
// away what they just did. Both this check and the assignment below run on the main
// dispatcher with no suspension point between them, so nothing can interleave.
if (_state.value !is ConversionState.Idle || activeWorkId != null) return@launch
// Only a job that is the sole explanation for its staged file gets to name the input.
// When several jobs report the same file — which nothing prevents while the staging
// name is derived from the input's display name — the file is still the user's, but
// saying which of them produced it would be a guess, so the card falls back to a
// neutral label rather than borrowing the other job's.
val tags = (reattachment as? Reattachment.Certain)?.job?.tags.orEmpty()
val input = InputFile(
// The picked URI is not recoverable — WorkManager gives back a job's tags and
// its output, never the Data it was enqueued with — and nothing in the states
// reattachment produces reads it. The card shows the name and size, which the
// tags carry; a reattached job that is cancelled goes to Idle rather than Ready,
// so this can never reach the Convert button. Leaving the probe unset costs the
// card its source details, and re-probing is what there is no URI for.
uri = Uri.EMPTY,
displayName = JobTags.displayNameOf(tags) ?: UNKNOWN_INPUT_NAME,
sizeBytes = JobTags.sizeBytesOf(tags) ?: 0L,
)
activeWorkId = reattachment.job.id
// No initial state of our own: the flow's first emission carries the job's real
// state, so observe() maps it exactly as it would for a conversion started here.
observe(reattachment.job.id, input, cancelled = ConversionState.Idle)
}
}
fun setPreset(format: OutputFormat) = _settings.update { it.copy(spec = format.spec) }
fun setContainer(container: Container) = _settings.update { it.copy(spec = it.spec.copy(container = container)) }
@@ -191,7 +257,13 @@ class ConversionViewModel @JvmOverloads constructor(
observe(request.id, input)
}
private fun observe(id: UUID, input: InputFile) {
/**
* @param cancelled where a cancellation lands. For a conversion started here that is the
* picked file, ready to convert again. For one picked up by [reattach] there is no picked
* file — the URI that job holds belongs to a process that no longer exists — so it lands
* on Idle instead, rather than offering a Convert button over a file nothing can open.
*/
private fun observe(id: UUID, input: InputFile, cancelled: ConversionState = ConversionState.Ready(input)) {
observer?.cancel()
observer = viewModelScope.launch {
workManager.getWorkInfoByIdFlow(id).collect { info ->
@@ -231,12 +303,17 @@ class ConversionViewModel @JvmOverloads constructor(
}
}
// A worker that dies before it can report anything leaves no output data at
// all — a foreground-service start refused after a process restart is one
// way — and an exception's message can be an empty string. Both would read
// as a failure with nothing said, so blank falls back like missing does.
WorkInfo.State.FAILED -> ConversionState.Failed(
info.outputData.getString(ConversionWorker.KEY_ERROR)
?.takeIf { it.isNotBlank() }
?: "Conversion failed.",
)
WorkInfo.State.CANCELLED -> ConversionState.Ready(input)
WorkInfo.State.CANCELLED -> cancelled
WorkInfo.State.BLOCKED -> ConversionState.Converting(input, 0)
}
}
@@ -335,4 +412,13 @@ class ConversionViewModel @JvmOverloads constructor(
}
return InputFile(uri, name, size)
}
private companion object {
/**
* Shown for a reattached job whose tags predate them — work enqueued by an earlier
* version of the app. Neutral on purpose: it is a real file of the user's, and calling
* it "unknown" would read as an error rather than as a gap in what survived.
*/
const val UNKNOWN_INPUT_NAME = "Media file"
}
}
@@ -20,6 +20,9 @@ import org.libremediaconverter.convert.ConversionDependencies
import org.libremediaconverter.convert.InputFile
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.work.ConcatWorker
import org.libremediaconverter.work.JobTags
import org.libremediaconverter.work.Reattachment
import org.libremediaconverter.work.jobSnapshots
import java.io.File
import java.util.UUID
@@ -62,6 +65,56 @@ class JoinViewModel @JvmOverloads constructor(
*/
private var pendingStaged: File? = null
init {
reattach()
}
/**
* Picks up a join this ViewModel did not start.
*
* The same defect as on the convert side, and the same shape of fix: a join outlives the
* process on purpose, so a process reclaimed after one finished came back to an empty screen
* with the joined file sitting unreachable in `cacheDir`. Found by querying for the worker's
* own class name, which WorkManager tags every request with, so nothing has to be persisted
* and work from an earlier version of the app is found too. [Reattachment.choose] carries
* the rules about which job and why.
*/
private fun reattach() {
viewModelScope.launch {
val reattachment = Reattachment.choose(
workManager.jobSnapshots(
tag = ConcatWorker::class.java.name,
outputPathKey = ConcatWorker.KEY_OUTPUT_PATH,
),
) ?: return@launch
// The query suspends, so the user may have picked files or started a join in the
// meantime. Theirs wins. No suspension point between this check and the assignment
// below, and both run on the main dispatcher, so nothing can interleave.
if (_state.value !is JoinState.Idle || activeWorkId != null) return@launch
// Every join stages under the same constant name, so two finished joins always
// report the same file and no tag of either can be trusted to describe it. That is
// what Ambiguous means here, and the count falls back rather than being borrowed —
// which costs nothing in practice, since the count is only rendered while a job is
// live and a live job names no file to be aliased on. What it does not cover is the
// stream-copy-or-re-encode line on Joined, which comes from the picked job's output
// and can therefore belong to the other one. Same cause, same fix: one staging name
// per job.
val tags = (reattachment as? Reattachment.Certain)?.job?.tags.orEmpty()
// Placeholders, and safe only because of where they can go. Joining reads nothing
// but the size of this list, Waiting and Joined read none of it, and a reattached
// job that is cancelled lands on Idle rather than Ready — the one state that would
// render these individually and offer to join them. Anything that starts drawing
// this list has to carry the names in the tags first.
val inputs = List(JobTags.inputCountOf(tags) ?: MIN_JOIN_INPUTS) {
InputFile(Uri.EMPTY, "", 0L)
}
activeWorkId = reattachment.job.id
observe(reattachment.job.id, inputs, cancelled = JoinState.Idle)
}
}
fun onInputsPicked(uris: List<Uri>) {
if (uris.size < 2) {
_state.value = JoinState.Failed("Pick at least two files to join.")
@@ -82,10 +135,19 @@ class JoinViewModel @JvmOverloads constructor(
activeWorkId = request.id
workManager.enqueue(request)
_state.value = JoinState.Joining(inputs)
observe(request.id, inputs)
}
/**
* @param cancelled where a cancellation lands. For a join started here that is the picked
* files, ready to join again. For one picked up by [reattach] there are no picked files —
* what that job holds are URIs granted to a process that no longer exists — so it lands on
* Idle rather than offering to re-join files nothing can open.
*/
private fun observe(id: UUID, inputs: List<InputFile>, cancelled: JoinState = JoinState.Ready(inputs)) {
observer?.cancel()
observer = viewModelScope.launch {
workManager.getWorkInfoByIdFlow(request.id).collect { info ->
workManager.getWorkInfoByIdFlow(id).collect { info ->
if (info == null) return@collect
_state.value = when (info.state) {
WorkInfo.State.RUNNING, WorkInfo.State.BLOCKED -> JoinState.Joining(inputs)
@@ -111,11 +173,16 @@ class JoinViewModel @JvmOverloads constructor(
}
}
// A worker that dies before it can report anything leaves no output data at
// all, and an exception's message can be an empty string. Both would read as
// a failure with nothing said, so blank falls back like missing does.
WorkInfo.State.FAILED -> JoinState.Failed(
info.outputData.getString(ConcatWorker.KEY_ERROR) ?: "Joining failed.",
info.outputData.getString(ConcatWorker.KEY_ERROR)
?.takeIf { it.isNotBlank() }
?: "Joining failed.",
)
WorkInfo.State.CANCELLED -> JoinState.Ready(inputs)
WorkInfo.State.CANCELLED -> cancelled
}
}
}
@@ -180,4 +247,13 @@ class JoinViewModel @JvmOverloads constructor(
}
return InputFile(uri, name, size)
}
private companion object {
/**
* Used when a reattached job carries no count tag — work enqueued by an earlier version
* of the app. Both the picker and the worker refuse fewer than two inputs, so this is a
* floor rather than a guess, and it keeps the screen from claiming a join of no files.
*/
const val MIN_JOIN_INPUTS = 2
}
}
@@ -95,8 +95,15 @@ class ConcatWorker(context: Context, params: WorkerParameters) : CoroutineWorker
private const val NOTIFICATION_ID = 1002
private const val TAG = "ConcatWorker"
/**
* How many files are being joined is tagged as well as passed as input `Data`, because
* `WorkInfo` gives a job's tags back and its input `Data` never. It is the one thing the
* join screen says about a job in flight, and after a restart nothing else can supply
* it. See [JobTags].
*/
fun request(inputs: List<Uri>, totalBytes: Long, format: OutputFormat = OutputFormat.MP4_H264) =
OneTimeWorkRequestBuilder<ConcatWorker>()
.addTag(JobTags.inputCount(inputs.size))
.setInputData(
Data.Builder()
.putStringArray(KEY_INPUT_URIS, inputs.map(Uri::toString).toTypedArray())
@@ -254,6 +254,12 @@ class ConversionWorker(context: Context, params: WorkerParameters) : CoroutineWo
fun outputNameFor(inputName: String, spec: OutputSpec): String = inputName.substringBeforeLast('.', inputName) +
"_converted.${spec.extension}"
/**
* The name and size are tagged as well as passed as input `Data`, and that is not
* redundant. `WorkInfo` hands back a job's tags and its output but never the `Data` it
* was enqueued with, so after a restart the tags are the only way for the UI to say
* *which file* a job it did not start is working on. See [JobTags].
*/
fun request(
inputUri: Uri,
displayName: String,
@@ -262,6 +268,8 @@ class ConversionWorker(context: Context, params: WorkerParameters) : CoroutineWo
quality: QualityTier = QualityTier.FAST,
enginePreference: EnginePreference = EnginePreference.AUTO,
) = OneTimeWorkRequestBuilder<ConversionWorker>()
.addTag(JobTags.displayName(displayName))
.addTag(JobTags.sizeBytes(sizeBytes))
.setInputData(
Data.Builder()
.putString(KEY_INPUT_URI, inputUri.toString())
@@ -0,0 +1,44 @@
package org.libremediaconverter.work
import androidx.work.WorkManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import java.io.File
/**
* Everything WorkManager still knows about one kind of this app's jobs.
*
* The framework half of reattachment, kept deliberately free of decisions: it queries, reads
* fields across, and stats one file. Which job to pick — and whether any of them is worth
* picking — is [Reattachment.choose], which is pure and tested on the JVM.
*
* `tag` is the worker's class name, which needs no cooperation from the enqueueing code:
* `WorkRequest.Builder` seeds every request's tag set with `workerClass.name`. That is what
* makes work enqueued by a previous run of the app — or a previous version of it — findable
* at all. R8 keeps those names (`-keepnames class * extends androidx.work.ListenableWorker`,
* from work-runtime's own consumer rules), so the key is stable in a minified build.
*
* Runs on [Dispatchers.IO] because it stats a file, and it does so here rather than at the
* call site so no caller can forget.
*/
suspend fun WorkManager.jobSnapshots(tag: String, outputPathKey: String): List<JobSnapshot> =
withContext(Dispatchers.IO) {
getWorkInfosByTagFlow(tag).first().map { info ->
val path = info.outputData.getString(outputPathKey)
// An empty file is treated as no file: it would publish as a zero-byte "conversion"
// rather than fail, which is worse than not offering it at all.
val output = path?.let(::File)?.takeIf { it.isFile && it.length() > 0L }
JobSnapshot(
id = info.id,
state = info.state,
runAttemptCount = info.runAttemptCount,
outputPath = path,
outputExists = output != null,
// Read here because this is the only place a clock is available at all: it is
// the sole way to tell two of the app's results apart. See Reattachment.choose.
outputModifiedAt = output?.lastModified() ?: 0L,
tags = info.tags,
)
}
}
@@ -0,0 +1,46 @@
package org.libremediaconverter.work
/**
* The little that has to travel with a job so the UI can describe it after a restart.
*
* `WorkInfo` exposes a job's id, state, tags, progress and output — never the `Data` it was
* enqueued with. So a ViewModel that finds a job it did not start can see *that* there is a
* conversion, and where its output went, but not what file it was converting. Tags are the
* only channel WorkManager gives back, which is why the display name and size ride on them.
*
* Deliberately not carried: the input `Uri`. It is the one field nothing in a reattached state
* reads, and a `content://` grant taken by a picker in a process that no longer exists is not
* something to hand back to the user as if it still worked.
*
* Values are read back leniently — a missing or malformed tag is null, never an exception.
* Work enqueued by an older version of the app carries none of these, and it is exactly the
* work most likely to still be sitting in the queue when this code first runs.
*/
object JobTags {
fun displayName(name: String): String = DISPLAY_NAME + name
fun sizeBytes(bytes: Long): String = SIZE_BYTES + bytes
fun inputCount(count: Int): String = INPUT_COUNT + count
fun displayNameOf(tags: Set<String>): String? = valueOf(tags, DISPLAY_NAME)
fun sizeBytesOf(tags: Set<String>): Long? = valueOf(tags, SIZE_BYTES)?.toLongOrNull()
fun inputCountOf(tags: Set<String>): Int? = valueOf(tags, INPUT_COUNT)?.toIntOrNull()
/**
* Matching on the whole tag rather than searching within it is what makes a display name
* safe to carry verbatim: a file called `lmc.size-bytes:9` becomes the tag
* `lmc.display-name:lmc.size-bytes:9`, which no other prefix matches.
*/
private fun valueOf(tags: Set<String>, prefix: String): String? =
tags.firstOrNull { it.startsWith(prefix) }?.removePrefix(prefix)
// Namespaced so they cannot collide with the worker class name WorkManager tags every
// request with, which is what makes the job findable in the first place.
private const val DISPLAY_NAME = "lmc.display-name:"
private const val SIZE_BYTES = "lmc.size-bytes:"
private const val INPUT_COUNT = "lmc.input-count:"
}
@@ -0,0 +1,173 @@
package org.libremediaconverter.work
import androidx.work.WorkInfo
import java.util.UUID
/**
* One of the app's own jobs, as WorkManager last reported it.
*
* Only the fields the reattachment decision reads. [outputExists] is deliberately a
* `Boolean` rather than a `File`: whether the staged output is still on disk is the one
* input to that decision that cannot be answered without touching the filesystem, so the
* edge answers it and the rule stays testable on the JVM.
*/
data class JobSnapshot(
val id: UUID,
val state: WorkInfo.State,
val runAttemptCount: Int,
/** Where the worker said it left the output, for a job that got that far. */
val outputPath: String?,
/** Whether [outputPath] still names a non-empty file. Answered from disk by the caller. */
val outputExists: Boolean,
/**
* When that file was last written, or 0 when there is none.
*
* The only ordering available anywhere in this data: [WorkInfo] carries no timestamp, and
* the caller is already stat'ing the file.
*/
val outputModifiedAt: Long = 0L,
/** The job's tags, carrying what [JobTags] put there. Not read by the decision. */
val tags: Set<String> = emptySet(),
)
/**
* Which of the app's own jobs a freshly created ViewModel should pick up, if any, and whether
* that job can be trusted to describe itself.
*
* A pure function rather than a branch inside the ViewModel, for the same reason as
* [FailureOutcome]: the condition that matters cannot be provoked in a test. It needs the
* process to be reclaimed while a job or its unsaved result is still around, which means a
* device, an `am kill` and a wait. Isolating the choice means the rule itself is verified on
* the JVM even though the situation that calls for it is not reproducible here.
*
* "Unfinished" here means unfinished *from the user's point of view*, not
* [WorkInfo.State.isFinished]. A conversion that succeeded and was never saved is finished
* work with a full-size file sitting in the cache and no route to it — that is the case this
* whole mechanism exists for, and it is why [WorkInfo.State.SUCCEEDED] is a candidate here.
*/
sealed interface Reattachment {
/** The job the UI should pick up. */
val job: JobSnapshot
/** Exactly one job explains what is on screen, so its tags describe it. */
data class Certain(override val job: JobSnapshot) : Reattachment
/**
* Several finished jobs name the same staged file, so the file is reachable but nothing can
* say which job produced it.
*
* Observed on a device rather than imagined: a tag query in a fresh process returned two
* SUCCEEDED jobs whose output paths were both `…/conversions/input_converted.mp4`, with one
* file on disk. Nothing gives a job a staging path of its own — the name is derived from the
* input's display name — so a later conversion overwrites an earlier one's output while both
* jobs go on reporting that path as their result.
*
* The file is not the ambiguous part: whichever entry is picked, the user is offered the
* bytes actually on disk, which is the thing that would otherwise be lost. What cannot be
* recovered is which job wrote them, so the caller is told not to describe it. A card
* labelled with the other job's input would be a confident lie, where a neutral label is
* merely thin. It resolves on its own once each job stages under a name of its own.
*/
data class Ambiguous(override val job: JobSnapshot) : Reattachment
companion object {
/**
* Picks the one job to reattach to, or null when there is nothing worth showing.
*
* Excluded outright:
*
* - **[WorkInfo.State.CANCELLED]** — the user already said no. Reattaching would undo
* that.
* - **[WorkInfo.State.FAILED]** — nothing to act on, and nothing marks a failure as seen,
* so it would reappear on every launch. That matters more than it looks: a worker
* interrupted by process death can come back FAILED rather than retried, because the
* restart's `setForeground` is refused as a background foreground-service start, so
* failures left behind by earlier sessions are ordinary rather than rare.
* - **[WorkInfo.State.SUCCEEDED] with no output file** — either it was saved, which
* deletes the staged copy, or the OS reclaimed the cache. Offering a Save button for a
* file that is gone turns a recoverable job into a failed save.
*
* Nothing filters by age, and nothing can. WorkManager keeps finished work for about a
* week and prunes on its own schedule, so a tag query in a fresh process routinely
* returns completed jobs from earlier sessions, and [WorkInfo] carries no timestamp to
* sort them by. Whether the staged file is still there is the only signal separating a
* result still worth offering from one already dealt with, which is why that check
* carries the weight here.
*
* It is also the seam for a neighbouring defect: a result the user dismissed with "Start
* over" currently keeps its staged file, so today it can be offered again on the next
* launch. Nothing here changes when that is fixed — the file stops existing and the job
* stops qualifying.
*
* Ranked, when more than one qualifies:
*
* 1. a job that is running now,
* 2. a job waiting to be retried, which has already done part of the work,
* 3. a job queued and not yet started,
* 4. a finished result still on disk.
*
* Live work outranks a finished result because a running job is holding a foreground
* notification: someone opening the app while that notification is in the shade expects
* to find that conversion, not a result from yesterday. It is also the right answer when
* the running job is overwriting the older one's staged file, which a shared staging name
* allows.
*
* Within a rank the **newest staged file** wins, and that is not a detail. Losing a tie
* is not the same as waiting for the next launch: the query has no `ORDER BY`, so its
* order is unspecified but stable, and an arbitrary winner would keep winning every
* launch while the other result stayed unreachable for as long as its file existed. Two
* results at different paths is reachable — dismiss one with "Start over", which leaves
* its file behind, then convert something else and do not save it.
*
* The ordering is the file's own modification time because there is nothing else:
* [WorkInfo] carries no timestamp at all, and the file is already being stat'ed for
* [JobSnapshot.outputExists]. The job that wrote most recently is the one the user is
* likeliest to be waiting for. It is a heuristic to the extent that a clock can move
* backwards, which is a better failure than an order that is unspecified and
* systematically repeats itself.
*
* Two kinds of tie survive that and both are meant to. Live jobs have written no file, so
* they have no timestamp and keep the query's order — and two of them are not reachable
* from the UI today, since every state that can start a job is left the moment it does.
* Aliases share a file and therefore share its timestamp, so they stay tied, which is
* exactly right: the pick decides nothing about which bytes the user gets, and
* [Ambiguous] answers the part that is genuinely unknown.
*/
fun choose(jobs: List<JobSnapshot>): Reattachment? {
// minWithOrNull keeps the first of equal elements, so the query's order is what
// breaks a tie the comparator leaves — deliberately, per the ordering notes above.
val chosen = jobs
.mapNotNull { job -> rank(job)?.let { rank -> rank to job } }
.minWithOrNull(
compareBy<Pair<Int, JobSnapshot>> { (rank, _) -> rank }
.thenByDescending { (_, job) -> job.outputModifiedAt },
)
?.second
?: return null
// Only a job that finished names a file, so only one of those can be aliased.
val path = chosen.outputPath ?: return Certain(chosen)
val aliases = jobs.filter { it.id != chosen.id && it.outputPath == path && rank(it) != null }
// Aliases carrying identical tags describe the same input, so nothing turns on which
// of them wrote the file and the label is safe either way. That is the ordinary case:
// the same file converted twice.
return if (aliases.all { it.tags == chosen.tags }) Certain(chosen) else Ambiguous(chosen)
}
private fun rank(job: JobSnapshot): Int? = when (job.state) {
WorkInfo.State.RUNNING -> RUNNING
// ENQUEUED after a run means a retry is pending — the reading observe() takes too.
WorkInfo.State.ENQUEUED -> if (job.runAttemptCount > 0) RETRYING else QUEUED
WorkInfo.State.BLOCKED -> QUEUED
WorkInfo.State.SUCCEEDED -> if (job.outputPath != null && job.outputExists) RESULT else null
WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> null
}
private const val RUNNING = 0
private const val RETRYING = 1
private const val QUEUED = 2
private const val RESULT = 3
}
}
@@ -0,0 +1,73 @@
package org.libremediaconverter.work
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* What has to survive a restart, and what a malformed tag does.
*
* The values come from a picker and go into WorkManager's database, so the encoder and the
* decoder are the two halves of one round trip and are tested as one. The lenient reads
* matter as much as the round trip: work enqueued by an older version of the app carries none
* of these tags, and it is exactly the work most likely to still be queued the first time
* this code runs.
*/
class JobTagsTest {
@Test
fun `a display name survives the round trip`() {
val tags = setOf("org.libremediaconverter.work.ConversionWorker", JobTags.displayName("holiday.mp4"))
assertEquals("holiday.mp4", JobTags.displayNameOf(tags))
}
@Test
fun `a display name that looks like another tag is still read back whole`() {
// Tags are matched by prefix over the whole string, so a file named after one of the
// other prefixes cannot be mistaken for it.
val name = "lmc.size-bytes:9"
val tags = setOf(JobTags.displayName(name), JobTags.sizeBytes(4096))
assertEquals(name, JobTags.displayNameOf(tags))
assertEquals(4096L, JobTags.sizeBytesOf(tags))
}
@Test
fun `a display name with spaces, colons and unicode is carried verbatim`() {
val name = "холидей: clip 2 — final.mkv"
assertEquals(name, JobTags.displayNameOf(setOf(JobTags.displayName(name))))
}
@Test
fun `a size survives the round trip`() {
assertEquals(9_000_000_000L, JobTags.sizeBytesOf(setOf(JobTags.sizeBytes(9_000_000_000L))))
}
@Test
fun `an input count survives the round trip`() {
assertEquals(7, JobTags.inputCountOf(setOf(JobTags.inputCount(7))))
}
@Test
fun `a job with no tags of ours reads back as nothing known`() {
// Work enqueued before this app version. The reattachment falls back rather than
// skipping the job, because the job is still the user's file.
val tags = setOf("org.libremediaconverter.work.ConversionWorker")
assertNull(JobTags.displayNameOf(tags))
assertNull(JobTags.sizeBytesOf(tags))
assertNull(JobTags.inputCountOf(tags))
}
@Test
fun `a size that is not a number reads as unknown rather than throwing`() {
assertNull(JobTags.sizeBytesOf(setOf("lmc.size-bytes:huge")))
assertNull(JobTags.inputCountOf(setOf("lmc.input-count:")))
}
@Test
fun `the three tags do not read each other`() {
val tags = setOf(JobTags.displayName("clip.mp4"), JobTags.sizeBytes(12), JobTags.inputCount(3))
assertEquals("clip.mp4", JobTags.displayNameOf(tags))
assertEquals(12L, JobTags.sizeBytesOf(tags))
assertEquals(3, JobTags.inputCountOf(tags))
}
}
@@ -0,0 +1,220 @@
package org.libremediaconverter.work
import androidx.work.WorkInfo
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import java.util.UUID
/**
* The rule for picking up a job the ViewModel did not start.
*
* Isolated from the ViewModel precisely so it can be tested: reaching this code for real means
* the process being reclaimed while a job — or a result nobody saved — is still around, which
* needs a device and an `am kill`. Extracting the choice means the rule is verified even though
* the situation that calls for it cannot be reproduced on the JVM.
*/
class ReattachmentTest {
@Test
fun `nothing to reattach to when there is no work at all`() {
assertNull(Reattachment.choose(emptyList()))
}
@Test
fun `a cancelled job is never reattached to`() {
// The user already said no. Bringing it back would undo that.
val cancelled = job(state = WorkInfo.State.CANCELLED, outputPath = "/cache/out.mp4", outputExists = true)
assertNull(Reattachment.choose(listOf(cancelled)))
}
@Test
fun `a failed job is not reattached to`() {
// Nothing marks a failure as seen, so it would reappear on every launch. Failures left
// by an interrupted worker are ordinary: a restart's setForeground can be refused.
assertNull(Reattachment.choose(listOf(job(state = WorkInfo.State.FAILED))))
}
@Test
fun `a finished result still on disk is offered`() {
val result = job(state = WorkInfo.State.SUCCEEDED, outputPath = "/cache/out.mp4", outputExists = true)
assertEquals(Reattachment.Certain(result), Reattachment.choose(listOf(result)))
}
@Test
fun `a finished result whose staged file is gone is not offered`() {
// Saved already, or the OS reclaimed the cache. A Save button here would fail on tap.
val vanished = job(state = WorkInfo.State.SUCCEEDED, outputPath = "/cache/out.mp4", outputExists = false)
assertNull(Reattachment.choose(listOf(vanished)))
}
@Test
fun `a job that reported success without a path is not offered`() {
val pathless = job(state = WorkInfo.State.SUCCEEDED, outputPath = null, outputExists = false)
assertNull(Reattachment.choose(listOf(pathless)))
}
@Test
fun `a running job is preferred to a finished result`() {
// A running job holds a foreground notification. Someone opening the app while that
// notification is in the shade is looking for that conversion.
val result = job(state = WorkInfo.State.SUCCEEDED, outputPath = "/cache/out.mp4", outputExists = true)
val running = job(state = WorkInfo.State.RUNNING)
assertEquals(Reattachment.Certain(running), Reattachment.choose(listOf(result, running)))
}
@Test
fun `a running job is preferred to a queued one`() {
val queued = job(state = WorkInfo.State.ENQUEUED)
val running = job(state = WorkInfo.State.RUNNING)
assertEquals(Reattachment.Certain(running), Reattachment.choose(listOf(queued, running)))
}
@Test
fun `a job waiting to retry is preferred to one that has never run`() {
// It has already done part of the work — most likely it exhausted the foreground
// budget mid-conversion — so it is the one closer to producing a file.
val fresh = job(state = WorkInfo.State.ENQUEUED, runAttemptCount = 0)
val retrying = job(state = WorkInfo.State.ENQUEUED, runAttemptCount = 1)
assertEquals(Reattachment.Certain(retrying), Reattachment.choose(listOf(fresh, retrying)))
}
@Test
fun `a queued job is preferred to a finished result`() {
val result = job(state = WorkInfo.State.SUCCEEDED, outputPath = "/cache/out.mp4", outputExists = true)
val queued = job(state = WorkInfo.State.ENQUEUED)
assertEquals(Reattachment.Certain(queued), Reattachment.choose(listOf(result, queued)))
}
@Test
fun `blocked work counts as queued rather than being ignored`() {
val blocked = job(state = WorkInfo.State.BLOCKED)
assertEquals(Reattachment.Certain(blocked), Reattachment.choose(listOf(blocked)))
}
@Test
fun `the newer of two results is the one offered`() {
// Losing this tie is not the same as waiting for the next launch: the query has no
// ORDER BY, so an arbitrary winner would win every launch and the other result would
// stay unreachable for as long as its file existed.
val older = finishedResult(STAGED, tags = emptySet(), modifiedAt = 1_000L)
val newer = finishedResult("/cache/beach_converted.mp4", tags = emptySet(), modifiedAt = 2_000L)
assertEquals(Reattachment.Certain(newer), Reattachment.choose(listOf(older, newer)))
}
@Test
fun `a newer result still does not outrank live work`() {
// Rank first, time second. A running job holds the notification the user is following.
val running = job(state = WorkInfo.State.RUNNING)
val newer = finishedResult(STAGED, tags = emptySet(), modifiedAt = Long.MAX_VALUE)
assertEquals(Reattachment.Certain(running), Reattachment.choose(listOf(newer, running)))
}
@Test
fun `two live jobs, which have written nothing to compare, resolve to the query's order`() {
val first = job(state = WorkInfo.State.RUNNING)
val second = job(state = WorkInfo.State.RUNNING)
assertEquals(Reattachment.Certain(first), Reattachment.choose(listOf(first, second)))
}
@Test
fun `a result is still found when everything else is unusable`() {
val result = job(state = WorkInfo.State.SUCCEEDED, outputPath = "/cache/out.mp4", outputExists = true)
val jobs = listOf(
job(state = WorkInfo.State.CANCELLED),
job(state = WorkInfo.State.FAILED),
job(state = WorkInfo.State.SUCCEEDED, outputPath = "/cache/gone.mp4", outputExists = false),
result,
)
assertEquals(Reattachment.Certain(result), Reattachment.choose(jobs))
}
// --- when two jobs claim the same staged file ---------------------------------------------
@Test
fun `two results naming the same file are still offered, but not attributed`() {
// Straight off a device: two SUCCEEDED jobs whose output path was the same
// input_converted.mp4, with one file on disk. The file is the user's either way; which
// job wrote it is not knowable, so the caller is told not to describe it.
val first = finishedResult(STAGED, tags = setOf(JobTags.displayName("holiday.mp4")))
val second = finishedResult(STAGED, tags = setOf(JobTags.displayName("holiday.mkv")))
assertEquals(Reattachment.Ambiguous(first), Reattachment.choose(listOf(first, second)))
}
@Test
fun `aliases that describe the same input are attributed after all`() {
// The ordinary way to end up with two: convert the same file twice. Nothing turns on
// which of them wrote the file, so the label is safe.
val tags = setOf(JobTags.displayName("holiday.mp4"), JobTags.sizeBytes(4_096))
val first = finishedResult(STAGED, tags = tags)
val second = finishedResult(STAGED, tags = tags)
assertEquals(Reattachment.Certain(first), Reattachment.choose(listOf(first, second)))
}
@Test
fun `results naming different files do not make each other ambiguous`() {
val first = finishedResult(STAGED, tags = setOf(JobTags.displayName("holiday.mp4")))
val second = finishedResult("/cache/beach_converted.mp4", tags = setOf(JobTags.displayName("beach.mp4")))
assertEquals(Reattachment.Certain(first), Reattachment.choose(listOf(first, second)))
}
@Test
fun `a job with no file yet is not aliased by every other job without one`() {
// Guards the obvious mistake: live jobs all carry a null output path, and grouping on
// that would make each of them ambiguous with all the others.
val running = job(state = WorkInfo.State.RUNNING, tags = setOf(JobTags.displayName("holiday.mp4")))
val queued = job(state = WorkInfo.State.ENQUEUED, tags = setOf(JobTags.displayName("beach.mp4")))
assertEquals(Reattachment.Certain(running), Reattachment.choose(listOf(running, queued)))
}
@Test
fun `an unusable alias does not make a result ambiguous`() {
// A cancelled or failed job deletes its staged file on the way out, so it never wrote
// what is on disk now and says nothing about who did.
val result = finishedResult(STAGED, tags = setOf(JobTags.displayName("holiday.mp4")))
val abandoned = job(
state = WorkInfo.State.CANCELLED,
outputPath = STAGED,
outputExists = true,
tags = setOf(JobTags.displayName("something else.mp4")),
)
assertEquals(Reattachment.Certain(result), Reattachment.choose(listOf(result, abandoned)))
}
private fun finishedResult(path: String, tags: Set<String>, modifiedAt: Long = 0L) = job(
state = WorkInfo.State.SUCCEEDED,
outputPath = path,
outputExists = true,
tags = tags,
modifiedAt = modifiedAt,
)
private fun job(
state: WorkInfo.State,
runAttemptCount: Int = 0,
outputPath: String? = null,
outputExists: Boolean = false,
tags: Set<String> = emptySet(),
modifiedAt: Long = 0L,
) = JobSnapshot(
id = UUID.randomUUID(),
state = state,
runAttemptCount = runAttemptCount,
outputPath = outputPath,
outputExists = outputExists,
outputModifiedAt = modifiedAt,
tags = tags,
)
private companion object {
/** One staging path, because the interesting cases are the ones that share it. */
const val STAGED = "/cache/holiday_converted.mp4"
}
}