Skip to content
Blog

jnigen and jni_util: Calling Java and Kotlin Code from Dart

How Dart-to-JVM interop actually works: what jnigen generates, what package:jni and jni_util provide at runtime, how to bind Java and Kotlin APIs step by step, and when this beats platform channels.

Published on • October 11, 2026

AI Assistant

Your team has a battle-tested Android library — say, an OCR engine or a billing client — written in Java or Kotlin, and a Flutter app that needs it. The plugin route means writing a channel, a method dispatcher, and serialization glue for every call, then maintaining it forever. There is another way: generate typed Dart bindings straight from the JVM bytecode and call the Java API as if it were a Dart API. That is what package:jnigen does, with package:jni running underneath and package:jni_util sharing plumbing between the two.

The catch is that this is a real FFI boundary, not a sandbox. Calls cross from the Dart heap into the JVM, references have lifecycles, exceptions can come from either side, and the whole mechanism is Android-only in Flutter today. This post walks the full path — architecture, setup, generation, runtime behavior — so you can decide where it fits before you commit.

In this tutorial, you will learn how to:

  • Explain how Dart calls into the JVM via dart:ffi, and why JNI is not the same as plain C interop
  • Configure the programmatic JniGenerator API and generate bindings with dart run tool/jnigen.dart
  • Bind your own Java code and Gradle library classes, including Kotlin sources compiled to bytecode
  • Understand what the package:jni runtime provides: Jni.spawn, GlobalJniEnv, JObject, and the collection wrappers
  • Handle JVM exceptions, global-reference lifetime, and manual release in hot loops
  • Choose between JNI, MethodChannel/Pigeon, and plugins for a given Android API
  • Avoid the classic failure modes: ProGuard pruning, missing classes, and isolate traps

Key technologies: package:jnigen (1.0.x, generator), package:jni (1.x, runtime support library), package:jni_util (shared utilities), dart:ffi, Android Gradle, JDK 17–21.

Prerequisites

  • A Flutter project targeting Android (the in_app_java example in the jnigen repo is a good scaffold)
  • Android SDK and a JDK; jnigen officially supports Java 17 through 21, and uses the JDK bundled with Flutter when available
  • CMake and a C toolchain, because package:jni ships native code
  • On Windows, the directory containing jvm.dll on PATH (typically %JAVA_HOME%\bin\server) so the JVM can be loaded

How the interop actually works

dart:ffi lets Dart call C functions and manipulate C memory. The JVM does not expose a C API — it exposes the Java Native Interface, a C ABI with an opaque JNIEnv vtable full of function pointers like CallVoidMethod and FindClass. So the stack looks like this:

Your Dart code
  -> generated bindings (package:jnigen output)
    -> package:jni helpers (typed wrappers, global refs)
      -> dart:ffi calls into the JNI C ABI
        -> JVM (Android runtime, or a spawned JVM on desktop)

JNIgen scans compiled JAR files or Java source code, builds an API description, and emits Dart bindings. Those bindings call through the C bindings, which call Java through JNI. The support library package:jni provides the base classes and common Java types the generated code depends on; package:jni_util is the shared utility layer both jni and jnigen build on (its pub.dev description is literally “Shared utility functions for package:jni and package:jnigen”, and it depends only on path).

Two properties of this design matter in practice:

  • Calls are synchronous. Unlike a MethodChannel, a JNI call blocks the calling Dart isolate until Java returns. On Android this happens on the UI thread if you call it there.
  • Objects cross as global references. Every Java object returned into Dart becomes a JNI global reference held by a JObject wrapper, cleaned up by a NativeFinalizer unless you release it sooner.

Setting up the project

Add the runtime and the generator:

flutter pub get   # at least once: jnigen reads Gradle classpaths from your project
flutter pub add jni dev:jnigen

Write the Java (or Kotlin) you want to call inside the android/ subproject. The jnigen getting-started example places a helper at android/app/src/main/java/com/example/in_app_java/AndroidUtils.java:

package com.example.in_app_java;

import android.app.Activity;
import android.widget.Toast;
import androidx.annotation.Keep;

@Keep
public abstract class AndroidUtils {
  private AndroidUtils() {}

  public static void showToast(Activity mainActivity, CharSequence text, int duration) {
    mainActivity.runOnUiThread(() -> Toast.makeText(mainActivity, text, duration).show());
  }
}

