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..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, @@ -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/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/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/README.md b/examples/java-app/README.md new file mode 100644 index 000000000..6c951e05f --- /dev/null +++ b/examples/java-app/README.md @@ -0,0 +1,84 @@ +# Java example + +Two devices, Alice and Bob, in one JVM: they pair, establish an end-to-end +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 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 +``` + +## 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. +- 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`), 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. + +```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..1828e4cc5 --- /dev/null +++ b/examples/java-app/src/main/java/com/example/offlineprotocol/Main.java @@ -0,0 +1,284 @@ +package com.example.offlineprotocol; + +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, 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}) + * 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". 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 { + 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())); + } + + 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())) + .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, 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 + 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) { + boolean carried = false; + for (Device to : devices) { + if (to.address.equals(message.recipientId())) { + to.engine.internetMessageReceived(from.address, message.data()); + 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()); + } + } + } + } + + 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); + } + + 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"))); + 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.engine.mlsHasSession(bob.address) && bob.engine.mlsHasSession(alice.address)); + + 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.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 { + for (Device device : devices) { + device.engine.stop(); + device.engine.close(); + } + } + } +}