Merge the staging-cleanup fix, and pin the seam the two changes share

The two changes meet at one line. `pendingStaged` is recorded in the `SUCCEEDED` branch of
each ViewModel's `observe()` collector, and reattachment reaches `Converted`/`Joined`
through that same collector rather than by building the state itself -- so a job picked up
from a previous process arrives with its cleanup handle already set, and "Start over" on it
deletes the staged file exactly as it does for a conversion run in this process. Nothing had
to be added for that; it falls out of routing reattachment through `observe()`.

Which is precisely why it needed a test. The claim is structural -- one assignment, in one
function, that both changes assume -- and the conflict here was `JoinViewModel`, where the
cleanup change edits a collector body that the reattachment change had moved out of `join()`
into a private `observe()`. Resolving that by putting the assignment back in `join()` would
compile, pass every test either branch brought, and silently leak a full-size file on the one
path both changes were written for. `ReattachedCleanupTest` fails if it lands anywhere else.

Verified the way the cleanup commit verified its own wiring: deleting `pendingStaged = staged`
from `ConversionViewModel.observe()` fails "start over on a reattached conversion deletes the
staged file" alongside the two `ConversionViewModelCleanupTest` cases that share the line.
Restored, all 183 JVM tests pass.

The reattachment path also gains JVM coverage it could not have had before this merge, since
Robolectric and the `ConversionDependencies` seams arrived with it: a ViewModel constructed
after a job has already finished, with nothing left that observed it, now demonstrably reaches
`Converted` with the display name recovered from the job's tags -- on a machine where no
instrumented test can run.

