DEV Community

Cover image for A Case Study in Integrating the Zoom Native SDK into a Flutter Monorepo
Syed Ibrahim
Syed Ibrahim

Posted on

A Case Study in Integrating the Zoom Native SDK into a Flutter Monorepo

Forking a plugin, fighting Gradle and AAR packaging, migrating a repo to Git LFS, and chasing a SQLite ABI conflict that passed a green build — and what it took to keep all of it from leaking into the rest of the application.

Integrating a third-party SDK into a Flutter application can look deceptively simple:

Add package → Initialize SDK → Authenticate → Join meeting
Enter fullscreen mode Exit fullscreen mode

That's roughly what I expected when I started integrating the native Zoom Meeting SDK into my Flutter monorepo. It didn't turn out that way.

The hard part wasn't calling Zoom's APIs from Dart. It was making a large native SDK coexist with an application that already had its own database layer, Android build configuration, package architecture, and repository constraints.

By the end, the integration touched:

  • Forking an existing Flutter Zoom plugin and owning the native bridge myself
  • Android namespace changes and Gradle AAR packaging restrictions
  • Jetpack Compose / ViewBinding alignment
  • 280+ MB Android binaries and 145+ MB iOS binaries, and a Git LFS migration to hold them
  • A duplicate libsqlite3.so that turned into a genuine ABI compatibility bug
  • Introducing a MeetingService abstraction so my courses domain package never has to know Zoom exists
  • Making Zoom an optional, per-client dependency, since not every client on the platform needs video conferencing

The most important lesson wasn't about Zoom. It was about how to integrate a native SDK without letting the SDK dictate the architecture of the application around it.


1. The Starting Point

My app is a Flutter monorepo with multiple packages:

cortex/
├── app/
├── packages/
│   ├── core/
│   ├── courses/
│   ├── exams/
│   ├── profile/
│   └── ...
Enter fullscreen mode Exit fullscreen mode

The courses package handles lessons, and, when appropriate, lets a student join a video conference. The obvious implementation would have been for courses to talk to Zoom directly — but that immediately creates coupling: now the domain package knows about a specific vendor.

I separated two concerns that are easy to conflate:

  1. The application needs a way to join a meeting.
  2. Zoom is one implementation of that capability.

That distinction shaped everything that followed.


2. Why I Forked the Plugin Instead of Using It As-Is

I started with the flutter_zoom_meeting_sdk package. It's a thin wrapper around Zoom's native Meeting SDK — its own dependencies are just flutter, http, and plugin_platform_interface. That's the plugin, not the SDK: it doesn't bundle any Zoom binaries. The documented setup is to download mobilertc.aar yourself from the Zoom App Marketplace and manually copy it into the plugin's own installation folder inside .pub-cache (android/libs), then hand-edit that copy's Gradle file to add Zoom's required dependencies.

That's workable for a quick prototype, but not for something I intended to keep working long-term. Anything placed by hand inside .pub-cache disappears the moment someone runs flutter pub cache clean or the plugin version changes — the build stops being reproducible from the repository itself, since the actual SDK binary and the Gradle edits it needs never get committed anywhere.

On top of that, the plugin also pulled in http for its own JWT-handling helpers, which overlapped with networking infrastructure I already had in the app. And since Zoom's SDK is large and native, I wanted direct control over its Android and iOS build configuration rather than treating it as an opaque pub.dev dependency I couldn't touch.

So I created my own local package:

packages/zoom/
Enter fullscreen mode Exit fullscreen mode

and moved the native bridge into it — copying the plugin's Kotlin/Swift bridge code into my own package, and moving the SDK binaries (and the Gradle changes they require) into the repository instead of .pub-cache. The goal wasn't to rewrite Zoom's SDK — it was to own the Flutter-to-Zoom bridge, while still using Zoom's actual native binaries underneath. That package became the boundary between my application and Zoom, and let me drop the third-party plugin from my dependency tree entirely.

