Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e879ce5e3 |
@@ -27,6 +27,9 @@ import org.robolectric.RobolectricTestRunner
|
|||||||
import org.robolectric.RuntimeEnvironment
|
import org.robolectric.RuntimeEnvironment
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.ExecutionException
|
||||||
|
import java.util.concurrent.Executor
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* That a refused foreground-service start does not end the job.
|
* That a refused foreground-service start does not end the job.
|
||||||
@@ -122,40 +125,6 @@ class DeniedForegroundStartTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a join denied past the attempt bound fails with a message the user can act on`() {
|
|
||||||
// The join twin of the conversion case above. ConcatWorker reaches the same FailureOutcome
|
|
||||||
// through its own `when`, and that arm was the only one of its three with no test -- so a
|
|
||||||
// join that gave up silently, or gave up with an empty Data, would have looked identical to
|
|
||||||
// one that retried.
|
|
||||||
val worker = concatWorker(runAttemptCount = FailureOutcome.MAX_FOREGROUND_START_ATTEMPTS)
|
|
||||||
|
|
||||||
val result = runBlocking { worker.doWork() }
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
ListenableWorker.Result.failure(
|
|
||||||
workDataOf(ConcatWorker.KEY_ERROR to FailureOutcome.FOREGROUND_DENIED_MESSAGE),
|
|
||||||
),
|
|
||||||
result,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a join that gives up collects the partial it had already staged`() {
|
|
||||||
// The delete lives on ConcatWorker's `catch (e: Throwable)` path, which every give-up goes
|
|
||||||
// through. Written first so a missing delete cannot pass by asking whether a file nobody
|
|
||||||
// wrote is absent.
|
|
||||||
concatStagedFile().writeBytes(ByteArray(PARTIAL_BYTES))
|
|
||||||
|
|
||||||
runBlocking { concatWorker(runAttemptCount = FailureOutcome.MAX_FOREGROUND_START_ATTEMPTS).doWork() }
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
"a join that gave up must not orphan what it staged",
|
|
||||||
emptyList<String>(),
|
|
||||||
stagedNames(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun conversionWorker(runAttemptCount: Int = 0): ConversionWorker =
|
private fun conversionWorker(runAttemptCount: Int = 0): ConversionWorker =
|
||||||
TestListenableWorkerBuilder<ConversionWorker>(
|
TestListenableWorkerBuilder<ConversionWorker>(
|
||||||
context = app,
|
context = app,
|
||||||
@@ -172,22 +141,18 @@ class DeniedForegroundStartTest {
|
|||||||
.setForegroundUpdater(DenyingForegroundUpdater)
|
.setForegroundUpdater(DenyingForegroundUpdater)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
private fun concatWorker(runAttemptCount: Int = 0): ConcatWorker = TestListenableWorkerBuilder<ConcatWorker>(
|
private fun concatWorker(): ConcatWorker = TestListenableWorkerBuilder<ConcatWorker>(
|
||||||
context = app,
|
context = app,
|
||||||
inputData = workDataOf(
|
inputData = workDataOf(
|
||||||
ConcatWorker.KEY_INPUT_URIS to arrayOf(INPUT.toString(), "content://test/second.mp4"),
|
ConcatWorker.KEY_INPUT_URIS to arrayOf(INPUT.toString(), "content://test/second.mp4"),
|
||||||
ConcatWorker.KEY_TOTAL_BYTES to INPUT_BYTES,
|
ConcatWorker.KEY_TOTAL_BYTES to INPUT_BYTES,
|
||||||
ConcatWorker.KEY_FORMAT to CONCAT_FORMAT.name,
|
ConcatWorker.KEY_FORMAT to OutputFormat.MP4_H264.name,
|
||||||
),
|
),
|
||||||
runAttemptCount = runAttemptCount,
|
runAttemptCount = 0,
|
||||||
).setId(CONCAT_ID)
|
).setId(CONCAT_ID)
|
||||||
.setForegroundUpdater(DenyingForegroundUpdater)
|
.setForegroundUpdater(DenyingForegroundUpdater)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
/** The staging path the join will compute, asked for rather than spelled out here. */
|
|
||||||
private fun concatStagedFile(): File =
|
|
||||||
publisher.createStagingFile(StagingNames.forJob(CONCAT_ID, CONCAT_FORMAT.extension))
|
|
||||||
|
|
||||||
/** The staging path the worker will compute, asked for rather than spelled out here. */
|
/** The staging path the worker will compute, asked for rather than spelled out here. */
|
||||||
private fun stagedFile(): File = publisher.createStagingFile(StagingNames.forJob(CONVERSION_ID, SPEC.extension))
|
private fun stagedFile(): File = publisher.createStagingFile(StagingNames.forJob(CONVERSION_ID, SPEC.extension))
|
||||||
|
|
||||||
@@ -199,7 +164,6 @@ class DeniedForegroundStartTest {
|
|||||||
const val INPUT_BYTES = 1024L
|
const val INPUT_BYTES = 1024L
|
||||||
const val PARTIAL_BYTES = 2048
|
const val PARTIAL_BYTES = 2048
|
||||||
val SPEC = OutputFormat.MP4_H265.spec
|
val SPEC = OutputFormat.MP4_H265.spec
|
||||||
val CONCAT_FORMAT = OutputFormat.MP4_H264
|
|
||||||
val CONVERSION_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000001")
|
val CONVERSION_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000001")
|
||||||
val CONCAT_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000002")
|
val CONCAT_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000002")
|
||||||
}
|
}
|
||||||
@@ -218,3 +182,18 @@ private object DenyingForegroundUpdater : ForegroundUpdater {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An already-failed future, written out rather than pulled from a futures library.
|
||||||
|
*
|
||||||
|
* `await()` takes the `isDone` fast path and unwraps the `ExecutionException`, which is what puts
|
||||||
|
* the platform's own exception in front of the worker's catch rather than a wrapper.
|
||||||
|
*/
|
||||||
|
private class FailedFuture(private val failure: Throwable) : ListenableFuture<Void> {
|
||||||
|
override fun addListener(listener: Runnable, executor: Executor): Unit = executor.execute(listener)
|
||||||
|
override fun cancel(mayInterruptIfRunning: Boolean): Boolean = false
|
||||||
|
override fun isCancelled(): Boolean = false
|
||||||
|
override fun isDone(): Boolean = true
|
||||||
|
override fun get(): Void = throw ExecutionException(failure)
|
||||||
|
override fun get(timeout: Long, unit: TimeUnit): Void = throw ExecutionException(failure)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,276 +0,0 @@
|
|||||||
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.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.SoftwareTranscoder
|
|
||||||
import org.libremediaconverter.convert.installTestWorkManager
|
|
||||||
import org.libremediaconverter.model.AudioCodec
|
|
||||||
import org.libremediaconverter.model.ContainerCapabilities
|
|
||||||
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.OutputSpec
|
|
||||||
import org.libremediaconverter.model.Validation
|
|
||||||
import org.libremediaconverter.model.VideoCodec
|
|
||||||
import org.robolectric.RobolectricTestRunner
|
|
||||||
import org.robolectric.RuntimeEnvironment
|
|
||||||
import java.io.File
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Jobs the worker refuses before it converts anything, and what it says about them.
|
|
||||||
*
|
|
||||||
* Two exits, both cold before this file, and both reachable for the same underlying reason: **a job
|
|
||||||
* does not have to come from the picker.** WorkManager keeps queued and finished work for about a
|
|
||||||
* week, so a downgrade or a rollback hands this build a job enqueued by another one — the premise
|
|
||||||
* `WorkerEnumFallbackTest` and `JobTags` are both written on — and `ConversionWorker.request(...)`
|
|
||||||
* is callable directly.
|
|
||||||
*
|
|
||||||
* What makes these worth their own file rather than another case in an existing one is that both
|
|
||||||
* are about the *message*. A refusal that fails with empty output `Data` renders the UI's generic
|
|
||||||
* "Conversion failed." with nothing else to say, which is the defect shape `DeniedForegroundStartTest`
|
|
||||||
* records from the device pass. Asserting the verdict alone would pass against exactly that.
|
|
||||||
*/
|
|
||||||
@UnstableApi
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
|
||||||
class RefusedJobTest {
|
|
||||||
|
|
||||||
private lateinit var app: Application
|
|
||||||
private lateinit var publisher: OutputPublisher
|
|
||||||
private lateinit var engine: RefusingTranscoder
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setUp() {
|
|
||||||
app = RuntimeEnvironment.getApplication()
|
|
||||||
publisher = AlwaysRoomPublisher(app)
|
|
||||||
engine = RefusingTranscoder()
|
|
||||||
ConversionDependencies.publisher = { publisher }
|
|
||||||
ConversionDependencies.software = { engine }
|
|
||||||
// Neither test is about probing or about this machine's codecs; both would otherwise decide
|
|
||||||
// the outcome for reasons no assertion mentions. See WorkerCancellationTest's setUp.
|
|
||||||
ConversionDependencies.probe = { _, _ -> InputProbe() }
|
|
||||||
ConversionDependencies.deviceCodecs = { DeviceCodecs.PERMISSIVE }
|
|
||||||
installTestWorkManager(app, Data.EMPTY)
|
|
||||||
}
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun tearDown() {
|
|
||||||
ConversionDependencies.reset()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a job with no input URI fails with a message rather than a bare failure`() {
|
|
||||||
val result = runBlocking { workerWithout(ConversionWorker.KEY_INPUT_URI).doWork() }
|
|
||||||
|
|
||||||
// `Failure.equals` compares output data, so this pins the message and the verdict together.
|
|
||||||
assertEquals(
|
|
||||||
ListenableWorker.Result.failure(workDataOf(ConversionWorker.KEY_ERROR to "No input file.")),
|
|
||||||
result,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a job with no input URI stages nothing`() {
|
|
||||||
// The URI read is the first thing doWork does -- above the space check, above the staging
|
|
||||||
// name, above the try. A refusal there must not have reserved anything.
|
|
||||||
runBlocking { workerWithout(ConversionWorker.KEY_INPUT_URI).doWork() }
|
|
||||||
|
|
||||||
assertEquals("a job refused for having no input must not stage a file", emptyList<String>(), stagedNames())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a spec the picker would never have allowed is refused with the reason`() {
|
|
||||||
// WAV carries PCM and nothing else. The picker cannot produce this combination today, which
|
|
||||||
// is exactly why the worker checks: the job can arrive from a queue written before the
|
|
||||||
// settings changed, or from a direct request(...) call.
|
|
||||||
val expected = ContainerCapabilities.validate(REFUSED_SPEC, InputProbe()) as? Validation.Invalid
|
|
||||||
?: throw AssertionError("the fixture spec is supposed to be invalid; ContainerCapabilities disagrees")
|
|
||||||
|
|
||||||
val result = runBlocking { worker(REFUSED_SPEC).doWork() }
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
ListenableWorker.Result.failure(workDataOf(ConversionWorker.KEY_ERROR to expected.message)),
|
|
||||||
result,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a refused spec never reaches an engine`() {
|
|
||||||
// The half that says it failed *before* converting rather than during. Without this, a
|
|
||||||
// worker that ran the job and then reported the validation message would pass the test
|
|
||||||
// above -- and would have spent the user's battery on a file it was going to refuse.
|
|
||||||
runBlocking { worker(REFUSED_SPEC).doWork() }
|
|
||||||
|
|
||||||
assertTrue("a refused spec must be refused before any engine runs", engine.invocations.isEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a valid spec is not refused`() {
|
|
||||||
// The control. Every assertion above is about a refusal, so without this they would all
|
|
||||||
// still pass against a worker that refused everything.
|
|
||||||
val result = runBlocking { worker(OutputFormat.MP4_H265.spec).doWork() }
|
|
||||||
|
|
||||||
assertEquals(ListenableWorker.Result.success(), stripOutput(result))
|
|
||||||
assertEquals(listOf(OutputFormat.MP4_H265.spec), engine.invocations)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- the same refusal, on the join side ----------------------------------
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a join of a single file is refused with a message rather than joined`() {
|
|
||||||
// The arm beside it -- a job with no URI array at all -- is covered on the device by
|
|
||||||
// `UnopenableUriTest.aJoinWithNoInputArrayFailsWithAMessage`. This one was covered by
|
|
||||||
// nothing in either source set, which a coverage report cannot say because it cannot see
|
|
||||||
// androidTest: the two arms are adjacent lines and only one of them had a test.
|
|
||||||
//
|
|
||||||
// Reachable for the reason this file's header gives, plus one of its own: `request(...)`
|
|
||||||
// takes a `List<Uri>` and checks nothing about its length, so a single-item join is a
|
|
||||||
// well-formed call, not a corrupted queue entry.
|
|
||||||
val result = runBlocking { joinWorker(INPUT).doWork() }
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
ListenableWorker.Result.failure(
|
|
||||||
workDataOf(ConcatWorker.KEY_ERROR to "Pick at least two files to join."),
|
|
||||||
),
|
|
||||||
result,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a join of two files is not refused for its count`() {
|
|
||||||
// The control, and the half that makes the test above bite on the boundary rather than on
|
|
||||||
// the message: without it, `uris.size < 3` passes everything here.
|
|
||||||
//
|
|
||||||
// It refuses the space instead of letting the job run, because the next thing past the
|
|
||||||
// count guard is `ConcatEngine`, which is native -- `NamingPublisher`'s KDoc records that
|
|
||||||
// no JVM test gets past it. A refusal with the *space* message is proof that execution
|
|
||||||
// reached line 57, which is proof it got past line 42, and it costs no engine to say so.
|
|
||||||
val noRoom = NamingPublisher(app).apply { refuseSpace = true }
|
|
||||||
ConversionDependencies.publisher = { noRoom }
|
|
||||||
|
|
||||||
val result = runBlocking { joinWorker(INPUT, SECOND_INPUT).doWork() }
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
ListenableWorker.Result.failure(
|
|
||||||
workDataOf(ConcatWorker.KEY_ERROR to "Not enough free space to join these files."),
|
|
||||||
),
|
|
||||||
result,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** [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 worker(spec: OutputSpec): ConversionWorker = build(
|
|
||||||
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_ENGINE_PREFERENCE to EnginePreference.FORCE_SOFTWARE.name,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The ordinary input `Data`, less one key.
|
|
||||||
*
|
|
||||||
* Built by removal rather than by spelling out a shorter map, so the test cannot drift into
|
|
||||||
* omitting something else as well and passing for a reason it does not name.
|
|
||||||
*/
|
|
||||||
private fun workerWithout(key: String): ConversionWorker {
|
|
||||||
val full = OutputFormat.MP4_H265.spec
|
|
||||||
val entries = mapOf(
|
|
||||||
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 full.container.name,
|
|
||||||
ConversionWorker.KEY_VIDEO_CODEC to full.videoCodec.name,
|
|
||||||
ConversionWorker.KEY_AUDIO_CODEC to full.audioCodec.name,
|
|
||||||
ConversionWorker.KEY_ENGINE_PREFERENCE to EnginePreference.FORCE_SOFTWARE.name,
|
|
||||||
) - key
|
|
||||||
return build(Data.Builder().putAll(entries).build())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun build(data: Data): ConversionWorker =
|
|
||||||
TestListenableWorkerBuilder<ConversionWorker>(context = app, inputData = data, runAttemptCount = 0)
|
|
||||||
.setId(JOB_ID)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A join job carrying [inputs], a declared total, and a format.
|
|
||||||
*
|
|
||||||
* The total is declared so `hasRoomFor` takes its `hasSpaceFor` branch: the other branch is
|
|
||||||
* `hasSpaceForUnknownSize`, which `NamingPublisher` does not override and which would measure
|
|
||||||
* this machine's real disk.
|
|
||||||
*/
|
|
||||||
private fun joinWorker(vararg inputs: Uri): ConcatWorker = TestListenableWorkerBuilder<ConcatWorker>(
|
|
||||||
context = app,
|
|
||||||
inputData = workDataOf(
|
|
||||||
ConcatWorker.KEY_INPUT_URIS to inputs.map(Uri::toString).toTypedArray(),
|
|
||||||
ConcatWorker.KEY_TOTAL_BYTES to INPUT_BYTES * inputs.size,
|
|
||||||
ConcatWorker.KEY_FORMAT to OutputFormat.MP4_H264.name,
|
|
||||||
),
|
|
||||||
runAttemptCount = 0,
|
|
||||||
).setId(JOB_ID).build()
|
|
||||||
|
|
||||||
private fun stagedNames(): List<String> =
|
|
||||||
publisher.createStagingFile("anything").parentFile?.listFiles().orEmpty().map { it.name }.sorted()
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
val INPUT: Uri = Uri.parse("file:///tmp/holiday.mp4")
|
|
||||||
const val DISPLAY_NAME = "holiday.mp4"
|
|
||||||
const val INPUT_BYTES = 1024L
|
|
||||||
|
|
||||||
/** A join needs two, and "two" is the boundary the count guard is about. */
|
|
||||||
val SECOND_INPUT: Uri = Uri.parse("file:///tmp/holiday-2.mp4")
|
|
||||||
|
|
||||||
/** WAV carries PCM and nothing else, so AAC in WAV has nowhere to go. */
|
|
||||||
val REFUSED_SPEC = OutputSpec(
|
|
||||||
org.libremediaconverter.model.Container.WAV,
|
|
||||||
VideoCodec.NONE,
|
|
||||||
AudioCodec.AAC,
|
|
||||||
)
|
|
||||||
val JOB_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000005")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An engine that records what it was asked for and writes an output, so a success is a success. */
|
|
||||||
private class RefusingTranscoder : SoftwareTranscoder {
|
|
||||||
|
|
||||||
/** Every spec that actually reached an engine. Empty is the assertion for a refused job. */
|
|
||||||
val invocations = mutableListOf<OutputSpec>()
|
|
||||||
|
|
||||||
override suspend fun run(
|
|
||||||
request: ConversionRequest,
|
|
||||||
inputPath: String,
|
|
||||||
output: File,
|
|
||||||
durationMs: Long,
|
|
||||||
onProgress: (Int) -> Unit,
|
|
||||||
) {
|
|
||||||
invocations += request.spec
|
|
||||||
output.writeBytes(ByteArray(OUTPUT_BYTES))
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val OUTPUT_BYTES = 512
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,12 @@
|
|||||||
package org.libremediaconverter.work
|
package org.libremediaconverter.work
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.work.Data
|
import androidx.work.Data
|
||||||
import androidx.work.ForegroundInfo
|
|
||||||
import androidx.work.ForegroundUpdater
|
|
||||||
import androidx.work.ListenableWorker
|
import androidx.work.ListenableWorker
|
||||||
import androidx.work.testing.TestListenableWorkerBuilder
|
import androidx.work.testing.TestListenableWorkerBuilder
|
||||||
import androidx.work.workDataOf
|
import androidx.work.workDataOf
|
||||||
import com.google.common.util.concurrent.ListenableFuture
|
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
@@ -22,7 +18,6 @@ import org.junit.runner.RunWith
|
|||||||
import org.libremediaconverter.convert.ConversionDependencies
|
import org.libremediaconverter.convert.ConversionDependencies
|
||||||
import org.libremediaconverter.convert.OutputPublisher
|
import org.libremediaconverter.convert.OutputPublisher
|
||||||
import org.libremediaconverter.convert.SoftwareTranscoder
|
import org.libremediaconverter.convert.SoftwareTranscoder
|
||||||
import org.libremediaconverter.convert.StagingNames
|
|
||||||
import org.libremediaconverter.convert.installTestWorkManager
|
import org.libremediaconverter.convert.installTestWorkManager
|
||||||
import org.libremediaconverter.model.ConversionRequest
|
import org.libremediaconverter.model.ConversionRequest
|
||||||
import org.libremediaconverter.model.DeviceCodecs
|
import org.libremediaconverter.model.DeviceCodecs
|
||||||
@@ -113,54 +108,6 @@ class WorkerCancellationTest {
|
|||||||
assertEquals("a failed attempt must not leave its partial behind", emptyList<String>(), stagedNames())
|
assertEquals("a failed attempt must not leave its partial behind", emptyList<String>(), stagedNames())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a cancelled join propagates instead of being turned into a Result`() {
|
|
||||||
val thrown = runCatching { runBlocking { concatWorker().doWork() } }.exceptionOrNull()
|
|
||||||
|
|
||||||
assertTrue(
|
|
||||||
"cancellation must leave doWork as cancellation, not as a Result; got $thrown",
|
|
||||||
thrown is CancellationException,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a cancelled join still deletes the partial it had already staged`() {
|
|
||||||
// Written first, so a missing delete cannot pass by asking whether a file nobody wrote is
|
|
||||||
// absent -- the same reason PartialThenFailingTranscoder writes before it throws.
|
|
||||||
concatStagedFile().writeBytes(ByteArray(PARTIAL_STAGED_BYTES))
|
|
||||||
|
|
||||||
runCatching { runBlocking { concatWorker().doWork() } }
|
|
||||||
|
|
||||||
assertEquals("a cancelled join must not leave its partial behind", emptyList<String>(), stagedNames())
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A join whose foreground start is cancelled rather than denied.
|
|
||||||
*
|
|
||||||
* The conversion twin cancels *inside the engine*, which is the honest shape there because
|
|
||||||
* `ConversionDependencies` has a seam for it. `ConcatWorker` calls `ConcatEngine` directly and
|
|
||||||
* has no such seam -- it is native, and nothing here gets past it -- so the cancellation is
|
|
||||||
* injected at the only other point inside the `try`: `setForeground`. That is not a contrivance.
|
|
||||||
* A job cancelled while WorkManager is promoting it to the foreground is precisely when the
|
|
||||||
* window is open, and what is being tested is the `catch` arm, which cannot tell where in the
|
|
||||||
* `try` the cancellation came from.
|
|
||||||
*/
|
|
||||||
private fun concatWorker(): 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 CONCAT_FORMAT.name,
|
|
||||||
),
|
|
||||||
runAttemptCount = 0,
|
|
||||||
).setId(CONCAT_ID)
|
|
||||||
.setForegroundUpdater(CancellingForegroundUpdater)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
/** The staging path the join will compute, asked for rather than spelled out here. */
|
|
||||||
private fun concatStagedFile(): File =
|
|
||||||
publisher.createStagingFile(StagingNames.forJob(CONCAT_ID, CONCAT_FORMAT.extension))
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A worker routed to the software engine, which is [failure] and nothing else.
|
* A worker routed to the software engine, which is [failure] and nothing else.
|
||||||
*
|
*
|
||||||
@@ -195,10 +142,7 @@ class WorkerCancellationTest {
|
|||||||
const val DISPLAY_NAME = "holiday.mp4"
|
const val DISPLAY_NAME = "holiday.mp4"
|
||||||
const val INPUT_BYTES = 1024L
|
const val INPUT_BYTES = 1024L
|
||||||
val SPEC = OutputFormat.MP4_H265.spec
|
val SPEC = OutputFormat.MP4_H265.spec
|
||||||
val CONCAT_FORMAT = OutputFormat.MP4_H264
|
|
||||||
const val PARTIAL_STAGED_BYTES = 2048
|
|
||||||
val JOB_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000003")
|
val JOB_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000003")
|
||||||
val CONCAT_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000004")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,19 +167,3 @@ private class PartialThenFailingTranscoder(private val failure: () -> Nothing) :
|
|||||||
const val PARTIAL_BYTES = 2048
|
const val PARTIAL_BYTES = 2048
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Stands in for a job cancelled while WorkManager is promoting it to the foreground.
|
|
||||||
*
|
|
||||||
* The mechanism `DeniedForegroundStartTest` documents, carrying a different exception:
|
|
||||||
* `WorkForegroundUpdater` propagates whatever the future failed with, and
|
|
||||||
* `ListenableFuture.await()` unwraps the `ExecutionException`, so the worker meets a bare
|
|
||||||
* `CancellationException` exactly where a real cancellation would put one.
|
|
||||||
*/
|
|
||||||
private object CancellingForegroundUpdater : ForegroundUpdater {
|
|
||||||
override fun setForegroundAsync(
|
|
||||||
context: Context,
|
|
||||||
id: UUID,
|
|
||||||
foregroundInfo: ForegroundInfo,
|
|
||||||
): ListenableFuture<Void> = FailedFuture(CancellationException("cancelled while going foreground"))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
package org.libremediaconverter.work
|
package org.libremediaconverter.work
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.google.common.util.concurrent.ListenableFuture
|
|
||||||
import org.libremediaconverter.convert.OutputPublisher
|
import org.libremediaconverter.convert.OutputPublisher
|
||||||
import org.libremediaconverter.convert.SoftwareTranscoder
|
import org.libremediaconverter.convert.SoftwareTranscoder
|
||||||
import org.libremediaconverter.model.ConversionRequest
|
import org.libremediaconverter.model.ConversionRequest
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.concurrent.ExecutionException
|
|
||||||
import java.util.concurrent.Executor
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scaffolding more than one worker test needs.
|
* Scaffolding more than one worker test needs.
|
||||||
@@ -72,25 +68,3 @@ object WritingTranscoder : SoftwareTranscoder {
|
|||||||
|
|
||||||
private const val OUTPUT_BYTES = 512
|
private const val OUTPUT_BYTES = 512
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* An already-failed future, written out rather than pulled from a futures library.
|
|
||||||
*
|
|
||||||
* `await()` takes the `isDone` fast path and unwraps the `ExecutionException`, which is what puts
|
|
||||||
* the original exception in front of the worker's `catch` rather than a wrapper. That is the whole
|
|
||||||
* mechanism behind driving a `ForegroundUpdater` to fail: `WorkForegroundUpdater` propagates
|
|
||||||
* whatever the future failed with rather than swallowing it, so `setForeground()` throws exactly
|
|
||||||
* what is handed here.
|
|
||||||
*
|
|
||||||
* Shared because two tests inject two different failures through it -- a denied foreground start
|
|
||||||
* and a cancellation -- and Kotlin will not take two file-private top-level classes of one name in
|
|
||||||
* one package.
|
|
||||||
*/
|
|
||||||
internal class FailedFuture(private val failure: Throwable) : ListenableFuture<Void> {
|
|
||||||
override fun addListener(listener: Runnable, executor: Executor): Unit = executor.execute(listener)
|
|
||||||
override fun cancel(mayInterruptIfRunning: Boolean): Boolean = false
|
|
||||||
override fun isCancelled(): Boolean = false
|
|
||||||
override fun isDone(): Boolean = true
|
|
||||||
override fun get(): Void = throw ExecutionException(failure)
|
|
||||||
override fun get(timeout: Long, unit: TimeUnit): Void = throw ExecutionException(failure)
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user