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.CompletableDeferred 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.ConversionRequest import org.libremediaconverter.model.Engine import org.libremediaconverter.model.OutputFormat import org.libremediaconverter.model.QualityTier 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 /** * A [SoftwareTranscoder] that holds the worker in [WorkInfo.State.RUNNING] until released. * * Declared here rather than in `FakeFailures` because it is the only test that needs a job to stay * live on demand, and the shape is specific to that: the others fake a *failure*, this fakes * *duration*. */ private class BlockingTranscoder(private val released: CompletableDeferred) : SoftwareTranscoder { override suspend fun run( request: ConversionRequest, inputPath: String, output: File, durationMs: Long, onProgress: (Int) -> Unit, ) { released.await() output.writeBytes(ByteArray(1_024)) } } @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() { // The suite runs without Android Test Orchestrator, so every class shares one process and // a swapped seam outlives the class that set it. Only one test here swaps one, but a // BlockingTranscoder left in place would hang the next class that converts anything. ConversionDependencies.reset() 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 } /** * Reattaching to a conversion that is **running right now**, which nothing had ever driven. * * This class covers a job that finished, one whose staged file is gone, an ambiguous pair, one * still queued, and one the user cancelled. [Reattachment.rank] gives * [WorkInfo.State.RUNNING] the **highest** rank of all — "live work outranks a finished result * because a running job is holding a foreground service" — and no test on either source set * ever produced one. `ReattachmentTest` exercises the ranking as a pure function over * fabricated snapshots; what was missing is a ViewModel meeting a real running job. * * It is also the likeliest reattachment there is: the user starts a conversion, leaves, and * comes back while it is still going. * * ## Why the engine is a fake here, and why that is not a weakening * * The job has to still be running when the ViewModel is built, and every real conversion in * this suite finishes in about a second — racing that is what made the cancellation tests flaky * enough to need retries (#224). A [SoftwareTranscoder] that blocks until released removes the * race outright: the job is `RUNNING` for exactly as long as the test wants. * * Nothing about reattachment depends on which engine is transcoding. What is under test is the * tag query, [Reattachment.choose] over live WorkManager state, and `observe` mapping it to * [ConversionState.Converting] — all of which run identically whatever is doing the work. * * ## What this does not do, and cannot (#230) * * It does not kill the process. `docs/defect-audit.md` D3/D13 record that `am kill` refuses a * process holding a foreground service, and there is a more basic obstacle: **instrumentation * runs in the app's own process**, so any route that really killed it would take the test * runner with it and there would be nothing left to assert with. A relaunch-and-observe test * needs two instrumentation runs, which the runner does not provide. * * So process death stays device-manual, and this is the closest observable analogue: a fresh * ViewModel, with no memory of the work, meeting a job that is genuinely mid-flight. */ @Test fun reattachesToAConversionThatIsStillRunning(): Unit = runBlocking { val released = CompletableDeferred() ConversionDependencies.software = { BlockingTranscoder(released) } val request = ConversionWorker.request( inputUri = Uri.fromFile(stage("running_input.mp3")), displayName = RUNNING_NAME, sizeBytes = RUNNING_SIZE, spec = OutputFormat.MP3.spec, quality = QualityTier.FAST, ) workManager.enqueue(request).result.get() // Deterministic: the worker cannot finish until this test lets it. withTimeout(TIMEOUT_MS) { workManager.getWorkInfoByIdFlow(request.id).first { it?.state == WorkInfo.State.RUNNING } } val reattached = awaitConversion() assertEquals(RUNNING_NAME, reattached.input.displayName) assertEquals(RUNNING_SIZE, reattached.input.sizeBytes) released.complete(Unit) workManager.cancelWorkById(request.id).result.get() } /** * 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 /** Read back off the job's tags by the reattaching ViewModel, so both have to survive. */ const val RUNNING_NAME = "still_running.mp3" const val RUNNING_SIZE = 4_242L } }