Skip to content
HeyDai blog
Go back

The Rust Core Is Done — So How Do Kotlin and Swift Actually Call It?

CryptoCore is finished. The Rust crate from the extension post — accounts, sessions, encrypt(), decrypt(), key management — compiles, passes its tests, and does exactly what an E2EE chat app needs. iOS links it. Now the Android app needs the same core, and both platforms need a real API, not a pile of C symbols.

“Just generate the bindings,” said every search result. Then the same search results handed me this:

UniFFI
cbindgen
bindgen
JNI
JNA
C FFI
swift-bridge
flutter_rust_bridge

I spent an evening trying to rank them, as if they were competing brands of the same product. That evening was wasted, and this post exists so yours isn’t.

Tip

UniFFI, cbindgen, JNI, and “raw FFI” are not four answers to one question. They sit at different layers of the same bridge between Kotlin/Swift and Rust. The real choice is not “which tool is best” but “how much of the bridge do I want a tool to build, and how much do I want to own?” Everything below is that one sentence, drawn out.

Table of contents

Open Table of contents

The Name Dump Is Not a Menu

Here is the whole bridge, once, before any tool gets named. A Kotlin or Swift call has to descend through every one of these layers to reach Rust:

Kotlin - session.encrypt

Kotlin wrapper

Swift - try session.encrypt

Swift wrapper

JNI or JNA - Android only

Swift and C interop

C ABI - extern C functions

Rust wrapper API

Rust core - CryptoCore

This is the most important picture in the post. Every name from the search results lives somewhere on this diagram — as a layer, or as a generator that writes one or more layers for you. None of them replaces the diagram. Once each name is pinned to its spot, the “which one?” question mostly answers itself.

FFI and the C ABI: The Riverbed

Rust’s nice types — Vec<u8>, Result<T, E>, &mut Session — mean nothing to Swift or Kotlin. What nearly every language can understand is the C calling convention. So the universal move is to flatten the boundary into C-shaped functions:

#[no_mangle]
pub extern "C" fn crypto_encrypt(
    session: *mut Session,
    input: *const u8,
    input_len: usize,
    output: *mut ByteBuffer,
) -> i32 {
    // ...
}

That surface — plain functions, pointers, integers — is the C ABI. FFI (Foreign Function Interface) is the broader concept: any mechanism that lets one language call another, of which the C ABI is the overwhelmingly common form (the Rustonomicon has a whole chapter on it).

So “UniFFI or FFI?” is a malformed question — like asking “should I take the car, or the road?” Every tool in the name dump ultimately drives over this same C ABI. FFI is not an option on the menu. It is the riverbed the bridge stands in.

JNI: Android’s On-Ramp

One asymmetry matters before any generator enters the picture. Swift can import a C header and call C functions almost directly. Kotlin on Android cannot: code on the JVM reaches native libraries through JNI, the Java Native Interface — declare an external fun, load the .so, and provide native-side functions with JNI’s exact naming and calling conventions.

class CryptoSession(private val nativeHandle: Long) {
    external fun encrypt(data: ByteArray): ByteArray
}

JNI is not a competitor to UniFFI or cbindgen. It is a layer — the on-ramp Android traffic must take to get onto the bridge at all. The open question is only ever: who writes that on-ramp, you or a generator? (There is a third possibility — route around JNI entirely — and one of the tools below takes it. Hold that thought.)

Multiply it out and the problem becomes visible. CryptoCore exposes accounts, inbound and outbound sessions, group sessions, one-time keys, signatures, serialization. Fifty-odd functions. Hand-writing the C ABI, the JNI layer, a Kotlin wrapper, and a Swift wrapper for each — then keeping all four in sync every time the Rust API moves — is not a chore. It is a second project.

cbindgen: It Prints the Sign, Not the Bridge

cbindgen has the most misleading position in the name dump, because its name sounds like “the thing that generates bindings.” Here is what it actually covers:

cbindgen generates only this

Kotlin wrapper - you write it