The @Keep annotation is not decoration. R8/ProGuard prunes code it believes is unreachable, and JNI class lookup happens at runtime, so the optimizer cannot see the reference. Without @Keep (or an equivalent keep rule), release builds fail with ClassNotFoundException even though debug builds work.

Configuring the generator

The YAML configuration format is deprecated and scheduled for removal. The supported path is a programmatic Dart script, conventionally at tool/jnigen.dart:

import 'dart:io';

import 'package:jnigen/jnigen.dart';

void main(List<String> args) async {
  final packageRoot = Platform.script.resolve('../');
  final generator = JniGenerator(
    input: Input(
      classes: [
        'com.example.in_app_java',
        'androidx.emoji2.text.EmojiCompat',
        'android.os.Build',
      ],
      sourcePath: [packageRoot.resolve('android/app/src/main/java')],
      androidSdk: AndroidSdk(
        addGradleDeps: true,
        androidExample: packageRoot,
      ),
    ),
    output: Output(
      dart: DartOutput(
        path: packageRoot.resolve('lib/android_utils.g.dart'),
        structure: OutputStructure.singleFile,
      ),
    ),
  );
  await generator.generate();
}

Run it whenever the configuration or the Java sources change:

dart run tool/jnigen.dart

Notes on the configuration:

  • classes takes fully-qualified class or package names. Many core java.lang and java.util types ship with package:jni already; listing them again fails with Fatal: Trying to re-import the generated classes.
  • AndroidSdk(addGradleDeps: true) runs a Gradle stub so JAR dependencies from your build.gradle are on the classpath during analysis.
  • OutputStructure.singleFile writes one Dart file; the default writes one file per class.

Calling the bindings from Dart

The generated API mirrors the Java surface. From the jnigen example app:

import 'package:flutter/widgets.dart';

void showToast() {
  final activity =
      androidActivity(PlatformDispatcher.instance.engineId!)?.as(Activity.type);
  final message = 'This is a native toast shown from a Flutter app via JNI.';
  AndroidUtils.showToast(
    activity,
    message.toJString().as(CharSequence.type),
    0,
  );
}

Three idioms are worth internalizing:

  • 'string'.toJString() (from package:jni’s ToJStringMethod extension) creates a JString, the wrapper for java.lang.String.
  • .as(Activity.type) casts a generic JObject to a generated subclass using the class’s JType token — this is how the type system recovers Java’s nominal typing.
  • Nested Java classes map to Dart classes with _ as separator: Example.NestedClass becomes Example_NestedClass.

Kotlin support

jnigen generates bindings for Java and Kotlin. Kotlin compiles to JVM bytecode, so the generator consumes Kotlin classes the same way it consumes Java ones: point Input.classes at the compiled output (or include Kotlin sources via the classpath) and generate. Anything not visible in the bytecode signature — Kotlin default arguments, suspend functions, extension functions, value classes — needs a Java-friendly facade: @JvmStatic, @JvmOverloads, or a thin Java wrapper. The dart.dev interop guide states this plainly: “You can compile Kotlin to Java bytecode, allowing package:jnigen to generate bindings for Kotlin as well.”

The runtime: what package:jni gives you

Generated bindings are not self-contained. package:jni supplies:

  • Jni utilities — Jni.spawn() starts a JVM on desktop and standalone targets (on Android the app already runs inside the Android JVM). Jni.env exposes a GlobalJniEnv, a thread-safe thin wrapper over JNIEnv* that always returns global references, because Dart makes no guarantee that straight-line code stays on one thread.
  • JObject — the base class for every generated binding; a high-level wrapper over a JNI global object reference.
  • Standard library types — JString, JList, JMap, JSet, JInteger, JArrayList, array types like JIntArray, plus converters such as toDartObject / toJObject and JListToDartList.
  • JImplementer — a builder for proxy objects that implement Java interfaces from Dart, which is how you hand callbacks across the boundary.
  • Manual-access API — JClass.forName, instanceMethodId, JInstanceMethodId, and friends for one-off calls without generated bindings.

On Dart standalone (non-Flutter) targets there is no automatic bundling of native libraries. The stopgap is:

dart run jni:setup          # builds JNI native deps into build/jni_libs

then Jni.spawn() at startup, and Jni.setDylibDir again for every new isolate.

Exceptions, lifetime, and the GC boundary

