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