diff --git a/app/src/androidTest/java/org/libremediaconverter/convert/ReattachOnLaunchTest.kt b/app/src/androidTest/java/org/libremediaconverter/convert/ReattachOnLaunchTest.kt new file mode 100644 index 0000000..6545315 --- /dev/null +++ b/app/src/androidTest/java/org/libremediaconverter/convert/ReattachOnLaunchTest.kt @@ -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() + + 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() + + 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() + + 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, output: Data): UUID { + val builder = OneTimeWorkRequestBuilder().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): UUID { + val builder = OneTimeWorkRequestBuilder() + .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 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 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 + } +} diff --git a/app/src/main/java/org/libremediaconverter/convert/ConversionViewModel.kt b/app/src/main/java/org/libremediaconverter/convert/ConversionViewModel.kt index 31edae5..805cb0f 100644 --- a/app/src/main/java/org/libremediaconverter/convert/ConversionViewModel.kt +++ b/app/src/main/java/org/libremediaconverter/convert/ConversionViewModel.kt @@ -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" + } } diff --git a/app/src/main/java/org/libremediaconverter/join/JoinViewModel.kt b/app/src/main/java/org/libremediaconverter/join/JoinViewModel.kt index 0f42ab2..25113f4 100644 --- a/app/src/main/java/org/libremediaconverter/join/JoinViewModel.kt +++ b/app/src/main/java/org/libremediaconverter/join/JoinViewModel.kt @@ -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) { 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, 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 + } } diff --git a/app/src/main/java/org/libremediaconverter/work/ConcatWorker.kt b/app/src/main/java/org/libremediaconverter/work/ConcatWorker.kt index 8511d25..542c26c 100644 --- a/app/src/main/java/org/libremediaconverter/work/ConcatWorker.kt +++ b/app/src/main/java/org/libremediaconverter/work/ConcatWorker.kt @@ -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, totalBytes: Long, format: OutputFormat = OutputFormat.MP4_H264) = OneTimeWorkRequestBuilder() + .addTag(JobTags.inputCount(inputs.size)) .setInputData( Data.Builder() .putStringArray(KEY_INPUT_URIS, inputs.map(Uri::toString).toTypedArray()) diff --git a/app/src/main/java/org/libremediaconverter/work/ConversionWorker.kt b/app/src/main/java/org/libremediaconverter/work/ConversionWorker.kt index 4b835fd..8775504 100644 --- a/app/src/main/java/org/libremediaconverter/work/ConversionWorker.kt +++ b/app/src/main/java/org/libremediaconverter/work/ConversionWorker.kt @@ -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() + .addTag(JobTags.displayName(displayName)) + .addTag(JobTags.sizeBytes(sizeBytes)) .setInputData( Data.Builder() .putString(KEY_INPUT_URI, inputUri.toString()) diff --git a/app/src/main/java/org/libremediaconverter/work/JobSnapshots.kt b/app/src/main/java/org/libremediaconverter/work/JobSnapshots.kt new file mode 100644 index 0000000..a7ed79c --- /dev/null +++ b/app/src/main/java/org/libremediaconverter/work/JobSnapshots.kt @@ -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 = + 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, + ) + } + } diff --git a/app/src/main/java/org/libremediaconverter/work/JobTags.kt b/app/src/main/java/org/libremediaconverter/work/JobTags.kt new file mode 100644 index 0000000..52b6320 --- /dev/null +++ b/app/src/main/java/org/libremediaconverter/work/JobTags.kt @@ -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? = valueOf(tags, DISPLAY_NAME) + + fun sizeBytesOf(tags: Set): Long? = valueOf(tags, SIZE_BYTES)?.toLongOrNull() + + fun inputCountOf(tags: Set): 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, 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:" +} diff --git a/app/src/main/java/org/libremediaconverter/work/Reattachment.kt b/app/src/main/java/org/libremediaconverter/work/Reattachment.kt new file mode 100644 index 0000000..4f943dd --- /dev/null +++ b/app/src/main/java/org/libremediaconverter/work/Reattachment.kt @@ -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 = 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): 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> { (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 + } +} diff --git a/app/src/test/java/org/libremediaconverter/work/JobTagsTest.kt b/app/src/test/java/org/libremediaconverter/work/JobTagsTest.kt new file mode 100644 index 0000000..1595787 --- /dev/null +++ b/app/src/test/java/org/libremediaconverter/work/JobTagsTest.kt @@ -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)) + } +} diff --git a/app/src/test/java/org/libremediaconverter/work/ReattachmentTest.kt b/app/src/test/java/org/libremediaconverter/work/ReattachmentTest.kt new file mode 100644 index 0000000..42725ff --- /dev/null +++ b/app/src/test/java/org/libremediaconverter/work/ReattachmentTest.kt @@ -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, 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 = 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" + } +}