From 033b6a61702f27c35767929c6533e928bcfc9000 Mon Sep 17 00:00:00 2001 From: Aditi Polkam Date: Tue, 20 Oct 2026 12:14:35 +0530 Subject: [PATCH 1/4] docs(bindings): a runnable Java example, run in CI Two devices in one JVM pair, establish an encrypted session and exchange messages, with the app doing only what a transport does: moving bytes between engines. It depends on the published coordinate and substitutes the checkout's binding through a composite build, so it cannot drift from the code it documents. --- .github/workflows/ci.yml | 6 + bindings/java/README.md | 2 + examples/java-app/README.md | 63 ++++++ examples/java-app/build.gradle.kts | 35 ++++ examples/java-app/settings.gradle.kts | 23 ++ .../com/example/offlineprotocol/Main.java | 196 ++++++++++++++++++ 6 files changed, 325 insertions(+) create mode 100644 examples/java-app/README.md create mode 100644 examples/java-app/build.gradle.kts create mode 100644 examples/java-app/settings.gradle.kts create mode 100644 examples/java-app/src/main/java/com/example/offlineprotocol/Main.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a6945396..327a4d9bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -614,6 +614,12 @@ jobs: working-directory: bindings/java run: gradle --no-daemon --stacktrace build publishAllPublicationsToStagingRepository + # The example app against the same library, so it cannot rot: two + # engines pair, encrypt and exchange messages in one JVM. + - name: Run the Java example + working-directory: examples/java-app + run: gradle --no-daemon --stacktrace run + security: name: Dependency Audit runs-on: ubuntu-latest diff --git a/bindings/java/README.md b/bindings/java/README.md index 31cbefb2c..52cd40457 100644 --- a/bindings/java/README.md +++ b/bindings/java/README.md @@ -114,6 +114,8 @@ library for your platform from the release's assets, and give the JVM its path: path. Run on JDK 22 or later with `--enable-native-access=ALL-UNNAMED`. On Android use the AAR (`com.offlineprotocol:offline-protocol-sdk`) instead. +A complete, runnable program is in [examples/java-app](../../examples/java-app/README.md). + ## Telemetry Pass `TelemetryConfig.os` and `osMajor` as the platform you actually run on: diff --git a/examples/java-app/README.md b/examples/java-app/README.md new file mode 100644 index 000000000..b3860cb8d --- /dev/null +++ b/examples/java-app/README.md @@ -0,0 +1,63 @@ +# Java example + +Two devices, Alice and Bob, in one JVM: they pair, establish an end-to-end +encrypted session and exchange two messages, using the +[Java binding](../../bindings/java/README.md). + +``` +alice off1q89jrev... +bob off1q9jxmhy... + ok: bob and alice are paired + ok: bob received "hello bob" (encrypted end to end) + ok: alice received "hello alice" +done +``` + +## What it shows + +- Creating an engine from a `ProtocolConfig` (every field spelled out: the + generated Java record has no defaults, see + [J2](../../docs/bridges/java.md#j2-the-generated-records-have-no-defaults)). +- The engine's own encrypted file stores (`initializeMlsWithFileStores`), so no + storage provider is needed. +- Pairing from events: the app reads `pollEvent()`, and answers + `connection_request_received` and `connection_accepted` with the key package + exchange. +- A transport's job, in a dozen lines: take what an engine wants to send + (`internetGetNextMessage`) and hand it to the engine it is addressed to + (`internetMessageReceived`). A real app does this over a radio or a socket. + Everything else here, the pairing, the MLS session and the encryption, is the + engine's own. + +## Run it + +You need JDK 22 or later and the native library. + +```bash +cargo build -p offline-protocol-uniffi # from the repository root +cd examples/java-app +gradle run # Gradle 9; -PnativeLibrary= for another library +``` + +`settings.gradle.kts` includes `bindings/java` as a composite build and +substitutes it for the published coordinate, so the example runs against the +source in this checkout. In your own project, drop that block and depend on a +release from Maven Central: + +```kotlin +dependencies { + implementation("com.offlineprotocol:offline-protocol-java:") +} +``` + +Run on JDK 22 or later with `--enable-native-access=ALL-UNNAMED` and the +library's path in `-Duniffi.component.offline_protocol.libraryOverride=` +(or on `java.library.path`); `build.gradle.kts` does both for `gradle run`. + +## Where to go next + +- Callbacks (`EventCallback`, transports, storage providers) run on the + engine's thread and must not call back in: + [J4](../../docs/bridges/java.md#j4-callbacks-run-on-the-engines-thread-and-must-not-call-back-in). +- The host fills the telemetry platform: + [J3](../../docs/bridges/java.md#j3-the-host-fills-the-telemetry-platform). diff --git a/examples/java-app/build.gradle.kts b/examples/java-app/build.gradle.kts new file mode 100644 index 000000000..fcc102a9d --- /dev/null +++ b/examples/java-app/build.gradle.kts @@ -0,0 +1,35 @@ +plugins { + application +} + +// Run Gradle on JDK 22 or later: the foreign function API the binding uses is +// final in Java 22. +tasks.withType().configureEach { + options.release.set(22) +} + +dependencies { + implementation("com.offlineprotocol:offline-protocol-java:0.0.0") + // The engine reports events as JSON; any JSON library reads them. + implementation("com.fasterxml.jackson.core:jackson-databind:2.17.2") +} + +application { + mainClass.set("com.example.offlineprotocol.Main") + // The binding calls native code, and the JVM asks permission for that. + applicationDefaultJvmArgs = listOf("--enable-native-access=ALL-UNNAMED") +} + +// The native library the binding loads. Build it first (cargo build -p +// offline-protocol-uniffi); -PnativeLibrary names another file. +tasks.named("run") { + val os = System.getProperty("os.name").lowercase() + val fileName = when { + os.contains("win") -> "offline_protocol_uniffi.dll" + os.contains("mac") -> "liboffline_protocol_uniffi.dylib" + else -> "liboffline_protocol_uniffi.so" + } + val library = providers.gradleProperty("nativeLibrary") + .orElse(layout.projectDirectory.file("../../target/debug/$fileName").asFile.absolutePath) + systemProperty("uniffi.component.offline_protocol.libraryOverride", library.get()) +} diff --git a/examples/java-app/settings.gradle.kts b/examples/java-app/settings.gradle.kts new file mode 100644 index 000000000..50da4156e --- /dev/null +++ b/examples/java-app/settings.gradle.kts @@ -0,0 +1,23 @@ +// The example depends on the Java binding by its published coordinate. The +// composite build below substitutes the checkout's copy for it, so the example +// runs against the source in this repository. In your own project, drop this +// block and depend on the version you want from Maven Central. +pluginManagement { + repositories { + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositories { + mavenCentral() + } +} + +rootProject.name = "offline-protocol-java-example" + +includeBuild("../../bindings/java") { + dependencySubstitution { + substitute(module("com.offlineprotocol:offline-protocol-java")).using(project(":")) + } +} diff --git a/examples/java-app/src/main/java/com/example/offlineprotocol/Main.java b/examples/java-app/src/main/java/com/example/offlineprotocol/Main.java new file mode 100644 index 000000000..1ba293439 --- /dev/null +++ b/examples/java-app/src/main/java/com/example/offlineprotocol/Main.java @@ -0,0 +1,196 @@ +package com.example.offlineprotocol; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.offlineprotocol.sdk.InternetMessage; +import com.offlineprotocol.sdk.MessagePriority; +import com.offlineprotocol.sdk.OfflineProtocol; +import com.offlineprotocol.sdk.OverflowPolicy; +import com.offlineprotocol.sdk.ProtocolConfig; +import com.offlineprotocol.sdk.ProtocolException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.List; +import java.util.function.BooleanSupplier; + +/** + * Two devices, Alice and Bob, in one JVM. They pair, establish an end-to-end + * encrypted session and exchange two messages. + * + *

