Skip to content

Releases: gmpassos/java_interop

1.4.0 — Java interfaces implemented in Dart

Choose a tag to compare

@gmpassos gmpassos released this 11 Aug 04:23

Java interfaces can now be implemented in Dart. Both this release and the 1.3.0
section of the changelog land together — 1.3.0 was never published on its own, so
its additions ship here.

Everything came out of porting a real Java library to Dart: XMLDSig signing, SOAP
over mutual TLS, JasperReports. About 530 lines of that port were things any
consumer would have had to write, plus one capability that could not be worked
around at all.

Implementing a Java interface

A library that wants a listener, a visitor, a Comparator or an SPI wants an
object, and JNI cannot make one: Proxy.newProxyInstance needs an
InvocationHandler and RegisterNatives needs a class, and both are Java.

final ordering = jvm.implementInterface(
  'java.util.Comparator',
  onInvoke: (call) => switch (call.methodName) {
    'compare' => (call.args[0] as int).compareTo(call.args[1] as int),
    _ => null,
  },
);
try {
  arrays.callJavaStatic('void sort(Object[], java.util.Comparator)', [
    values,
    ordering.instance,
  ]);
} finally {
  ordering.release();
}

No jar ships. The one Java class this needs stays readable in the repository and
travels as compiled bytes in Dart source, loaded with JNI DefineClass. A JDK is
needed only by the maintainer who regenerates it.

Arguments arrive as Dart values, with boxed primitives unboxed. Return values are
narrowed to the method's declared type on the Java side, because Dart has one
integer type and cannot know whether 1 means a byte or a long. toString,
equals and hashCode are answered in Java by default — Proxy routes them to
the handler like any other method, and a proxy without them breaks the first time
it reaches a HashMap or a log line.

Threading

Three facts were measured rather than assumed, and each shaped the design:

  • a Dart isolateLocal callback entered from another thread aborts the
    process
    before any Dart code runs, so the thread check lives in Java, where
    Thread.currentThread() is free and reliable;
  • a listener callback is safe from any thread but runs after the calling frame
    is gone, when every jobject in its arguments is already dead;
  • an isolate is not pinned to an OS thread: await Isolate.run resumes the
    main isolate on the thread the child just freed.

So a reentrant call returns a value; a void call from a JVM-owned thread is
queued and runs on the next turn of the event loop; and a call from a JVM thread
that wants a value throws IllegalStateException naming both threads. Nothing
can deadlock, and nothing silently does nothing. A released proxy called from a
JVM thread — the one case with neither a handler nor an error callback left — is
dropped and counted in droppedProxyCalls, because throwing there would end the
isolate over something the caller could not prevent.

Also new

  • JavaException.causes, walking getCause(), with isCausedBy and causeOf.
    Libraries rewrap relentlessly, so the class that explains a failure is usually
    not the one thrown — and a timeout is retryable where the same wrapper around a
    validation failure is not.
  • JavaArray.toBytes()Uint8List, because Java's byte is signed and every
    Dart API that consumes bytes is not.
  • Jvm.isAttached / created, systemProperty, resourceExists,
    resourceUrls, requireClasses — so "something else booted the VM without my
    jars" fails at startup rather than as a NoClassDefFoundError an hour later.
  • localFrameReturning, for the case a scope cannot serve: returning a reference
    from localFrame yields a dangling handle that nothing reports.
  • jvm.classFor(name) and JavaClass.enumConstant, so the member-id cache pays
    off by default instead of by discipline.
  • jvm.synchronized(object, body) and jvm.refTypeOf(ref).
  • newJava('(byte[])') accepting the declaration the way Java source writes it.
  • A README section on caller-sensitive JDK APIs — Logger.getLogger,
    Class.forName(String) and friends — which fail under JNI because
    Reflection.getCallerClass() has no Java frame to inspect.

Verification

467 tests, up from 366. The ones that matter cover failures that abort the
process rather than throw, so they run as separate processes: several isolates
each binding their own handler class, and Jvm.isAttached proven in both states.
dart format, dart analyze --fatal-infos --fatal-warnings and dart doc --dry-run clean on Linux and macOS.

1.2.0

Choose a tag to compare

