Merge branch 'fix/native-boundary-guards' into scratch/integrate-d2-d3

This commit is contained in:
2026-08-22 19:57:11 -05:00
12 changed files with 520 additions and 45 deletions
-1
View File
@@ -29,7 +29,6 @@
android:name=".LibreMediaConverterApp"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
@@ -3,6 +3,7 @@ package org.libremediaconverter.convert
import android.app.Application
import android.net.Uri
import android.provider.OpenableColumns
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import androidx.media3.common.util.UnstableApi
@@ -20,6 +21,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.libremediaconverter.ffmpeg.isNativeLoadFailure
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.ContainerCapabilities
@@ -220,7 +222,7 @@ class ConversionViewModel @JvmOverloads constructor(
// would read as the app having ignored the tap.
_state.value = ConversionState.Ready(file)
val probe = withContext(Dispatchers.IO) { ConversionDependencies.probe(getApplication(), uri) }
val probe = withContext(Dispatchers.IO) { probeOrUnreadable(uri) }
// Only fill in the probe if the user has not moved on in the meantime.
_state.update { current ->
if (current is ConversionState.Ready && current.input.uri == uri) {
@@ -232,6 +234,34 @@ class ConversionViewModel @JvmOverloads constructor(
}
}
/**
* Probing, with the one failure the pick must survive rather than propagate.
*
* This runs inside `viewModelScope.launch`, which has no exception handler, so anything
* that escapes here abandons the launch — the file card never fills in — and reaches the
* thread's default handler, which on a device takes the process down. Picking a file is
* not a place to crash from.
*
* The one condition that reaches this is FFmpegKit's native library failing to load,
* which arrives as an `Error` rather than an `Exception`; [MediaProbe] handles its own
* FFprobe call now, and this covers the seam and the platform extractor beside it. The
* answer is [MediaProbe.UNREADABLE] — the same value [MediaProbe.probe] returns when
* neither of its probes could read the file, because that is what has happened.
*
* Anything else is rethrown deliberately. An [OutOfMemoryError] here is about this
* process, not about this file, and reporting it as an unreadable video would let the app
* carry on in a state it cannot honour. See
* [org.libremediaconverter.ffmpeg.isNativeLoadFailure] for which is which and why the
* distinction is drawn by a predicate rather than by the catch clause.
*/
private fun probeOrUnreadable(uri: Uri): InputProbe = try {
ConversionDependencies.probe(getApplication(), uri)
} catch (e: Error) {
if (!isNativeLoadFailure(e)) throw e
Log.w(TAG, "Could not probe $uri; reporting it as unreadable.", e)
MediaProbe.UNREADABLE
}
/**
* Enqueues the conversion rather than running it inline.
*
@@ -420,5 +450,7 @@ class ConversionViewModel @JvmOverloads constructor(
* it "unknown" would read as an error rather than as a gap in what survived.
*/
const val UNKNOWN_INPUT_NAME = "Media file"
const val TAG = "ConversionViewModel"
}
}
@@ -8,6 +8,7 @@ import android.util.Log
import com.arthenica.ffmpegkit.FFmpegKitConfig
import com.arthenica.ffmpegkit.FFprobeKit
import com.arthenica.ffmpegkit.MediaInformation
import org.libremediaconverter.ffmpeg.isNativeLoadFailure
import org.libremediaconverter.model.ConcatInput
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.InputKind
@@ -33,6 +34,21 @@ import org.libremediaconverter.model.InputProbe
*/
object MediaProbe {
/**
* What [probe] reports when nothing could read the input.
*
* Named rather than inlined because a caller that has to handle [probe] itself failing
* needs to land on the same answer — see `ConversionViewModel.onInputPicked`. Two
* different spellings of "unreadable" would be two different behaviours downstream, since
* the router keys off [InputProbe.UNPARSEABLE] and the source-info card off the kind.
*/
val UNREADABLE = InputProbe(
videoCodec = InputProbe.UNPARSEABLE,
hasVideo = true,
durationMs = 0,
kind = InputKind.UNPARSEABLE,
)
fun probe(context: Context, uri: Uri): InputProbe {
val extracted = probeWithExtractor(context, uri)
val info = probeWithFFprobe(context, uri)
@@ -45,12 +61,7 @@ object MediaProbe {
// Not a failure: an unparseable input is a strong signal that this job belongs on
// FFmpeg. Reporting an unknown codec makes the router say so.
Log.i(TAG, "Neither MediaExtractor nor FFprobe could read $uri; routing to FFmpeg.")
return InputProbe(
videoCodec = InputProbe.UNPARSEABLE,
hasVideo = true,
durationMs = 0,
kind = InputKind.UNPARSEABLE,
)
return UNREADABLE
}
return InputProbe(
@@ -145,6 +156,15 @@ object MediaProbe {
} catch (e: Exception) {
Log.i(TAG, "FFprobe could not read $uri.", e)
null
} catch (e: Error) {
// Touching FFmpegKit at all loads its native library, and a failure there arrives as
// an Error, which the clause above cannot see -- so an unloadable library used to
// take the whole file pick down instead of reporting an unreadable file. Anything
// that is not that library failing to load is still this JVM's problem, not this
// file's, and is rethrown: see isNativeLoadFailure.
if (!isNativeLoadFailure(e)) throw e
Log.w(TAG, "FFmpegKit's native library could not be loaded; probing $uri without FFprobe.", e)
null
}
private fun readMediaInformation(path: String): FFprobeInfo? {
@@ -80,18 +80,20 @@ object ConversionDependencies {
*
* Here for a reason the others are not, and the reason is worth recording rather than
* just working around. [MediaProbe] spawns FFprobe, and when FFmpegKit's native library
* cannot load its initialiser throws a bare `java.lang.Error` — which
* `probeWithFFprobe`'s own `catch (e: Exception)` does not catch, and which
* `ConversionViewModel.onInputPicked` does not catch either. Picking a file would then
* fail with an uncaught error rather than the "could not read this file" the code was
* written to give.
* cannot load, the failure arrives as a `java.lang.Error` rather than an `Exception` —
* which is why `probeWithFFprobe`'s `catch (e: Exception)` did not see it, and why
* `ConversionViewModel.onInputPicked` used to abandon its `viewModelScope.launch`
* instead of reporting a file it could not read.
*
* **That is a latent production hazard, found here and deliberately not fixed here.**
* It cannot fire on a device that ships the `.so` files, which is every real install,
* so making `MediaProbe` catch `Throwable` would be a behaviour change to the pick path
* on the strength of a condition no user meets — its own commit, with its own test.
* What this seam does is narrower: it keeps the JVM out of that path, which is what
* makes the ViewModel reachable from a unit test at all.
* **Both of those are guarded now**, by
* [org.libremediaconverter.ffmpeg.isNativeLoadFailure] — which also documents what the
* boundary actually throws, since all three of the obvious guesses turn out to be
* wrong. This seam is no longer what stands between a JVM test and an uncaught error.
*
* It still earns its place: injecting a probe is how a test reaches a *chosen* outcome
* for a file rather than the unreadable verdict the JVM has no libraries to improve on,
* and how the error path itself is forced — see `ConversionViewModelProbeFailureTest`,
* which drives an `OutOfMemoryError` through here to pin that the guard stays narrow.
*
* Instrumented tests and the app itself get the real probe, exactly as before.
*/
@@ -0,0 +1,59 @@
package org.libremediaconverter.ffmpeg
/**
* Whether [error] is FFmpegKit failing to load its native library, rather than this JVM
* being in trouble.
*
* ## Why a predicate rather than a catch clause
*
* `config/detekt/detekt.yml` turns `TooGenericExceptionCaught` off with a written argument:
* the engine boundaries sit in front of native code whose failure types are undocumented,
* "enumerating it would mean guessing, and a guess that is wrong crashes the app on a file
* it could have simply reported as unreadable." That argument is about *exceptions*, and it
* applies unchanged one level up — except that on the `Error` side the opposite mistake is
* available too. `catch (Throwable)` at a boundary that spawns a native process would
* swallow a genuine [OutOfMemoryError] and let the app carry on pretending it had merely
* met an unreadable file.
*
* So this names the failure instead of the catch clause. Everything it does not recognise is
* rethrown.
*
* ## What the boundary actually throws
*
* Read off the shipped AAR and confirmed by `MediaProbeNativeLoadTest`, because all three of
* the obvious guesses are wrong:
*
* - `NativeLoader.loadLibrary` catches the `UnsatisfiedLinkError` that `System.loadLibrary`
* raises and rethrows `java.lang.Error(message, cause)` — a **bare** `Error`, which is
* neither an `Exception` nor a [LinkageError]. `catch (e: UnsatisfiedLinkError)` sees
* nothing. Its `cause` is the original `UnsatisfiedLinkError`, which is what identifies it
* here; matching on the message would be matching on a format string.
* - That throw happens under `FFmpegKitConfig.<clinit>`, so what a caller sees also depends
* on how the runtime treats an initializer that fails: observed as
* `ExceptionInInitializerError` on the JVM under Robolectric, and recorded as the bare
* `Error` in `docs/defect-audit.md`. Both shapes are handled rather than either being
* assumed.
* - Every touch **after** the first is a third type again — `NoClassDefFoundError: Could not
* initialize class …`, the JVM's own record that the class is poisoned. A guard written
* for the first shape alone would let the second pick onwards crash, which is the harder
* half to notice.
*
* All of the class-loading shapes are [LinkageError]s, and none of the errors that mean this
* JVM is failing — [OutOfMemoryError], `StackOverflowError`, the rest of
* `VirtualMachineError` — is one. That disjointness is what makes this narrow rather than a
* blanket `catch (Throwable)`.
*
* ## When it can fire
*
* Not on a healthy install: the `.so` files ship in the APK. A corrupted install or an ABI
* mismatch is the realistic device path, and the JVM unit tests are the other, where the
* libraries are absent by construction.
*/
internal fun isNativeLoadFailure(error: Error): Boolean = when {
// NoClassDefFoundError, ExceptionInInitializerError, UnsatisfiedLinkError: the JVM's
// whole vocabulary for "the code could not be loaded".
error is LinkageError -> true
// FFmpegKit's own bare java.lang.Error, identified by what it wraps.
error.cause is UnsatisfiedLinkError -> true
else -> false
}
-13
View File
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>
+38 -12
View File
@@ -1,19 +1,45 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
What leaves the device when the system backs the app up or transfers it to a new one.
The app stores nothing of its own: no settings, no library, no history. Everything it
writes is either a staged conversion under cacheDir, which backup never takes and which
"Start over" and the staging sweep delete anyway, or WorkManager's queue.
So WorkManager's queue is the entire payload, and restoring it onto another device is
wrong rather than merely useless. Every row references a content:// URI granted to this
install on this device and an output path under this install's cacheDir; the grants do
not survive the transfer and the cache is not copied, so a restored row names a file that
cannot exist. ConversionViewModel queries WorkManager by tag on launch to reattach to
work it did not start, so those rows would not sit inert either: a fresh install would
come up reattached to a job the user never ran on it. WorkManager declares no backup
exclusion of its own, so nothing upstream prevents this.
Excluded from both paths for that reason. allowBackup stays true, because the decision
belongs here, where it is legible and per-file, rather than as an app-wide switch that
would also turn off device-to-device transfer for anything this app stores later. A
contributor who adds real user data should add the include for it here.
Each file is named rather than the whole "database" domain being excluded in one line.
A <exclude> carrying no path is skipped unchecked by lint's FullBackupContent detector,
so that spelling could silently protect nothing; these paths are ones the gate verifies.
androidx.work.workdb is Room-backed and therefore in WAL mode, hence the two sidecars.
Note that res/xml/backup_rules.xml is deliberately absent. android:fullBackupContent is
only read on Android 11 and lower, and minSdk is 33, so it could never have applied here.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
<exclude domain="database" path="androidx.work.workdb" />
<exclude domain="database" path="androidx.work.workdb-wal" />
<exclude domain="database" path="androidx.work.workdb-shm" />
<exclude domain="sharedpref" path="androidx.work.util.preferences.xml" />
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
<!-- Same reasoning: a content:// grant does not survive reaching a new device
either, so a transferred queue is as unusable as a restored one. -->
<exclude domain="database" path="androidx.work.workdb" />
<exclude domain="database" path="androidx.work.workdb-wal" />
<exclude domain="database" path="androidx.work.workdb-shm" />
<exclude domain="sharedpref" path="androidx.work.util.preferences.xml" />
</device-transfer>
-->
</data-extraction-rules>
</data-extraction-rules>
@@ -0,0 +1,151 @@
package org.libremediaconverter.convert
import android.app.Application
import android.net.Uri
import android.os.Looper
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.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.InputKind
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.util.concurrent.TimeUnit
/**
* That a probe which throws leaves a screen the user can act on, not a dead coroutine.
*
* [MediaProbeNativeLoadTest] covers the boundary itself. This covers the other half of the
* same defect: `onInputPicked` runs inside `viewModelScope.launch`, so anything the probe
* throws and does not handle leaves the launch with no result at all — the file card never
* fills in, and on a device the default handler takes the process down.
*
* The seam is what makes that testable. Injecting a prober that throws reproduces the
* condition exactly, without depending on which types FFmpegKit happens to throw today.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class ConversionViewModelProbeFailureTest {
private lateinit var app: Application
@Before
fun setUp() {
app = RuntimeEnvironment.getApplication()
installTestWorkManager(app, workDataOf(ConversionWorker.KEY_OUTPUT_PATH to "/dev/null"))
}
@After
fun tearDown() {
ConversionDependencies.reset()
}
/**
* The exact observed failure: FFmpegKit's loader rethrows a bare [Error] whose cause is
* the `UnsatisfiedLinkError` `System.loadLibrary` raised.
*/
@Test
fun `a native load failure during the probe reports an unreadable file`() {
ConversionDependencies.probe = { _, _ ->
throw Error(
"FFmpegKit failed to start on brand: robolectric.",
UnsatisfiedLinkError("dlopen failed: library \"libffmpegkit.so\" not found"),
)
}
val probe = pickedProbe()
assertNotNull("the pick must finish; a thrown Error used to abandon the launch", probe)
assertEquals(InputKind.UNPARSEABLE, probe?.kind)
assertEquals(InputProbe.UNPARSEABLE, probe?.videoCodec)
}
/** Every touch after the first throws this instead, so the guard has to cover it too. */
@Test
fun `a NoClassDefFoundError from a poisoned class reports an unreadable file`() {
ConversionDependencies.probe = { _, _ ->
throw NoClassDefFoundError("Could not initialize class com.arthenica.ffmpegkit.FFmpegKitConfig")
}
assertEquals(InputKind.UNPARSEABLE, pickedProbe()?.kind)
}
/**
* The line the guard must not cross.
*
* `MediaProbe` spawns a native process, so an [OutOfMemoryError] raised in it is a real
* one about this JVM, not a report about the file. Swallowing it would turn "the device
* is out of memory" into "this video looks unreadable" and let the app carry on in a
* state it cannot honour — which is the regression a blanket `catch (Throwable)` would
* have introduced, and the reason this defect was left open rather than fixed carelessly.
*/
@Test
fun `an OutOfMemoryError is not swallowed`() {
ConversionDependencies.probe = { _, _ -> throw OutOfMemoryError("Failed to allocate 512 MB") }
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
viewModel.onInputPicked(INPUT)
// The observable difference, and the reason this is asserted on state rather than on a
// caught throwable: the probe hop is on Dispatchers.IO, so an error that escapes lands
// on that thread's handler rather than at this call. What must not happen is the card
// filling in with an "unreadable" verdict the app would then act on.
val settled = settle(viewModel)
assertEquals(ConversionState.Ready(InputFile(INPUT, "input", 0L)), settled)
assertNull("an OOM must not be reported as a probe result", (settled as ConversionState.Ready).input.probe)
}
/** A working probe is untouched by any of this. */
@Test
fun `a probe that succeeds still fills the card in`() {
ConversionDependencies.probe = { _, _ -> InputProbe(videoCodec = "h264", kind = InputKind.VIDEO) }
assertEquals("h264", pickedProbe()?.videoCodec)
}
/**
* Drives a real pick and returns the probe the card ended up with.
*
* Asserting on the probe rather than merely on `Ready` is deliberate: `onInputPicked`
* sets `Ready` *before* it probes, so a test that only checked the state would have
* passed against the unguarded code.
*/
private fun pickedProbe(): InputProbe? {
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
viewModel.onInputPicked(INPUT)
val ready = awaitState(viewModel.state, "Ready with a probe") {
it is ConversionState.Ready && it.input.probe != null
}
assertNull("nothing here should reach a terminal failure", (ready as? ConversionState.Failed))
return (ready as ConversionState.Ready).input.probe
}
/**
* Pumps the looper the way [awaitState] does, but for a fixed span and without requiring
* anything to happen — here "the pick never came back" is the expected outcome, so there
* is no predicate to wait on.
*/
private fun settle(viewModel: ConversionViewModel): ConversionState {
val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(SETTLE_MS)
while (System.nanoTime() < deadline) {
shadowOf(Looper.getMainLooper()).idle()
Thread.sleep(POLL_MS)
}
return viewModel.state.value
}
private companion object {
val INPUT: Uri = Uri.parse("content://test/holiday.mp4")
const val SETTLE_MS = 500L
const val POLL_MS = 5L
}
}
@@ -0,0 +1,93 @@
package org.libremediaconverter.convert
import android.net.Uri
import com.arthenica.ffmpegkit.FFmpegKitConfig
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.InputKind
import org.libremediaconverter.model.InputProbe
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
/**
* That a failed native load is reported, not thrown.
*
* The JVM is the only place this is reachable: there are no `.so` files here by
* construction, which is exactly the shape a corrupted install or an ABI mismatch has on a
* device. So the condition that cannot be provoked on working hardware is free here, and
* these tests are the only ones that can exercise it at all.
*/
@RunWith(RobolectricTestRunner::class)
class MediaProbeNativeLoadTest {
/**
* What the boundary actually throws, pinned against the library rather than assumed.
*
* This is the test that justifies the shape of the guard, and it contradicts the obvious
* guess. `NativeLoader.loadLibrary` catches `UnsatisfiedLinkError` from
* `System.loadLibrary` and rethrows `java.lang.Error(message, cause)` — so
* `UnsatisfiedLinkError` never escapes, and because a bare `Error` *is* an `Error`, JLS
* 12.4.2 propagates it out of the static initialiser unwrapped rather than boxing it in
* `ExceptionInInitializerError`. Catching either of those two named types would catch
* nothing at all.
*
* The second touch of the class is a different type again — `NoClassDefFoundError`, the
* JVM's own "this class already failed to initialise" — so a guard written for one shape
* lets the other through. Both are asserted, in whichever order this classloader reaches
* them.
*/
@Test
fun `loading FFmpegKit without its native library throws an Error, not an Exception`() {
val thrown: Throwable? = runCatching { FFmpegKitConfig.getLogLevel() }.exceptionOrNull()
// The whole defect in one assertion: `catch (e: Exception)` could never have seen this.
assertTrue(
"expected the native load to fail with something no catch (e: Exception) can see, got $thrown",
thrown !is Exception,
)
assertTrue("expected an Error, got $thrown", thrown is Error)
val error = thrown as Error
// Either the first touch (bare Error wrapping UnsatisfiedLinkError) or a later one
// (NoClassDefFoundError). Both are native-load failures; neither is a VirtualMachineError.
assertTrue(
"expected a bare Error caused by UnsatisfiedLinkError or a NoClassDefFoundError, got $error",
error is NoClassDefFoundError || error.cause is UnsatisfiedLinkError,
)
}
/**
* The defect itself: picking a file must not die because FFprobe could not start.
*
* `probeWithFFprobe` guarded its call with `catch (e: Exception)`, which an `Error` walks
* straight through. With neither probe able to read the file, the designed answer is the
* unparseable probe — "nothing could read it, route it to FFmpeg" — not a throw.
*/
@Test
fun `probe reports an unreadable input instead of throwing when FFprobe cannot start`() {
val probe = MediaProbe.probe(RuntimeEnvironment.getApplication(), CONTENT_URI)
assertEquals(InputKind.UNPARSEABLE, probe.kind)
assertEquals(InputProbe.UNPARSEABLE, probe.videoCodec)
}
/**
* The second call takes the other branch — `NoClassDefFoundError` rather than the bare
* `Error` — so a guard that covered only the first shape would still crash every pick
* after the first one.
*/
@Test
fun `a second probe is guarded too, though the JVM throws a different Error by then`() {
val first = MediaProbe.probe(RuntimeEnvironment.getApplication(), CONTENT_URI)
val second = MediaProbe.probe(RuntimeEnvironment.getApplication(), CONTENT_URI)
assertEquals(InputKind.UNPARSEABLE, first.kind)
assertEquals(InputKind.UNPARSEABLE, second.kind)
}
private companion object {
/** `content://` so the probe takes the SAF branch, which is what a real pick does. */
val CONTENT_URI: Uri = Uri.parse("content://test/holiday.mp4")
}
}
@@ -0,0 +1,89 @@
package org.libremediaconverter.ffmpeg
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Where the guard draws its line.
*
* The whole point of naming the predicate was that "catch what a failed native load throws"
* and "do not swallow an OutOfMemoryError in a method that spawns a native process" are two
* requirements a catch clause cannot express together. These are that pair, written down.
*/
class NativeLoadFailureTest {
// --- Recognised: the installation is broken, not this JVM ------------------------------
/**
* FFmpegKit's own shape. `NativeLoader.loadLibrary` catches the `UnsatisfiedLinkError`
* that `System.loadLibrary` raises and rethrows a bare `java.lang.Error` wrapping it, so
* the type carries no information and the cause is what identifies it.
*/
@Test
fun `a bare Error wrapping an UnsatisfiedLinkError is a native load failure`() {
val error = Error("FFmpegKit failed to start on brand: robolectric.", UnsatisfiedLinkError("dlopen failed"))
assertTrue(isNativeLoadFailure(error))
}
/** Every touch of the class after the first one, which is the easier half to miss. */
@Test
fun `a NoClassDefFoundError is a native load failure`() {
val error = NoClassDefFoundError("Could not initialize class com.arthenica.ffmpegkit.FFmpegKitConfig")
assertTrue(isNativeLoadFailure(error))
}
/** What the first touch looked like when the JVM wrapped the failing initializer. */
@Test
fun `an ExceptionInInitializerError is a native load failure`() {
assertTrue(isNativeLoadFailure(ExceptionInInitializerError("Exception java.lang.Error: FFmpegKit failed")))
}
/** If a later FFmpegKit stops wrapping, the raw error is recognised on its own. */
@Test
fun `a plain UnsatisfiedLinkError is a native load failure`() {
assertTrue(isNativeLoadFailure(UnsatisfiedLinkError("dlopen failed: libffmpegkit.so not found")))
}
// --- Not recognised: this JVM is in trouble and must be allowed to say so ---------------
/**
* The regression the narrow guard exists to prevent. `catch (Throwable)` here would report
* "out of memory" to the user as "this file looks unreadable".
*/
@Test
fun `an OutOfMemoryError is not a native load failure`() {
assertFalse(isNativeLoadFailure(OutOfMemoryError("Failed to allocate a 512 MB allocation")))
}
@Test
fun `a StackOverflowError is not a native load failure`() {
assertFalse(isNativeLoadFailure(StackOverflowError()))
}
@Test
fun `an AssertionError is not a native load failure`() {
assertFalse(isNativeLoadFailure(AssertionError("a broken invariant is not a broken install")))
}
/**
* A bare `Error` on its own says nothing. Only the `UnsatisfiedLinkError` underneath it
* makes it FFmpegKit's, so matching the type alone would be a blanket catch wearing a
* predicate's clothes.
*/
@Test
fun `a bare Error with no cause is not a native load failure`() {
assertFalse(isNativeLoadFailure(Error("something else went wrong")))
}
/** An OOM does not become catchable by acquiring a cause. */
@Test
fun `an OutOfMemoryError caused by something else is still not a native load failure`() {
val error = OutOfMemoryError("Java heap space")
error.initCause(IllegalStateException("some unrelated cause"))
assertFalse(isNativeLoadFailure(error))
}
}