jni_util: The Tiny Dart Package Keeping JNI Interop Working
jni_util 1.0.0 is a small dart.dev package for JDK detection that both package:jni and package:jnigen depend on. Here's what it exposes and when you'd call it.
Published on • October 19, 2026
AI Assistant

If you’ve ever generated Java bindings for a Flutter app with jnigen, you’ve already depended on jni_util without knowing it. The package hit 1.0.0 on pub.dev in July 2026 under the verified dart.dev publisher, and it’s one of those infrastructure packages worth understanding: it’s tiny, it’s published by the Dart team, and it solves one specific problem that both halves of Dart’s JNI stack share — finding a working JDK.
What the package actually is
jni_util describes itself in one line: “Shared utility functions for package:jni and package:jnigen.” The changelog for 1.0.0 is equally terse: “Initial version containing shared JDK detection utilities.” It ships with a single runtime dependency (path), BSD-3-Clause licensing, and lives in the dart-lang/native monorepo alongside the rest of the native interop tooling.
The entire public API is four members:
javaHome— aUri?with the Java home path Flutter itself uses, detected viaflutter config --machine.javaEnvironment— a copy ofPlatform.environmentwithJAVA_HOMEpointed at Flutter’s JDK (when detected) andJAVA_HOME/binprepended toPATH.resolveJavaExecutable(String exec)— resolves names likejava,javac, orjavapto full paths insideJAVA_HOME/bin.minJavaVersionandmaxJavaVersion— constants bounding the supported JDK range.
import 'package:jni_util/jni_util.dart';
void main() {
// Flutter's bundled JDK, if one is configured.
print('Java home: ${javaHome ?? "not found, falling back to JAVA_HOME/PATH"}');
// Resolve tool paths the same way jnigen does.
final javac = resolveJavaExecutable('javac');
final javap = resolveJavaExecutable('javap');
// An environment map you can hand to a spawned process.
javaEnvironment.forEach((key, value) {
if (key == 'JAVA_HOME' || key == 'PATH') print('$key=$value');
});
}
There’s no widget, no plugin, and no Android/iOS runtime code. It’s pure Dart that answers “where is a good JDK on this machine?” — which is exactly what code-generation tooling needs before it does anything else.
Why both jni and jnigen depend on it
The problem jni_util solves shows up clearly in jnigen’s requirements: the generator needs a JDK (officially Java 17 through 21), and it wants the same JDK Flutter is using. Its documented resolution order is: use the JDK configured in Flutter (typically Android Studio’s bundled JDK), and if that fails, fall back to JAVA_HOME and PATH. That detection logic is precisely what javaHome, javaEnvironment, and resolveJavaExecutable implement — so rather than each package carrying its own copy, both package:jni (the runtime support library) and package:jnigen (the bindings generator) take a dependency on jni_util.
That shared-dependency structure is why a “1 like” package can still show millions of downloads on pub.dev: download counts aggregate across every pub get that pulls it in transitively, and nearly every app using JNI bindings pulls it in through both parents.
Where jni_util sits in the interop stack
If you’re new to Dart’s JNI story, the stack has three layers:
package:jniis the runtime. It exposesJNIEnvandJavaVMaccess (with aGlobalJniEnvabstraction so the env can be used from multiple threads),Jni.spawnfor starting a JVM on desktop platforms, theJObjectbase class that generated bindings extend, and hand-written wrappers for common Java types likeJList,JMap, andJInteger.package:jnigenis the generator. It scans compiled JARs or Java sources, builds an API summary via the JDK’s Doclet API, and emits Dart bindings that call throughdart:ffito JNI. Configuration is a Dart script (tool/jnigen.dart) instantiatingJniGenerator— the YAML config format is deprecated.package:jni_utilis the shared plumbing underneath both: JDK discovery and executable resolution.
// tool/jnigen.dart — jnigen needs a JDK; jni_util is how it finds one.
import 'dart:io';
import 'package:jnigen/jnigen.dart';
Future<void> main() async {
final packageRoot = Platform.script.resolve('../');
final generator = JniGenerator(
input: Input(
classes: ['com.example.in_app_java'],
sourcePath: [packageRoot.resolve('android/app/src/main/java')],
),
output: Output(
dart: DartOutput(
path: packageRoot.resolve('lib/android_utils.g.dart'),
structure: OutputStructure.singleFile,
),
),
);
await generator.generate();
}
Platform reality checks
The interop stack works best where a JVM is already running. On Android, your Flutter app runs embedded in the Android JVM, so bindings just work. On desktop, you spawn one yourself with Jni.spawn, and for Dart standalone targets you first build the native JNI libraries with dart run jni:setup (they land in build/jni_libs). Windows needs jvm.dll from your JDK on PATH; macOS supports Dart standalone but Flutter-on-macOS support was still marked “not yet” on jnigen’s platform table at the time of writing.
Two operational details bite in production. First, ProGuard prunes classes it deems inaccessible, but JNI class lookup happens at runtime — so release builds throw ClassNotFoundError unless you keep classes with @Keep or proguard rules. Second, reference management: every Java object returned into Dart creates a JNI global reference. NativeFinalizer usually suffices, but if you create references in a loop, use the FFI Arena or release explicitly.
When you’d use jni_util directly
You probably won’t add it as an app dependency — jni and jnigen already bring it in. Where it earns its keep directly is in custom tooling: build scripts that need to invoke javac or javap, CI steps that verify a JDK is present before running binding generation, or diagnostics that print which JDK your Flutter install is actually using. Instead of shelling out to java -version and parsing strings, call resolveJavaExecutable and javaEnvironment — the same resolution the official tooling trusts.