JNI bridge - you write it

Swift wrapper - you write it

C interop

crypto.h

Rust extern C functions - you write them

Rust core

cbindgen reads the extern "C" functions you already wrote in Rust and generates a matching C/C++ header. That’s the whole job — and it’s a genuinely valuable one, because the alternative is maintaining crypto.h by hand and discovering at runtime that it drifted from the Rust source. As Mozilla puts it: you could write the headers yourself, but “it’s not a particularly good use of your time”.

What cbindgen does not produce: a Kotlin API, the JNI implementation, a Swift-friendly wrapper, object lifecycle management, Result-to-exception mapping, or any async bridging. Above the header, everything is still yours.

bindgen: Same Name, Opposite Direction

One more naming trap and the map is complete. bindgen — no “c” prefix — solves the reverse problem: it reads existing C/C++ headers and generates Rust code so that Rust can call C.

C/C++  ── bindgen ──▶  Rust        (Rust is the caller)
Rust   ── cbindgen ──▶ C header    (Rust is the callee)

If your task is putting a Rust core under a mobile app, bindgen is pointing the wrong way. It shows up in search results anyway, which is how it earns its place in the confusion.

UniFFI: The Contractor

UniFFI attacks the problem from the top. Instead of describing your C ABI, you describe your Rust API — with proc-macros directly on the Rust code:

#[derive(uniffi::Object)]
pub struct Session { /* ... */ }

#[uniffi::export]
impl Session {
    pub fn encrypt(&self, plaintext: Vec<u8>) -> Result<Vec<u8>, CryptoError> {
        // ...
    }
}

From that, UniFFI generates the Kotlin API, the Swift API, the scaffolding C ABI under both, and the type conversions in between — strings, byte vectors, records, enums, Option, Result-to-exception mapping, object handles with lifecycle management, callbacks, even async functions bridged to native futures. Official targets are Kotlin, Swift, Python, and Ruby, with third-party generators for C#, Go, and more.

UniFFI generates all of this

Your Rust API

UniFFI

Kotlin API

Kotlin glue - JNA calls

Swift API

Swift glue - C interop

Scaffolding C ABI

Rust core

Put the two coverage diagrams side by side and the relationship is finally honest. With cbindgen you say: “here is my C ABI, print the header.” With UniFFI you say: “here is my Rust API, build me the bridge.” They are not rivals; they are a small tool and a large one standing on the same riverbed.

And here is the detail that broke my mental model twice: UniFFI’s Kotlin bindings contain no JNI code at all. The generated Kotlin calls the C ABI through JNA — Java Native Access, a library that invokes native functions dynamically, required as a dependency of every UniFFI Kotlin build. UniFFI’s answer to “who writes the JNI on-ramp?” is nobody — it routes around JNI entirely. That choice is why you never touch an external fun, and it is also a real trade-off: JNA’s dynamic dispatch costs more per call than compiled JNI, and the project has been experimenting with alternative FFI strategies partly to work around JNA’s stability and safety rough edges on Android. If someone’s diagram shows UniFFI “generating the JNI layer,” the diagram is wrong in a way that matters once you profile.

One Table Before the Evidence

ToolWhat it actually doesKotlin APISwift APIGlue you still write
Hand-written C FFIYou design and expose a C ABIyou write ityou write itall of it
cbindgenGenerates C header from your extern "C" APIJNI + wrapper still yoursimport C + wrapper yoursmost of it
Hand-written JNIBridges JVM ↔ native, Android onlyfull controldoesn’t applyall of Android’s
UniFFIGenerates Kotlin/Swift APIs + glue from Rust APIgeneratedgeneratedlittle
flutter_rust_bridgeGenerates Dart ↔ Rust bindingsnot the targetnot the targetlittle, for Flutter
bindgenGenerates Rust code to call C/C++wrong direction

The Better Question: What Should the Boundary Look Like?

“UniFFI or cbindgen?” is downstream of a design decision that deserves to come first: at what level of abstraction should Kotlin and Swift see Rust?