Exceptions. Java exceptions do not vanish at the boundary. package:jni defines a family of Dart errors for JNI failures — JniError, JniGenericError, NoSuchMethodError, JNullError, JniOutOfMemoryError, JniThreadDetachedError, UseAfterReleaseError, DoubleReleaseError — and JThrowable wraps a java.lang.Throwable. Treat generated calls like any FFI: catch narrowly, and remember that an uncaught Java exception surfaces as a Dart error with a different stack shape than you are used to. On desktop, Jni.env.ExceptionDescribe() prints a pending JNI exception to stdout when you are debugging something opaque; on Android, enable CheckJNI for clearer diagnostics.

Lifetime. Each Java object returned to Dart creates a JNI global reference. The docs are explicit that NativeFinalizer deletion is “usually sufficient.” When you create many references — the FAQ’s example is a loop — release eagerly instead:

using((arena) {
  // Allocations inside are freed when the arena scope exits.
}, arena);

// Or release a single reference explicitly:
// obj.release();

UseAfterReleaseError and DoubleReleaseError exist precisely because manual management goes wrong; they turn a silent JVM crash into a catchable Dart error.

The general rule is the one the jnigen FAQ states: keep the interface between languages sparse. Batch data across the boundary; do not call Java once per list item from a Dart for loop.

Platform support and caveats

PlatformDart standaloneFlutter
Androidn/aSupported (app runs in the Android JVM)
LinuxSupportedSupported
WindowsSupportedSupported
macOSSupportedNot yet

Practical caveats that bite real projects:

  • Android-only in Flutter for now. If you need the same native code on iOS, JNI is not the bridge; you would need a separate Objective-C/Swift interop path.
  • Missing classes at runtime. JNIgen does not get classes onto the device. A Gradle dependency does that on Android; on desktop you pass the classpath to Jni.spawn. Libraries that depend on java.awt and other desktop-only namespaces simply do not exist on Android.
  • Android SDK stubs. The android.** namespace is not distributed through Gradle. SDK stub JARs (android.jar) are usable but post-API-28 stubs are incomplete for source parsing, and compiled JARs lack JavaDoc and parameter names.
  • Standalone uses the Flutter SDK’s dart. Due to a pubspec issue, the dart command must come from the Flutter SDK even for pure Dart projects.

JNI vs platform channels vs plugins

Flutter’s own guidance on calling Android APIs offers two answers: FFI (with ffigen or jnigen) and MethodChannel (typically with Pigeon). The tradeoffs:

Concernjnigen/JNIMethodChannel + Pigeon
Call styleSynchronous, directAlways asynchronous
TypingGenerated Dart classesGenerated message codecs
Overhead per callOne FFI trampolineChannel hop + serialization
Sharing codeAndroid only (Flutter)Android, iOS, desktop via same channel API
Best forDense calls into large Java/Kotlin APIsSparse calls, platform UI, iOS parity

Use JNI when you are pulling a substantial Java or Kotlin API surface into an Android-first app and call it frequently enough that channel latency and serialization matter. Use Pigeon when the call is occasional, you want multi-platform symmetry, or you need platform UI (dialogs, permission flows). Plugins remain the easiest path whenever a maintained package already exists on pub.dev.

Performance notes

  • Cross-boundary calls are cheap relative to channels for chatty APIs — there is no codec — but they still leave Dart and enter the JVM. Amortize with batched methods.
  • String conversion (toJString, JString.toDartString) copies; avoid it in inner loops over large payloads.
  • Because JNI calls are synchronous, long-running Java work blocks the UI thread if called from it. Push heavy work onto a Java executor or a Dart isolate and marshal results back.
  • Keep the boundary surface narrow: a handful of coarse methods beats hundreds of generated one-liners called from UI code.

Common pitfalls

  • ClassNotFoundException only in release — add @Keep or ProGuard keep rules for every class reached via JNI. The in_app_java example ships a proguard-rules.pro for external libraries.
  • Trying to re-import core classes — do not list java.lang.String and friends in classes; package:jni already provides them.
  • NoClassDefFound / NoSuchMethodFound on desktop — almost always a typo in the class or method signature, or a missing classpath entry for Jni.spawn.
  • Calling from a non-main isolate — each new isolate needs Jni.setDylibDir (standalone) and a correctly attached thread; test multi-isolate paths explicitly.
  • Forgetting to regenerate — a stale *.g.dart after a Java signature change produces confusing NoSuchMethodErrors. Regenerate in CI if the Java side changes often.

Further reading