@gmpassos gmpassos released this 10 Aug 09:33

Signatures written as Java, so a JNI descriptor never has to be typed by hand.

A descriptor fails in the least helpful way there is: J is long and I is int, Z is boolean, a class needs L, a trailing ; and slashes instead of dots — and any of it wrong surfaces as a NoSuchMethodError from inside the VM, pointing at nothing.

Declarations

Name and types written once, as Java:

instance.callJava('String greet()');
fixtures.callJavaStatic('int add(int, int)', [2, 40]);
clazz.newJava('(String, int)', ['demo', 7]);
object.getJavaField('int intField');
object.setJavaField('String stringField', 'text');

Also callJavaAs<T> / callJavaStaticAs<T>, and getJavaStaticField / setJavaStaticField.

The declaration is what javap prints, so it pastes in unedited — modifiers, annotations, parameter names, generic arguments and a throws clause are ignored, java.lang is implicit, varargs count as an array:

Written Descriptor
jsig('public static int add(int a, int b)') (II)I
jsig('java.util.List<String> subList(int, int)') (II)Ljava/util/List;
jsig('void write(byte[]) throws java.io.IOException') ([B)V
jsig('String format(String, Object...)') (Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/String;
jsig('(String, int)') (Ljava/lang/String;I)V (constructor)
jtype('int[][]') [[I

Typed builder

Impossible to malform, and const-constructible for a signature on a hot path:

const add = JSig.of([JType.int_, JType.int_], returns: JType.int_);
JType.of('java.util.List').array;   // [Ljava/util/List;
JType.int_.array.array;             // [[I

JavaMethod.parse and JavaField.parse expose the parsed form when the name and the signature are wanted separately.

Notes

  • Nothing is deprecated. jsig and jtype return plain descriptor strings, so they drop into the existing call / callStatic / getField, and every 1.1.1 call site keeps working.
  • Declarations are parsed once and cached by string, the same reasoning as the member-id cache: a call in a loop re-parses nothing.
  • A malformed declaration raises a JniError at the call site naming the token that failed, rather than a NoSuchMethodError from the VM later. That is still a runtime check — the typed builder is the compile-time one.
  • 366 tests (56 new), on Linux and macOS, and on the declared 3.10 SDK floor.

Full changelog: https://github.com/gmpassos/java_interop/blob/main/CHANGELOG.md

1.1.1

Choose a tag to compare

@gmpassos gmpassos released this 10 Aug 09:07

First release on pub.dev. Repository, examples and CI — no library changes: the only edit under lib/ since 1.1.0 is a corrected doc comment, so upgrading changes nothing at runtime.

Compatibility

  • SDK floor drops from ^3.12.2 to ^3.10.0. CI runs dart analyze and the full suite on a pinned 3.10.0 SDK as well as on stable, so the floor is exercised rather than asserted.

Examples

  • Three task-oriented examples, none needing a jar or a class path: jdk_apis.dart (SHA-256 via MessageDigest, locale-aware currency via NumberFormat/Locale, a deflate/inflate round trip using a Java array as a buffer Java writes into), collections.dart (ArrayList/HashMap driven with plain Dart values, ending in reusable dartListFrom/dartMapFrom), and performance.dart (holding a JavaClass for its member-id cache, scoping references with localFrame, with timings).
  • example/example.md indexes all five and points at the right one for a given task.
  • The tour in example/main.dart is rewritten against the JDK, so it needs no jar either.
  • The greeter moved to example/greeter/, a standalone project with a path dependency on this package.

Project layout

  • The test suite and the greeter example each own everything they build and run from — test/java/test/build/fixtures.jar, example/greeter/java/example/greeter/build/greeter.jar — with a java_home.sh apiece. They previously shared one java/ directory and one jar.

Repository

  • Apache 2.0 LICENSE.
  • GitHub Actions CI: format, analyze and dart doc; the full suite and every example on Linux and macOS, since locating and loading libjvm is the per-platform part of this package.
  • Coverage on both platforms, uploaded to Codecov (95%).

Fixed

  • The package's own usage snippet told readers to load build/fixtures.jar, a filename that never existed in the repository.

Full changelog: https://github.com/gmpassos/java_interop/blob/main/CHANGELOG.md