I also took ownership of the Android namespace, changing it from the upstream plugin's com.simitgroup to com.testpress.flutter_zoom_meeting_sdk, updating package declarations, manifest entries, and Gradle config to match. Once you fork a native plugin, you inherit its entire native build identity — it's no longer "a package from pub.dev," it's part of your codebase.

I also had to rework how the native bridge managed Flutter's platform-channel event sinks. The original implementation captured the EventSink too early — before the Flutter engine had finished initializing — so events from Zoom sometimes arrived to a listener holding a stale null reference. The fix was to resolve the sink lazily via a closure at the moment it was actually needed, rather than caching it at construction time. It's a good reminder that a platform channel existing in code doesn't mean its runtime endpoint is ready the instant an object is constructed.


3. Android Build Problems: AARs Are Not Normal Dependencies

AARs don't drop in like a normal file

The Zoom Android SDK ships as an AAR — mobilertc.aar. My first instinct was to treat it like any local file:

implementation(files("libs/mobilertc.aar"))
Enter fullscreen mode Exit fullscreen mode

Android Gradle Plugin doesn't allow that when you're building another AAR yourself — the resulting library would be broken because the local AAR's classes and resources wouldn't be packaged correctly. The fix was to configure a local flatDir repository and resolve it as a proper dependency instead of an arbitrary file:

implementation(
    group = "",
    name = "mobilertc",
    ext = "aar"
)
Enter fullscreen mode Exit fullscreen mode

The SDK raised the floor on the rest of the toolchain

Zoom's SDK also came with its own minimum requirements for the rest of the Android build: minSdk 26, NDK 27.0.12077973, Java 17 as both source and target compatibility, and core library desugaring enabled (coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")). None of that is optional — it's what the native SDK needs to link and run, so I had to raise my app's own Android build to match.

The namespace change had downstream fallout

The namespace change from earlier surfaced stale imports and references to native constants that didn't exist in my SDK version, and Zoom's native UI components required aligning ViewBinding and Jetpack Compose (via the Compose BOM) with the rest of the app's dependency graph. None of this was a Dart problem — it was the cost of bringing a large native Android SDK into a build that already had its own dependency graph.


4. Then GitHub Rejected the Repository

Once the SDK was building locally, I hit a completely different wall: GitHub wouldn't accept the push. mobilertc.aar was around 282 MB, and the iOS MobileRTC binaries were around 145 MB — both well past GitHub's 100 MB per-file limit.

Compression wasn't a real option. An AAR is already essentially a ZIP archive, so zipping it again doesn't meaningfully shrink 280 MB. I needed a system built for large binaries: Git LFS.

Instead of storing the full binary in the Git object database, Git LFS stores a small pointer and keeps the actual payload in LFS storage. Since the binaries were already committed to history, tracking them going forward wasn't enough — I had to migrate history itself:

git lfs migrate import \
  --include="*.aar,MobileRTC" \
  --include-ref=refs/heads/feat/zoom-plugin
Enter fullscreen mode Exit fullscreen mode

This rewrote hundreds of commits on the feature branch, which then had to be force-pushed.

Git LFS introduced its own gotcha shortly after: Gradle choked trying to process mobilertc.aar when it was only ~134 bytes — a pointer file, not the actual archive, because the real LFS payload hadn't been pulled. The fix was making sure .gitattributes correctly tracked the binaries and running git lfs pull to materialize the real files:

*.aar filter=lfs diff=lfs merge=lfs -text
MobileRTC filter=lfs diff=lfs merge=lfs -text
Enter fullscreen mode Exit fullscreen mode

The underlying lesson: Git LFS is transparent to your build system only when the working tree actually contains the real binary. Without the LFS client, or without pulling the objects, your compiler sees the pointer, not the SDK.


5. The Real Problem: Two libsqlite3.so Files

Once the SDK was compiling, I hit the most interesting failure of the whole project:

Execution failed for task ':app:mergeDebugNativeLibs'
2 files found with path 'lib/armeabi-v7a/libsqlite3.so'
Enter fullscreen mode Exit fullscreen mode

Gradle pointed to two sources: mobilertc and sqlite3-native-library. I wasn't intentionally shipping two SQLite libraries — one came from Zoom, and the other turned out to come from my own database stack.

