D14: picking a file died instead of reporting an unreadable one when FFmpegKit's native library could not load. `probeWithFFprobe` guarded its call with `catch (e: Exception)`, and `ConversionViewModel.onInputPicked` guarded nothing, so the failure escaped a `viewModelScope.launch` -- which has no handler, and on a device ends the process. All three of the obvious narrow guards catch nothing, which is why this needed reading the AAR rather than guessing. `NativeLoader.loadLibrary` catches the `UnsatisfiedLinkError` that `System.loadLibrary` raises and rethrows a *bare* `java.lang.Error` wrapping it, so `UnsatisfiedLinkError` never escapes and the escaping type carries no information at all. Every touch of the class after the first is a different type again -- `NoClassDefFoundError` -- so a guard written for the first shape lets the second pick onwards crash, which is the harder half to notice. Both are in the test output verbatim. `catch (Throwable)` was the wrong answer for the reason the audit gave: it would swallow a genuine `OutOfMemoryError` in a method that spawns a native process, turning "this device is out of memory" into "this file looks unreadable" and letting the app act on it. So the line is drawn by a named predicate, `isNativeLoadFailure`, rather than by the catch clause -- every class-loading shape is a `LinkageError`, and nothing that means the JVM is failing is one. That disjointness is what makes the guard narrow. This is consistent with the position `config/detekt/detekt.yml` already takes for `TooGenericExceptionCaught`: the boundary's failure types are undocumented, so guessing crashes the app on a file it could have reported. One level up the opposite mistake is available too, and the predicate is what lets both be avoided at once. `TooGenericExceptionThrown` is relaxed for the test source sets only. A test that reproduces a failed native load has to throw what the library throws, and a tidier subclass would leave it passing against a defect it no longer reproduces. Main source is untouched by that and throws nothing generic. `ConversionDependencies.probe`'s KDoc is rewritten rather than left. It said this hazard was "deliberately not fixed here ... its own commit, with its own test", which this is -- leaving it would have replaced one true comment with a false one, which is the same defect class as the D11 work. Both halves are covered independently: reverting the `MediaProbe` catch reds only the two `MediaProbeNativeLoadTest` cases, reverting the ViewModel guard reds only the two injected-seam cases, and widening the ViewModel guard to `Throwable` reds the OutOfMemoryError case -- so the narrowness is pinned, not just the catch. Audited the sibling boundaries named in the audit and left all three alone: `FFmpegEngine` and `ConcatEngine` both construct and run under `catch (e: Throwable)` in their workers, and `Media3Engine` has no native loader of this kind and already routes failures through `runCatching`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
94 lines
4.3 KiB
Kotlin
94 lines
4.3 KiB
Kotlin
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")
|
|
}
|
|
}
|