Reattach to conversions and joins the ViewModel did not start
The queue surviving process death is the stated reason this app uses WorkManager, and
the ViewModel was where that protection stopped. `activeWorkId` and `observer` are
plain fields, so a process reclaimed after a conversion finished came back to Idle
while the output sat in `cacheDir` with nothing in the UI able to reach it. The
realistic window 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.
Both ViewModels now query their own worker's class name on init and pick up what they
find. Nothing is persisted for it, and nothing needed to be: `WorkRequest.Builder`
seeds every request's tag set with `workerClass.name` (`tags = mutableSetOf(
workerClass.name)`, work-runtime 2.11.2), and R8 keeps those names through
work-runtime's own consumer rule, `-keepnames class * extends
androidx.work.ListenableWorker`. A UUID in a `SavedStateHandle` would have been both
more machinery and less: it cannot find work enqueued by a previous install.
What the query cannot return is the job's input. `WorkInfo` hands back id, state, tags,
progress, output and run-attempt count -- never the `Data` a request was enqueued with
-- so a ViewModel could see that a conversion existed and where its output went, but
not which file it was converting. The display name and size therefore ride on tags too,
which is the whole of the production change to the request builders. The picked `Uri`
deliberately does not: nothing in a reattached state reads it, and a `content://` grant
taken by a picker in a process that no longer exists is not something to hand back as
though it still worked.
The decision is a pure function on the JVM test stack, following `FailureOutcome`:
`Reattachment.choose` takes what WorkManager reported and answers which job, if any.
Cancelled work is excluded -- the user already said no. Failed work is excluded, which
matters more than it looks now that a device has shown an interrupted worker coming
back FAILED rather than retried, its restart's `setForeground` refused as a background
foreground-service start: nothing marks a failure as seen, so it would otherwise
reappear on every launch. A success whose staged file is gone is excluded, because a
Save button that fails on tap is worse than no button. Live work outranks a finished
result, since a running job holds a foreground notification and someone opening the app
while that notification is in the shade is looking for that conversion.
Where several jobs qualify, 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 tag 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. It is reachable today -- dismiss one result with "Start over", which leaves its
file behind, then convert something else and do not save it. `WorkInfo` carries no
timestamp of any kind, but the edge is already stat'ing the file, so the file's own
mtime is the ordering. A clock that moves backwards makes it a heuristic; an order that
is unspecified and repeats itself is worse.
Aliases are the tie that does not resolve, and that case is not hypothetical. A tag
query on a device returned two SUCCEEDED jobs whose output paths were both
`.../conversions/input_converted.mp4`, with one file on disk -- the staging name is
derived from the input's display name, so a later job overwrites an earlier one's output
and both go on reporting it. Checking the file does not separate them, and neither does
its mtime, since they share it. So the two halves are separated instead. The file is
offered, because it is the user's file either way and losing it is the defect being
fixed; the label is not, because saying which job produced it would be a guess.
`Reattachment.Ambiguous` says so and the card falls back to a neutral name rather than
borrowing the other job's. Aliases whose tags are identical -- the ordinary case, the
same file converted twice -- stay attributed, since nothing turns on which wrote it.
Age is not filtered on, and cannot be: WorkManager keeps finished work about a week and
prunes on its own schedule, so a query in a fresh process routinely returns jobs from
earlier sessions. 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.
Two smaller behaviours fall out of reattaching rather than starting:
- A reattached job that is cancelled lands on Idle rather than Ready. Ready would put
a Convert button over an input URI that belongs to a dead process. `observe()` takes
the cancelled destination as a defaulted parameter, so a job started here is
unchanged.
- A FAILED job's message falls back when blank, not only when absent. A worker killed
before it can report leaves no output data at all, and an exception's message can be
the empty string; both used to reach the screen as a failure with nothing said.
Deliberately not fixed here, each being its own change: the save dialog's suggested name
and MIME still come from the current picker rather than from the job that ran, so a
reattached job in a non-default format is offered the default extension; a result
dismissed with "Start over" still keeps its staged file, so it can be offered again next
launch -- the file check closes that for free once the file is deleted; and
`setForeground` still sits outside `doWork`'s try, so its throw bypasses the retry
decision entirely.
Tested where it can be. 28 JVM tests cover the choice and the tag round trip, including
the ordering, the aliasing rules and a display name that looks like another tag. The
reattachment itself is instrumented: a ViewModel constructed against the real
WorkManager is the next launch, with no memory of the work. `WorkManagerTestInitHelper`
is deliberately not used -- its `setDelegate` replaces the singleton for the whole
process, which would quietly turn `ConversionWorkerTest` into a synchronous test double
depending on class order.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,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
|
||||
|
||||
@@ -102,6 +105,69 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) {
|
||||
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)) }
|
||||
|
||||
@@ -161,7 +227,13 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) {
|
||||
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 ->
|
||||
@@ -197,12 +269,17 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -279,4 +356,13 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ import org.libremediaconverter.convert.InputFile
|
||||
import org.libremediaconverter.convert.OutputPublisher
|
||||
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
|
||||
|
||||
@@ -44,6 +47,56 @@ class JoinViewModel(app: Application) : AndroidViewModel(app) {
|
||||
private var observer: Job? = null
|
||||
private var activeWorkId: UUID? = 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.")
|
||||
@@ -64,10 +117,19 @@ class JoinViewModel(app: Application) : AndroidViewModel(app) {
|
||||
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)
|
||||
@@ -89,11 +151,16 @@ class JoinViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,4 +208,13 @@ class JoinViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user