Merge remote-tracking branch 'origin/main' into tools/api-37-emulator

This commit is contained in:
2026-08-22 23:52:58 -05:00
19 changed files with 1202 additions and 24 deletions
@@ -400,8 +400,21 @@ class ConversionViewModel @JvmOverloads constructor(
activeWorkId?.let(workManager::cancelWorkById)
}
/**
* Copies the staged result out to the destination the user picked.
*
* The existence check is not redundant with the one reattachment already made. That one ran
* inside a tag query which, for a result offered on launch, can be hours older than the tap —
* and `cacheDir` is exactly the directory the OS empties when it wants space, which is also
* what the sweep does to anything a day old. Without it the file's absence arrived as
* `staged.inputStream()` throwing, and `e.message` put a raw ENOENT path on screen.
*/
fun save(destination: Uri) {
val converted = _state.value as? ConversionState.Converted ?: return
if (!converted.staged.isFile) {
_state.value = ConversionState.Failed(STAGED_FILE_GONE_MESSAGE)
return
}
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
@@ -64,9 +64,18 @@ object InputQuery {
* A join's total is only as good as its worst-known part. Adding up the ones that answered
* would produce a lower bound that reads exactly like a real total, and the space check has
* no way to tell the two apart — which is the same conflation this whole file exists to end.
*
* The sum saturates rather than wrapping. Sizes reach here non-negative — both of the ways one
* is found reject a negative answer — but nothing bounds their *sum*, and a total that wrapped
* negative would not be harmless nonsense: `OutputPublisher.hasSpaceFor` compares it against
* free space, so the largest join representable would come back as the one with the most room.
*/
fun total(sizes: List<Long?>): Long? = sizes.fold(0L as Long?) { running, size ->
if (running == null || size == null) null else running + size
when {
running == null || size == null -> null
size > Long.MAX_VALUE - running -> Long.MAX_VALUE
else -> running + size
}
}
/**
@@ -6,6 +6,23 @@ import android.provider.DocumentsContract
import android.provider.OpenableColumns
import java.io.File
/**
* What a save has to say when the staged file is not there any more.
*
* Reachable without anything going wrong: staging lives in `cacheDir`, which the OS reclaims
* whenever it wants the space, and [sweepStaging] collects anything a day old. A result offered by
* reattachment is the likeliest to meet it — the check that decided the file existed ran during a
* tag query that can be hours old by the time the Save button is tapped.
*
* A written sentence rather than the exception's message, which is what used to reach the screen:
* `/data/user/0/org.libremediaconverter/cache/conversions/4b4882….mp4: open failed: ENOENT (No such
* file or directory)` is a true statement about a path the user has never seen and cannot act on.
* Kept next to [OutputPublisher] because both ViewModels need it and staging is what it is about.
*/
const val STAGED_FILE_GONE_MESSAGE: String =
"The finished file is no longer in the cache, so there is nothing left to save. " +
"Start over to make it again."
/**
* Staging and publication of conversion output.
*
@@ -50,8 +67,18 @@ open class OutputPublisher(private val context: Context) {
* through its engine, with a message of its own.
*
* Open so a test can force a full disk; see `FakeFailures` in the instrumented source set.
*
* Written as `free - headroom > required` rather than the equivalent-looking
* `free > required + headroom`. The second overflows: a request within 128 MiB of
* [Long.MAX_VALUE] wraps the sum negative, every free-space measurement beats a negative
* number, and the check answers "plenty of room" to the largest request it can be given. That
* is reachable rather than theoretical — [InputQuery.total] sums a join's inputs, so the number
* arriving here is not bounded by any single file. Both operands are clamped at zero first, so
* the subtraction cannot underflow and a nonsense negative size decides exactly as zero does
* instead of buying slack.
*/
open fun hasSpaceFor(bytes: Long): Boolean = stagingDir.usableSpace > bytes + SPACE_HEADROOM_BYTES
open fun hasSpaceFor(bytes: Long): Boolean =
stagingDir.usableSpace.coerceAtLeast(0L) - SPACE_HEADROOM_BYTES > bytes.coerceAtLeast(0L)
/**
* The same check for a job whose input size nobody could determine — see [InputQuery].
@@ -111,14 +138,22 @@ open class OutputPublisher(private val context: Context) {
* which is the right way round, since a flush that failed means the bytes are not
* durably there to begin with.
*
* A failure from `openOutputStream` itself is deliberately outside the guard. Nothing
* has been written at that point, so there is nothing of ours to remove.
* `openOutputStream` is inside the guard as well, and the reasoning that used to keep it
* out -- "nothing has been written at that point, so there is nothing of ours to remove" --
* was wrong about what exists. SAF's `CreateDocument` contract creates the document *before*
* this is called, which is why every fixture in `OutputPublisherPublishTest` starts as an
* existing empty file. So a provider that hands out no stream at all -- gone between the
* picker and the write, or simply returning null -- left a zero-byte file at the name the
* user chose while the UI said "Could not save the file". The two bounds above are what make
* removing it safe, and they apply to this case exactly as they do to a failed copy. A
* provider that will not open its own empty document may well refuse to delete it too, which
* is already [deletePartialOutput]'s documented no-op path.
*/
open fun publish(staged: File, destination: Uri) {
val destinationWasEmpty = destinationIsKnownEmpty(destination)
val out = context.contentResolver.openOutputStream(destination)
?: error("Could not open destination for writing: $destination")
try {
val out = context.contentResolver.openOutputStream(destination)
?: error("Could not open destination for writing: $destination")
out.use { sink -> staged.inputStream().use { source -> source.copyTo(sink) } }
} catch (failure: Throwable) {
if (destinationWasEmpty) deletePartialOutput(destination, failure)
@@ -18,6 +18,7 @@ import kotlinx.coroutines.withContext
import org.libremediaconverter.convert.ConversionDependencies
import org.libremediaconverter.convert.InputFile
import org.libremediaconverter.convert.InputQuery
import org.libremediaconverter.convert.STAGED_FILE_GONE_MESSAGE
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.work.ConcatWorker
import org.libremediaconverter.work.JobTags
@@ -221,8 +222,20 @@ class JoinViewModel @JvmOverloads constructor(
activeWorkId?.let(workManager::cancelWorkById)
}
/**
* Copies the staged result out to the destination the user picked.
*
* The existence check is the same one `ConversionViewModel.save` makes, for the same reason: a
* join offered by reattachment was last seen during a tag query that may be hours old, and
* `cacheDir` is reclaimed by the OS and swept by this app. Without it the file's absence
* reached the screen as a raw ENOENT path.
*/
fun save(destination: Uri) {
val joined = _state.value as? JoinState.Joined ?: return
if (!joined.staged.isFile) {
_state.value = JoinState.Failed(STAGED_FILE_GONE_MESSAGE)
return
}
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
@@ -46,9 +46,12 @@ class ConcatWorker(context: Context, params: WorkerParameters) : CoroutineWorker
val declaredTotal = inputData
.takeIf { it.hasKeyWithValueOfType<Long>(KEY_TOTAL_BYTES) }
?.getLong(KEY_TOTAL_BYTES, 0L)
val format = OutputFormat.valueOf(
inputData.getString(KEY_FORMAT) ?: DEFAULT_FORMAT.name,
)
// Looked up rather than `valueOf` -- see the same three reads in ConversionWorker. This one
// is above the try as well, so a format name this build does not define used to throw past
// the catch: FAILED with no error in the output Data, and no staged.delete().
val format = inputData.getString(KEY_FORMAT)
?.let { name -> OutputFormat.entries.firstOrNull { it.name == name } }
?: DEFAULT_FORMAT
if (!hasRoomFor(declaredTotal, uris)) {
return Result.failure(workDataOf(KEY_ERROR to "Not enough free space to join these files."))
@@ -67,12 +67,18 @@ class ConversionWorker(context: Context, params: WorkerParameters) : CoroutineWo
.takeIf { it.hasKeyWithValueOfType<Long>(KEY_SIZE_BYTES) }
?.getLong(KEY_SIZE_BYTES, 0L)
val spec = readSpec()
val quality = QualityTier.valueOf(
inputData.getString(KEY_QUALITY) ?: QualityTier.FAST.name,
)
val preference = EnginePreference.valueOf(
inputData.getString(KEY_ENGINE_PREFERENCE) ?: EnginePreference.AUTO.name,
)
// Looked up rather than `valueOf`, for the reason [readSpec] gives twelve lines below and
// for one more: these three reads sit ABOVE the try. A name this build does not define --
// which is what a downgrade with work still queued produces, the same previous-version case
// JobTags is written for -- threw IllegalArgumentException straight past the catch, taking
// the retry, the error message and the staged file's delete with it. That is the escape
// shape setForeground was moved inside the try to end.
val quality = inputData.getString(KEY_QUALITY)
?.let { name -> QualityTier.entries.firstOrNull { it.name == name } }
?: QualityTier.FAST
val preference = inputData.getString(KEY_ENGINE_PREFERENCE)
?.let { name -> EnginePreference.entries.firstOrNull { it.name == name } }
?: EnginePreference.AUTO
if (!hasRoomFor(declaredSize, inputUri)) {
return Result.failure(workDataOf(KEY_ERROR to "Not enough free space to convert."))
@@ -104,10 +104,13 @@ sealed interface Reattachment {
* 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.
* It is also the seam a neighbouring fix acts through. "Start over" deletes the staged
* file, so a result the user dismissed stops qualifying here without this rule needing to
* know that happened — the file stops existing and the job falls out. What survives is the
* narrower gap that delete cannot close: `reset()` dispatches it to
* [kotlinx.coroutines.Dispatchers.IO] and it is cancelled with the Activity, so a
* dismissal on the way out of the app can leave the file behind. That is what
* `OutputPublisher.sweepStaging` is for, and its own KDoc names this case.
*
* Ranked, when more than one qualifies:
*
@@ -0,0 +1,98 @@
package org.libremediaconverter
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.convert.StagingSweep
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.io.File
import java.util.concurrent.TimeUnit
/**
* That process start actually sweeps.
*
* [StagingSweepTest][org.libremediaconverter.convert.StagingSweepTest] pins the age rule and
* `OutputPublisherStagingTest` pins the sweep against a real filesystem; neither says anything
* about whether anything calls it, and deleting the one line that does left the whole suite green.
* That line is the only reason this Application class exists, and it is the backstop for every leak
* `discardStaged` cannot reach — a process reclaimed before a save, a worker that failed before its
* output ever became a `Converted` state, a `reset()` whose delete was cancelled with the Activity.
*
* `onCreate()` is called again rather than a second Application being built: it is what the
* framework calls at process start, the scope it launches on is already there, and the first test
* below is what pins that the framework calls it on *this* class.
*/
@RunWith(RobolectricTestRunner::class)
class AppStartSweepTest {
private lateinit var app: LibreMediaConverterApp
private lateinit var stagingDir: File
@Before
fun setUp() {
// The cast is an assertion in itself: Robolectric builds the Application named in the
// merged manifest, so this fails if `android:name` ever stops pointing here -- in which
// case the sweep below would be perfectly correct code that never runs.
app = RuntimeEnvironment.getApplication() as LibreMediaConverterApp
stagingDir = File(app.cacheDir, "conversions").apply { mkdirs() }
stagingDir.listFiles()?.forEach { it.delete() }
}
@Test
fun `the application the manifest starts is the one that sweeps`() {
assertEquals(LibreMediaConverterApp::class.java, RuntimeEnvironment.getApplication().javaClass)
}
@Test
fun `process start collects an abandoned staged file and leaves a live one alone`() {
val abandoned = stagedFile("abandoned.mp4")
val live = stagedFile("live.mp4")
// Set explicitly. Relying on a file being written "long enough ago" is not something a test
// can arrange, and the grace period is a day.
assertTrue(
abandoned.setLastModified(System.currentTimeMillis() - StagingSweep.GRACE_PERIOD_MS - ONE_MINUTE_MS),
)
// Both files are still here on the way in. The Application was already constructed once
// before this test ran, so without this the sweep that call started could be the one that
// collected the file, and the assertion below would be about the wrong process start.
assertTrue(abandoned.exists() && live.exists())
app.onCreate()
awaitGone(abandoned)
// The other half, and the one that says the sweep is a sweep rather than a
// `clearStaging()`: the directory is shared by the convert tab, the join tab and
// ConcatEngine's list file, so deleting everything could take a file from a running job.
assertTrue("a file written moments ago belongs to a live job", live.exists())
}
/**
* Waits for [file] to be deleted.
*
* The sweep runs on `Dispatchers.IO`, deliberately: it lists a directory and stats every entry
* on the path that decides how long the launcher icon stays unresponsive. So there is nothing
* to join, and the wait is a bounded poll — long enough for a directory listing, short enough
* that a sweep which never happens fails rather than hangs.
*/
private fun awaitGone(file: File) {
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(AWAIT_TIMEOUT_SECONDS)
while (System.nanoTime() < deadline) {
if (!file.exists()) return
Thread.sleep(POLL_INTERVAL_MS)
}
fail("process start left ${file.name} in staging; nothing swept it")
}
private fun stagedFile(name: String): File = File(stagingDir, name).apply { writeBytes(ByteArray(4096)) }
private companion object {
const val ONE_MINUTE_MS = 60L * 1000
const val AWAIT_TIMEOUT_SECONDS = 10L
const val POLL_INTERVAL_MS = 5L
}
}
@@ -0,0 +1,106 @@
package org.libremediaconverter
import android.content.res.XmlResourceParser
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.xmlpull.v1.XmlPullParser
/**
* What the app lets leave the device.
*
* `data_extraction_rules.xml` is a resource rather than code, so nothing was checking it: reverting
* the whole file to the template's boilerplate left the unit tests green AND `lintDebug` green, and
* a future edit dropping the excludes would ship in silence. The failure it would cause is one
* nobody meets in development — a cloud restore or a device-to-device transfer.
*
* What is at stake is written in the file itself. WorkManager's queue is the app's entire backup
* payload, and every row in it references a `content://` URI granted to one install on one device
* and an output path under that install's `cacheDir`. Neither survives the transfer, and the rows
* are not inert when they arrive: reattachment queries WorkManager by tag on launch, so a fresh
* install would come up attached to a job the user never ran on it.
*
* Read out of the compiled resource table rather than off `src/main/res`, so what is asserted is
* what the APK actually carries. Note the limit of that: this pins the rules' content, not the
* `android:dataExtractionRules` attribute that points the system at them.
*/
@RunWith(RobolectricTestRunner::class)
class BackupExclusionsTest {
@Test
fun `the work queue is excluded from cloud backup and from device transfer alike`() {
// Both sections, because they are separately honoured: `allowBackup` stays true and the
// exclusion is per-file, so an edit that dropped either half would leave the other looking
// like the whole answer.
assertEquals(
mapOf(
"cloud-backup" to WORK_MANAGER_STATE,
"device-transfer" to WORK_MANAGER_STATE,
),
excludesBySection(),
)
}
/**
* Every `<exclude>` in the rules, as `domain:path`, grouped by the section it sits in.
*
* Both halves of each entry, because an `<exclude>` carrying no path is skipped unchecked by
* lint's own detector — so that spelling could protect nothing while still looking like a rule.
*/
private fun excludesBySection(): Map<String, Set<String>> {
// Both sections start present and empty, so a section deleted outright fails as an empty
// set rather than as a missing key -- the same finding either way, said the same way.
val found = SECTIONS.associateWith { mutableSetOf<String>() }
var section: String? = null
RuntimeEnvironment.getApplication().resources.getXml(R.xml.data_extraction_rules).use { parser ->
while (parser.next() != XmlPullParser.END_DOCUMENT) {
section = parser.sectionAfter(section, found)
}
}
return found
}
/** Folds one parse event into [found], and answers which section the parser is now inside. */
private fun XmlResourceParser.sectionAfter(section: String?, found: Map<String, MutableSet<String>>): String? =
when {
eventType == XmlPullParser.START_TAG && name in SECTIONS -> name
eventType == XmlPullParser.END_TAG && name == section -> null
eventType == XmlPullParser.START_TAG && name == "exclude" && section != null ->
section.also { found.getValue(it) += entry() }
else -> section
}
private fun XmlResourceParser.entry(): String = "${attribute("domain")}:${attribute("path")}"
/**
* The value of the attribute called [name] on the current tag.
*
* Walked by index rather than looked up by namespace. These attributes carry the `android`
* namespace in the source file, but a parser over the *compiled* resource reports them with
* none, so `getAttributeValue(namespace, name)` answers null for every one of them.
*/
private fun XmlResourceParser.attribute(name: String): String? =
(0 until attributeCount).firstOrNull { getAttributeName(it) == name }?.let { getAttributeValue(it) }
private companion object {
/** The two ways data leaves a device, both of which these rules have to answer. */
val SECTIONS = setOf("cloud-backup", "device-transfer")
/**
* WorkManager's own storage, spelled the way WorkManager spells it.
*
* The database is Room-backed and therefore in WAL mode, hence the two sidecars. Pinning
* the spelling is the point rather than a cost: a WorkManager release renaming its database
* would silently un-exclude the queue, and this failing is how anyone would find out.
*/
val WORK_MANAGER_STATE = setOf(
"database:androidx.work.workdb",
"database:androidx.work.workdb-wal",
"database:androidx.work.workdb-shm",
"sharedpref:androidx.work.util.preferences.xml",
)
}
}
@@ -0,0 +1,107 @@
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.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 org.robolectric.Shadows.shadowOf
import java.io.ByteArrayOutputStream
import java.io.File
/**
* What the user is told when the file went away between being offered and being saved.
*
* Not a corner: staging is `cacheDir`, which is what the OS empties when it wants space, and the
* sweep collects anything a day old. Reattachment is where the two are furthest apart — the check
* that decided the file existed ran inside a tag query on launch, and the Save button may not be
* tapped for hours.
*
* The real [OutputPublisher] rather than the recording stub, because the defect is what the *real*
* publish does with a staged file that is not there: `staged.inputStream()` throws, and `save()`
* put `e.message` on screen — a `/data/user/0/…/4b4882….mp4: open failed: ENOENT` path the user has
* never seen and can do nothing with.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class MissingStagedFileTest {
private lateinit var app: Application
private lateinit var publisher: OutputPublisher
private lateinit var staged: File
@Before
fun setUp() {
app = RuntimeEnvironment.getApplication()
publisher = OutputPublisher(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,
ConcatWorker.KEY_OUTPUT_PATH to staged.absolutePath,
),
)
// A destination that really opens, so the save gets far enough to reach the staged file.
// Without this the failure would be about the destination and the test would pass while
// saying nothing.
shadowOf(app.contentResolver).registerOutputStreamSupplier(DESTINATION) { ByteArrayOutputStream() }
}
@After
fun tearDown() {
ConversionDependencies.reset()
}
@Test
fun `saving a conversion whose staged file has gone says so in a sentence`() {
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 }
assertTrue("the fixture must start with a real staged file", staged.delete())
viewModel.save(DESTINATION)
val failed = awaitState(viewModel.state, "Failed") { it is ConversionState.Failed }
assertEquals(STAGED_FILE_GONE_MESSAGE, (failed as ConversionState.Failed).message)
}
@Test
fun `saving a join whose staged file has gone says so in a sentence`() {
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 }
assertTrue("the fixture must start with a real staged file", staged.delete())
viewModel.save(DESTINATION)
val failed = awaitState(viewModel.state, "Failed") { it is JoinState.Failed }
assertEquals(STAGED_FILE_GONE_MESSAGE, (failed as JoinState.Failed).message)
}
private companion object {
val DESTINATION: Uri = Uri.parse("content://test/destination.mp4")
}
}
@@ -265,10 +265,34 @@ class OutputPublisherPublishTest {
)
}
@Test
fun `a destination the provider will not open does not stay behind as an empty file`() {
// No stream supplier is registered for this URI and the fake provider does not implement
// openFile, which is a provider that has gone away between the picker and the write.
//
// The document exists all the same: SAF's CreateDocument contract created it before
// publish() was ever called, so "nothing has been written yet" was never the same claim as
// "there is nothing of ours here". Leaving it means a zero-byte file at the name the user
// chose, while the screen says the save failed.
val destination = FakeSafProvider.backingFile(documentUri)
assertEquals("the fixture starts as the empty document SAF hands back", 0L, destination.length())
val failure = runCatching { publisher.publish(staged, documentUri) }.exceptionOrNull()
assertTrue("a destination that will not open must not appear to succeed, got $failure", failure != null)
assertEquals(listOf(documentUri), FakeSafProvider.deleteRequests)
assertFalse(
"a zero-byte file must not be left at the name the user picked",
destination.exists(),
)
}
@Test
fun `a destination that cannot be opened at all fails without any cleanup`() {
// The JVM twin of UnopenableUriTest's unwritable-destination case. Nothing was
// written, so there is nothing of ours to remove.
// The JVM twin of UnopenableUriTest's unwritable-destination case. The open sits inside
// the guarded region now, so what keeps this one untouched is the guard rather than the
// placement: nothing answers for that authority, so no size can be read, and "I could not
// tell" must never authorise a delete.
val failure = runCatching { publisher.publish(staged, deadUri) }.exceptionOrNull()
assertTrue("publishing to a dead provider must not appear to succeed, got $failure", failure != null)
@@ -0,0 +1,214 @@
package org.libremediaconverter.convert
import android.app.Application
import android.net.Uri
import android.os.Looper
import android.util.Log
import androidx.media3.common.util.UnstableApi
import androidx.work.Configuration
import androidx.work.WorkManager
import androidx.work.testing.SynchronousExecutor
import androidx.work.testing.WorkManagerTestInitHelper
import androidx.work.workDataOf
import kotlinx.coroutines.Dispatchers
import org.junit.After
import org.junit.Assert.assertEquals
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 org.robolectric.Shadows.shadowOf
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executor
import java.util.concurrent.TimeUnit
/**
* The two decisions `reattach()` makes that [org.libremediaconverter.work.Reattachment] cannot.
*
* `Reattachment.choose` answers "which job", and twenty tests pin it. What it does not decide is
* whether the answer may still be used by the time it arrives, or how much of it the card is
* allowed to believe — and both of those live in the ViewModel, where nothing was asserting them.
* Deleting either guard left the whole suite green.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class ReattachGuardsTest {
private lateinit var app: Application
private lateinit var publisher: RecordingPublisher
private lateinit var workManager: WorkManager
private lateinit var staged: File
private lateinit var queries: HoldableTaskExecutor
@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)) }
queries = HoldableTaskExecutor()
WorkManagerTestInitHelper.initializeTestWorkManager(
app,
Configuration.Builder()
.setMinimumLoggingLevel(Log.ASSERT)
.setExecutor(SynchronousExecutor())
.setTaskExecutor(queries)
.setWorkerFactory(
SucceedingWorkerFactory(workDataOf(ConversionWorker.KEY_OUTPUT_PATH to staged.absolutePath)),
)
.build(),
)
workManager = WorkManager.getInstance(app)
}
@After
fun tearDown() {
queries.release()
ConversionDependencies.reset()
}
/**
* The race the guard exists for: the tag query suspends, and while it is away the user picks a
* file of their own. Reattaching over that would throw away what they just did — and, worse,
* point the Save button at yesterday's file while the card named today's.
*
* Made deterministic by holding WorkManager's task executor rather than by hoping the pick wins:
* the query cannot complete until this test lets it, so the pick has landed before the guard is
* ever reached.
*/
@Test
fun `a file picked while the query was in flight is not reattached over`() {
finishAConversionWithNobodyWatching()
val picked = File(app.cacheDir, "beach.mp4").apply { writeBytes(ByteArray(2048)) }
queries.hold()
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
viewModel.onInputPicked(Uri.fromFile(picked))
val ready = awaitState(viewModel.state, "Ready") { it is ConversionState.Ready }
// The URI, because that is what tells the two inputs apart: a reattached job's is
// Uri.EMPTY -- WorkManager never hands back the Data a request was enqueued with -- while a
// picked file's is the one the picker returned.
assertEquals(Uri.fromFile(picked), (ready as ConversionState.Ready).input.uri)
queries.release()
settle()
// The query really did run and really did reach the guard -- without this the assertion
// below would pass just as well against a reattachment that never arrived.
assertTrue("the reattach query should have been held, then run", queries.heldTasks > 0)
val current = viewModel.state.value
assertTrue("the user's pick must survive a late reattachment, got $current", current is ConversionState.Ready)
assertEquals(Uri.fromFile(picked), (current as ConversionState.Ready).input.uri)
assertEquals(2_048L, current.input.sizeBytes)
}
/**
* Two finished jobs naming one staged file, which is exactly what the device produced before
* staging was keyed on the job id.
*
* The file is the user's either way, so it is still offered. Which job wrote it is not
* knowable, so the card must not borrow either job's input name: a card labelled with the other
* conversion's file is a confident lie, where a neutral label is merely thin.
*/
@Test
fun `a result two jobs both claim is offered without being attributed to either`() {
finishAConversionWithNobodyWatching(displayName = "holiday.mp4")
finishAConversionWithNobodyWatching(displayName = "beach.mp4")
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
val converted = awaitState(viewModel.state, "Converted") { it is ConversionState.Converted }
converted as ConversionState.Converted
assertEquals(
"the bytes on disk are what the user gets back",
staged.absolutePath,
converted.staged.absolutePath,
)
assertEquals(
"neither job's name may be claimed for the other's file",
"Media file",
converted.input.displayName,
)
// The size travels in the same tags as the name, so it goes the same way rather than being
// reported as one job's number against the other job's file.
assertEquals(null, converted.input.sizeBytes)
}
/** Pumps the main looper for long enough that anything already dispatched has run. */
private fun settle() {
repeat(SETTLE_PUMPS) {
shadowOf(Looper.getMainLooper()).idle()
Thread.sleep(SETTLE_INTERVAL_MS)
}
}
private fun finishAConversionWithNobodyWatching(displayName: String = "holiday.mp4") {
workManager.enqueue(
ConversionWorker.request(
inputUri = Uri.parse("content://test/$displayName"),
displayName = displayName,
sizeBytes = 4_096L,
),
).result.get()
}
private companion object {
const val SETTLE_PUMPS = 60
const val SETTLE_INTERVAL_MS = 5L
}
}
/**
* WorkManager's task executor, with a brake the test can apply.
*
* The reattachment query is a suspending call the ViewModel makes in `init`, so a test that wants
* to act "while it is in flight" has to be able to stop it finishing. Holding the executor it runs
* on is the only seam for that: `jobSnapshots` takes no dispatcher, and racing it would make the
* assertion depend on which of two IO hops happened to return first.
*
* Never applied on the main thread. The test releases the brake from there, so a wait taken on that
* thread would deadlock the loop that was going to end it. The wait is bounded for the same class of
* reason: a wiring mistake should turn the test red, not hang the build.
*/
private class HoldableTaskExecutor : Executor {
private val released = CountDownLatch(1)
@Volatile
private var holding = false
/** How many tasks were actually held. Zero means the brake never gripped anything. */
@Volatile
var heldTasks = 0
private set
fun hold() {
holding = true
}
fun release() {
holding = false
released.countDown()
}
override fun execute(command: Runnable) {
if (holding && Looper.myLooper() != Looper.getMainLooper()) {
heldTasks++
check(released.await(HOLD_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
"a held WorkManager task was never released"
}
}
command.run()
}
private companion object {
const val HOLD_TIMEOUT_SECONDS = 10L
}
}
@@ -0,0 +1,79 @@
package org.libremediaconverter.convert
import android.content.Context
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.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.io.File
/**
* The two sums the space check is made of, at the sizes where addition stops working.
*
* `SpaceCheckTest` pins which *question* each worker asks; this pins what the answer is once the
* number is large. Both halves were live on main: `hasSpaceFor` added the headroom to the request
* before comparing, and [InputQuery.total] folded a join's inputs with nothing stopping the sum
* from wrapping. A wrapped total is not merely nonsense — it is negative, and every free-space
* measurement beats a negative number, so the check that exists to refuse impossible jobs approved
* the most impossible one it can be handed.
*
* Nothing here is about the *allocatable-versus-usable* question, which is a separate decision
* still parked. This is the arithmetic on whichever number that decision ends up producing.
*/
@RunWith(RobolectricTestRunner::class)
class SpaceArithmeticTest {
private lateinit var context: Context
private lateinit var publisher: OutputPublisher
@Before
fun setUp() {
context = RuntimeEnvironment.getApplication()
publisher = OutputPublisher(context)
}
@Test
fun `a request no disk could hold is refused rather than wrapping into plenty of room`() {
assertFalse("eight exabytes do not fit anywhere", publisher.hasSpaceFor(Long.MAX_VALUE))
// Just inside the headroom of the maximum, which is the arithmetic's actual edge: this is
// the range where `bytes + headroom` goes negative while `bytes` alone still looks huge.
assertFalse(publisher.hasSpaceFor(Long.MAX_VALUE - ONE_HUNDRED_MIB))
}
// The clamp on a negative size is deliberately NOT asserted here. It only changes the answer
// when free space is below the headroom, which this test cannot arrange -- the publisher reads
// the host's real cache volume -- so any assertion available would pass against the unclamped
// arithmetic too, and a test that cannot fail is worse than the gap it appears to close.
@Test
fun `an ordinary request is still allowed, so the refusals above are not vacuous`() {
val free = File(context.cacheDir, "conversions").usableSpace
assertTrue(
"a one-byte conversion must fit; the volume under the cache reports $free bytes free",
publisher.hasSpaceFor(1L),
)
}
@Test
fun `a join total too large to represent saturates instead of turning negative`() {
val enormous = listOf(FOUR_EXABYTES, FOUR_EXABYTES, FOUR_EXABYTES)
val total = InputQuery.total(enormous)
assertEquals(Long.MAX_VALUE, total)
// The whole point, in the shape the defect had: this total is handed straight to the space
// check by ConcatWorker, and before the clamp it arrived negative and was approved.
assertFalse("a join of three four-exabyte files does not fit", publisher.hasSpaceFor(total!!))
}
private companion object {
const val ONE_HUNDRED_MIB = 100L * 1024 * 1024
/** Big enough that three of them overflow, small enough to be a plausible `statSize`. */
const val FOUR_EXABYTES = 4_000_000_000_000_000_000L
}
}
@@ -2,7 +2,9 @@ package org.libremediaconverter.work
import android.app.ForegroundServiceStartNotAllowedException
import androidx.work.WorkInfo
import androidx.work.WorkRequest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@@ -144,8 +146,48 @@ class FailureOutcomeTest {
)
}
@Test
fun `the attempt bound outlasts a night rather than being a round number`() {
// The KDoc argues the value rather than picking one: ten attempts against WorkManager's
// default backoff span about eight and a half hours, which is what turns "the user will
// have opened the app before this gives up" into a claim instead of a hope.
//
// Asserted as that span rather than as the literal 10, so a deliberate re-tune keeping the
// property passes while an accidental one fails. The accident is not hypothetical: at 2 the
// job gives up about ninety seconds after process death -- losing exactly the long
// conversion the retry exists to protect -- and every other test in this file still passes,
// because they are all written against the constant rather than against its value.
var delay = WorkRequest.DEFAULT_BACKOFF_DELAY_MILLIS
var span = 0L
repeat(FailureOutcome.MAX_FOREGROUND_START_ATTEMPTS) {
span += delay
// Doubling per attempt, clamped, exactly as WorkManager schedules it. Coerced each
// time round so the arithmetic cannot overflow whatever the bound is set to.
delay = (delay * 2).coerceAtMost(WorkRequest.MAX_BACKOFF_MILLIS)
}
assertTrue(
"the retry budget must outlast a night; it spans ${span / MILLIS_PER_HOUR.toDouble()} hours",
span >= MINIMUM_RETRY_SPAN_MS,
)
}
private fun denied() = ForegroundServiceStartNotAllowedException(
"startForegroundService() not allowed: service " +
"org.libremediaconverter/androidx.work.impl.foreground.SystemForegroundService",
)
private companion object {
const val MILLIS_PER_HOUR = 60L * 60 * 1000
/**
* How long the denied-start retries have to keep going.
*
* Eight hours rather than the eight and a half the default backoff actually produces. The
* claim is about covering a night between one opening of the app and the next; pinning the
* exact arithmetic would instead fail on a WorkManager release that re-tuned its own
* constants without anything this app decides having changed.
*/
const val MINIMUM_RETRY_SPAN_MS = 8L * MILLIS_PER_HOUR
}
}
@@ -0,0 +1,183 @@
package org.libremediaconverter.work
import android.app.Application
import android.content.Context
import android.util.Log
import androidx.media3.common.util.UnstableApi
import androidx.work.Configuration
import androidx.work.ListenableWorker
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.Worker
import androidx.work.WorkerFactory
import androidx.work.WorkerParameters
import androidx.work.testing.SynchronousExecutor
import androidx.work.testing.WorkManagerTestInitHelper
import androidx.work.workDataOf
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
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
/**
* The edge that feeds the reattachment decision.
*
* [Reattachment.choose] is a pure function with twenty tests, and every input it reasons over is
* computed here — by the one part of reattachment that has to touch WorkManager and the
* filesystem. That asymmetry was the gap: the rule was pinned exhaustively while the values it
* ran on were pinned nowhere, so a regression in this file left the whole suite green. Two
* demonstrated ones: dropping the empty-file filter offered a zero-byte staged file as a savable
* result, and hardcoding [JobSnapshot.outputModifiedAt] to zero starved the newest-file tie-break
* of the only data it has.
*
* A real `WorkManager` and a real `cacheDir`, because both are what the code under test is for.
* The worker never runs: [EchoingWorkerFactory] stands in for a job that finished in a process
* that no longer exists, which is the only way a snapshot with an output path comes to exist at
* all.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class JobSnapshotsTest {
private lateinit var app: Application
private lateinit var workManager: WorkManager
private lateinit var stagingDir: File
@Before
fun setUp() {
app = RuntimeEnvironment.getApplication()
WorkManagerTestInitHelper.initializeTestWorkManager(
app,
Configuration.Builder()
.setMinimumLoggingLevel(Log.ASSERT)
.setExecutor(SynchronousExecutor())
.setTaskExecutor(SynchronousExecutor())
.setWorkerFactory(EchoingWorkerFactory)
.build(),
)
workManager = WorkManager.getInstance(app)
stagingDir = File(app.cacheDir, "conversions").apply { mkdirs() }
stagingDir.listFiles()?.forEach { it.delete() }
}
@Test
fun `a staged file with nothing in it is not an output`() {
// Zero bytes is what a job killed before its engine wrote anything leaves behind. Treating
// it as a result would publish it: the user taps Save and gets a zero-byte "conversion"
// rather than a message, which is worse than not being offered it.
val empty = stagedFile("empty.mp4", bytes = 0)
val real = stagedFile("real.mp4", bytes = 4096)
// Never created at all -- the OS reclaimed the cache, or the file was saved and deleted.
val reclaimed = File(stagingDir, "reclaimed.mp4")
listOf(empty, real, reclaimed).forEach(::finishedWithOutput)
val snapshots = snapshots()
assertEquals(
"only a file with bytes in it is a result",
mapOf(
empty.absolutePath to false,
real.absolutePath to true,
reclaimed.absolutePath to false,
),
snapshots.associate { it.outputPath to it.outputExists },
)
// The path is still reported for all three. It is what the worker said; whether it still
// names anything is the separate question above.
assertEquals(
setOf(empty.absolutePath, real.absolutePath, reclaimed.absolutePath),
snapshots.mapNotNull { it.outputPath }.toSet(),
)
// And a file that is not an output has no time either: an mtime read off a zero-byte
// leftover would feed the tie-break a moment nothing produced.
assertEquals(0L, snapshotFor(snapshots, empty).outputModifiedAt)
}
@Test
fun `each result carries the time its own file was last written`() {
val older = stagedFile("older.mp4", bytes = 4096)
val newer = stagedFile("newer.mp4", bytes = 4096)
// Set explicitly rather than relying on the order the two were written: a filesystem is
// free to give both the same mtime, and then the fixture would be testing nothing.
assertTrue(older.setLastModified(OLDER_MS))
assertTrue(newer.setLastModified(NEWER_MS))
assertTrue(
"the two fixtures must really carry different times, got ${older.lastModified()}",
older.lastModified() < newer.lastModified(),
)
listOf(older, newer).forEach(::finishedWithOutput)
val snapshots = snapshots()
// Compared against what the filesystem stored rather than against what was requested,
// because mtime granularity is the filesystem's business and not this test's claim.
assertEquals(older.lastModified(), snapshotFor(snapshots, older).outputModifiedAt)
assertEquals(newer.lastModified(), snapshotFor(snapshots, newer).outputModifiedAt)
// Why the field exists, asserted through the rule that reads it: the tag query has no
// ORDER BY, so without a real time here an arbitrary winner would win every launch while
// the other result stayed unreachable for as long as its file existed.
assertEquals(newer.absolutePath, Reattachment.choose(snapshots)?.job?.outputPath)
}
private fun snapshots(): List<JobSnapshot> = runBlocking {
workManager.jobSnapshots(
tag = ConversionWorker::class.java.name,
outputPathKey = ConversionWorker.KEY_OUTPUT_PATH,
)
}
private fun snapshotFor(snapshots: List<JobSnapshot>, output: File): JobSnapshot =
snapshots.single { it.outputPath == output.absolutePath }
private fun stagedFile(name: String, bytes: Int): File =
File(stagingDir, name).apply { writeBytes(ByteArray(bytes)) }
/**
* A conversion that finished with [output] as its result and nobody watching.
*
* Built rather than taken from `ConversionWorker.request`, because what has to reach
* `jobSnapshots` is the *output* `Data` of a finished job, and a request only carries input.
*/
private fun finishedWithOutput(output: File) {
workManager.enqueue(
OneTimeWorkRequestBuilder<ConversionWorker>()
.setInputData(workDataOf(ConversionWorker.KEY_OUTPUT_PATH to output.absolutePath))
.build(),
).result.get()
}
private companion object {
/** Two fixed moments a day apart, so the ordering is stated rather than raced for. */
const val OLDER_MS = 1_700_000_000_000L
const val NEWER_MS = OLDER_MS + 24L * 60 * 60 * 1000
}
}
/**
* Stands in for whichever job finished before this process existed, reporting the output path it
* was handed.
*
* The real [ConversionWorker] cannot run here — it drives Media3 and FFmpeg through native
* libraries that do not exist on the JVM — and what `jobSnapshots` needs from it is only a
* SUCCEEDED `WorkInfo` carrying an output path. Echoing the input means one factory can produce
* several jobs with results of their own, which is what the ordering and aliasing cases need.
*/
private object EchoingWorkerFactory : WorkerFactory() {
override fun createWorker(
appContext: Context,
workerClassName: String,
workerParameters: WorkerParameters,
): ListenableWorker = object : Worker(appContext, workerParameters) {
override fun doWork(): Result {
val path = inputData.getString(ConversionWorker.KEY_OUTPUT_PATH)
?: return Result.success()
return Result.success(workDataOf(ConversionWorker.KEY_OUTPUT_PATH to path))
}
}
}
@@ -10,11 +10,11 @@ import kotlinx.coroutines.runBlocking
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.OutputPublisher
import org.libremediaconverter.convert.installTestWorkManager
import org.libremediaconverter.model.DeviceCodecs
import org.libremediaconverter.model.EnginePreference
@@ -35,19 +35,24 @@ import java.util.UUID
*
* These drive the real worker rather than the naming function, because the naming function was
* never the part that was wrong. What was wrong is which name the worker asked for.
*
* Both workers, for the same reason. The join side collided harder — `joined.<ext>` is one string
* for every join of a format, where a conversion at least needed two inputs of the same name — and
* it was the half with no test at all: reverting `ConcatWorker` to that constant left all 257
* tests green.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class PerJobStagingTest {
private lateinit var app: Application
private lateinit var publisher: OutputPublisher
private lateinit var publisher: NamingPublisher
private lateinit var stagingDir: File
@Before
fun setUp() {
app = RuntimeEnvironment.getApplication()
publisher = AlwaysRoomPublisher(app)
publisher = NamingPublisher(app)
ConversionDependencies.publisher = { publisher }
ConversionDependencies.probe = { _, _ -> InputProbe() }
ConversionDependencies.deviceCodecs = { DeviceCodecs.PERMISSIVE }
@@ -56,6 +61,9 @@ class PerJobStagingTest {
stagingDir = publisher.createStagingFile("anything").parentFile!!
stagingDir.listFiles()?.forEach { it.delete() }
// Asking for the directory above is itself a staging request; the tests are about the ones
// the workers make.
publisher.requestedNames.clear()
}
@After
@@ -104,8 +112,34 @@ class PerJobStagingTest {
)
}
@Test
fun `two joins of the same format stage under names of their own`() {
runBlocking { concatWorker(JOB_A).doWork() }
runBlocking { concatWorker(JOB_B).doWork() }
// Read off what the worker asked for rather than off the directory, and not for
// convenience: ConcatEngine is native, so neither join gets past it here, and the catch on
// the way out deletes whatever was staged. The name is where the collision lived --
// "joined.${format.extension}" is one string for every join of a format, so two joins were
// one file, exactly as two conversions of a same-named input were.
val names = publisher.requestedNames
assertEquals("each join must stage under a name of its own, got $names", 2, names.toSet().size)
assertTrue("the first join's name must carry its own job id, got ${names[0]}", names[0].contains("$JOB_A"))
assertTrue("the second join's name must carry its own job id, got ${names[1]}", names[1].contains("$JOB_B"))
}
private fun stagedNames(): List<String> = stagingDir.listFiles().orEmpty().map { it.name }.sorted()
private fun concatWorker(id: UUID): ConcatWorker = TestListenableWorkerBuilder<ConcatWorker>(
context = app,
inputData = workDataOf(
ConcatWorker.KEY_INPUT_URIS to arrayOf(INPUT.toString(), "file:///tmp/second.mp4"),
ConcatWorker.KEY_TOTAL_BYTES to INPUT_BYTES,
ConcatWorker.KEY_FORMAT to JOIN_FORMAT.name,
),
runAttemptCount = 0,
).setId(id).build()
private fun conversionWorker(
id: UUID,
runAttemptCount: Int = 0,
@@ -129,6 +163,7 @@ class PerJobStagingTest {
const val DISPLAY_NAME = "input.mp4"
const val INPUT_BYTES = 1024L
val SPEC = OutputFormat.MP4_H265.spec
val JOIN_FORMAT = OutputFormat.MP4_H264
val JOB_A: UUID = UUID.fromString("00000000-0000-4000-8000-00000000000a")
val JOB_B: UUID = UUID.fromString("00000000-0000-4000-8000-00000000000b")
}
@@ -35,6 +35,16 @@ class ReattachmentTest {
assertNull(Reattachment.choose(listOf(job(state = WorkInfo.State.FAILED))))
}
@Test
fun `a failed job is not reattached to even when it left a file behind`() {
// The fixture that matters, and the one every other FAILED case here was missing: a job
// killed mid-write leaves a partial in staging -- the 2 MB orphan the device pass found --
// so the exclusion has to hold for a FAILED job that really does name a file on disk.
// Ranking it like a result would offer the user a truncated file with a Save button.
val partial = job(state = WorkInfo.State.FAILED, outputPath = STAGED, outputExists = true)
assertNull(Reattachment.choose(listOf(partial)))
}
@Test
fun `a finished result still on disk is offered`() {
val result = job(state = WorkInfo.State.SUCCEEDED, outputPath = "/cache/out.mp4", outputExists = true)
@@ -0,0 +1,173 @@
package org.libremediaconverter.work
import android.app.Application
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import androidx.work.Data
import androidx.work.ListenableWorker
import androidx.work.testing.TestListenableWorkerBuilder
import androidx.work.workDataOf
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.convert.ConversionDependencies
import org.libremediaconverter.convert.SoftwareTranscoder
import org.libremediaconverter.convert.StagingNames
import org.libremediaconverter.convert.installTestWorkManager
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.DeviceCodecs
import org.libremediaconverter.model.EnginePreference
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.io.File
import java.util.UUID
/**
* Input `Data` naming something this build does not define.
*
* Not a malformed-input hypothetical: WorkManager keeps queued and finished work for about a week,
* so a downgrade — or any rollback with work still in the queue — hands this build a job enqueued
* by another one. That is the same previous-version case [JobTags] is written for.
*
* What made it worth a test is *where* the reads are. All three sit above the workers' `try`, so an
* unknown name threw `IllegalArgumentException` out of `doWork()` entirely: WorkManager logged
* FAILURE with `reschedule = false`, the output `Data` reached the UI with zero entries so the
* screen said "Conversion failed." with nothing else, and the staged file was never deleted. That
* is the signature `setForeground` was moved inside the `try` to end, reached through a different
* door.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class WorkerEnumFallbackTest {
private lateinit var app: Application
private lateinit var publisher: NamingPublisher
@Before
fun setUp() {
app = RuntimeEnvironment.getApplication()
publisher = NamingPublisher(app)
ConversionDependencies.publisher = { publisher }
ConversionDependencies.probe = { _, _ -> InputProbe() }
ConversionDependencies.deviceCodecs = { DeviceCodecs.PERMISSIVE }
// The progress notification builds its cancel action from WorkManager.getInstance().
installTestWorkManager(app, Data.EMPTY)
}
@After
fun tearDown() {
ConversionDependencies.reset()
}
@Test
fun `a quality tier this build does not define falls back to the default`() {
val transcoder = RequestRecordingTranscoder()
ConversionDependencies.software = { transcoder }
val result = runBlocking { conversionWorker(quality = "ULTRA_FIDELITY").doWork() }
// A Result at all is half the assertion -- the read is above the try, so the defect was an
// exception rather than a wrong answer. The other half is which tier ran: falling back to
// something arbitrary would silently convert at a quality nobody asked for.
assertEquals(ListenableWorker.Result.success(), stripOutput(result))
assertEquals(listOf(QualityTier.FAST), transcoder.qualities)
}
@Test
fun `an engine preference this build does not define does not end the job`() {
// Refused on space, which is the first thing below the three reads: it proves the reads
// were reached and returned, without dragging in a routing decision this test is not about.
publisher.refuseSpace = true
val result = runBlocking { conversionWorker(preference = "FORCE_QUANTUM").doWork() }
assertEquals(
ListenableWorker.Result.failure(
workDataOf(ConversionWorker.KEY_ERROR to "Not enough free space to convert."),
),
result,
)
}
@Test
fun `an output format this build does not define falls back to the default`() {
runBlocking { concatWorker(format = "AVI_MPEG4").doWork() }
// The join itself fails -- ConcatEngine is native and there is no seam for it here -- so
// what is asserted is the name it staged under, which is where the format actually lands.
// A format nobody could resolve must produce the default's extension, not no extension and
// not a throw on the way past.
assertEquals(
listOf(StagingNames.forJob(CONCAT_ID, ConcatWorker.DEFAULT_FORMAT.extension)),
publisher.requestedNames,
)
}
/** [ListenableWorker.Result.Success] compares its output data, which these tests do not pin. */
private fun stripOutput(result: ListenableWorker.Result): ListenableWorker.Result =
if (result is ListenableWorker.Result.Success) ListenableWorker.Result.success() else result
private fun conversionWorker(
quality: String = QualityTier.FAST.name,
preference: String = EnginePreference.FORCE_SOFTWARE.name,
): ConversionWorker = TestListenableWorkerBuilder<ConversionWorker>(
context = app,
inputData = workDataOf(
ConversionWorker.KEY_INPUT_URI to INPUT.toString(),
ConversionWorker.KEY_DISPLAY_NAME to DISPLAY_NAME,
ConversionWorker.KEY_SIZE_BYTES to INPUT_BYTES,
ConversionWorker.KEY_CONTAINER to SPEC.container.name,
ConversionWorker.KEY_VIDEO_CODEC to SPEC.videoCodec.name,
ConversionWorker.KEY_AUDIO_CODEC to SPEC.audioCodec.name,
ConversionWorker.KEY_QUALITY to quality,
ConversionWorker.KEY_ENGINE_PREFERENCE to preference,
),
runAttemptCount = 0,
).setId(CONVERSION_ID).build()
private fun concatWorker(format: String): ConcatWorker = TestListenableWorkerBuilder<ConcatWorker>(
context = app,
inputData = workDataOf(
ConcatWorker.KEY_INPUT_URIS to arrayOf(INPUT.toString(), "file:///tmp/second.mp4"),
ConcatWorker.KEY_TOTAL_BYTES to INPUT_BYTES,
ConcatWorker.KEY_FORMAT to format,
),
runAttemptCount = 0,
).setId(CONCAT_ID).build()
private companion object {
val INPUT: Uri = Uri.parse("file:///tmp/holiday.mp4")
const val DISPLAY_NAME = "holiday.mp4"
const val INPUT_BYTES = 1024L
val SPEC = OutputFormat.MP4_H265.spec
val CONVERSION_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000021")
val CONCAT_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000022")
}
}
/** An engine that writes the output and remembers what it was asked to produce. */
private class RequestRecordingTranscoder : SoftwareTranscoder {
val qualities = mutableListOf<QualityTier>()
override suspend fun run(
request: ConversionRequest,
inputPath: String,
output: File,
durationMs: Long,
onProgress: (Int) -> Unit,
) {
qualities += request.quality
output.writeBytes(ByteArray(OUTPUT_BYTES))
}
private companion object {
const val OUTPUT_BYTES = 512
}
}
@@ -24,6 +24,31 @@ open class AlwaysRoomPublisher(context: Context) : OutputPublisher(context) {
override fun hasSpaceFor(bytes: Long): Boolean = true
}
/**
* An [AlwaysRoomPublisher] that records the staging names it is asked for.
*
* For a conversion the staged file survives the job and a directory listing says everything. For a
* join it does not: `ConcatEngine` is native, so no test here gets past it, and the catch on the way
* out deletes what was staged. The name the worker *asked* for is then the only place its job id
* and its output format are legible at all — the same reason `SpaceCheckTest` records the question
* rather than the verdict.
*/
open class NamingPublisher(context: Context) : AlwaysRoomPublisher(context) {
/** Every name passed to [createStagingFile], in order. */
val requestedNames = mutableListOf<String>()
/** Set to refuse every space check, the way `FakeFailures.FullDisk` does. */
var refuseSpace = false
override fun hasSpaceFor(bytes: Long): Boolean = !refuseSpace
override fun createStagingFile(name: String): File {
requestedNames += name
return super.createStagingFile(name)
}
}
/**
* An engine that writes the output file and nothing else.
*