Back to research

The FFmpeg Ghost: How a Bundled Native Library Broke Every Flutter Plugin

A transitive native dependency crashed silently during Android plugin registration, taking down Firebase, path_provider, and every other platform channel with it. The error message pointed everywhere except the actual cause. Here's how I traced it.

The symptom

My Flutter voice-notes app, Loose Tongues, worked perfectly in local debug and release builds. But every deployed APK from CI failed with:

PlatformException(channel-error, Unable to establish connection on channel:
"dev.flutter.pigeon.firebase_core_platform_interface.FirebaseCoreHostApi.initializeCore")

Firebase wouldn't initialize. The app showed a blank screen. No auth, no Firestore, nothing.

The misleading trail

The error message says "Firebase init failed," so naturally I spent hours investigating Firebase:

Then I noticed something: path_provider was also failing with the exact same "Unable to establish connection on channel" error. And google_fonts. Every Pigeon-based plugin was dead.

Key insight
When multiple unrelated platform channels all fail simultaneously, the problem isn't in any individual plugin. Something is preventing plugin registration entirely.

The breakthrough: fresh project comparison

I created a fresh Flutter 3.41.5 project, added firebase_core with the same google-services.json and package name, built a release APK, and installed it on the same emulator.

It worked.

Same Flutter version, same Firebase config, same emulator, same signing key. The only difference was the other dependencies. The bug was in the app's dependency tree, not Flutter or Firebase.

Reading the right logs

I'd been filtering adb logcat for Flutter-level messages. The real error was in the Android native logs, which I'd been ignoring:

E/GeneratedPluginsRegister: Tried to automatically register plugins with
  FlutterEngine but could not find or invoke the GeneratedPluginRegistrant.
E/GeneratedPluginsRegister: java.lang.reflect.InvocationTargetException
  ...
Caused by: java.lang.Error: FFmpegKit failed to start
Caused by: java.lang.UnsatisfiedLinkError: Bad JNI version returned
  from JNI_OnLoad in "libffmpegkit_abidetect.so": 0

FFmpegKit's native library was crashing during JNI_OnLoad. Flutter's GeneratedPluginRegistrant registers all plugins in a single method call — alphabetically by package name. One unrecoverable crash aborts the entire registration. Every plugin registered after ffmpeg_kit never initialises. That includes firebase_core, path_provider, and everything else.

The dependency chain

I wasn't using FFmpegKit directly. The dependency chain was:

loose_tongues
  └── whisper_ggml ^1.7.0        # on-device Whisper transcription
        └── ffmpeg_kit_flutter_new_min ^2.1.0  # audio format conversion
              └── libffmpegkit_abidetect.so  # 💥 crashes on load

whisper_ggml uses FFmpegKit to convert audio formats (e.g., m4a to WAV) before passing them to Whisper. The native FFmpegKit library worked on older Flutter/Kotlin/Gradle combinations but broke with Flutter 3.41's Kotlin 2.2.20 and newer Android build tooling.

Why it hid for months

Local development was pinned to Flutter 3.38.2, which shipped with an older Kotlin version where FFmpegKit's JNI interface was compatible. CI used channel: 'stable', which silently pulled Flutter 3.41.5 with Kotlin 2.2.20. The newer toolchain changed native linking behaviour, causing JNI_OnLoad to return an incompatible version.

The moment I upgraded local Flutter to 3.41.5, the bug reproduced instantly — on the emulator, in both debug and release mode. The "CI vs local" framing was a red herring. It was always a Flutter version issue; I just hadn't been running the same version locally.

The fix

The whisper_ggml package has a fork called whisper_ggml_plus that decoupled FFmpegKit in v1.3.0. The swap was straightforward:

# pubspec.yaml
dependencies:
-  whisper_ggml: ^1.7.0
+  whisper_ggml_plus: ^1.3.0

Three additional changes were needed:

  1. Record audio as WAV. Without FFmpegKit to convert m4a, I switched the recorder from AudioEncoder.aacLc to AudioEncoder.wav. Whisper expects 16kHz mono PCM anyway.
  2. Simplify voice activity detection. The VAD used FFmpegKit.execute('-af volumedetect'). I replaced it with a file-size heuristic — crude but zero dependencies.
  3. Handle new model enum. whisper_ggml_plus adds WhisperModel.largeV3Turbo which needed to be added to switch expressions.

APK size dropped from 123MB to 66MB. Firebase initialized on the first launch.

Lessons

1. One native crash kills all plugins

Flutter's GeneratedPluginRegistrant.registerWith() is a single method that registers every plugin sequentially. If any plugin throws during registration, none of the subsequent plugins get registered. There's no isolation, no try/catch per plugin (despite what the generated code looks like — the catch blocks only handle class-not-found, not native load failures).

2. Filter for native logs, not just Flutter logs

The Flutter-level error (PlatformException: channel-error) is a downstream symptom. The root cause only appears in Android's native logcat. When debugging platform channel failures, always check:

adb logcat | grep "GeneratedPlugin\|UnsatisfiedLink\|JNI\|Caused by"

3. Transitive native dependencies are invisible risks

My pubspec.yaml didn't mention FFmpegKit. It came in through whisper_ggml, which used it for audio conversion. The 60MB of native .so files were silently bundled into every APK. Audit your transitive dependencies, especially those with native code.

4. Test deployed builds, not just local

The bug hid for months because local and CI used different Flutter versions. Pinning your CI Flutter version to match local (or vice versa) would have surfaced this immediately. Better yet: test the actual APK that CI produces, not a local build with a different toolchain.

5. Surface real errors early

The original code silently caught Firebase init failures and continued, leading to a confusing "No Firebase App [DEFAULT] has been created" error deep in the auth flow. Adding an error screen that displayed the actual PlatformException message cut the debugging loop from "deploy and check" to "read the screen."


The whole investigation took most of a day. The fix was a one-line dependency swap. That's usually how it goes with native interop bugs — the hard part is never the fix, it's finding which layer lied to you.