Covers bindings/java: the typed Java API generated from the interface
definition, for the JVM on desktop and server.
Read the shared contract first. This document covers what is specific to Java.
The Java binding is generated by uniffi-bindgen-java from the same UDL as
Swift, Kotlin, Python and Ruby, and committed under bindings/java/src/main/java.
It is a member of the artifact set in
C1. It talks to the native
library through the Java foreign function API, not through JNA and not through
the C API: there is no hand-written code in it at all, which is why most
of the rules below are about what the host owes.
It is not the Kotlin binding. That one is the Android library and the React Native Android bridge, built on JNA and shipped as an AAR. A JVM application that is not on Android uses this one.
| Rule | How it reaches the Java binding |
|---|---|
| C1 | Applies in full. scripts/generate-bindings.sh runs the Java generator with the other four, CI deletes and diffs bindings/java/src/main/java/com/offlineprotocol/sdk with them, and scripts/tests/test-generate-bindings.sh pins the output path, the call and the generator series |
| C2 | The generated ProtocolException and MlsStorageException decode by positional discriminant, like every other binding. Append-only keeps them correct |
| C3 | EventCallback and pollEvent deliver the engine's JSON. An event's fields appear in no Java type, and a field added to an event is invisible here until the host reads it |
| C6 | Does not apply as a parser rule: there is none. See J2 for what it costs |
| C9 | cargo test proves nothing about this binding. gradle build in bindings/java runs ConsumerCheckTest against the real library, and CI runs it on Linux, macOS and Windows |
| C11 | A ProtocolStateStorageProvider is an adapter like any other: OfflineProtocolLib.runStorageConformance is the bar |
| C12 | The exception. See J3 |
uniffi-bindgen-java is a community generator, not part of uniffi-rs. Its
0.5 series generates for UniFFI 0.32, 0.3 for 0.31, and 0.2 for 0.29; no
release matches an arbitrary UniFFI version, which is why the workspace pins a
uniffi the generator has a series for. The generated Java checks the library's
FFI contract version and every function's checksum when it loads, so a Java
generated against another UniFFI fails at the first call, not at build time.
scripts/generate-bindings.sh therefore refuses any series but the one in
JAVA_BINDGEN_SERIES, before it writes anything, as it refuses a
uniffi-bindgen whose minor disagrees with the pin. A UniFFI bump moves the
uniffi pin, JAVA_BINDGEN_SERIES, and the installs in CI and the release
workflow together, and needs a generator release for the new minor.
A dictionary field with a default in the UDL (require_encryption = true) has
that default in the Rust, the Swift initializer, the Kotlin parameter default
and the Python keyword default. Java has no default arguments, and the
generator does not emit overloads, so new ProtocolConfig(...) takes all 35
fields positionally and a caller spells the definition's defaults out. Optional
fields (T?) take null.
This is the one place a Java caller can disagree with the other bindings about
a default and nothing catches it. When the definition's default for a field
changes, the Java caller keeps passing the old one. ConsumerCheckTest.config()
is the reference: it carries the definition's defaults, field for field, and is
the first place to update.
Rule C12 says a binding fills TelemetryConfig.os and os_major, never the
app. There is no hand-written layer in the Java binding to do it, so the host
passes them, as the C binding's host passes os_major
(c.md K3). They must be the
platform the process actually runs on (TelemetryOs.LINUX, MACOS, WINDOWS)
and its major version, read from the JVM's os.name and os.version, and not
a value forwarded from configuration: the ingest keys its per-platform
aggregates on them.
every_bridge_reads_the_telemetry_config_section has nothing to read for Java,
so nothing enforces this. A wrapper that adds a lifecycle should report
notifyAppState on backgrounding, and no call to disableTelemetry,
endTelemetrySession or flushTelemetryBlocking belongs on a UI thread.
An EventCallback or transport callback runs synchronously, on the thread
inside the engine, before the engine call that produced it returns: the host's
own thread for an event sendMessage emitted, or one of the engine's. The
rules are the C binding's K2:
a callback must be safe from any thread, must not call back into the engine,
and must not throw. Copy the event, queue it, return. The supported
alternative is pollEvent. Storage providers are called from any thread,
concurrently, so they must be thread-safe.
Closing the engine from a callback is such a call. close() frees the engine,
and the engine is mid-call.
The foreign function API is final in Java 22, and the generated code needs it,
so the minimum is JDK 22. The code is compiled with --release 22; a host
on an older JVM cannot load it. The JVM must be allowed to call native code:
--enable-native-access=ALL-UNNAMED on the class path, or the module's name.
Without it the first call warns, and a future JDK will refuse.
The native library is found through java.library.path, or by an absolute
path in -Duniffi.component.offline_protocol.libraryOverride. It is not
bundled in the jar.
com.offlineprotocol:offline-protocol-java is the Java binding, published at
the release's version beside the Android library (offline-protocol-sdk), by
the same switch and the same signing, and as its own deployment. The two are
different artifacts for different runtimes and never share a coordinate; an
application on Android uses the AAR, and any other JVM uses this jar.
The jar contains the generated Java and the license notices under META-INF.
It contains no native code. The host supplies the native library for its
platform, from the release's assets, and points the JVM at it (J5). Publishing
a jar that appeared to work and failed at the first call on a platform it did
not carry would be worse than a jar that says what it needs, so the POM
description says so.
The release does three things before anything is uploaded: it builds and tests
the committed sources against the release profile's library, writes the Maven
repository, and checks that the five files Central requires are all there. The
signing key reaches only scripts/maven-central-bundle.sh, never a Gradle
build. A version on Maven Central is permanent, so a dry run of the release
rehearses the upload against the real Portal and drops it.
gradle publishAllPublicationsToStagingRepository writes the same repository
under bindings/java/build/maven, and the Java Binding job in CI runs it on
every pull request, so a POM or publication error shows there and not on a tag.
The same things every binding owes, with nothing provided: the transports, two
storage providers or the built-in file stores, the lifecycle (start, stop,
process on a timer), and one engine freed once with no call in flight when it
is. Java's try-with-resources frees it once; it does not wait for calls in
flight on other threads.