A real app moves bytes over a radio or a socket. Here the "network" is a + * loop that takes what one engine wants to send ({@code internetGetNextMessage}) + * and hands it to the engine it is addressed to ({@code internetMessageReceived}), + * which is exactly what a transport does. Nothing else is simulated: the + * pairing, the MLS session and the encryption are the engine's own. + */ +public final class Main { + private static final ObjectMapper JSON = new ObjectMapper(); + + /** One device: an engine, the name it pairs under, and the events it has seen. */ + private static final class Device { + final String name; + final OfflineProtocol engine; + final String address; + final List seen = new ArrayList<>(); + + Device(String name, Path storage) throws Exception { + this.name = name; + this.engine = new OfflineProtocol(config(name)); + + // The engine's own encrypted file stores. An app with a keystore of + // its own implements MlsStorageProvider and ProtocolStateStorageProvider + // and calls initializeMls instead. + byte[] storeKey = new byte[32]; + new SecureRandom().nextBytes(storeKey); + engine.initializeMlsWithFileStores( + Files.createDirectory(storage.resolve("mls")).toString(), + Files.createDirectory(storage.resolve("state")).toString(), + storeKey); + this.address = engine.localAddress(); + + engine.start(); + // The internet slot is the carrier here, and it is "up". + engine.internetStatusChanged(true); + } + + List keyPackage() throws Exception { + return engine.mlsGetOrCreateKeyPackage().keyPackageData(); + } + + /** Reads the engine's events and answers the pairing ones, as an app does. */ + void handleEvents() throws Exception { + String json; + while ((json = engine.pollEvent()) != null) { + JsonNode event = JSON.readTree(json); + seen.add(event); + switch (event.path("type").asText()) { + case "connection_request_received" -> { + String from = event.path("sender").asText(); + importKeyPackage(from, event.path("key_package")); + engine.acceptConnectionRequest(from, name, keyPackage()); + tryEstablish(from); + } + case "connection_accepted" -> { + String from = event.path("accepted_by").asText(); + importKeyPackage(from, event.path("key_package")); + tryEstablish(from); + } + default -> { } + } + } + } + + /** + * Best effort. With auto_key_exchange on, the engine finishes the session + * itself once the peer's key package arrives over the wire, so a refusal + * for want of one here only means "not yet". + */ + private void tryEstablish(String peer) { + try { + engine.establishSecureSession(peer); + } catch (ProtocolException.SessionNotReady notYet) { + // The key package comes next. + } catch (ProtocolException e) { + throw new IllegalStateException(e); + } + } + + private void importKeyPackage(String peer, JsonNode keyPackage) throws Exception { + if (keyPackage.isArray() && !keyPackage.isEmpty()) { + List bytes = new ArrayList<>(keyPackage.size()); + keyPackage.forEach(b -> bytes.add((byte) b.asInt())); + engine.mlsImportKeyPackage(peer, bytes); + } + } + + boolean saw(String type) { + return seen.stream().anyMatch(e -> type.equals(e.path("type").asText())); + } + + String received(String content) { + return seen.stream() + .filter(e -> "message_received".equals(e.path("type").asText())) + .filter(e -> content.equals(e.path("content").asText())) + .map(e -> e.path("sender").asText()) + .findFirst() + .orElse(null); + } + } + + /** The configuration this example needs; every field is spelled out (docs/bridges/java.md, J2). */ + static ProtocolConfig config(String profile) { + return new ProtocolConfig( + "com.example.java", profile, + false, false, true, false, false, true, // ble, wifi direct, internet, reticulum, nostr, prefer online + (byte) 8, true, true, true, true, // initial ttl, encryption, auto key exchange, store pending, require encryption + 64L, 4096L, 60_000L, OverflowPolicy.DROP_OLDEST, + false, 256, true, true, false, false, // edge-driven DM, group size, group relay x2, admin commits, transport identity + true, true, true, false, true, true, true, // binary wire, nostr x3, compact envelope, rich payload, recovery + null, null, // mesh relay, custody + true, true, true); // data, control freshness, encrypt service messages + } + + /** The network: whatever one device wants to send goes to the device it is addressed to. */ + private static void carry(List devices) throws Exception { + for (Device from : devices) { + InternetMessage message; + while ((message = from.engine.internetGetNextMessage()) != null) { + for (Device to : devices) { + if (to.address.equals(message.recipientId())) { + to.engine.internetMessageReceived(from.address, message.data()); + } + } + } + } + } + + private static void until(List devices, String what, BooleanSupplier done) throws Exception { + long deadline = System.nanoTime() + 20_000_000_000L; + while (!done.getAsBoolean()) { + if (System.nanoTime() > deadline) { + throw new IllegalStateException("timed out waiting for: " + what); + } + for (Device device : devices) { + device.engine.process(); + device.handleEvents(); + } + carry(devices); + Thread.sleep(50); + } + System.out.println(" ok: " + what); + } + + public static void main(String[] args) throws Exception { + Path root = Files.createTempDirectory("offline-protocol-java-example"); + Device alice = new Device("alice", Files.createDirectory(root.resolve("alice"))); + Device bob = new Device("bob", Files.createDirectory(root.resolve("bob"))); + List devices = List.of(alice, bob); + System.out.println("alice " + alice.address); + System.out.println("bob " + bob.address); + + try { + // Pairing: Alice asks, Bob accepts, and each ends up holding the + // other's key package, which is what the session is made from. + alice.engine.sendConnectionRequest(bob.address, alice.name, alice.keyPackage(), null); + until(devices, "bob and alice are paired", + () -> alice.saw("connection_accepted") && alice.engine.hasPendingKeyPackage(bob.address)); + + alice.engine.sendMessage(bob.address, "hello bob", MessagePriority.MEDIUM, null); + until(devices, "bob received \"hello bob\" (encrypted end to end)", + () -> bob.received("hello bob") != null); + + bob.engine.sendMessage(alice.address, "hello alice", MessagePriority.MEDIUM, null); + until(devices, "alice received \"hello alice\"", + () -> alice.received("hello alice") != null); + + System.out.println("done"); + } finally { + for (Device device : devices) { + device.engine.stop(); + device.engine.close(); + } + } + } +} From 06de5b969b4c04595f6393527b79d9a52414ed74 Mon Sep 17 00:00:00 2001 From: Aditi Polkam Date: Wed, 21 Oct 2026 16:50:07 +0530 Subject: [PATCH 2/4] docs(java): expand example with messaging, files, and groups --- examples/java-app/README.md | 27 +++++- .../com/example/offlineprotocol/Main.java | 88 +++++++++++++++++-- 2 files changed, 107 insertions(+), 8 deletions(-) diff --git a/examples/java-app/README.md b/examples/java-app/README.md index b3860cb8d..6c951e05f 100644 --- a/examples/java-app/README.md +++ b/examples/java-app/README.md @@ -1,15 +1,24 @@ # Java example Two devices, Alice and Bob, in one JVM: they pair, establish an end-to-end -encrypted session and exchange two messages, using the +encrypted session, use conversation signals, transfer a file, and create an +MLS group, using the [Java binding](../../bindings/java/README.md). ``` alice off1q89jrev... bob off1q9jxmhy... ok: bob and alice are paired + ok: bob sees alice online + ok: bob sees alice typing ok: bob received "hello bob" (encrypted end to end) - ok: alice received "hello alice" + ok: alice received a read receipt + ok: alice received a threaded reply + ok: bob received the original file bytes + ok: bob joined the MLS group + ok: bob received the group message +alice transport [Internet] +group members [off1..., off1...] done ``` @@ -23,12 +32,24 @@ done - Pairing from events: the app reads `pollEvent()`, and answers `connection_request_received` and `connection_accepted` with the key package exchange. +- Presence and typing indicators, followed by a normal encrypted message, a + read receipt tied to its message ID, and a high-priority rich reply threaded + to the original message. +- `sendFile` with byte-for-byte verification of the reassembled `file_received` + event. That event contains base64-encoded data for the host to save or use. +- Creating an MLS group, inviting the paired peer, and sending a group message. +- Reading active transports and group membership back from the SDK. - A transport's job, in a dozen lines: take what an engine wants to send (`internetGetNextMessage`) and hand it to the engine it is addressed to - (`internetMessageReceived`). A real app does this over a radio or a socket. + (`internetMessageReceived`), then acknowledge the successful carrier write + (`internetConfirmSent`). A real app does this over a radio or a socket. Everything else here, the pairing, the MLS session and the encryption, is the engine's own. +The in-process carrier delivers frames directly between the two engines. It +does not act as an internet relay or record transport metrics; the event checks +show that the SDK processed each feature end to end. + ## Run it You need JDK 22 or later and the native library. diff --git a/examples/java-app/src/main/java/com/example/offlineprotocol/Main.java b/examples/java-app/src/main/java/com/example/offlineprotocol/Main.java index 1ba293439..527708457 100644 --- a/examples/java-app/src/main/java/com/example/offlineprotocol/Main.java +++ b/examples/java-app/src/main/java/com/example/offlineprotocol/Main.java @@ -2,22 +2,29 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.offlineprotocol.sdk.ContentType; import com.offlineprotocol.sdk.InternetMessage; import com.offlineprotocol.sdk.MessagePriority; import com.offlineprotocol.sdk.OfflineProtocol; import com.offlineprotocol.sdk.OverflowPolicy; +import com.offlineprotocol.sdk.PresenceStatus; import com.offlineprotocol.sdk.ProtocolConfig; import com.offlineprotocol.sdk.ProtocolException; +import com.offlineprotocol.sdk.SendMessageOptions; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.security.SecureRandom; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; import java.util.List; import java.util.function.BooleanSupplier; +import java.util.function.Predicate; /** * Two devices, Alice and Bob, in one JVM. They pair, establish an end-to-end - * encrypted session and exchange two messages. + * encrypted session, exchange conversation signals, and transfer a file. * *

