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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user