Boundary shaped like an SDK. Mobile code works with real objects — account.createSession(), try session.encrypt(message) — and Rust’s enums, errors, and async functions surface as idiomatic Kotlin and Swift. Both platforms get the same semantics from one source of truth. This is the shape UniFFI is built for.

Boundary shaped like an engine. The surface is deliberately tiny and C-flavored: e2ee_create, e2ee_encrypt, e2ee_destroy, everything passing bytes, handles, and error codes. Each platform writes its own wrapper with full control over its shape. The C ABI itself becomes the stable contract — which also makes it consumable from C++, Unity, Go, or whatever shows up next year. This is where cbindgen plus hand-written wrappers earns its keep.

Neither shape is “correct.” They are different answers to how much the boundary should promise — and the three biggest Rust-on-mobile deployments I know of split exactly along this line.

Who Actually Ships What

Firefox: UniFFI as policy

Mozilla’s Application Services is a collection of Rust components — sync, logins, search suggestions — shared across Firefox Desktop, Android, and iOS, with Kotlin and Swift bindings generated by UniFFI (which Mozilla built for exactly this). Their Android FAQ is unambiguous: use UniFFI to expose Rust to Kotlin; hand-written bindings are the fallback only if UniFFI can’t meet the need. On iOS the pipeline lands close to home for this series: Rust builds into an XCFramework, ships together with UniFFI-generated Swift sources as a Swift Package, and Firefox iOS consumes it like any other dependency — with all the how-many-copies-am-I-shipping questions that consuming one native artifact from several targets brings.

Element X: generated bindings can carry E2EE

For a crypto core, the sharper evidence is the Matrix Rust SDK. Element’s next-generation clients — Element X on Android and iOS — run the Matrix protocol, sync, storage, and end-to-end encryption from one Rust SDK. The Element X Android onboarding docs state it plainly: the Rust SDK is embedded via UniFFI and packaged as an AAR, with all cryptography (built on vodozemac) living on the Rust side. The iOS app wraps the generated uniffi::Object instances behind its own Swift layer. This is the existence proof that matters: a stateful, session-heavy, production E2EE SDK — not a demo that adds two integers — living behind generated bindings.

Signal: the custom bridge, and what it costs

Signal’s libsignal is also a Rust core — and Signal did not put a generator on top. They built their own bridge layer: #[bridge_fn] procedural macros that generate three parallel glue surfaces from one Rust function — JNI for Java/Android, a C ABI for Swift, and a Node/Neon binding for TypeScript. cbindgen appears here too, in a supporting role: producing the function prototypes that the Java and Swift sides consume.

Why carry that weight? Their coding guidelines give the answer: the priority order is ease of use and protection against misuse, then maintainability, then code size, then performance — and, critically, “the bridging layer is not API.” The Java, Swift, and TypeScript libraries are the product. Each is allowed to be shaped for its own ecosystem rather than mirroring Rust. That’s a deliberate trade: maximum control over three public APIs, paid for with a custom macro system, three glue surfaces, and the build infrastructure to sustain them — a real subsystem, owned forever.

Three production deployments, three positions on the same axis. Which is the actual lesson: don’t choose a binding strategy by the logo attached to it. Firefox needed reusable components across many apps; Element X needed one high-level SDK on two platforms; Signal needed three hand-shaped public APIs and has the engineering budget to own a bridge. Same riverbed, different bridges.

The Real Trade-Off: Automation vs Control

The lazy version — “UniFFI is easy, custom FFI is fast” — points decisions the wrong way. The honest axis is: generators buy automation (types, errors, lifecycle, async handled for you; API changes propagate by regeneration), hand-built bridges buy control (every byte crossing the boundary is yours to shape).

On performance specifically, the boundary’s shape usually matters more than its implementation. If one call does real work — an encryptBatch() that serializes, hits the session store, runs the ratchet — the bridge overhead disappears into the noise regardless of tool. If the API forces a foreign call per tiny item, every FFI is slow, hand-written included. A chatty boundary is an API design bug, not a tooling bug; measure before blaming the generator (and remember the JNA caveat above if Android profiling does flag the bridge).

