Skip to content

Commit 2daf12d

Browse files
committed
feat(mobile): add iOS and Android UniFFI host packages
Add uniffi-bindgen-cli (the thin uniffi_bindgen_main wrapper) and the mobile host packages it feeds: the io.parity:truapi-host-android Maven library (AAR + Gradle publication, distributed via JitPack) and the TrUAPIHost Swift Package. `make uniffi` generates the Kotlin + Swift bindings from the truapi-server cdylib into the gitignored binding paths. The host-packages CI workflow gains the android-assemble job and the android/ios path triggers alongside the existing WASM bundle checks.
1 parent 2135473 commit 2daf12d

19 files changed

Lines changed: 1732 additions & 0 deletions

File tree

.github/workflows/host-packages.yml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ on:
44
branches: [main]
55
paths:
66
- 'js/packages/truapi-host-wasm/**'
7+
- 'android/**'
8+
- 'ios/**'
79
- 'rust/crates/truapi-server/**'
810
- 'rust/crates/truapi-platform/**'
911
- 'rust/crates/truapi-codegen/**'
@@ -12,6 +14,8 @@ on:
1214
pull_request:
1315
paths:
1416
- 'js/packages/truapi-host-wasm/**'
17+
- 'android/**'
18+
- 'ios/**'
1519
- 'rust/crates/truapi-server/**'
1620
- 'rust/crates/truapi-platform/**'
1721
- 'rust/crates/truapi-codegen/**'
@@ -69,3 +73,36 @@ jobs:
6973
run: cd js/packages/truapi && npm install --no-fund --no-audit && npm run build
7074
- name: Test @parity/truapi-host-wasm
7175
run: cd js/packages/truapi-host-wasm && npm install --no-fund --no-audit && npm test
76+
android-assemble:
77+
# Smoke-builds the Android library + verifies the Maven publication
78+
# can be assembled (writes to maven-local). No release happens here;
79+
# distribution is via JitPack, which builds tagged commits per
80+
# jitpack.yml and serves the io.parity:truapi-host-android artifact.
81+
runs-on: ubuntu-latest
82+
steps:
83+
- uses: actions/checkout@v4
84+
- uses: dtolnay/rust-toolchain@stable
85+
- uses: actions/setup-java@v4
86+
with:
87+
distribution: temurin
88+
java-version: 17
89+
# No Gradle wrapper in the repo: pin the Gradle version here instead of
90+
# drifting with the runner image. 8.7 is the AGP 8.5.x baseline.
91+
- uses: gradle/actions/setup-gradle@v4
92+
with:
93+
gradle-version: '8.7'
94+
# The Kotlin sources import the gitignored uniffi.truapi_server.*
95+
# bindings; generate them from the truapi-server cdylib first.
96+
- name: Generate UniFFI bindings
97+
run: make uniffi
98+
- name: Assemble truapi-host-android (release)
99+
run: gradle :truapi-host:assembleRelease --no-daemon --stacktrace
100+
- name: Build Maven publication into local repo
101+
run: gradle :truapi-host:publishReleasePublicationToMavenLocal --no-daemon --stacktrace
102+
- name: Sanity-check published artifacts
103+
run: |
104+
set -eux
105+
base="$HOME/.m2/repository/io/parity/truapi-host-android/0.1.0"
106+
test -s "$base/truapi-host-android-0.1.0.aar"
107+
test -s "$base/truapi-host-android-0.1.0.pom"
108+
test -s "$base/truapi-host-android-0.1.0-sources.jar"

Cargo.lock

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

android/.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
.gradle/
2+
build/
3+
local.properties
4+
*.iml
5+
.idea/