A real app moves bytes over a radio or a socket. Here the "network" is a * loop that takes what one engine wants to send ({@code internetGetNextMessage}) @@ -109,6 +116,18 @@ boolean saw(String type) { return seen.stream().anyMatch(e -> type.equals(e.path("type").asText())); } + boolean saw(String type, Predicate matches) { + return seen.stream().anyMatch(e -> type.equals(e.path("type").asText()) && matches.test(e)); + } + + boolean inGroup(String groupId) { + try { + return engine.listGroups().contains(groupId); + } catch (ProtocolException e) { + throw new IllegalStateException(e); + } + } + String received(String content) { return seen.stream() .filter(e -> "message_received".equals(e.path("type").asText())) @@ -140,6 +159,9 @@ private static void carry(List devices) throws Exception { for (Device to : devices) { if (to.address.equals(message.recipientId())) { to.engine.internetMessageReceived(from.address, message.data()); + // The carrier accepted the frame. A real socket bridge + // confirms only after its write succeeds. + from.engine.internetConfirmSent(message.messageId()); } } } @@ -162,6 +184,12 @@ private static void until(List devices, String what, BooleanSupplier don System.out.println(" ok: " + what); } + private static List bytes(byte[] data) { + List result = new ArrayList<>(data.length); + for (byte value : data) result.add(value); + return result; + } + public static void main(String[] args) throws Exception { Path root = Files.createTempDirectory("offline-protocol-java-example"); Device alice = new Device("alice", Files.createDirectory(root.resolve("alice"))); @@ -177,13 +205,63 @@ public static void main(String[] args) throws Exception { until(devices, "bob and alice are paired", () -> alice.saw("connection_accepted") && alice.engine.hasPendingKeyPackage(bob.address)); - alice.engine.sendMessage(bob.address, "hello bob", MessagePriority.MEDIUM, null); + alice.engine.sendPresenceUpdate(bob.address, PresenceStatus.ONLINE); + until(devices, "bob sees alice online", + () -> bob.saw("presence_updated", e -> + alice.address.equals(e.path("peer_id").asText()) + && "online".equalsIgnoreCase(e.path("status").asText()))); + + alice.engine.sendTypingIndicator(bob.address, bob.address, true); + until(devices, "bob sees alice typing", + () -> bob.saw("typing_indicator_received", e -> + alice.address.equals(e.path("sender").asText()) + && e.path("is_typing").asBoolean())); + + String greetingId = alice.engine.sendMessage(bob.address, "hello bob", MessagePriority.MEDIUM, null); until(devices, "bob received \"hello bob\" (encrypted end to end)", () -> bob.received("hello bob") != null); - bob.engine.sendMessage(alice.address, "hello alice", MessagePriority.MEDIUM, null); - until(devices, "alice received \"hello alice\"", - () -> alice.received("hello alice") != null); + bob.engine.sendReadReceipt(alice.address, List.of(greetingId)); + until(devices, "alice received a read receipt", + () -> alice.saw("read_receipt_received", e -> + e.path("message_ids").isArray() + && e.path("message_ids").size() == 1 + && greetingId.equals(e.path("message_ids").get(0).asText()))); + + // Reply by message ID so the recipient can thread the conversation. + bob.engine.sendMessageRich(alice.address, "hello alice", + new SendMessageOptions(MessagePriority.HIGH, greetingId, ContentType.TEXT, + null, null, null, "com.example.java")); + until(devices, "alice received a threaded reply", + () -> alice.saw("message_received", e -> + "hello alice".equals(e.path("content").asText()) + && greetingId.equals(e.path("reply_to_msg").asText()))); + + // sendFile chunks and encrypts the bytes; file_received carries + // reassembled data as base64 for the host to save or display. + byte[] attachment = "Offline Protocol Java attachment\n".getBytes(StandardCharsets.UTF_8); + String fileId = alice.engine.sendFile(bob.address, bytes(attachment), "note.txt"); + until(devices, "bob received the original file bytes", + () -> bob.saw("file_received", e -> + fileId.equals(e.path("file_id").asText()) + && "note.txt".equals(e.path("file_name").asText()) + && Arrays.equals(attachment, Base64.getDecoder().decode( + e.path("file_data").asText())))); + + // MLS groups use the same carrier, but maintain their own group + // membership and encryption state. + String groupId = alice.engine.createGroup("Project team").groupId(); + alice.engine.inviteToGroup(groupId, bob.address); + until(devices, "bob joined the MLS group", + () -> bob.inGroup(groupId)); + alice.engine.sendGroupMessage(groupId, "team update", MessagePriority.HIGH, null); + until(devices, "bob received the group message", + () -> bob.saw("group_message_received", e -> + groupId.equals(e.path("group_id").asText()) + && "team update".equals(e.path("content").asText()))); + + System.out.println("alice transport " + alice.engine.getActiveTransports()); + System.out.println("group members " + alice.engine.getGroupInfo(groupId).members()); System.out.println("done"); } finally { From 4967839a27834836946894b98861f63d7953d2bd Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Fri, 23 Oct 2026 11:18:57 +0530 Subject: [PATCH 3/4] fix(bindings): give the Java configs the SDK's 24 h pending lifetime The Java record has no defaults (J2), so every caller spells out all 35 fields, and the three copies we hand people all said the pending queue keeps a frame for 60 seconds. The SDK default is 24 hours. That queue holds a message that arrives before its session confirms. Under the deferred-ACK model it is the only copy, and the constant's own documentation records that two minutes was already too short for a peer whose Welcome is slow. Sixty seconds is worse, and nothing compiles differently when it is wrong. It turns out the binding's consumer check, which J2 names as the reference for the definition's defaults, carried the wrong value, the README quick start copied it, and the new example copied it again. Put 86_400_000 in all three and say what the four numbers are, so the next copy has a chance of staying right. --- bindings/java/README.md | 2 +- .../test/java/com/offlineprotocol/sdk/ConsumerCheckTest.java | 2 +- .../src/main/java/com/example/offlineprotocol/Main.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bindings/java/README.md b/bindings/java/README.md index 52cd40457..87ff280cf 100644 --- a/bindings/java/README.md +++ b/bindings/java/README.md @@ -49,7 +49,7 @@ ProtocolConfig config = new ProtocolConfig( "com.example.app", "default", false, false, true, false, false, false, // ble, wifi direct, internet, reticulum, nostr, prefer online (byte) 8, true, true, true, true, // ttl, encryption, auto key exchange, store pending, require encryption - 64L, 4096L, 60_000L, OverflowPolicy.DROP_OLDEST, + 64L, 4096L, 86_400_000L, OverflowPolicy.DROP_OLDEST, // pending queue: per peer, global, ttl (24 h), overflow false, 256, true, true, false, false, true, true, true, false, true, true, true, null, null, diff --git a/bindings/java/src/test/java/com/offlineprotocol/sdk/ConsumerCheckTest.java b/bindings/java/src/test/java/com/offlineprotocol/sdk/ConsumerCheckTest.java index 704002d31..edfa386da 100644 --- a/bindings/java/src/test/java/com/offlineprotocol/sdk/ConsumerCheckTest.java +++ b/bindings/java/src/test/java/com/offlineprotocol/sdk/ConsumerCheckTest.java @@ -29,7 +29,7 @@ static ProtocolConfig config() { "com.example.java", "java-consumer", false, false, true, false, false, false, // ble, wifi direct, internet, reticulum, nostr, prefer online (byte) 8, true, true, true, true, // initial ttl, encryption, auto key exchange, store pending, require encryption - 64L, 4096L, 60_000L, OverflowPolicy.DROP_OLDEST, + 64L, 4096L, 86_400_000L, OverflowPolicy.DROP_OLDEST, // pending queue: per peer, global, ttl (24 h), overflow false, 256, true, true, false, false, // edge-driven DM, group size, group relay x2, admin commits, transport identity true, true, true, false, true, true, true, // binary wire, nostr x3, compact envelope, rich payload, recovery null, null, // mesh relay, custody diff --git a/examples/java-app/src/main/java/com/example/offlineprotocol/Main.java b/examples/java-app/src/main/java/com/example/offlineprotocol/Main.java index 527708457..2e0d3fd7e 100644 --- a/examples/java-app/src/main/java/com/example/offlineprotocol/Main.java +++ b/examples/java-app/src/main/java/com/example/offlineprotocol/Main.java @@ -144,7 +144,7 @@ static ProtocolConfig config(String profile) { "com.example.java", profile, false, false, true, false, false, true, // ble, wifi direct, internet, reticulum, nostr, prefer online (byte) 8, true, true, true, true, // initial ttl, encryption, auto key exchange, store pending, require encryption - 64L, 4096L, 60_000L, OverflowPolicy.DROP_OLDEST, + 64L, 4096L, 86_400_000L, OverflowPolicy.DROP_OLDEST, // pending queue: per peer, global, ttl (24 h), overflow false, 256, true, true, false, false, // edge-driven DM, group size, group relay x2, admin commits, transport identity true, true, true, false, true, true, true, // binary wire, nostr x3, compact envelope, rich payload, recovery null, null, // mesh relay, custody From 1d47a3d6cf73101a3d7a8113b36d1b8dde142c02 Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Fri, 23 Oct 2026 11:19:04 +0530 Subject: [PATCH 4/4] docs(bindings): make the Java example's carrier and pairing check honest The example sells its carrier as "a transport's job, in a dozen lines", and people will copy exactly those lines. They confirmed a frame once carried and did nothing with a frame nobody could carry. A real bridge reports an outcome for every frame it takes. A frame given neither sits in the transport until its timeout and then counts as lost. Report the failure instead. In this run no frame goes uncarried, so this changes the contract shown, not the outcome. The "paired" step waited for Alice to hold Bob's key package. That is what a session is made from, not a session. Wait until each side holds one. Both sides calling establish is fine, because the engine settles a both-create race by a tiebreak, so say that where it happens. While at it, list the example in the docs index with the others. --- docs/README.md | 1 + .../com/example/offlineprotocol/Main.java | 20 ++++++++++++++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/docs/README.md b/docs/README.md index ef32f19f1..98424e933 100644 --- a/docs/README.md +++ b/docs/README.md @@ -135,6 +135,7 @@ in here fails **silently** when violated. | [Offline Demo](../examples/demo-app/README.md) | The reference React Native app: discovery, 1:1 chat, groups, mesh services and telemetry in one place | | [Service Discovery](../examples/service-discovery-app/README.md) | React Native app registering, discovering and invoking services over the mesh | | [Nostr Transport](../examples/nostr-example/README.md) | Minimal React Native app using the Nostr relay transport | +| [Java](../examples/java-app/README.md) | Two devices in one JVM pairing, messaging, sending a file and forming a group over the Java binding | | `cargo run -p offline-protocol --example replicated_notes` | Replicated documents: open a store, edit, persist, reopen | | `cargo run -p offline-protocol-data --example offline_merge` | What two people editing the same document offline actually get back | diff --git a/examples/java-app/src/main/java/com/example/offlineprotocol/Main.java b/examples/java-app/src/main/java/com/example/offlineprotocol/Main.java index 2e0d3fd7e..1828e4cc5 100644 --- a/examples/java-app/src/main/java/com/example/offlineprotocol/Main.java +++ b/examples/java-app/src/main/java/com/example/offlineprotocol/Main.java @@ -92,7 +92,9 @@ void handleEvents() throws Exception { /** * Best effort. With auto_key_exchange on, the engine finishes the session * itself once the peer's key package arrives over the wire, so a refusal - * for want of one here only means "not yet". + * for want of one here only means "not yet". Both sides calling this is + * fine: when both create a session, the engine keeps one by a tiebreak + * and the other side adopts it. */ private void tryEstablish(String peer) { try { @@ -156,14 +158,22 @@ private static void carry(List devices) throws Exception { for (Device from : devices) { InternetMessage message; while ((message = from.engine.internetGetNextMessage()) != null) { + boolean carried = false; for (Device to : devices) { if (to.address.equals(message.recipientId())) { to.engine.internetMessageReceived(from.address, message.data()); - // The carrier accepted the frame. A real socket bridge - // confirms only after its write succeeds. - from.engine.internetConfirmSent(message.messageId()); + carried = true; } } + // Every frame taken gets an outcome, as a real socket bridge + // reports one after its write: confirmed once carried, failed + // when there was nobody to carry it to. A frame given neither + // waits out the transport's timeout before it counts as lost. + if (carried) { + from.engine.internetConfirmSent(message.messageId()); + } else { + from.engine.internetSendFailed(message.messageId()); + } } } } @@ -203,7 +213,7 @@ public static void main(String[] args) throws Exception { // other's key package, which is what the session is made from. alice.engine.sendConnectionRequest(bob.address, alice.name, alice.keyPackage(), null); until(devices, "bob and alice are paired", - () -> alice.saw("connection_accepted") && alice.engine.hasPendingKeyPackage(bob.address)); + () -> alice.engine.mlsHasSession(bob.address) && bob.engine.mlsHasSession(alice.address)); alice.engine.sendPresenceUpdate(bob.address, PresenceStatus.ONLINE); until(devices, "bob sees alice online",