I use Drift for local storage, and the dependency chain looked like:

packages/core → drift → sqlite3 → native SQLite
Enter fullscreen mode Exit fullscreen mode

The key detail: newer versions of the sqlite3 package moved native SQLite compilation into Flutter's Native Assets system. So even without anyone writing sqlite3_native_library anywhere, the dependency graph was quietly producing a libsqlite3.so of its own.

Here's the part that made this more than a packaging annoyance: two files with the same name aren't necessarily the same binary. Zoom's native database layer expected specific SQLite symbols, and when the wrong implementation got packaged, the dynamic linker couldn't resolve one of them:

java.lang.UnsatisfiedLinkError:
dlopen failed:
cannot locate symbol "sqlite3_trace_v2"
referenced by "libZMDB.so"
Enter fullscreen mode Exit fullscreen mode

The tempting, wrong fix

Gradle offers a built-in escape hatch:

packaging {
    jniLibs {
        pickFirsts.add("**/libsqlite3.so")
    }
}
Enter fullscreen mode Exit fullscreen mode

This makes the build succeed — Gradle just picks one of the duplicate files and moves on. But pickFirst only answers which file gets packaged, not which implementation is actually compatible with everything that loads it. In my case, the build went green and the app still crashed at runtime. A successful build is not the same thing as a correct native integration.

The actual fix

Instead of overriding the packaging decision, I traced the dependency graph back to its source:

drift → sqlite3 3.x → Flutter Native Assets → libsqlite3.so
Enter fullscreen mode Exit fullscreen mode

Once I knew why a second SQLite binary existed, the fix was straightforward — constrain the version so the newer Native Assets path never kicks in:

dependencies:
  drift: ^2.21.0
  sqlite3: "<3.0.0"
  sqlite3_flutter_libs: ^0.5.30
Enter fullscreen mode Exit fullscreen mode

I kept sqlite3_flutter_libs deliberately — it's not redundant with sqlite3. sqlite3 provides the Dart-side FFI interface; sqlite3_flutter_libs provides the native library that interface talks to on platforms where Zoom isn't involved. Removing it would have broken Drift on any build where Zoom's native SQLite wasn't present. The fix worked because it changed what gets generated, not which of two already-generated binaries wins a coin flip.

One thing worth being explicit about: this pairing is the pre-3.0 model. sqlite3's own upgrade guide recommends dropping sqlite3_flutter_libs entirely once you move to sqlite3: ^3.0.0, because 3.x switched to Flutter's hooks system to download and bundle SQLite automatically — sqlite3_flutter_libs is a no-op from 0.6.0 onward. I went the other way on purpose. Upgrading to 3.x wouldn't have solved my problem; it just moves how the second libsqlite3.so gets generated, from an old build script to a hook, while still generating one. Staying on sqlite3 <3.0.0 with the real sqlite3_flutter_libs (0.5.x, before it became a no-op) is what actually avoided producing a second native SQLite build in the first place.


At this point the native integration problem was solved — Zoom initialized, authenticated, and joined meetings without crashing. But a different problem was still open: the integration worked, yet I didn't want the rest of the application to know how it worked.

6. Separating Zoom from courses

courses shouldn't know Zoom exists. So at the core layer I defined an abstraction:

abstract class MeetingService {
  Future<void> joinMeeting({
    required String jwtToken,
    required String meetingNumber,
    required String password,
    required String displayName,
  });
}
Enter fullscreen mode Exit fullscreen mode

courses only depends on MeetingService — it has no idea whether the implementation is Zoom, Teams, Meet, or nothing at all. I introduced a small registry in packages/core so an implementation can be registered without core ever importing Zoom:

packages/core
    ├── MeetingService
    ├── MeetingServiceRegistry
    └── meetingServiceProvider

packages/zoom
    └── ZoomMeetingService implements MeetingService
Enter fullscreen mode Exit fullscreen mode

Laid out in full, the composition looks like this:

                         ┌───────────────┐
                         │      app      │
                         │  Composition  │
                         │     Root      │
                         └───────┬───────┘
                                 │
                       registers implementation
                                 │
                                 ▼