android/truapi-host/README.md

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
# TrUAPI Android host adapter
2+
3+
*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.*
4+
5+
Distributed as a Maven artifact built on demand from git tags by [JitPack](https://jitpack.io/), no Maven Central account required on either side.
6+
7+
## Consume
8+
9+
Add the JitPack Maven repository and the artifact to your app's Gradle build:
10+
11+
```kotlin
12+
// settings.gradle.kts
13+
dependencyResolutionManagement {
14+
repositories {
15+
google()
16+
mavenCentral()
17+
maven { url = uri("https://jitpack.io") }
18+
}
19+
}
20+
```
21+
22+
```kotlin
23+
// app/build.gradle.kts
24+
dependencies {
25+
implementation("com.github.paritytech.truapi:truapi-host:0.1.0")
26+
}
27+
```
28+
29+
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.
30+
31+
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/<abi>/` (see "Linking the cdylib" below).
32+
33+
### Compatibility
34+
35+
- **minSdk**: 29 (Android 10). Aligns with the polkadot-app-android-v2 floor.
36+
- **AGP**: built with 8.5.2; AGP 8.5+ consumers are fine. AAR is forward-compatible with newer AGPs.
37+
- **Kotlin**: built with 1.9.24. Newer Kotlin compilers (2.x) read 1.9 metadata fine.
38+
- **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.
39+
40+
## Public surface
41+
42+
The public surface lives in [`src/main/kotlin/io/parity/truapi/TrUAPIHost.kt`](src/main/kotlin/io/parity/truapi/TrUAPIHost.kt):
43+
44+
- `HostBridge` - callback bundle the embedding app implements. Splits device permissions, remote permissions, navigation, push, feature support, and scoped storage.
45+
- `HostStorage` - read/write/clear interface the host backs with its own persistence.
46+
- `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, and native change notifications for session storage, theme, and preimage updates.
47+
48+
## Architecture
49+
50+
```text
51+
product app in WebView
52+
Uint8Array frames via @parity/truapi createWebSocketProvider
53+
|
54+
v ws://127.0.0.1:<port>/?t=<token>
55+
TrUAPIHostCore.startWsBridge()
56+
→ libtruapi_server.so (tokio WS server)
57+
→ Rust dispatcher
58+
```
59+
60+
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`, session storage, chain JSON-RPC, confirmations, preimage, theme, `featureSupported`, `storage`) reach the embedder through `HostBridge`.
61+
62+
## Permissions split
63+
64+
The core's `Permissions` platform trait has two methods, and so does the bridge:
65+
66+
- `devicePermission(request)` - OS-scoped grants (camera, mic, location, push). `request` is a SCALE-encoded `v01::HostDevicePermissionRequest`.
67+
- `remotePermission(request)` - per-product capability bundles. `request` is a SCALE-encoded `v01::RemotePermissionRequest`.
68+
69+
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).
70+
71+
## Example
72+
73+
> **Threading:** the Rust core invokes every `HostBridge` callback on a
74+
> background thread it owns, never the UI thread. Marshal any UI work
75+
> (navigation, prompts, notifications, touching the `WebView`) onto the main
76+
> thread with `Handler(Looper.getMainLooper())` or a `Dispatchers.Main`
77+
> `CoroutineScope`. UI-decision callbacks (`navigateTo`, `devicePermission`,
78+
> `remotePermission`, the `confirm*` family, `submitPreimage`) each run on
79+
> their own blocking-pool thread, so it is safe to block the calling thread
80+
> (e.g. with a `CountDownLatch`) until the main-thread prompt resolves; other
81+
> TrUAPI traffic keeps flowing while you wait. The remaining callbacks (auth
82+
> state, storage, session, chain, feature, theme, preimage lookups) run
83+
> inline on the dispatcher thread and must return promptly without blocking.
84+
85+
```kt
86+
import android.os.Handler
87+
import android.os.Looper
88+
import android.webkit.WebView
89+
import io.parity.truapi.HostBridge
90+
import io.parity.truapi.HostStorage
91+
import io.parity.truapi.PairingDeeplinkScheme
92+
import io.parity.truapi.RuntimeConfig
93+
import io.parity.truapi.TrUAPIHostCore
94+
import uniffi.truapi_server.AuthState
95+
import uniffi.truapi_server.HostTheme
96+
import java.util.concurrent.CountDownLatch
97+
98+
class MyStorage : HostStorage {
99+
private val map = mutableMapOf<String, ByteArray>()
100+
override fun read(key: String) = map[key]
101+
override fun write(key: String, value: ByteArray) { map[key] = value }
102+
override fun clear(key: String) { map.remove(key) }
103+
}
104+
105+
class MyBridge(private val webView: WebView) : HostBridge {
106+
private val main = Handler(Looper.getMainLooper())
107+
108+
override val storage = MyStorage()
109+
110+
override fun navigateTo(url: String) {
111+
main.post { /* startActivity(Intent(ACTION_VIEW, Uri.parse(url))) */ }
112+
}
113+
114+
override fun pushNotification(payload: ByteArray): UInt {
115+
val id = 1u
116+
main.post { /* show notification */ }
117+
return id
118+
}
119+
120+
override fun cancelNotification(id: UInt) {
121+
main.post { /* cancel notification */ }
122+
}
123+
124+
override fun devicePermission(request: ByteArray): Boolean {
125+
// Called on a blocking-pool thread; prompt on the main thread and
126+
// wait. Blocking here does not stall other TrUAPI traffic.
127+
val latch = CountDownLatch(1)
128+
var granted = false
129+
main.post { /* show prompt, set granted, then */ latch.countDown() }
130+
latch.await()
131+
return granted
132+
}
133+
134+
override fun remotePermission(request: ByteArray): Boolean = false
135+
override fun featureSupported(request: ByteArray): Boolean = false
136+
137+
// Core-owned auth state stream: render AuthState.Pairing as the pairing
138+
// QR sheet, connected/disconnected as the account badge, and login-failed
139+
// as a retryable error. When the user closes the pairing sheet, report it
140+
// with `core.cancelLogin()`.
141+
override fun authStateChanged(state: AuthState) {
142+
main.post { /* render the state */ }
143+
}
144+
145+
override fun chainConnect(genesisHash: ByteArray): UInt? {
146+
val id = 1u
147+
main.post { /* open JSON-RPC connection, forward responses via core.notifyChainResponse */ }
148+
return id
149+
}
150+
151+
override fun chainSend(connectionId: UInt, request: String) {
152+
/* send JSON-RPC request on the host connection */
153+
}
154+
155+
override fun chainClose(connectionId: UInt) {
156+
/* close host connection */
157+
}
158+
}
159+
160+
val webView: WebView = existingWebView
161+
val runtimeConfig = RuntimeConfig(
162+
productLabel = "my-product",
163+
productId = "my-product.dot",
164+
siteId = "host.example",
165+
hostName = "My Host",
166+
hostIcon = "https://host.example/icon.png",
167+
peopleChainGenesisHash = ByteArray(32),
168+
pairingDeeplinkScheme = PairingDeeplinkScheme.POLKADOT_APP,
169+
)
170+
val core = TrUAPIHostCore(MyBridge(webView), runtimeConfig)
171+
val endpoint = core.startWsBridge()
172+
val wsUrl = "ws://127.0.0.1:${endpoint.port.toInt()}/?t=${endpoint.token}"
173+
174+
// Call these from host/platform observers so native subscriptions see updates
175+
// after their immediate current item.
176+
core.notifySessionStoreChanged()
177+
core.notifyThemeChanged(HostTheme.DARK)
178+
core.notifyPreimageChanged(preimageKey, preimageBytesOrNull)
179+
core.notifyChainResponse(chainConnectionId, jsonRpcResponse)
180+
core.notifyChainClosed(chainConnectionId)
181+
182+
// Inject `wsUrl` into the product page; product JS calls
183+
// `@parity/truapi`'s `createWebSocketProvider(wsUrl)` to open the wire.
184+
webView.loadUrl("https://your-product.example/?truapi=${java.net.URLEncoder.encode(wsUrl, "UTF-8")}")
185+
186+
// On logout:
187+
core.disconnect()
188+
```
189+
190+
## Linking the cdylib
191+
192+
The native runtime ships separately. JNA looks for `libtruapi_server.so` in the standard `jniLibs` paths; bundle the per-ABI builds under:
193+
194+
```
195+
src/main/jniLibs/arm64-v8a/libtruapi_server.so
196+
src/main/jniLibs/armeabi-v7a/libtruapi_server.so
197+
src/main/jniLibs/x86_64/libtruapi_server.so
198+
```
199+
200+
Cross-build the cdylib for each Android ABI from the truapi monorepo. Two options, pick whichever fits the host app's existing toolchain:
201+
202+
**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`:
203+
204+
```kotlin
205+
// app/build.gradle.kts (or a dedicated :truapi-cdylib module)
206+
plugins {
207+
alias(libs.plugins.mozilla.rust.android)
208+
}
209+
210+
cargo {
211+
module = "<path>/truapi/rust/crates/truapi-server"
212+
libname = "truapi_server"
213+
targets = listOf("arm64", "arm", "x86_64")
214+
profile = "release"
215+
features { defaultAnd(arrayOf("ws-bridge")) }
216+
}
217+
218+
tasks.matching { it.name.matches("merge.*JniLibFolders".toRegex()) }.configureEach {
219+
inputs.dir(layout.buildDirectory.dir("rustJniLibs/android"))
220+
dependsOn("cargoBuild")
221+
}
222+
```
223+
224+
**Option B: `cargo-ndk` from the command line.** Standalone, no Gradle plugin required:
225+
226+
```bash
227+
cargo install cargo-ndk
228+
cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 \
229+
-o app/src/main/jniLibs \
230+
build --release -p truapi-server --features ws-bridge
231+
```
232+
233+
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`).
234+
235+
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.
236+
237+
## Maintainers: cutting a release
238+
239+
JitPack builds on demand from any git tag in `paritytech/truapi`, so a release is just:
240+
241+
1. Bump `publicationVersion` in `android/truapi-host/build.gradle.kts`.
242+
2. Commit. Open a PR. Merge.
243+
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`.
244+
245+
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.
246+
247+
For local development, publish into the dev `~/.m2`:
248+
249+
```bash
250+
gradle :truapi-host:publishReleasePublicationToMavenLocal
251+
# or
252+
make android-publish-local
253+
```
254+
255+
The artifact lands under `~/.m2/repository/io/parity/truapi-host-android/<version>/`. Consumers pointing at `mavenLocal()` can resolve it via `io.parity:truapi-host-android:<version>`. These local coordinates differ from the JitPack consumer coordinate (`com.github.paritytech.truapi:truapi-host:<tag>`): 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`.
256+
257+
## Regenerating the UniFFI bindings
258+
259+
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:
260+
261+
```bash
262+
cargo build -p truapi-server --release --features ws-bridge
263+
cargo run -p uniffi-bindgen-cli -- generate \
264+
--library target/release/libtruapi_server.so \
265+
--language kotlin \
266+
--out-dir android/truapi-host/src/main/kotlin/generated
267+
```
268+
269+
Or run `make uniffi` from the repo root.

0 commit comments

Comments
 (0)