Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
163ce54b77 | ||
|
|
d293646f69 | ||
|
|
5416788274 | ||
|
|
ad2a75d9a0 | ||
|
|
2b921fafe4 | ||
|
|
98c0e4dba2 | ||
|
|
cf540f1ecc | ||
|
|
e0412329ff | ||
|
|
948d53b67e | ||
|
|
54932e97c6 |
@@ -4,10 +4,20 @@ import android.media.MediaExtractor
|
|||||||
import android.media.MediaFormat
|
import android.media.MediaFormat
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import com.arthenica.ffmpegkit.FFmpegKit
|
||||||
|
import com.arthenica.ffmpegkit.FFmpegSession
|
||||||
|
import com.arthenica.ffmpegkit.ReturnCode
|
||||||
|
import com.arthenica.ffmpegkit.SessionState
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Assert.fail
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
@@ -139,6 +149,160 @@ class FFmpegEngineTest {
|
|||||||
assertEquals("OggS", magic)
|
assertEquals("OggS", magic)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The percentage itself, which every other test in this class computes and none of them reads.
|
||||||
|
*
|
||||||
|
* `FFmpegEngine` derives progress as `stats.time / durationMs * 100`, and the statistics
|
||||||
|
* callback runs on every conversion here — but every call site omits `onProgress`, so until
|
||||||
|
* this test nothing on any source set had ever looked at the number (#229). #196 covered the
|
||||||
|
* *worker's* progress lambda, and did it with a fake engine that reports whatever the test
|
||||||
|
* tells it to; `ProgressNotificationTest` covers throttling the same way. The arithmetic was
|
||||||
|
* the one part with no reader.
|
||||||
|
*
|
||||||
|
* ## Why the duration is deliberately wrong
|
||||||
|
*
|
||||||
|
* `sample_h264.mp4` is exactly 3.000 s, and this passes **30 s** as the duration. So the
|
||||||
|
* conversion still encodes the whole clip, `stats.time` still climbs to about 3000 ms, and the
|
||||||
|
* reported percentage tops out around **10** rather than 100.
|
||||||
|
*
|
||||||
|
* That is what makes the assertion bite. A range check alone is worthless here: replacing
|
||||||
|
* `percent` with a constant `0` satisfies "every value is in 0..100" and "the values never go
|
||||||
|
* backwards", and so does a list of `[0, 100]`. Pinning the *band* rejects every constant, and
|
||||||
|
* — because the band is a tenth of the way up — it also rejects an implementation that ignores
|
||||||
|
* `durationMs`, which would report ~100 for the same run.
|
||||||
|
*
|
||||||
|
* The bound is deliberately loose (5..25 for an expected 10). The last statistics callback can
|
||||||
|
* land slightly before the final frame, so the peak is "about 3000 ms of a claimed 30 000",
|
||||||
|
* not exactly it.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun progressIsReportedAsAFractionOfTheDurationItWasGiven() {
|
||||||
|
val seen = mutableListOf<Int>()
|
||||||
|
val out = outputFor("out_progress.mp4")
|
||||||
|
runBlocking {
|
||||||
|
engine.run(
|
||||||
|
request = ConversionRequest(spec = OutputFormat.MP4_H264.spec, quality = QualityTier.BEST),
|
||||||
|
inputPath = input.absolutePath,
|
||||||
|
output = out,
|
||||||
|
// Ten times the fixture's real 3 s. See the KDoc.
|
||||||
|
durationMs = 30_000,
|
||||||
|
onProgress = { percent -> seen += percent },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue("the statistics callback never reported progress", seen.isNotEmpty())
|
||||||
|
assertTrue("progress out of range: $seen", seen.all { it in 0..100 })
|
||||||
|
assertEquals("progress went backwards: $seen", seen.sorted(), seen)
|
||||||
|
// The band. Rejects any constant, and rejects ignoring durationMs (which would read ~100).
|
||||||
|
val peak = seen.max()
|
||||||
|
assertTrue(
|
||||||
|
"3 s of media against a claimed 30 s should peak near 10%, got $peak from $seen",
|
||||||
|
peak in 5..25,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancelling a *running* conversion actually stops the native session.
|
||||||
|
*
|
||||||
|
* Nothing on any source set did this before (#224). Every `cancel` in `app/src/androidTest` is
|
||||||
|
* `WorkManager.cancelWorkById` against work that is **queued or already finished** — the two in
|
||||||
|
* `ReattachOnLaunchTest` cancel a job carrying a one-hour initial delay, and one immediately
|
||||||
|
* after enqueue. On the JVM, `WorkerCancellationTest` and `HardwareFallbackTest`'s cancellation
|
||||||
|
* case drive a `SoftwareTranscoder` double that records the call. No test had ever asked a real
|
||||||
|
* native session to stop. This is `docs/defect-audit.md` **D10**'s forcing condition.
|
||||||
|
*
|
||||||
|
* It is the one path where cancelling wrong is silently expensive rather than loudly broken: a
|
||||||
|
* missed `FFmpegKit.cancel` leaves the native process encoding to completion while the UI says
|
||||||
|
* the job is cancelled, and nothing reports the battery and thermal cost.
|
||||||
|
*
|
||||||
|
* ## Why the assertion is the session's return code, not the output file
|
||||||
|
*
|
||||||
|
* The obvious assertion — the partial output is gone — **cannot fail**, so it would have been a
|
||||||
|
* vacuous test. `invokeOnCancellation` deletes the path, and on POSIX unlinking a file ffmpeg
|
||||||
|
* still holds open leaves ffmpeg writing to the unlinked inode; the path stays gone whether or
|
||||||
|
* not the cancel ever reached the session. Deleting `FFmpegKit.cancel` and keeping
|
||||||
|
* `output.delete()` passes that check every time.
|
||||||
|
*
|
||||||
|
* What distinguishes them is the session's own verdict: a cancelled session ends with the
|
||||||
|
* cancel return code, a completed one ends successfully. That is a fact about the session
|
||||||
|
* rather than about timing, so it is read *after* waiting for the session to leave
|
||||||
|
* [SessionState.RUNNING] rather than at a fixed delay.
|
||||||
|
*
|
||||||
|
* ## Why it cancels on RUNNING rather than on the first progress callback
|
||||||
|
*
|
||||||
|
* Measured. Cancelling from the first `onProgress` was tried first and **failed on a local API
|
||||||
|
* 34 emulator with `state=COMPLETED rc=0`** — every committed fixture is 2-3 s at 320x240, and
|
||||||
|
* the encode finishes before the first statistics callback has been delivered and acted on. The
|
||||||
|
* progress callback proves the session is running, but arrives too late to interrupt anything.
|
||||||
|
* `FFmpegKit.listSessions` shows the session [SessionState.RUNNING] far earlier.
|
||||||
|
*
|
||||||
|
* ## Why it retries, which is the part that took two attempts to get right
|
||||||
|
*
|
||||||
|
* Waiting for `RUNNING` is not on its own enough. With `MP4_H265` at [QualityTier.BEST] this
|
||||||
|
* passed four consecutive local runs and all five CI legs, then failed on the API 34 and 35 legs
|
||||||
|
* of the next PR with `state=COMPLETED rc=0`. Nothing had changed: on a loaded runner the thread
|
||||||
|
* that observed `RUNNING` can be descheduled long enough for a short encode to finish before it
|
||||||
|
* calls `cancel`. A longer timeout does not help — the wait already succeeded.
|
||||||
|
*
|
||||||
|
* Two changes together, because neither is sufficient:
|
||||||
|
*
|
||||||
|
* - **A slower encode.** `WEBM_VP9` at `BEST` is the slowest thing this builder emits:
|
||||||
|
* `libvpx-vp9 -crf 31 -b:v 0`, with `-deadline realtime` added **only** on
|
||||||
|
* [QualityTier.FAST]. Probed on an API 34 emulator, that session is still `RUNNING` at 1 s
|
||||||
|
* and finished by 2 s, against well under a second for x265 `-preset medium`.
|
||||||
|
* - **Retrying the attempt.** An attempt whose session finished before the cancel landed has
|
||||||
|
* not tested anything, so it is not a failure — it is a miss, and it is retried. Only
|
||||||
|
* exhausting [CANCEL_ATTEMPTS] is a failure, and its message says which case it hit.
|
||||||
|
*
|
||||||
|
* That keeps the mutation honest: with `FFmpegKit.cancel` removed **every** attempt ends
|
||||||
|
* `COMPLETED`, so the test still fails — it just takes [CANCEL_ATTEMPTS] tries to say so.
|
||||||
|
*
|
||||||
|
* The session is identified by diffing against the ids present before each attempt, because
|
||||||
|
* this class has already produced eight of them by the time this executes.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun cancellingARunningConversionCancelsTheNativeSession(): Unit = runBlocking {
|
||||||
|
val outcomes = mutableListOf<String>()
|
||||||
|
|
||||||
|
repeat(CANCEL_ATTEMPTS) { attempt ->
|
||||||
|
val before = FFmpegKit.listSessions().map { it.getSessionId() }.toSet()
|
||||||
|
val out = outputFor("out_cancelled_$attempt.webm")
|
||||||
|
|
||||||
|
val job = launch(Dispatchers.IO) {
|
||||||
|
engine.run(
|
||||||
|
// The slowest target this builder emits -- see the KDoc. Not decoration:
|
||||||
|
// with a faster one this loses the race on a loaded CI runner.
|
||||||
|
request = ConversionRequest(spec = OutputFormat.WEBM_VP9.spec, quality = QualityTier.BEST),
|
||||||
|
inputPath = input.absolutePath,
|
||||||
|
output = out,
|
||||||
|
durationMs = 3_000,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val ours = withTimeout(TIMEOUT_MS) {
|
||||||
|
var found: FFmpegSession? = null
|
||||||
|
while (found == null) {
|
||||||
|
found = FFmpegKit.listSessions().firstOrNull { it.getSessionId() !in before }
|
||||||
|
if (found == null) delay(POLL_MS)
|
||||||
|
}
|
||||||
|
found
|
||||||
|
}
|
||||||
|
job.cancelAndJoin()
|
||||||
|
withTimeout(TIMEOUT_MS) {
|
||||||
|
while (ours.getState() == SessionState.RUNNING) delay(POLL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ReturnCode.isCancel(ours.getReturnCode())) return@runBlocking
|
||||||
|
// The encode beat us to it. That attempt proved nothing either way, so try again.
|
||||||
|
outcomes += "state=${ours.getState()} rc=${ours.getReturnCode()}"
|
||||||
|
}
|
||||||
|
|
||||||
|
fail(
|
||||||
|
"never interrupted a running session in $CANCEL_ATTEMPTS attempts, so either every " +
|
||||||
|
"encode finished first or cancellation does not reach it: $outcomes",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// --- the quality tier the GPL licence was taken for --------------------
|
// --- the quality tier the GPL licence was taken for --------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -176,4 +340,19 @@ class FFmpegEngineTest {
|
|||||||
}.exceptionOrNull()
|
}.exceptionOrNull()
|
||||||
assertTrue("expected an FFmpegException, got $failure", failure is FFmpegEngine.FFmpegException)
|
assertTrue("expected an FFmpegException, got $failure", failure is FFmpegEngine.FFmpegException)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** Generous: it bounds a hang, and every wait here normally settles in well under a second. */
|
||||||
|
const val TIMEOUT_MS = 30_000L
|
||||||
|
const val POLL_MS = 50L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many times to try to catch the session mid-encode.
|
||||||
|
*
|
||||||
|
* Each miss costs about the length of one VP9 encode -- a second or two -- and a miss is
|
||||||
|
* the loaded-runner case rather than a defect. Five is enough that exhausting them means
|
||||||
|
* cancellation is not reaching the session, which is what the failure message says.
|
||||||
|
*/
|
||||||
|
const val CANCEL_ATTEMPTS = 5
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,20 @@ import android.media.MediaFormat
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import com.arthenica.ffmpegkit.FFmpegKit
|
||||||
|
import com.arthenica.ffmpegkit.FFmpegSession
|
||||||
|
import com.arthenica.ffmpegkit.ReturnCode
|
||||||
|
import com.arthenica.ffmpegkit.SessionState
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Assert.fail
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
@@ -16,6 +26,7 @@ import org.libremediaconverter.convert.MediaProbe
|
|||||||
import org.libremediaconverter.convert.StagingNames
|
import org.libremediaconverter.convert.StagingNames
|
||||||
import org.libremediaconverter.ffmpeg.ConcatEngine
|
import org.libremediaconverter.ffmpeg.ConcatEngine
|
||||||
import org.libremediaconverter.model.ConcatStrategy
|
import org.libremediaconverter.model.ConcatStrategy
|
||||||
|
import org.libremediaconverter.work.ConcatWorker
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,6 +61,82 @@ class ConcatEngineTest {
|
|||||||
(staged + listOf(clipA, clipB, clipMismatched)).forEach { it.delete() }
|
(staged + listOf(clipA, clipB, clipMismatched)).forEach { it.delete() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancelling a *running* join actually stops the native session.
|
||||||
|
*
|
||||||
|
* The `FFmpegEngine` half of #224 landed first (PR #236); this is the same gap in
|
||||||
|
* [ConcatEngine]. Before these two, no test on any source set had ever asked a real native
|
||||||
|
* session to stop — every `cancel` in `app/src/androidTest` targets WorkManager entries that
|
||||||
|
* are queued or already finished.
|
||||||
|
*
|
||||||
|
* ## Two things carried over from the conversion side, both measured there
|
||||||
|
*
|
||||||
|
* **The assertion is the session's return code.** A cancelled session ends with the cancel
|
||||||
|
* code, a completed one does not. The alternative — checking the output file — is even less
|
||||||
|
* available here than it was for conversions: [ConcatEngine] does not delete its output on
|
||||||
|
* cancellation at all. Its `invokeOnCancellation` is `FFmpegKit.cancel(...)` and nothing else,
|
||||||
|
* where [org.libremediaconverter.ffmpeg.FFmpegEngine]'s also deletes the partial. Whether that
|
||||||
|
* asymmetry is deliberate is a separate question from this test, which is why this asserts the
|
||||||
|
* thing that is true of both.
|
||||||
|
*
|
||||||
|
* **The cancel is triggered on [SessionState.RUNNING], not on progress.** `ConcatWorker`
|
||||||
|
* publishes no progress at all, so there is no callback to hang it on even in principle — but
|
||||||
|
* the conversion side established the deeper reason: the committed clips are 2 s at 320x240 and
|
||||||
|
* the encode outruns a callback-triggered cancel.
|
||||||
|
*
|
||||||
|
* **And the attempt is retried**, for the reason the conversion side measured the hard way: on
|
||||||
|
* a loaded runner the thread that observed `RUNNING` can be descheduled long enough for a short
|
||||||
|
* encode to finish before it calls `cancel`, which failed two CI legs there. An attempt whose
|
||||||
|
* session finished first has tested nothing, so it is a miss rather than a failure; only
|
||||||
|
* exhausting [CANCEL_ATTEMPTS] fails, and with `FFmpegKit.cancel` removed every attempt misses,
|
||||||
|
* so the mutation still bites.
|
||||||
|
*
|
||||||
|
* The inputs are deliberately the **mismatched** pair, so [ConcatStrategy.REENCODE] is chosen.
|
||||||
|
* A stream copy of two short clips is close to instantaneous and would leave nothing to
|
||||||
|
* interrupt; re-encoding is the case where a user would actually reach for Cancel.
|
||||||
|
*
|
||||||
|
* *Mutation:* drop `FFmpegKit.cancel(session.getSessionId())` from `ConcatEngine`'s
|
||||||
|
* `invokeOnCancellation` — the session runs to completion and this fails.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun cancellingARunningJoinCancelsTheNativeSession(): Unit = runBlocking {
|
||||||
|
val outcomes = mutableListOf<String>()
|
||||||
|
|
||||||
|
repeat(CANCEL_ATTEMPTS) { attempt ->
|
||||||
|
val before = FFmpegKit.listSessions().map { it.getSessionId() }.toSet()
|
||||||
|
val out = output("cancelled_join_$attempt.mp4")
|
||||||
|
|
||||||
|
val job = launch(Dispatchers.IO) {
|
||||||
|
engine.join(
|
||||||
|
listOf(Uri.fromFile(clipA), Uri.fromFile(clipMismatched)),
|
||||||
|
out,
|
||||||
|
ConcatWorker.DEFAULT_FORMAT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val ours = withTimeout(TIMEOUT_MS) {
|
||||||
|
var found: FFmpegSession? = null
|
||||||
|
while (found == null) {
|
||||||
|
found = FFmpegKit.listSessions().firstOrNull { it.getSessionId() !in before }
|
||||||
|
if (found == null) delay(POLL_MS)
|
||||||
|
}
|
||||||
|
found
|
||||||
|
}
|
||||||
|
job.cancelAndJoin()
|
||||||
|
withTimeout(TIMEOUT_MS) {
|
||||||
|
while (ours.getState() == SessionState.RUNNING) delay(POLL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ReturnCode.isCancel(ours.getReturnCode())) return@runBlocking
|
||||||
|
outcomes += "state=${ours.getState()} rc=${ours.getReturnCode()}"
|
||||||
|
}
|
||||||
|
|
||||||
|
fail(
|
||||||
|
"never interrupted a running join in $CANCEL_ATTEMPTS attempts, so either every " +
|
||||||
|
"encode finished first or cancellation does not reach it: $outcomes",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun copyAsset(name: String): File {
|
private fun copyAsset(name: String): File {
|
||||||
val out = File(context.cacheDir, name)
|
val out = File(context.cacheDir, name)
|
||||||
InstrumentationRegistry.getInstrumentation().context.assets
|
InstrumentationRegistry.getInstrumentation().context.assets
|
||||||
@@ -180,4 +267,13 @@ class ConcatEngineTest {
|
|||||||
a.width != mismatched.width || a.height != mismatched.height,
|
a.width != mismatched.width || a.height != mismatched.height,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** Generous: it bounds a hang, and both waits here normally settle in well under a second. */
|
||||||
|
const val TIMEOUT_MS = 30_000L
|
||||||
|
const val POLL_MS = 50L
|
||||||
|
|
||||||
|
/** See the conversion side: a miss is the loaded-runner case, not a defect. */
|
||||||
|
const val CANCEL_ATTEMPTS = 5
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+129
@@ -0,0 +1,129 @@
|
|||||||
|
package org.libremediaconverter.work
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.WorkInfo
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.libremediaconverter.model.OutputFormat
|
||||||
|
import org.libremediaconverter.model.QualityTier
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Cancel button in the notification shade actually cancels the job.
|
||||||
|
*
|
||||||
|
* `ConversionNotifications.build` attaches one action, wired to
|
||||||
|
* `WorkManager.createCancelPendingIntent(id)`. Before this test `createCancelPendingIntent` had
|
||||||
|
* **no references anywhere outside its own declaration** — no JVM test, no instrumented test
|
||||||
|
* (#227).
|
||||||
|
*
|
||||||
|
* That matters more than an ordinary uncovered line. A conversion runs in a foreground service and
|
||||||
|
* the user is invited to leave the app; once they do, this action is the only way to stop it. If
|
||||||
|
* the `PendingIntent` carries the wrong id, the button does nothing, the notification stays, and
|
||||||
|
* the job runs to completion — with no error, no log, and no screen to look at.
|
||||||
|
*
|
||||||
|
* ## Why this fires the intent rather than reading the shade
|
||||||
|
*
|
||||||
|
* The obvious version asks `NotificationManager.getActiveNotifications()` for id 1001 and taps what
|
||||||
|
* it finds. That was rejected: the instrumented suite grants no runtime permissions, so
|
||||||
|
* `POST_NOTIFICATIONS` is denied throughout, and whether a suppressed foreground-service
|
||||||
|
* notification is returned there is a platform detail that varies — the test would be asserting
|
||||||
|
* something about notification *visibility* rather than about cancellation.
|
||||||
|
*
|
||||||
|
* The `PendingIntent` is the subject; where it is read from is incidental. Building the
|
||||||
|
* notification for a real, live work id and firing its action exercises exactly the thing that can
|
||||||
|
* be wrong — a real `PendingIntent` dispatch reaching real `WorkManager` — and does it the same way
|
||||||
|
* on every API level.
|
||||||
|
*
|
||||||
|
* ## Why the job is delayed rather than running
|
||||||
|
*
|
||||||
|
* A conversion of the committed 3 s fixture finishes in well under a second on an emulator
|
||||||
|
* (`HardwareFallbackTest` completed one in 448 ms), so racing a cancel against a running job would
|
||||||
|
* be flaky in the direction that fails. An initial delay keeps the job reliably `ENQUEUED`, which
|
||||||
|
* is a state `cancelWorkById` acts on identically — what is under test is whether firing the action
|
||||||
|
* reaches WorkManager with the right id, not which state it interrupts.
|
||||||
|
*
|
||||||
|
* *Mutation:* build the `PendingIntent` from `UUID.randomUUID()` instead of the request's id. The
|
||||||
|
* notification looks identical and the job is never cancelled.
|
||||||
|
*/
|
||||||
|
@UnstableApi
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class NotificationCancelActionTest {
|
||||||
|
|
||||||
|
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
private val workManager = WorkManager.getInstance(context)
|
||||||
|
private lateinit var input: File
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
input = File(context.cacheDir, "cancel_action_sample.mp4")
|
||||||
|
InstrumentationRegistry.getInstrumentation().context.assets
|
||||||
|
.open("sample_h264.mp4")
|
||||||
|
.use { asset -> input.outputStream().use { asset.copyTo(it) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
input.delete()
|
||||||
|
File(context.cacheDir, "conversions").listFiles()?.forEach { it.delete() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theNotificationsCancelActionCancelsThatJob(): Unit = runBlocking {
|
||||||
|
val request = ConversionWorker.request(
|
||||||
|
inputUri = Uri.fromFile(input),
|
||||||
|
displayName = input.name,
|
||||||
|
sizeBytes = input.length(),
|
||||||
|
spec = OutputFormat.MP4_H264.spec,
|
||||||
|
quality = QualityTier.FAST,
|
||||||
|
).let { base ->
|
||||||
|
// Rebuild with a delay so the job stays ENQUEUED for the whole test. See the KDoc.
|
||||||
|
OneTimeWorkRequestBuilder<ConversionWorker>()
|
||||||
|
.setInputData(base.workSpec.input)
|
||||||
|
.setInitialDelay(1, TimeUnit.HOURS)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
workManager.enqueue(request).result.get()
|
||||||
|
|
||||||
|
// The job is queued and waiting, which is the state the cancel has to interrupt.
|
||||||
|
assertEquals(
|
||||||
|
WorkInfo.State.ENQUEUED,
|
||||||
|
withTimeout(TIMEOUT_MS) {
|
||||||
|
workManager.getWorkInfoByIdFlow(request.id).first { it != null }
|
||||||
|
}?.state,
|
||||||
|
)
|
||||||
|
|
||||||
|
val notification = ConversionNotifications(context)
|
||||||
|
.build(request.id, title = input.name, percent = 0, indeterminate = true)
|
||||||
|
val action = notification.actions?.firstOrNull()
|
||||||
|
assertNotNull("the progress notification carries no action to cancel with", action)
|
||||||
|
|
||||||
|
// The whole point: fire it the way the shade would, and see the job stop.
|
||||||
|
action!!.actionIntent.send()
|
||||||
|
|
||||||
|
val terminal = withTimeout(TIMEOUT_MS) {
|
||||||
|
workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished }
|
||||||
|
}
|
||||||
|
assertEquals(
|
||||||
|
"firing the notification's Cancel action must cancel the job it was built for",
|
||||||
|
WorkInfo.State.CANCELLED,
|
||||||
|
terminal?.state,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TIMEOUT_MS = 30_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user