From 8f1f78aa2c3302ddbf7f01ad8d5eb5b5daf48993 Mon Sep 17 00:00:00 2001 From: tarikgul Date: Fri, 17 Jul 2026 13:39:55 -0400 Subject: [PATCH 1/3] feat(android): host adapter package + Kotlin/Android build tooling Add the android/truapi-host package (Kotlin host adapter shell over the truapi-server UniFFI core, localhost WebSocket bridge transport) matched to the current core FFI surface: HostCoreStorage, a single confirmUserAction, local signing session activation, and LocalhostBridgeBootstrap. Add Makefile targets uniffi-kotlin (Kotlin binding generation), android-jni (cargo-ndk cross-compile of libtruapi_server.so per ABI), and android-publish-local; gitignore the generated bindings and jniLibs. --- .gitignore | 1 + Makefile | 27 +- android/.gitignore | 5 + android/truapi-host/README.md | 304 +++++++++ android/truapi-host/build.gradle.kts | 118 ++++ android/truapi-host/consumer-rules.pro | 11 + .../truapi-host/src/main/AndroidManifest.xml | 2 + .../kotlin/io/parity/truapi/TrUAPIHost.kt | 599 ++++++++++++++++++ build.gradle.kts | 8 + gradle.properties | 5 + settings.gradle.kts | 30 + 11 files changed, 1109 insertions(+), 1 deletion(-) create mode 100644 android/.gitignore create mode 100644 android/truapi-host/README.md create mode 100644 android/truapi-host/build.gradle.kts create mode 100644 android/truapi-host/consumer-rules.pro create mode 100644 android/truapi-host/src/main/AndroidManifest.xml create mode 100644 android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore index 9dc237bb..f84a9849 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,7 @@ playground/test/generated/ # Auto-generated FFI / WASM binding outputs android/truapi-host/src/main/kotlin/generated/ +android/truapi-host/src/main/jniLibs/ ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift ios/truapi-host/Sources/truapi_serverFFI/ rust/crates/truapi-server/pkg/ diff --git a/Makefile b/Makefile index 2d2fbf72..573af9a8 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ # Run `make help` for the list of targets. .DEFAULT_GOAL := help -.PHONY: help setup build codegen test check clean playground wasm wasm-crypto-test uniffi dotli-link dev dev-bootstrap dev-link-check e2e-dotli headless install matrix explorer +.PHONY: help setup build codegen test check clean playground wasm wasm-crypto-test uniffi uniffi-kotlin android-jni android-publish-local dotli-link dev dev-bootstrap dev-link-check e2e-dotli headless install matrix explorer CARGO ?= cargo TRUAPI_PKG := js/packages/truapi @@ -104,6 +104,31 @@ uniffi: ## Regenerate Swift bindings from truapi-server cdylib. cp $(UNIFFI_SWIFT_TMP)/truapi_serverFFI.modulemap \ ios/truapi-host/Sources/truapi_serverFFI/include/module.modulemap +UNIFFI_KOTLIN_OUT := android/truapi-host/src/main/kotlin/generated + +uniffi-kotlin: ## Regenerate Kotlin UniFFI bindings from the truapi-server cdylib. + $(CARGO) build -p truapi-server --release --features ws-bridge + rm -rf $(UNIFFI_KOTLIN_OUT) + mkdir -p $(UNIFFI_KOTLIN_OUT) + $(CARGO) run -p uniffi-bindgen-cli -- generate \ + --library $(UNIFFI_CDYLIB) \ + --language kotlin \ + --out-dir $(UNIFFI_KOTLIN_OUT) + +# Android ABIs to cross-compile the cdylib for. arm64 + armv7 cover physical +# devices; x86_64 covers the emulator on Intel/Apple-silicon hosts. +ANDROID_ABIS ?= arm64-v8a armeabi-v7a x86_64 +ANDROID_JNILIBS := android/truapi-host/src/main/jniLibs + +android-jni: ## Cross-compile libtruapi_server.so for Android ABIs into jniLibs (needs cargo-ndk + NDK). + @command -v cargo-ndk >/dev/null || { echo "cargo-ndk not found: cargo install cargo-ndk"; exit 1; } + $(CARGO) ndk $(foreach abi,$(ANDROID_ABIS),-t $(abi)) \ + -o $(ANDROID_JNILIBS) \ + build --release -p truapi-server --features ws-bridge + +android-publish-local: uniffi-kotlin android-jni ## Build bindings + cdylib, then publish the AAR to ~/.m2 (needs Gradle + JDK 17). + gradle :truapi-host:publishReleasePublicationToMavenLocal + test: ## Run Rust + TypeScript client tests. cargo test --workspace cd $(TRUAPI_PKG) && npm test diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 00000000..98778d9e --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,5 @@ +.gradle/ +build/ +local.properties +*.iml +.idea/ diff --git a/android/truapi-host/README.md b/android/truapi-host/README.md new file mode 100644 index 00000000..56dc7dc7 --- /dev/null +++ b/android/truapi-host/README.md @@ -0,0 +1,304 @@ +# TrUAPI Android host adapter + +*Kotlin wrapper around the TrUAPI Rust core (UniFFI). Wire decoding, request routing, and subscription lifecycle stay in the Rust core; products connect through the localhost WebSocket bridge.* + +Distributed as a Maven artifact built on demand from git tags by [JitPack](https://jitpack.io/), no Maven Central account required on either side. + +## Consume + +Add the JitPack Maven repository and the artifact to your app's Gradle build: + +```kotlin +// settings.gradle.kts +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + maven { url = uri("https://jitpack.io") } + } +} +``` + +```kotlin +// app/build.gradle.kts +dependencies { + implementation("com.github.paritytech.truapi:truapi-host:0.1.0") +} +``` + +JitPack fetches the tag `0.1.0` from `paritytech/truapi`, runs `make android-publish-local` against it (driven by `jitpack.yml` at the repo root, including UniFFI binding generation), and serves the resulting AAR + POM + sources jar. First fetch takes ~1 minute while JitPack builds; subsequent consumers hit the cache. + +The artifact bundles the Kotlin host adapter (`io.parity.truapi.*`) and the generated UniFFI bindings (`uniffi.truapi_server.*`). It does **not** bundle the native `libtruapi_server.so` cdylib, integrators build that per Android ABI and drop it into their app's `src/main/jniLibs//` (see "Linking the cdylib" below). + +### Compatibility + +- **minSdk**: 29 (Android 10). Aligns with the polkadot-app-android-v2 floor. +- **AGP**: built with 8.5.2; AGP 8.5+ consumers are fine. AAR is forward-compatible with newer AGPs. +- **Kotlin**: built with 1.9.24. Newer Kotlin compilers (2.x) read 1.9 metadata fine. +- **Transitive dependency**: the AAR pulls `net.java.dev.jna:jna:5.14.0` (UniFFI's runtime). Consumers that don't already use JNA will see ~1.5MB added to their app. + +## Public surface + +The public surface lives in [`src/main/kotlin/io/parity/truapi/TrUAPIHost.kt`](src/main/kotlin/io/parity/truapi/TrUAPIHost.kt): + +- `HostBridge` - callback bundle the embedding app implements. Splits device permissions, remote permissions, navigation, push, feature support, a single `confirmUserAction`, and both storage backends. +- `HostStorage` - product-scoped read/write/clear interface the host backs with its own persistence. +- `HostCoreStorage` - core-owned read/write/clear interface for auth session, pairing identity, and persisted permission decisions (`key` is a SCALE-encoded `CoreStorageKey`). +- `TrUAPIHostCore` - owning wrapper around the UniFFI-generated `NativeTrUApiCore`. Holds the bridge alive for the lifetime of the core and exposes the localhost WebSocket bridge, core-owned disconnect, local-session activation, permission-authorization status, and native change notifications for session storage, theme, and preimage updates. +- `LocalhostBridgeBootstrap` - JS snippet that publishes the WS bridge endpoint (`window.__truapi_localhost`) to the product page so it can dial back in. + +## Architecture + +```text +product app in WebView + Uint8Array frames via @parity/truapi createWebSocketProvider + | + v ws://127.0.0.1:/?t= +TrUAPIHostCore.startWsBridge() + → libtruapi_server.so (tokio WS server) + → Rust dispatcher +``` + +The product running in the `WebView` opens a `WebSocket` to the localhost port + token returned by `startWsBridge`. From there the Rust core handles the wire protocol directly. Outbound responses and host-side capability callbacks (`navigateTo`, `pushNotification`, `cancelNotification`, `devicePermission`, `remotePermission`, `authStateChanged`, core storage, chain JSON-RPC, `confirmUserAction`, preimage lookup, theme, `featureSupported`, `storage`) reach the embedder through `HostBridge`. Bulletin preimage build/sign/submit now happens inside the core, so the host only serves `lookupPreimage`. + +## Permissions split + +The core's `Permissions` platform trait has two methods, and so does the bridge: + +- `devicePermission(request)` - OS-scoped grants (camera, mic, location, push). `request` is a SCALE-encoded `v01::HostDevicePermissionRequest`. +- `remotePermission(request)` - per-product capability bundles. `request` is a SCALE-encoded `v01::RemotePermissionRequest`. + +Both return a `Boolean` granted flag. SCALE decoding for the UI prompt is done by the `@parity/truapi` JS client (or any consumer that links the protocol crate's types directly). + +## Example + +> **Threading:** the Rust core invokes every `HostBridge` callback on a +> background thread it owns, never the UI thread. Marshal any UI work +> (navigation, prompts, notifications, touching the `WebView`) onto the main +> thread with `Handler(Looper.getMainLooper())` or a `Dispatchers.Main` +> `CoroutineScope`. UI-decision callbacks (`navigateTo`, `devicePermission`, +> `remotePermission`, `confirmUserAction`) each run on their own blocking-pool +> thread, so it is safe to block the calling thread (e.g. with a +> `CountDownLatch`) until the main-thread prompt resolves; other TrUAPI traffic +> keeps flowing while you wait. The remaining callbacks (auth state, storage, +> core storage, chain, feature, theme, preimage lookups) run inline on the +> dispatcher thread and must return promptly without blocking. + +```kt +import android.os.Handler +import android.os.Looper +import android.webkit.WebView +import io.parity.truapi.HostBridge +import io.parity.truapi.HostCoreStorage +import io.parity.truapi.HostStorage +import io.parity.truapi.LocalhostBridgeBootstrap +import io.parity.truapi.PairingDeeplinkScheme +import io.parity.truapi.RuntimeConfig +import io.parity.truapi.TrUAPIHostCore +import uniffi.truapi_server.AuthState +import uniffi.truapi_server.HostTheme +import java.util.concurrent.CountDownLatch + +class MyStorage : HostStorage { + private val map = mutableMapOf() + override fun read(key: String) = map[key] + override fun write(key: String, value: ByteArray) { map[key] = value } + override fun clear(key: String) { map.remove(key) } +} + +// Core-owned storage: keyed by SCALE-encoded CoreStorageKey bytes. Back it with +// real persistence (e.g. EncryptedSharedPreferences); an in-memory map is shown +// for brevity. +class MyCoreStorage : HostCoreStorage { + private val map = HashMap() + private fun k(key: ByteArray) = key.joinToString("") { "%02x".format(it) } + override fun read(key: ByteArray) = map[k(key)] + override fun write(key: ByteArray, value: ByteArray) { map[k(key)] = value } + override fun clear(key: ByteArray) { map.remove(k(key)) } +} + +class MyBridge(private val webView: WebView) : HostBridge { + private val main = Handler(Looper.getMainLooper()) + + override val storage = MyStorage() + override val coreStorage = MyCoreStorage() + + override fun navigateTo(url: String) { + main.post { /* startActivity(Intent(ACTION_VIEW, Uri.parse(url))) */ } + } + + override fun pushNotification(payload: ByteArray): UInt { + val id = 1u + main.post { /* show notification */ } + return id + } + + override fun cancelNotification(id: UInt) { + main.post { /* cancel notification */ } + } + + override fun devicePermission(request: ByteArray): Boolean { + // Called on a blocking-pool thread; prompt on the main thread and + // wait. Blocking here does not stall other TrUAPI traffic. + val latch = CountDownLatch(1) + var granted = false + main.post { /* show prompt, set granted, then */ latch.countDown() } + latch.await() + return granted + } + + override fun remotePermission(request: ByteArray): Boolean = false + override fun featureSupported(request: ByteArray): Boolean = false + + // Core-owned auth state stream: render AuthState.Pairing as the pairing + // QR sheet, connected/disconnected as the account badge, and login-failed + // as a retryable error. When the user closes the pairing sheet, report it + // with `core.cancelLogin()`. + override fun authStateChanged(state: AuthState) { + main.post { /* render the state */ } + } + + override fun chainConnect(genesisHash: ByteArray): UInt? { + val id = 1u + main.post { /* open JSON-RPC connection, forward responses via core.notifyChainResponse */ } + return id + } + + override fun chainSend(connectionId: UInt, request: String) { + /* send JSON-RPC request on the host connection */ + } + + override fun chainClose(connectionId: UInt) { + /* close host connection */ + } + + // One confirmation callback for every reviewed core action. Decode + // `review` (SCALE `UserConfirmationReview`) with the @parity/truapi JS + // client to pick the prompt (sign payload / raw / create tx / alias / + // resource allocation / preimage submit). + override fun confirmUserAction(review: ByteArray): Boolean { + val latch = CountDownLatch(1) + var approved = false + main.post { /* show prompt, set approved, then */ latch.countDown() } + latch.await() + return approved + } +} + +val webView: WebView = existingWebView +val runtimeConfig = RuntimeConfig( + productId = "my-product.dot", + hostName = "My Host", + hostIcon = "https://host.example/icon.png", + peopleChainGenesisHash = ByteArray(32), + bulletinChainGenesisHash = ByteArray(32), + // Optional: activate a local signing session from host-held BIP-39 entropy + // (no SSO pairing). Omit for the QR pairing flow. + localSessionSecret = null, + pairingDeeplinkScheme = PairingDeeplinkScheme.POLKADOT_APP, +) +val core = TrUAPIHostCore(MyBridge(webView), runtimeConfig) +val endpoint = core.startWsBridge() + +// Call these from host/platform observers so native subscriptions see updates +// after their immediate current item. +core.notifySessionStoreChanged() +core.notifyThemeChanged(HostTheme.DARK) +core.notifyPreimageChanged(preimageKey, preimageBytesOrNull) +core.notifyChainResponse(chainConnectionId, jsonRpcResponse) +core.notifyChainClosed(chainConnectionId) + +// Publish the bridge endpoint to the product page by injecting the bootstrap +// snippet at document start, then load the product. The page reads +// `window.__truapi_localhost.url` and passes it to `@parity/truapi`'s +// `createWebSocketProvider`. +val bootstrap = LocalhostBridgeBootstrap.script(endpoint.port, endpoint.token) +main.post { + webView.evaluateJavascript(bootstrap, null) // or add as a document-start script + webView.loadUrl("https://your-product.example/") +} + +// On logout: +core.disconnect() +``` + +## Linking the cdylib + +The native runtime ships separately. JNA looks for `libtruapi_server.so` in the standard `jniLibs` paths; bundle the per-ABI builds under: + +``` +src/main/jniLibs/arm64-v8a/libtruapi_server.so +src/main/jniLibs/armeabi-v7a/libtruapi_server.so +src/main/jniLibs/x86_64/libtruapi_server.so +``` + +Cross-build the cdylib for each Android ABI from the truapi monorepo. Two options, pick whichever fits the host app's existing toolchain: + +**Option A: `mozilla-rust-android-gradle` plugin.** Recommended if the host app already uses it (polkadot-app-android-v2 does, for `bandersnatch-crypto`). Vendor `paritytech/truapi` as a git submodule, add a small Gradle module that points the plugin at `rust/crates/truapi-server`: + +```kotlin +// app/build.gradle.kts (or a dedicated :truapi-cdylib module) +plugins { + alias(libs.plugins.mozilla.rust.android) +} + +cargo { + module = "/truapi/rust/crates/truapi-server" + libname = "truapi_server" + targets = listOf("arm64", "arm", "x86_64") + profile = "release" + features { defaultAnd(arrayOf("ws-bridge")) } +} + +tasks.matching { it.name.matches("merge.*JniLibFolders".toRegex()) }.configureEach { + inputs.dir(layout.buildDirectory.dir("rustJniLibs/android")) + dependsOn("cargoBuild") +} +``` + +**Option B: `cargo-ndk` from the command line.** Standalone, no Gradle plugin required: + +```bash +cargo install cargo-ndk +cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 \ + -o app/src/main/jniLibs \ + build --release -p truapi-server --features ws-bridge +``` + +Both options require the Android NDK installed and the matching Rust targets (`rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android`). + +Pre-built per-ABI `.so` files bundled inside the AAR are tracked as a follow-up so consumers eventually don't need a Rust toolchain at all. + +## Maintainers: cutting a release + +JitPack builds on demand from any git tag in `paritytech/truapi`, so a release is just: + +1. Bump `publicationVersion` in `android/truapi-host/build.gradle.kts`. +2. Commit. Open a PR. Merge. +3. Tag the merge commit with the version: `git tag truapi-host-android@0.1.0 && git push origin truapi-host-android@0.1.0`. + +That's the entire release flow, the iOS Swift Package follows the same pattern. The first consumer to pull the tag will trigger JitPack to build the artifact; subsequent fetches hit the cache. + +For local development, publish into the dev `~/.m2`: + +```bash +gradle :truapi-host:publishReleasePublicationToMavenLocal +# or +make android-publish-local +``` + +The artifact lands under `~/.m2/repository/io/parity/truapi-host-android//`. Consumers pointing at `mavenLocal()` can resolve it via `io.parity:truapi-host-android:`. These local coordinates differ from the JitPack consumer coordinate (`com.github.paritytech.truapi:truapi-host:`): JitPack derives the group and artifactId from the repo and Gradle subproject, overriding the `io.parity:truapi-host-android` coordinates set in `build.gradle.kts`. + +## Regenerating the UniFFI bindings + +The ignored Kotlin bindings under `src/main/kotlin/generated/uniffi/` are produced from the workspace `uniffi-bindgen-cli`. Regenerate them before building or publishing the Android host package: + +```bash +cargo build -p truapi-server --release --features ws-bridge +cargo run -p uniffi-bindgen-cli -- generate \ + --library target/release/libtruapi_server.so \ + --language kotlin \ + --out-dir android/truapi-host/src/main/kotlin/generated +``` + +Or run `make uniffi` from the repo root. diff --git a/android/truapi-host/build.gradle.kts b/android/truapi-host/build.gradle.kts new file mode 100644 index 00000000..c79d8a3c --- /dev/null +++ b/android/truapi-host/build.gradle.kts @@ -0,0 +1,118 @@ +// TrUAPI Android host adapter. +// +// Publishes `io.parity:truapi-host-android` to Maven. Products running in a +// `WebView` connect to the Rust core via its localhost WebSocket bridge +// (`TrUAPIHostCore.startWsBridge`); the Rust core (compiled to +// `libtruapi_server.so`) handles wire decoding, routing, subscription +// lifecycle, and host capability dispatch. + +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") + id("maven-publish") +} + +android { + namespace = "io.parity.truapi" + compileSdk = 34 + + defaultConfig { + // minSdk 29 matches the polkadot-app-android-v2 floor; raise here + // first and bump consumers' floors if we ever depend on a newer API. + minSdk = 29 + consumerProguardFiles("consumer-rules.pro") + } + + sourceSets { + getByName("main") { + java.srcDirs("src/main/kotlin") + manifest.srcFile("src/main/AndroidManifest.xml") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + publishing { + singleVariant("release") { + withSourcesJar() + withJavadocJar() + } + } +} + +dependencies { + // UniFFI Kotlin bindings use JNA for FFI. + api("net.java.dev.jna:jna:5.14.0@aar") +} + +// Coordinates for the local Maven publication (`publishToMavenLocal`). +// Distribution is via JitPack: a git tag drives `jitpack.yml`, and JitPack +// derives the consumer coordinates from the repo + subproject as +// `com.github.paritytech.truapi:truapi-host:`, overriding the group and +// artifactId below. These fields only matter for local testing. +val publicationGroup = "io.parity" +val publicationArtifact = "truapi-host-android" +val publicationVersion = "0.1.0" + +group = publicationGroup +version = publicationVersion + +publishing { + publications { + register("release") { + groupId = publicationGroup + artifactId = publicationArtifact + version = publicationVersion + + afterEvaluate { + from(components["release"]) + } + + pom { + name.set("TrUAPI Android host adapter") + description.set( + "Kotlin wrapper around the TrUAPI Rust core (UniFFI). " + + "Hosts integrating a `WebView`-based product link the " + + "`libtruapi_server` cdylib and route product traffic " + + "through the localhost WebSocket bridge." + ) + url.set("https://github.com/paritytech/truapi") + licenses { + license { + name.set("MIT") + url.set("https://github.com/paritytech/truapi/blob/main/LICENSE") + } + } + scm { + connection.set("scm:git:https://github.com/paritytech/truapi.git") + developerConnection.set("scm:git:ssh://git@github.com/paritytech/truapi.git") + url.set("https://github.com/paritytech/truapi") + } + developers { + developer { + name.set("Parity Technologies") + email.set("admin@parity.io") + organization.set("Parity Technologies") + organizationUrl.set("https://parity.io") + } + } + } + } + } + + repositories { + // Maven Local for `gradle publishToMavenLocal` during development + // and for JitPack's build environment (see `jitpack.yml`). + // Consumers fetch the published artifact via JitPack at + // `com.github.paritytech.truapi:truapi-host:` after the + // repo is tagged. + mavenLocal() + } +} diff --git a/android/truapi-host/consumer-rules.pro b/android/truapi-host/consumer-rules.pro new file mode 100644 index 00000000..759ad9b2 --- /dev/null +++ b/android/truapi-host/consumer-rules.pro @@ -0,0 +1,11 @@ +# ProGuard / R8 rules applied to consumers of `io.parity:truapi-host-android`. +# +# JNA reflects into our generated UniFFI types at runtime, so the bindings +# package and the public Kotlin surface must survive shrinking. + +-keep class uniffi.truapi_server.** { *; } +-keep class io.parity.truapi.** { *; } + +# JNA itself. +-keep class com.sun.jna.** { *; } +-keepclassmembers class * extends com.sun.jna.** { *; } diff --git a/android/truapi-host/src/main/AndroidManifest.xml b/android/truapi-host/src/main/AndroidManifest.xml new file mode 100644 index 00000000..8072ee00 --- /dev/null +++ b/android/truapi-host/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt b/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt new file mode 100644 index 00000000..0c42cff9 --- /dev/null +++ b/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt @@ -0,0 +1,599 @@ +// TrUAPIHost - Android host adapter. +// +// The Rust core (compiled to `libtruapi_server.so` and surfaced via UniFFI in +// `src/main/kotlin/generated/uniffi/truapi_server/truapi_server.kt`) owns the +// wire protocol, request routing, subscription lifecycle, and platform trait +// dispatch. +// +// This file exposes: +// +// * `HostBridge` - the Kotlin-friendly callback interface the embedding app +// implements. It splits device and remote permissions, mirroring the +// `Permissions` platform trait in the Rust core. +// * `HostStorage` / `HostCoreStorage` - the product-scoped and core-owned +// key-value backends the host persists. +// * `TrUAPIHostCore` - owning wrapper around the UniFFI-generated +// `NativeTrUApiCore`. Holds the bridge alive for the lifetime of the core +// and exposes session + WS-bridge controls plus native change notifications. +// * `LocalhostBridgeBootstrap` - JS snippet that publishes the WS bridge +// endpoint to the product page so it can dial back in. +// +// Products running inside a `WebView` connect to the Rust core via the +// localhost WebSocket bridge. Start it with `core.startWsBridge()` and load +// the product page with a `LocalhostBridgeBootstrap.script(...)` snippet +// injected at document start so the page's `@parity/truapi` +// `createWebSocketProvider` can dial `ws://127.0.0.1:/?t=`. + +package io.parity.truapi + +import uniffi.truapi_server.AuthState +import uniffi.truapi_server.HostCallbacks +import uniffi.truapi_server.HostNavigateRejection +import uniffi.truapi_server.HostRejection +import uniffi.truapi_server.HostStorageException +import uniffi.truapi_server.HostTheme +import uniffi.truapi_server.NativeTrUApiCore +import uniffi.truapi_server.NativePairingDeeplinkScheme as UniFfiNativePairingDeeplinkScheme +import uniffi.truapi_server.NativePermissionAuthorizationStatus +import uniffi.truapi_server.NativeRuntimeConfig as UniFfiNativeRuntimeConfig +import uniffi.truapi_server.NativeRuntimeConfigException +import uniffi.truapi_server.WsBridgeEndpoint +import uniffi.truapi_server.WsBridgeStartException + +/** Package metadata. */ +object TrUAPIHost { + const val VERSION = "0.1.0" +} + +/** Deeplink scheme used when the Rust core builds SSO pairing payloads. */ +enum class PairingDeeplinkScheme { + POLKADOT_APP, + POLKADOT_APP_DEV; + + internal fun toNative(): UniFfiNativePairingDeeplinkScheme = + when (this) { + POLKADOT_APP -> UniFfiNativePairingDeeplinkScheme.POLKADOT_APP + POLKADOT_APP_DEV -> UniFfiNativePairingDeeplinkScheme.POLKADOT_APP_DEV + } +} + +/** + * Static product and pairing config supplied before the Rust core handles + * product calls. One core instance represents one product identity. + * + * [hostName], [hostIcon], [hostVersion], [platformType], and [platformVersion] + * describe the host to the wallet during SSO pairing. + * [peopleChainGenesisHash] and [bulletinChainGenesisHash] must each be exactly + * 32 bytes. [localSessionSecret] optionally activates a local signing session + * from host-held BIP-39 entropy (no SSO pairing needed). + */ +data class RuntimeConfig( + val productId: String, + val hostName: String, + val hostIcon: String? = null, + val hostVersion: String? = null, + val platformType: String? = null, + val platformVersion: String? = null, + val peopleChainGenesisHash: ByteArray, + val bulletinChainGenesisHash: ByteArray, + val localSessionSecret: ByteArray? = null, + val localSessionLiteUsername: String? = null, + val pairingDeeplinkScheme: PairingDeeplinkScheme = PairingDeeplinkScheme.POLKADOT_APP, +) { + internal fun toNative(): UniFfiNativeRuntimeConfig = + UniFfiNativeRuntimeConfig( + productId = productId, + hostName = hostName, + hostIcon = hostIcon, + hostVersion = hostVersion, + platformType = platformType, + platformVersion = platformVersion, + peopleChainGenesisHash = peopleChainGenesisHash, + bulletinChainGenesisHash = bulletinChainGenesisHash, + localSessionSecret = localSessionSecret, + localSessionLiteUsername = localSessionLiteUsername, + pairingDeeplinkScheme = pairingDeeplinkScheme.toNative(), + ) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is RuntimeConfig) return false + return productId == other.productId && + hostName == other.hostName && + hostIcon == other.hostIcon && + hostVersion == other.hostVersion && + platformType == other.platformType && + platformVersion == other.platformVersion && + peopleChainGenesisHash.contentEquals(other.peopleChainGenesisHash) && + bulletinChainGenesisHash.contentEquals(other.bulletinChainGenesisHash) && + (localSessionSecret ?: ByteArray(0)).contentEquals( + other.localSessionSecret ?: ByteArray(0), + ) && + localSessionLiteUsername == other.localSessionLiteUsername && + pairingDeeplinkScheme == other.pairingDeeplinkScheme + } + + override fun hashCode(): Int { + var result = productId.hashCode() + result = 31 * result + hostName.hashCode() + result = 31 * result + (hostIcon?.hashCode() ?: 0) + result = 31 * result + (hostVersion?.hashCode() ?: 0) + result = 31 * result + (platformType?.hashCode() ?: 0) + result = 31 * result + (platformVersion?.hashCode() ?: 0) + result = 31 * result + peopleChainGenesisHash.contentHashCode() + result = 31 * result + bulletinChainGenesisHash.contentHashCode() + result = 31 * result + (localSessionSecret?.contentHashCode() ?: 0) + result = 31 * result + (localSessionLiteUsername?.hashCode() ?: 0) + result = 31 * result + pairingDeeplinkScheme.hashCode() + return result + } +} + +/** + * Product-scoped key-value storage the host provides to the Rust core. Throws + * [HostStorageException] to signal quota exhaustion or unknown failure; the + * core maps both onto the v0.1 `HostLocalStorageReadError` wire shape. + */ +interface HostStorage { + @Throws(HostStorageException::class) + fun read(key: String): ByteArray? + + @Throws(HostStorageException::class) + fun write(key: String, value: ByteArray) + + @Throws(HostStorageException::class) + fun clear(key: String) +} + +/** + * Core-owned key-value storage the host backs with its own persistence. The + * core writes auth session, pairing identity, and persisted permission + * decisions here; [key] is a SCALE-encoded `CoreStorageKey`. Throws + * [HostRejection] on failure. + */ +interface HostCoreStorage { + @Throws(HostRejection::class) + fun read(key: ByteArray): ByteArray? + + @Throws(HostRejection::class) + fun write(key: ByteArray, value: ByteArray) + + @Throws(HostRejection::class) + fun clear(key: ByteArray) +} + +/** + * Host-side callback bundle that the Rust core invokes for capabilities the + * native shell owns. The interface mirrors the underlying UniFFI surface but + * keeps the permission split explicit: + * + * * [devicePermission] handles camera / mic / push prompts and similar + * OS-scoped grants. `request` is a SCALE-encoded + * `v01::HostDevicePermissionRequest`. + * * [remotePermission] handles per-product capability bundles requested by + * the application running inside the WebView. `request` is a SCALE-encoded + * `v01::RemotePermissionRequest`. + * + * Embedders typically wire the SCALE payloads through the generated + * `@parity/truapi` client running on the JS side for UI rendering, then report + * the user's decision as a `Boolean`. + * + * Threading: the Rust core invokes every callback on a background thread it + * owns, never the UI (main) thread. UI-decision callbacks ([navigateTo], + * [devicePermission], [remotePermission], and [confirmUserAction]) each run on + * their own thread from a blocking pool, so an implementation may safely block + * its calling thread (e.g. with a `CountDownLatch`) until the user decides; + * other TrUAPI traffic keeps flowing. The remaining callbacks (auth state, + * storage, core storage, chain, feature, theme, preimage lookups) run inline on + * the dispatcher thread and must return promptly without blocking. Any UI work + * MUST still be marshalled onto the main thread, e.g. with + * `Handler(Looper.getMainLooper()).post { ... }` or a `CoroutineScope` bound to + * `Dispatchers.Main`. Touching views or the `WebView` directly from a callback + * throws `CalledFromWrongThreadException`. + */ +interface HostBridge { + /** Lifecycle logger. Marker is a stable slug, detail is free-form. */ + fun onCoreLog(marker: String, detail: String) {} + + /** + * Open a URL in the system browser. Invoked on a blocking-pool thread; + * marshal the UI launch (e.g. `startActivity`) to the main thread. May + * block the calling thread if the user has to approve the navigation. + */ + @Throws(HostNavigateRejection::class) + fun navigateTo(url: String) + + /** + * Deliver a push notification (SCALE-encoded `HostPushNotificationRequest`) + * and return the host-assigned notification id. Invoked on the dispatcher + * thread; marshal any UI work to the main thread and return promptly. + */ + @Throws(HostRejection::class) + fun pushNotification(payload: ByteArray): UInt = 0u + + /** Cancel a previously scheduled notification id. */ + @Throws(HostRejection::class) + fun cancelNotification(id: UInt) {} + + /** + * Prompt for a device-level permission. Returns whether it was granted. + * Invoked on a blocking-pool thread; present the prompt on the main thread + * and block the calling thread until the user decides. Blocking here does + * not stall other TrUAPI traffic. + */ + @Throws(HostRejection::class) + fun devicePermission(request: ByteArray): Boolean + + /** + * Prompt for a remote (product-scoped) permission bundle. Invoked on a + * blocking-pool thread; present the prompt on the main thread and block the + * calling thread until the user decides. Blocking here does not stall other + * TrUAPI traffic. + */ + @Throws(HostRejection::class) + fun remotePermission(request: ByteArray): Boolean + + /** + * Observe an auth state change. The core emits states only when they + * actually change, in transition order: render [AuthState.Pairing] as the + * pairing QR UI, connected/disconnected as the account badge, and + * login-failed as a retryable error. Report a user dismissal of the pairing + * UI through [TrUAPIHostCore.cancelLogin]. Invoked on the dispatcher thread; + * marshal the state to the main thread and return promptly. + */ + fun authStateChanged(state: AuthState) {} + + /** Open a JSON-RPC chain connection and return a host-assigned id, or null if unsupported. */ + @Throws(HostRejection::class) + fun chainConnect(genesisHash: ByteArray): UInt? = null + + /** Send one JSON-RPC request on a native chain connection. */ + @Throws(HostRejection::class) + fun chainSend(connectionId: UInt, request: String) {} + + /** Close a native chain connection. */ + @Throws(HostRejection::class) + fun chainClose(connectionId: UInt) {} + + /** + * Confirm one user-reviewed core action. `review` is a SCALE-encoded + * `UserConfirmationReview`; decode it to pick the prompt (sign payload, + * sign raw, create transaction, account alias, resource allocation, or + * preimage submit). Invoked on a blocking-pool thread; present the prompt on + * the main thread and block the calling thread until the user decides. + */ + @Throws(HostRejection::class) + fun confirmUserAction(review: ByteArray): Boolean = false + + /** Return the current preimage value for [key], or null for a miss. */ + @Throws(HostRejection::class) + fun lookupPreimage(key: ByteArray): ByteArray? = null + + /** Return the current host theme. */ + @Throws(HostRejection::class) + fun currentTheme(): HostTheme = HostTheme.DARK + + /** + * Answer a feature-support query. Invoked on the dispatcher thread; must + * return promptly. + */ + @Throws(HostRejection::class) + fun featureSupported(request: ByteArray): Boolean + + /** Product-scoped key-value storage for the Rust core. */ + val storage: HostStorage + + /** Core-owned key-value storage for auth session / pairing identity / permission decisions. */ + val coreStorage: HostCoreStorage +} + +/** + * Adapter from the public [HostBridge] surface to the generated UniFFI + * [HostCallbacks] interface. Keeps the public API stable even if uniffi-bindgen + * renames generated symbols. + */ +private class HostCallbackAdapter(private val bridge: HostBridge) : HostCallbacks { + override fun onCoreLog(marker: String, detail: String) = + bridge.onCoreLog(marker, detail) + + override fun navigateTo(url: String) = + bridge.navigateTo(url) + + override fun pushNotification(payload: ByteArray): UInt = + bridge.pushNotification(payload) + + override fun cancelNotification(id: UInt) = + bridge.cancelNotification(id) + + override fun devicePermission(request: ByteArray): Boolean = + bridge.devicePermission(request) + + override fun remotePermission(request: ByteArray): Boolean = + bridge.remotePermission(request) + + override fun authStateChanged(state: AuthState) = + bridge.authStateChanged(state) + + override fun coreStorageRead(key: ByteArray): ByteArray? = + bridge.coreStorage.read(key) + + override fun coreStorageWrite(key: ByteArray, value: ByteArray) = + bridge.coreStorage.write(key, value) + + override fun coreStorageClear(key: ByteArray) = + bridge.coreStorage.clear(key) + + override fun chainConnect(genesisHash: ByteArray): UInt? = + bridge.chainConnect(genesisHash) + + override fun chainSend(connectionId: UInt, request: String) = + bridge.chainSend(connectionId, request) + + override fun chainClose(connectionId: UInt) = + bridge.chainClose(connectionId) + + override fun confirmUserAction(review: ByteArray): Boolean = + bridge.confirmUserAction(review) + + override fun lookupPreimage(key: ByteArray): ByteArray? = + bridge.lookupPreimage(key) + + override fun currentTheme(): HostTheme = + bridge.currentTheme() + + override fun featureSupported(request: ByteArray): Boolean = + bridge.featureSupported(request) + + override fun localStorageRead(key: String): ByteArray? = + bridge.storage.read(key) + + override fun localStorageWrite(key: String, value: ByteArray) = + bridge.storage.write(key, value) + + override fun localStorageClear(key: String) = + bridge.storage.clear(key) +} + +/** + * Bootstrap helper for the native localhost WebSocket bridge that the Rust core + * stands up via [TrUAPIHostCore.startWsBridge] when the cdylib is built with the + * `ws-bridge` feature. + */ +object LocalhostBridgeBootstrap { + /** + * Returns a `