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
@@ -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
}
}