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
@@ -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
}