Conflict resolution: both sides kept in `JoinViewModel`, with the cleanup handle declared
alongside the other fields and the reattachment `init` after them. Nothing else conflicted;
`ConversionViewModel` merged clean because the reattachment change touches the `CANCELLED` and
`FAILED` branches while the cleanup change touches `SUCCEEDED`. No build file, manifest or
`OutputPublisher` line in this merge is mine -- they arrive from the cleanup commit verbatim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 18:51:42 -05:00
co-authored by Claude Opus 5
16 changed files with 999 additions and 10 deletions
@@ -0,0 +1,118 @@
package org.libremediaconverter.convert
import android.app.Application
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import androidx.work.workDataOf
import kotlinx.coroutines.Dispatchers
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.work.ConversionWorker
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.io.File
/**
* That `reset()` actually deletes — the wiring, not the tool.
*
* D2 was never that `OutputPublisher` could not delete a file. It was that "Start over"
* dropped the reference without calling anything. So this drives the real ViewModel through
* a real `WorkManager` to `Converted` and then asserts on the filesystem.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class ConversionViewModelCleanupTest {
private lateinit var app: Application
private lateinit var publisher: RecordingPublisher
private lateinit var staged: File
@Before
fun setUp() {
app = RuntimeEnvironment.getApplication()
publisher = RecordingPublisher(app)
ConversionDependencies.publisher = { publisher }
// MediaProbe spawns FFprobe, whose loader throws a bare java.lang.Error with no
// native library present. Without this the pick dies before the test starts.
ConversionDependencies.probe = { _, _ -> InputProbe() }
staged = publisher.createStagingFile("holiday.mp4").apply { writeBytes(ByteArray(4096)) }
installTestWorkManager(app, workDataOf(ConversionWorker.KEY_OUTPUT_PATH to staged.absolutePath))
}
@After
fun tearDown() {
ConversionDependencies.reset()
}
@Test
fun `start over on a finished conversion deletes the staged file`() {
val viewModel = convertedViewModel()
assertTrue("the conversion should have produced a staged file", staged.exists())
viewModel.reset()
assertEquals(ConversionState.Idle, viewModel.state.value)
assertEquals("reset() should have discarded exactly the staged file", listOf(staged), publisher.discarded)
assertFalse("Start over must not leave a full-size copy in cache", staged.exists())
}
@Test
fun `reset after a successful save does not try to delete again`() {
val viewModel = convertedViewModel()
viewModel.save(DESTINATION)
awaitState(viewModel.state, "Saved") { it is ConversionState.Saved }
// save() deletes the staged file itself, through File.delete() rather than through
// the publisher, so assert the disappearance as well as the absent second discard.
assertFalse("a successful save should have removed the staged file", staged.exists())
viewModel.reset()
// Discarding again would be a delete aimed at a path this ViewModel no longer owns.
assertEquals(emptyList<File>(), publisher.discarded)
assertFalse(staged.exists())
}
@Test
fun `a failed save keeps the staged file, and a later reset collects it`() {
val viewModel = convertedViewModel()
publisher.publishFailure = IllegalStateException("destination volume full")
viewModel.save(DESTINATION)
awaitState(viewModel.state, "Failed") { it is ConversionState.Failed }
// The deliberate decision, pinned: the staged file may be the only copy of an hour
// of transcoding, and the destination did not receive it.
assertTrue("a failed save must not destroy the only copy", staged.exists())
assertEquals(emptyList<File>(), publisher.discarded)
// Failed carries no file reference at all, so this only works because the handle is
// a ViewModel field rather than something read back out of the state machine.
viewModel.reset()
assertEquals(listOf(staged), publisher.discarded)
assertFalse(staged.exists())
}
/** A ViewModel driven all the way to [ConversionState.Converted]. */
private fun convertedViewModel(): ConversionViewModel {
// Unconfined so reset()'s delete runs inline instead of on a real IO thread.
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
viewModel.onInputPicked(Uri.parse("content://test/holiday.mp4"))
awaitState(viewModel.state, "Ready") { it is ConversionState.Ready }
viewModel.convert()
awaitState(viewModel.state, "Converted") { it is ConversionState.Converted }
return viewModel
}
private companion object {
val DESTINATION: Uri = Uri.parse("content://test/destination.mp4")
}
}
@@ -0,0 +1,93 @@
package org.libremediaconverter.convert
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.io.File
/**
* What a pure function cannot say: the file is really gone.
*
* [StagingSweepTest] pins the rule; this pins the effect. Robolectric gives each test a
* real, empty `cacheDir` on a temp path, so this drives the actual [OutputPublisher] over
* the actual filesystem — the same calls `reset()` makes, without needing a ViewModel (both
* of those construct a `WorkManager`, which is not initialised on the JVM classpath).
*
* The instrumented suite cannot run on the development host, so this is the only place the
* "Start over leaks a full-size copy" defect can be caught before CI.
*/
@RunWith(RobolectricTestRunner::class)
class OutputPublisherStagingTest {
private lateinit var cacheDir: File
private lateinit var publisher: OutputPublisher
@Before
fun setUp() {
val context = RuntimeEnvironment.getApplication()
cacheDir = context.cacheDir
publisher = OutputPublisher(context)
}
@Test
fun `discarding a staged output actually removes it`() {
// This is the leak in D2: convert, decline to save, tap "Start over".
val staged = publisher.createStagingFile("holiday.mp4").apply { writeBytes(ByteArray(4096)) }
assertTrue("the staged file should exist to begin with", staged.exists())
assertTrue("discard should report that it deleted the file", publisher.discardStaged(staged))
assertFalse("the staged file should be gone after the reset path runs", staged.exists())
}
@Test
fun `discarding a file that is already gone is not an error`() {
// reset() after a successful save, or two resets in a row. Neither should throw.
val staged = publisher.createStagingFile("already_published.mp4")
assertFalse(publisher.discardStaged(staged))
}
@Test
fun `a file outside the staging directory is refused`() {
// The handle can originate in WorkInfo.outputData, which is a string the ViewModel
// turns straight into a File. Nothing else checks where it points.
val outsider = File(cacheDir, "someone_elses.bin").apply { writeBytes(ByteArray(16)) }
assertFalse(publisher.discardStaged(outsider))
assertTrue("a file outside staging must survive", outsider.exists())
}
@Test
fun `a path that climbs out of the staging directory is refused`() {
// The naive parent check -- comparing path strings -- passes this one.
val outsider = File(cacheDir, "climbed_to.bin").apply { writeBytes(ByteArray(16)) }
val escaping = File(cacheDir, "conversions/../climbed_to.bin")
assertFalse(publisher.discardStaged(escaping))
assertTrue("a traversal must not delete outside staging", outsider.exists())
}
@Test
fun `the sweep collects an orphan and leaves a live job alone`() {
val orphan = publisher.createStagingFile("orphan.mp4").apply { writeBytes(ByteArray(4096)) }
val liveOutput = publisher.createStagingFile("joined.mp4").apply { writeBytes(ByteArray(4096)) }
val liveList = publisher.createStagingFile("concat_list.txt").apply { writeText("file 'a.mp4'\n") }
assertTrue(orphan.setLastModified(System.currentTimeMillis() - StagingSweep.GRACE_PERIOD_MS - 60_000))
publisher.sweepStaging()
assertFalse("an abandoned output should be collected", orphan.exists())
assertTrue("a live job's output must survive", liveOutput.exists())
assertTrue("a live join's list file must survive", liveList.exists())
}
@Test
fun `the sweep tolerates a staging directory that does not exist yet`() {
File(cacheDir, "conversions").deleteRecursively()
publisher.sweepStaging()
}
}
@@ -0,0 +1,134 @@
package org.libremediaconverter.convert
import android.app.Application
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import androidx.work.WorkManager
import androidx.work.workDataOf
import kotlinx.coroutines.Dispatchers
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
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.InputProbe
import org.libremediaconverter.work.ConcatWorker
import org.libremediaconverter.work.ConversionWorker
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.io.File
/**
* Where the two staging fixes meet: a result nobody started is still a result somebody owns.
*
* Reattachment hands the user back a job this ViewModel did not start, which means the
* ViewModel inherits the staged file along with it — and "Start over" on that file has to
* delete it exactly as it would for a conversion run in this process. Neither fix implies
* the other: cleanup only reaches a file whose handle was recorded, and reattachment only
* records one because it goes through the same `observe()` the normal success path does.
* That is a structural claim about one function, which is precisely the kind that a merge
* resolving the two changes into different places would quietly break. Hence a test rather
* than a comment.
*
* These run a real `WorkManager` and a real `OutputPublisher` against a real `cacheDir`, so
* what is asserted at the end is the filesystem.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class ReattachedCleanupTest {
private lateinit var app: Application
private lateinit var publisher: RecordingPublisher
private lateinit var workManager: WorkManager
private lateinit var staged: File
@Before
fun setUp() {
app = RuntimeEnvironment.getApplication()
publisher = RecordingPublisher(app)
ConversionDependencies.publisher = { publisher }
ConversionDependencies.probe = { _, _ -> InputProbe() }
staged = publisher.createStagingFile("holiday_converted.mp4").apply { writeBytes(ByteArray(4096)) }
installTestWorkManager(app, workDataOf(ConversionWorker.KEY_OUTPUT_PATH to staged.absolutePath))
workManager = WorkManager.getInstance(app)
}
@After
fun tearDown() {
ConversionDependencies.reset()
}
/**
* The defect end to end, on the JVM: work that finished with no ViewModel left to see it,
* and a ViewModel created afterwards that finds it anyway.
*/
@Test
fun `a conversion that finished before this ViewModel existed is picked up`() {
finishAConversionWithNobodyWatching()
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
val converted = awaitState(viewModel.state, "Converted") { it is ConversionState.Converted }
converted as ConversionState.Converted
assertEquals(staged.absolutePath, converted.staged.absolutePath)
// The name came back through the job's tags — the only channel WorkManager returns,
// since WorkInfo never carries the Data a request was enqueued with.
assertEquals("holiday.mp4", converted.input.displayName)
assertEquals(4_096L, converted.input.sizeBytes)
}
@Test
fun `start over on a reattached conversion deletes the staged file`() {
finishAConversionWithNobodyWatching()
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
awaitState(viewModel.state, "Converted") { it is ConversionState.Converted }
assertTrue("the reattached result should still be on disk", staged.exists())
viewModel.reset()
assertEquals(ConversionState.Idle, viewModel.state.value)
assertEquals(
"a reattached result is still this ViewModel's to discard",
listOf(staged),
publisher.discarded,
)
assertFalse("Start over must not leave a full-size copy in cache", staged.exists())
}
@Test
fun `start over on a reattached join deletes the staged file`() {
workManager.enqueue(
ConcatWorker.request(
inputs = listOf(Uri.parse("content://test/one.mp4"), Uri.parse("content://test/two.mp4")),
totalBytes = 8_192L,
),
).result.get()
val viewModel = JoinViewModel(app, Dispatchers.Unconfined)
awaitState(viewModel.state, "Joined") { it is JoinState.Joined }
viewModel.reset()
assertEquals(listOf(staged), publisher.discarded)
assertFalse(staged.exists())
}
/**
* A job from a process that is gone: enqueued and finished before any ViewModel exists, so
* nothing observed it and nothing recorded its output.
*/
private fun finishAConversionWithNobodyWatching() {
workManager.enqueue(
ConversionWorker.request(
inputUri = Uri.parse("content://test/holiday.mp4"),
displayName = "holiday.mp4",
sizeBytes = 4_096L,
),
).result.get()
}
}
@@ -0,0 +1,106 @@
package org.libremediaconverter.convert
import android.content.Context
import android.net.Uri
import android.os.Looper
import android.util.Log
import androidx.work.Configuration
import androidx.work.Data
import androidx.work.ListenableWorker
import androidx.work.Worker
import androidx.work.WorkerFactory
import androidx.work.WorkerParameters
import androidx.work.testing.SynchronousExecutor
import androidx.work.testing.WorkManagerTestInitHelper
import kotlinx.coroutines.flow.StateFlow
import org.robolectric.Shadows.shadowOf
import java.io.File
import java.util.concurrent.TimeUnit
/**
* Shared scaffolding for the two ViewModel cleanup tests.
*
* These tests exist because [StagingSweepTest] and [OutputPublisherStagingTest] both prove
* the *tool* works while saying nothing about whether anything calls it — and the wiring is
* where D2 actually lived. Deleting the `discardStaged` line from either `reset()` left all
* of the earlier tests green.
*/
/**
* A real [OutputPublisher] that records what it was asked to discard.
*
* It still really deletes, so the assertions are about the filesystem rather than about a
* mock's memory. [publish] is stubbed because the SAF destination is not what these tests
* are about, and because making it throw is the only way to reach the failed-save branch
* deterministically.
*/
open class RecordingPublisher(context: Context) : OutputPublisher(context) {
val discarded = mutableListOf<File>()
/** When set, [publish] throws it — the failed-save path. */
var publishFailure: Throwable? = null
override fun publish(staged: File, destination: Uri) {
publishFailure?.let { throw it }
}
override fun discardStaged(staged: File): Boolean {
discarded += staged
return super.discardStaged(staged)
}
}
/**
* Stands in for whichever worker is enqueued and succeeds immediately with [outputData].
*
* The real workers cannot run here: both drive FFmpeg or Media3 through native libraries
* that do not exist on the JVM. What the ViewModel actually needs from them is one
* `SUCCEEDED` `WorkInfo` carrying an output path, and that is exactly what this produces —
* through a real `WorkManager`, so the ViewModel's own observer, its `SUCCEEDED` branch and
* its cleanup handle are all the production ones.
*/
class SucceedingWorkerFactory(private val outputData: Data) : WorkerFactory() {
override fun createWorker(
appContext: Context,
workerClassName: String,
workerParameters: WorkerParameters,
): ListenableWorker = object : Worker(appContext, workerParameters) {
override fun doWork(): Result = Result.success(outputData)
}
}
/** Installs a synchronous test WorkManager whose workers succeed with [outputData]. */
fun installTestWorkManager(context: Context, outputData: Data) {
WorkManagerTestInitHelper.initializeTestWorkManager(
context,
Configuration.Builder()
.setMinimumLoggingLevel(Log.ASSERT)
.setExecutor(SynchronousExecutor())
.setTaskExecutor(SynchronousExecutor())
.setWorkerFactory(SucceedingWorkerFactory(outputData))
.build(),
)
}
/**
* Waits for [predicate] to hold, pumping the main looper as it goes.
*
* Both ViewModels hop to a real `Dispatchers.IO` for file metadata and resume on the main
* looper, which Robolectric leaves paused. So neither a bare read of `state.value` nor a
* single `idle()` is enough, and the timeout is generous because it only has to be longer
* than a few file stats — in practice this converges in milliseconds.
*/
fun <T> awaitState(state: StateFlow<T>, description: String, predicate: (T) -> Boolean): T {
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(AWAIT_TIMEOUT_SECONDS)
while (System.nanoTime() < deadline) {
shadowOf(Looper.getMainLooper()).idle()
val current = state.value
if (predicate(current)) return current
Thread.sleep(POLL_INTERVAL_MS)
}
throw AssertionError("Timed out waiting for $description; state was ${state.value}")
}
private const val AWAIT_TIMEOUT_SECONDS = 10L
private const val POLL_INTERVAL_MS = 5L
@@ -0,0 +1,77 @@
package org.libremediaconverter.convert
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The orphan-collection rule.
*
* Timestamps are passed in as values rather than read off real files on purpose. The
* interesting part of this decision is clock arithmetic — the boundary, and a clock that
* has moved backwards — and a test that created real files would be measuring the
* filesystem's mtime granularity instead of the rule.
*/
class StagingSweepTest {
private val now = 1_700_000_000_000L
private val grace = StagingSweep.GRACE_PERIOD_MS
@Test
fun `an orphan older than the grace period is collectable`() {
// Left behind by a process that died, or by a "Start over" whose delete never ran.
val entries = listOf(StagingSweep.Entry("orphan.mp4", now - grace - 1))
assertEquals(listOf("orphan.mp4"), StagingSweep.collectable(entries, now))
}
@Test
fun `a file written moments ago is left alone`() {
// The in-flight guard. A live job's output has its mtime refreshed by every write,
// so a running conversion always looks young; deleting it would destroy the job.
val entries = listOf(StagingSweep.Entry("in_progress.mp4", now - 1_000))
assertEquals(emptyList<String>(), StagingSweep.collectable(entries, now))
}
@Test
fun `the grace boundary itself collects`() {
// Pins the comparison: age >= grace collects, age one millisecond short does not.
assertTrue(StagingSweep.isCollectable(lastModifiedMs = now - grace, nowMs = now))
assertFalse(StagingSweep.isCollectable(lastModifiedMs = now - grace + 1, nowMs = now))
}
@Test
fun `a file dated in the future is left alone`() {
// The clock moved backwards — an RTC correction, or the user setting the date. The
// age is negative, which says nothing about whether the file is still in use, so
// the safe answer is to keep it and let a later sweep decide.
val entries = listOf(StagingSweep.Entry("tomorrow.mp4", now + grace))
assertEquals(emptyList<String>(), StagingSweep.collectable(entries, now))
}
@Test
fun `an empty directory yields nothing`() {
assertEquals(emptyList<String>(), StagingSweep.collectable(emptyList(), now))
}
@Test
fun `a mixed directory names only the orphans`() {
// The whole point of narrowing the old clearStaging(): a sweep that runs while a
// join is live must not take the list file out from under it.
val entries = listOf(
StagingSweep.Entry("orphan.mp4", now - grace - 1),
StagingSweep.Entry("concat_list.txt", now - 5_000),
StagingSweep.Entry("joined.mp4", now - 5_000),
StagingSweep.Entry("older_orphan.webm", now - grace * 7),
)
assertEquals(listOf("orphan.mp4", "older_orphan.webm"), StagingSweep.collectable(entries, now))
}
@Test
fun `a shorter grace period can be asked for explicitly`() {
// The caller owns the period; the constant is only a default.
val entries = listOf(StagingSweep.Entry("recent.mp4", now - 60_000))
assertEquals(emptyList<String>(), StagingSweep.collectable(entries, now))
assertEquals(listOf("recent.mp4"), StagingSweep.collectable(entries, now, gracePeriodMs = 30_000))
}
}
@@ -0,0 +1,113 @@
package org.libremediaconverter.join
import android.app.Application
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import androidx.work.workDataOf
import kotlinx.coroutines.Dispatchers
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.convert.ConversionDependencies
import org.libremediaconverter.convert.RecordingPublisher
import org.libremediaconverter.convert.awaitState
import org.libremediaconverter.convert.installTestWorkManager
import org.libremediaconverter.work.ConcatWorker
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.io.File
/**
* The join tab's half of the same defect.
*
* Carried separately rather than parameterised with the convert tab, because the two are
* independent ViewModels that can each hold a staged file at the same time — the reason
* `clearStaging()` could not simply be wired up.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class JoinViewModelCleanupTest {
private lateinit var app: Application
private lateinit var publisher: RecordingPublisher
private lateinit var staged: File
@Before
fun setUp() {
app = RuntimeEnvironment.getApplication()
publisher = RecordingPublisher(app)
ConversionDependencies.publisher = { publisher }
staged = publisher.createStagingFile("joined.mp4").apply { writeBytes(ByteArray(4096)) }
installTestWorkManager(app, workDataOf(ConcatWorker.KEY_OUTPUT_PATH to staged.absolutePath))
}
@After
fun tearDown() {
ConversionDependencies.reset()
}
@Test
fun `start over on a finished join deletes the staged file`() {
val viewModel = joinedViewModel()
assertTrue("the join should have produced a staged file", staged.exists())
viewModel.reset()
assertEquals(JoinState.Idle, viewModel.state.value)
assertEquals(listOf(staged), publisher.discarded)
assertFalse("Start over must not leave a full-size copy in cache", staged.exists())
}
@Test
fun `reset after a successful save does not try to delete again`() {
val viewModel = joinedViewModel()
viewModel.save(DESTINATION)
awaitState(viewModel.state, "Saved") { it is JoinState.Saved }
// save() deletes the staged file itself, through File.delete() rather than through
// the publisher, so assert the disappearance as well as the absent second discard.
assertFalse("a successful save should have removed the staged file", staged.exists())
viewModel.reset()
assertEquals(emptyList<File>(), publisher.discarded)
assertFalse(staged.exists())
}
@Test
fun `a failed save keeps the staged file, and a later reset collects it`() {
val viewModel = joinedViewModel()
publisher.publishFailure = IllegalStateException("destination volume full")
viewModel.save(DESTINATION)
awaitState(viewModel.state, "Failed") { it is JoinState.Failed }
assertTrue("a failed save must not destroy the only copy", staged.exists())
assertEquals(emptyList<File>(), publisher.discarded)
viewModel.reset()
assertEquals(listOf(staged), publisher.discarded)
assertFalse(staged.exists())
}
/** A ViewModel driven all the way to [JoinState.Joined]. */
private fun joinedViewModel(): JoinViewModel {
// Unconfined so reset()'s delete runs inline instead of on a real IO thread.
val viewModel = JoinViewModel(app, Dispatchers.Unconfined)
viewModel.onInputsPicked(listOf(Uri.parse("content://test/a.mp4"), Uri.parse("content://test/b.mp4")))
awaitState(viewModel.state, "Ready") { it is JoinState.Ready }
viewModel.join()
awaitState(viewModel.state, "Joined") { it is JoinState.Joined }
return viewModel
}
private companion object {
val DESTINATION: Uri = Uri.parse("content://test/destination.mp4")
}
}