Files
LibreMediaConverter/app/src/androidTest/java/org/libremediaconverter/convert/ReattachOnLaunchTest.kt
T
JMR-devandClaude Opus 5 ec969c41dc Reattach to conversions and joins the ViewModel did not start
The queue surviving process death is the stated reason this app uses WorkManager, and
the ViewModel was where that protection stopped. `activeWorkId` and `observer` are
plain fields, so a process reclaimed after a conversion finished came back to Idle
while the output sat in `cacheDir` with nothing in the UI able to reach it. The
realistic window is not a crash mid-transcode -- it is the job finishing, the user not
saving yet, and the process being reclaimed hours later as an ordinary background one.

Both ViewModels now query their own worker's class name on init and pick up what they
find. Nothing is persisted for it, and nothing needed to be: `WorkRequest.Builder`
seeds every request's tag set with `workerClass.name` (`tags = mutableSetOf(
workerClass.name)`, work-runtime 2.11.2), and R8 keeps those names through
work-runtime's own consumer rule, `-keepnames class * extends
androidx.work.ListenableWorker`. A UUID in a `SavedStateHandle` would have been both
more machinery and less: it cannot find work enqueued by a previous install.

What the query cannot return is the job's input. `WorkInfo` hands back id, state, tags,
progress, output and run-attempt count -- never the `Data` a request was enqueued with
-- so a ViewModel could see that a conversion existed and where its output went, but
not which file it was converting. The display name and size therefore ride on tags too,
which is the whole of the production change to the request builders. The picked `Uri`
deliberately does not: nothing in a reattached state reads it, and a `content://` grant
taken by a picker in a process that no longer exists is not something to hand back as
though it still worked.

The decision is a pure function on the JVM test stack, following `FailureOutcome`:
`Reattachment.choose` takes what WorkManager reported and answers which job, if any.
Cancelled work is excluded -- the user already said no. Failed work is excluded, which
matters more than it looks now that a device has shown an interrupted worker coming
back FAILED rather than retried, its restart's `setForeground` refused as a background
foreground-service start: nothing marks a failure as seen, so it would otherwise
reappear on every launch. A success whose staged file is gone is excluded, because a
Save button that fails on tap is worse than no button. Live work outranks a finished
result, since a running job holds a foreground notification and someone opening the app
while that notification is in the shade is looking for that conversion.

Where several jobs qualify, the newest staged file wins, and that is not a detail.
Losing a tie is not the same as waiting for the next launch: the tag query has no
`ORDER BY`, so its order is unspecified but stable, and an arbitrary winner would keep
winning every launch while the other result stayed unreachable for as long as its file
existed. It is reachable today -- dismiss one result with "Start over", which leaves its
file behind, then convert something else and do not save it. `WorkInfo` carries no
timestamp of any kind, but the edge is already stat'ing the file, so the file's own
mtime is the ordering. A clock that moves backwards makes it a heuristic; an order that
is unspecified and repeats itself is worse.

Aliases are the tie that does not resolve, and that case is not hypothetical. A tag
query on a device returned two SUCCEEDED jobs whose output paths were both
`.../conversions/input_converted.mp4`, with one file on disk -- the staging name is
derived from the input's display name, so a later job overwrites an earlier one's output
and both go on reporting it. Checking the file does not separate them, and neither does
its mtime, since they share it. So the two halves are separated instead. The file is
offered, because it is the user's file either way and losing it is the defect being
fixed; the label is not, because saying which job produced it would be a guess.
`Reattachment.Ambiguous` says so and the card falls back to a neutral name rather than
borrowing the other job's. Aliases whose tags are identical -- the ordinary case, the
same file converted twice -- stay attributed, since nothing turns on which wrote it.

Age is not filtered on, and cannot be: WorkManager keeps finished work about a week and
prunes on its own schedule, so a query in a fresh process routinely returns jobs from
earlier sessions. Whether the staged file is still there is the only signal separating a
result still worth offering from one already dealt with, which is why that check carries
the weight.

Two smaller behaviours fall out of reattaching rather than starting:

  - A reattached job that is cancelled lands on Idle rather than Ready. Ready would put
    a Convert button over an input URI that belongs to a dead process. `observe()` takes
    the cancelled destination as a defaulted parameter, so a job started here is
    unchanged.

  - A FAILED job's message falls back when blank, not only when absent. A worker killed
    before it can report leaves no output data at all, and an exception's message can be
    the empty string; both used to reach the screen as a failure with nothing said.

Deliberately not fixed here, each being its own change: the save dialog's suggested name
and MIME still come from the current picker rather than from the job that ran, so a
reattached job in a non-default format is offered the default extension; a result
dismissed with "Start over" still keeps its staged file, so it can be offered again next
launch -- the file check closes that for free once the file is deleted; and
`setForeground` still sits outside `doWork`'s try, so its throw bypasses the retry
decision entirely.

Tested where it can be. 28 JVM tests cover the choice and the tag round trip, including
the ordering, the aliasing rules and a display name that looks like another tag. The
reattachment itself is instrumented: a ViewModel constructed against the real
WorkManager is the next launch, with no memory of the work. `WorkManagerTestInitHelper`
is deliberately not used -- its `setDelegate` replaces the singleton for the whole
process, which would quietly turn `ConversionWorkerTest` into a synchronous test double
depending on class order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 18:43:10 -05:00

331 lines
14 KiB
Kotlin

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