┌──────────────┐        ┌─────────────────┐
│   courses    │───────▶│      core       │
│              │        │                 │
│  Conference  │        │ MeetingService  │
│      UI      │        │    Provider     │
└──────────────┘        └────────┬────────┘
                                  ▲
                                  │ implements
                                  │
                         ┌────────┴────────┐
                         │      zoom       │
                         │                 │
                         │ ZoomMeeting     │
                         │ Service         │
                         └────────┬────────┘
                                  │
                          Native Zoom SDK
Enter fullscreen mode Exit fullscreen mode

courses depends on a capability (MeetingService), not on zoom directly. zoom implements that capability. app is the only place that knows both exist, and it's the only place that wires them together.

Since I already use Riverpod, it became the composition layer:

final meetingServiceProvider = Provider<MeetingService?>((ref) {
  return MeetingServiceRegistry.instance;
});
Enter fullscreen mode Exit fullscreen mode

courses reads the provider and calls joinMeeting() — it never imports package:zoom. The lobby UI wraps this in a simple loading state (joining = true/false) around the tap-to-attend button, so a native initialize/authenticate/open-meeting sequence can't be triggered twice by an impatient double-tap.


7. Making Zoom Optional, Per Client

This platform serves multiple clients, and not all of them need video conferencing — some don't offer live classes at all. Bundling a ~280 MB native SDK into every client's build regardless of whether it's ever used isn't a reasonable default, so Zoom needed to be a dependency that could be included or excluded per client rather than something every build carries permanently.

The first version of this was more literal than it needed to be: dynamically rewriting pubspec.yaml at build time based on a zoom_enabled flag, then running flutter pub get before building each client. It worked, but it made the Dart dependency graph itself something the build had to mutate on every run, which is fragile in ways that are hard to see until CI catches it.

The version that stuck moved the decision to the build composition layer instead: whether a given client's build includes packages/zoom — and therefore registers ZoomMeetingService against MeetingService — is a per-client build-time decision, resolved once at the composition root rather than by rewriting the dependency manifest at runtime. Clients that don't need conferencing simply don't pull the package in, don't carry the native binaries, and never touch the SQLite/Gradle complexity described above. Clients that do need it get the full MeetingService → ZoomMeetingService wiring.

The requirement was never that the Dart abstraction disappear for clients without conferencing — MeetingService can stay defined in core either way. The actual requirement was narrower: clients without conferencing shouldn't carry the native Zoom SDK. Once that was the target, the composition-layer approach was the simpler way to hit it. If MeetingService isn't registered for a given client, that capability just isn't available, and the surrounding UI treats it like any other feature that client doesn't have.


8. What the Final Package Actually Looked Like

Zoom's pieces were scattered across a lot of this article, so here's the whole thing in one place:

packages/
└── zoom/
    ├── lib/
    │   └── zoom_meeting_service.dart
    ├── android/
    │   ├── src/main/kotlin/com/testpress/flutter_zoom_meeting_sdk/
    │   ├── libs/
    │   │   └── mobilertc.aar
    │   └── build.gradle.kts
    └── ios/
        ├── MobileRTC.xcframework/
        ├── MobileRTCResources.bundle
        └── zoom.podspec
Enter fullscreen mode Exit fullscreen mode
  • Dart layerZoomMeetingService, the concrete implementation of MeetingService.
  • Android — the Kotlin bridge plus mobilertc.aar, resolved as a flatDir dependency.
  • iOS — the Swift bridge plus MobileRTC.xcframework and its resource bundle, wired through zoom.podspec.
  • Git LFS — tracks the native binaries in both platform folders.
  • core — owns the MeetingService abstraction and registry; never imports this package.
  • app — the only place that imports zoom directly, and only for clients that need it.

Everything Zoom-specific stays inside this one package. Nothing outside it needs to know Zoom is what's behind MeetingService.


Solutions That Didn't Work

Some of the most useful information here is what I tried and abandoned:

Attempt Result Why
Use the upstream plugin directly Too much external/native coupling SDK and build assumptions baked into the plugin
Commit the native binaries normally GitHub rejected the push Past the 100 MB per-file limit
Compress the AAR before committing Didn't help An AAR is already a ZIP archive
pickFirst on the duplicate libsqlite3.so Build succeeded, app crashed at runtime ABI/symbol mismatch between the two binaries
Remove sqlite3_flutter_libs entirely Fixed the duplicate, broke Drift elsewhere Drift still needs a native SQLite on builds without Zoom
Let courses call Zoom directly Worked, technically Wrong dependency direction — domain package coupled to a vendor
Dynamically rewrite pubspec.yaml per client Worked, but fragile Better handled once, at the build composition layer

9. What the Native Dependency Graph Taught Me

At the Dart level, my dependency graph looked harmless:

courses → core → drift
Enter fullscreen mode Exit fullscreen mode

The actual build graph was closer to:

core → drift → sqlite3 → Native Assets → libsqlite3.so
                                              │
zoom → mobilertc.aar → libZMDB.so ───────────┘
Enter fullscreen mode Exit fullscreen mode

A package can introduce native behavior transitively, in ways that are invisible from pubspec.yaml alone. I wasn't thinking "I added a native SQLite library" — but that's exactly what the dependency graph was doing on my behalf.

That's also why pickFirst deserves more suspicion than it usually gets. It's the right tool when two dependencies genuinely ship the same binary. It's the wrong tool when they ship different builds of the same-named library — at which point it silently converts a build-time error into a runtime linker failure, which is strictly harder to debug. My build-time error (Duplicate libsqlite3.so) was actually more useful than the alternative would have been, because it forced me to find the real source instead of papering over it.

The debugging pattern that worked, in order: read the exact build error before touching Gradle; ask why the duplicate exists in the first place; trace the transitive dependency; understand what changed between versions; and fix the source of the conflict rather than the symptom.


Principles Worth Keeping

A few things generalize beyond Zoom:

Depend on capabilities, not vendors. courses → MeetingService, not courses → Zoom. The vendor sits behind an interface the domain layer never has to know about.

Solve dependency conflicts at their source. A version constraint that prevents an unwanted binary from being generated is more maintainable than a packaging override that arbitrarily picks between two binaries that already exist.

A green build isn't proof of a correct native integration. pickFirst can make Gradle stop complaining while the dynamic linker still fails at runtime. Native compatibility has to be validated at runtime, not just at build time.

Large vendor binaries need their own storage strategy. Once your repository contains hundreds of megabytes of native SDK, Git LFS isn't an afterthought — it's part of the architecture.

Own the fork when the third-party wrapper doesn't fit. Forking a plugin isn't inherently bad. It's the right call when the native SDK is central to your app, the existing wrapper carries incompatible assumptions, and you need real control over namespaces, build configuration, and reproducibility. The important thing is to own the fork intentionally, rather than accidentally maintaining a fragile copy of someone else's package.


Conclusion

What started as "add Zoom meetings to my Flutter app" turned into a broader exercise in figuring out where responsibility actually belongs: the native SDK behind a plugin boundary, the Zoom-specific implementation in its own package, the courses domain depending on a capability instead of a vendor, large binaries in a system built for large binaries, and the decision of which clients even carry Zoom pushed out to the composition root instead of baked into the domain code.

The mysterious libsqlite3.so collision turned out to be the most valuable failure in the whole project, because chasing it down — instead of silencing it with pickFirst — is what taught me the question worth asking whenever two native dependencies collide:

When two native dependencies collide, don't immediately ask which one should win. Ask why both are there in the first place.

Top comments (1)

Collapse
 
syed11 profile image
Syed Ibrahim

The libsqlite3.so conflict was probably the most frustrating part of this integration. The build was green, but Zoom was still crashing at runtime because the wrong SQLite binary was being loaded.

I'm curious how others handle transitive native dependency conflicts in Android/iOS projects. Do you usually pin versions, exclude the conflicting dependency, or take a different approach?

Would love to hear how you've handled similar issues.