What E2EE Adds to the Question

For CryptoCore there is a class of risk I weight far above per-call overhead — this is, after all, the machinery real messengers hang their entire security story on: who owns what, and for how long. Crypto objects are long-lived and stateful — accounts, ratchet sessions, group sessions. A Kotlin reference outliving its Rust object is a use-after-free; the reverse is a leak; two threads advancing one ratchet is state corruption. UniFFI ships lifecycle machinery for this (Kotlin object handles expose an explicit close()/destroy() because JVM finalizers can’t be trusted); a hand-built bridge means designing those contracts yourself.

Tip

Whatever generates the boundary, secrets still cross it. Before shipping, answer for your own bridge: how many times is a key or plaintext buffer copied? Who frees it, and when? Does anything convert a secret into a garbage-collected String? Can an error message or log line carry one out? (Signal’s guidelines flatly ban user data and keys from logs.) That review is owed to every binding strategy — this deserves its own post.

One structural habit makes all of this smaller: don’t expose the whole Rust API. Put a thin crypto-mobile-api crate between the core and the generator, exposing only what mobile needs. The internal crates stay free to change; the binding boundary becomes an anti-corruption layer instead of a mirror of your internals.

The Decision Tree

Only Dart / Flutter

Kotlin + Swift

C, C++, Unity, many languages

yes

no - a few byte-in byte-out calls

no

yes

A Rust library needs mobile callers

Who calls it?

Start with flutter_rust_bridge

Is the API high-level - objects, enums, errors, async?

Stable C ABI + cbindgen + per-platform wrappers

Start with UniFFI

Hit a real constraint UniFFI can't meet?

Keep UniFFI

Prototype a custom bridge - adopt only if it pays its maintenance cost

A note on the Flutter branch, since this blog’s migration series lives half in that world: flutter_rust_bridge targets Dart. In an app that is mid-migration — Flutter screens on a native host — the Rust core should still bind at the native layer (UniFFI to Kotlin/Swift), with Flutter reaching it through the same platform channels it already uses for everything the host owns. Binding Rust to Dart directly in a hybrid app just builds a second bridge you’ll have to demolish as screens go native.

And the Signal branch deserves its warning label: the custom bridge becomes rational only under stacked constraints — very large API, three-plus platforms, hard performance or code-size budgets, and public APIs that must be hand-shaped per ecosystem. That’s “we are building a cross-language SDK platform,” a different project than “Kotlin and Swift need to call our Rust.” Start there without those constraints and you’ve bought Signal’s maintenance bill without Signal’s reasons.

The Checklist I’d Run Tomorrow

Mostly “shared mobile SDK” answers → start with UniFFI. Mostly “small stable native engine” → C ABI + cbindgen. Mostly “multi-platform SDK product with bespoke APIs” → now, and only now, price out a custom bridge.

For CryptoCore the answers point one way: two platforms, one semantics, a stateful object-heavy API, and every engineering hour better spent on session lifecycle and storage than on glue. So the plan is a small intentional crypto-mobile-api crate exposed through UniFFI — validated with a vertical slice first (restore account → create session → encrypt → serialize, exercised for big buffers, error paths, panics, threads, and restarts) before the other forty functions follow. Not because UniFFI always wins, but because owning thousands of lines of bridge code is a cost you should be forced into by a real constraint — never a default you drift into. Abstraction can always be lowered later; complexity bought early is rarely refunded.

Next in this series: UniFFI says it generated the bindings — but what exactly is in them? From cargo build to .so, XCFramework, and AAR: what actually ships in the box.

Sources

Layer foundations

Generators

Production deployments


Share this post:

Part of the iOS series


Previous Post
Two Frameworks Need SQLCipher, So Does the Extension — How Many Copies Does the iPhone End Up With?
Next Post
The App Went to the Background — Is My Code Still Running?