Kotlin bindings for the truapi-provider crate (UniFFI). An embedded smoldot light client and the bundled chain-spec catalog stay in Rust; the host addresses a chain by genesis hash and exchanges JSON-RPC strings.
Status: there is no remote coordinate yet. JitPack cannot serve this module as it stands — it builds from a git tag, and both the bindings and the cdylib are generated rather than committed — and no hosted Maven publication is wired up. Until one is, integrate with
make provider-android-publish-local+mavenLocal(). The module itself has not been built on CI or a machine with the Android toolchain, so treat the Gradle wiring below as unverified.
Unlike truapi-host, whose AAR leaves the cdylib to the integrator, this AAR bundles libtruapi_provider.so for every published ABI. That is the point of the package: a consumer adds one coordinate and calls ChainProvider(), with no Rust toolchain and no dependency on the crate.
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
mavenLocal() // until a hosted publication exists, see Status above
}
}// app/build.gradle.kts
dependencies {
implementation("io.parity:truapi-provider-android:0.1.0")
}The consuming app must declare android.permission.INTERNET — the light client dials peers over TCP and WebSocket.
Chain specs are compiled into the cdylib, so the app ships no spec files of its own and never refreshes them. Picking up a spec refresh means taking a newer version of this artifact.
- minSdk: 29 (Android 10). Matches the
truapi-hostfloor so a host can depend on both. - ABIs:
arm64-v8a,armeabi-v7a,x86_64(ANDROID_ABISoverrides the set). Each carries a copy of the light client, so the AAR is large; split APKs or an App Bundle keep the shipped size to one ABI. - Transitive dependency: the AAR pulls
net.java.dev.jna:jna:5.14.0(UniFFI's runtime), shared withtruapi-hostwhen both are present.
Regenerate the bindings and the .so together. The generated Kotlin declares a checksum guard but never invokes it, so a stale .so paired with fresh bindings is not detected on this platform, and a callback whose arity changed keeps the same C symbol name, so the linker does not catch it either. iOS runs the equivalent guard before installing the callback vtable.
Everything is generated from ffi.rs into uniffi.truapi_provider.*:
ChainProvider- construct one per process and share it. Every connection runs on the single embedded light client, so they share sync, peers, and warm state while keeping their own request queue and response stream.connect(genesisHash, listener)resolves the network from the bundled catalog (relay wiring and statement-store placement included), so the 32-byte genesis hash is the only argument. At most 32 connections are held at once: past that,connect(genesisHash, listener)throwsChainProviderException.Connectrather than adding another chain, anddisconnect()hands the slot back. That ceiling is a backstop against connections you never close, not a budget to spend; the catalog resolves eight chains, so one reused connection per chain stays well under it.ChainMessageListener- the host implements it;onMessage(message)receives each JSON-RPC response and notification,onClosed(reason)fires once the pump stops and names why. Both may throw: a listener that throws stops the pump for that connection rather than being called again for every response, and an exception it does not declare is reported asChainProviderException.Listenerinstead of aborting the process.ChainCloseReason-StreamEndedwhen the response stream ended, which includes your owndisconnect()coming back to you, andListenerFailedwhen your listener rejected a message and the connection was closed for it. It says why the pump stopped, not whether you should reconnect; keep anelsebranch, since variants may be added. That is source compatibility only: adding a variant does not change theonClosedchecksum, so bindings older than the.sopass the integrity check and then fail to decode the reason, which surfaces asonClosednever firing.reasononListenerFailedis bounded to 256 Unicode scalar values, which is up to 512 in Kotlin'sString.length(UTF-16 code units) and up to 1024 bytes. Reconnect from a serial executor off the pump thread:connect(genesisHash, listener)refuses to run inside a listener callback and throwsChainProviderException.Connectif you try. Do not re-queue work withsend(request)fromonClosed: the connection is already closed by then, andsendon a closed connection is dropped silently, with no exception and no response frame.ChainConnection-send(request)queues a request,disconnect()tears the connection down. It is not calledclose, because uniffi's generated Kotlin object already hasAutoCloseable.close()for handle disposal.ChainProvider.withStorage(store)- build a provider that resumes each chain from stored finalized state instead of warp syncing from the checkpoint in the chain spec. The plainChainProvider()stores nothing, soloadDatabaseandsaveDatabasethrow on it rather than doing nothing. The crate ships no storage of its own: you own where the bytes live, and with it whether they survive a cache eviction.StorageClient- implement it over your own storage, typicallycontext.filesDirrather thancacheDir, which the OS reclaims under pressure.load(genesisHash)answers the stored string ornull,save(genesisHash, blob)replaces it, and both take a 32-byte hash asByteArray. A client that cannot answer must throw: returning no blob means "nothing stored yet" and lets the next write replace good state. Both are suspending functions awaited on the thread the caller drives, so the implementation must not requireDispatchers.Main.loadDatabase(genesisHash)- read the stored state for a chain in before connecting to it. Call it first: smoldot consumes a blob only on the first add of a chain, so a later call cannot take effect and answersfalse.saveDatabase(genesisHash)- snapshot the finalized state of a running chain into your storage. Answersfalse, without writing, for a chain that has finalized nothing yet or that the embedded client is not running. It is a full round trip through the light client, so drive it while the app is alive and treat a call fromonPauseas best effort.watchLifecycle(genesisHash, listener)- watch the sync progress of a chain something is connected to. YourChainLifecycleListenergetsonLifecycle(state)with the currentChainLifecycle(phase:Connecting,Syncing(at, target)orReady;peers;health:OkorStalled(reason)), then once per change, on a background thread the crate owns.onEnded()fires once the watch ends:stop()on the returnedLifecycleWatch, the chain no longer running, or your listener throwing. Closing the watch handle stops it too. A parachain goes fromConnectingstraight toReady, so watch its relay for warp sync progress. Requests sent on a connection are held until the chain first reachesReady, apart from chain-spec queries, statement-store and Bitswap calls.ChainProviderException-Connectwhen the genesis is outside the catalog or the transport fails,BadGenesiswhen the hash is not 32 bytes,Listenerwhen the listener the host installed failed in a way it did not declare,Lifecyclewhen the chain to watch is not running,Storagewhen your storage failed or the provider has none. Your client reports its own failures asStorageClientException. Adding a case here does not change the checksum of the methods that return it, so bindings older than the.sopass the integrity check and then fail to decode the new case. Regenerate bindings and.sotogether, exactly asChainCloseReasonabove requires.
host app
ChainProvider().connect(genesisHash, listener)
|
v
libtruapi_provider.so (embedded smoldot + bundled chain-spec catalog)
→ one light client per process, one added chain per connection
→ responses pumped on a Rust-owned thread into ChainMessageListener
A connection is a raw JSON-RPC string pipe. The provider does no decoding: what smoldot answers is what the listener receives.
Threading: the crate pumps each connection's responses on a background thread it owns, so
onMessageandonClosedare never called on the UI thread — marshal any UI work onto it withHandler(Looper.getMainLooper())or aDispatchers.MainCoroutineScope.connect(genesisHash, listener)is blocking and adds the chain on the calling thread, so keep it off the UI thread.
import android.os.Handler
import android.os.Looper
import uniffi.truapi_provider.ChainCloseReason
import uniffi.truapi_provider.ChainConnection
import uniffi.truapi_provider.ChainMessageListener
import uniffi.truapi_provider.ChainProvider
class Responses : ChainMessageListener {
private val main = Handler(Looper.getMainLooper())
// A JSON-RPC response or subscription notification, verbatim from smoldot.
override fun onMessage(message: String) {
main.post { /* decode and render */ }
}
override fun onClosed(reason: ChainCloseReason) {
// Reached whichever way the connection ended, including your own
// disconnect(). Reconnect on your own intent, not on this alone.
main.post {
when (reason) {
is ChainCloseReason.ListenerFailed -> { /* this listener rejected a message: reason.reason */ }
else -> { /* drop the connection */ }
}
}
}
}
// One provider per process; hold it for the app's lifetime.
val provider = ChainProvider()
// 32 raw bytes, not a hex string. Must be a chain in the bundled catalog.
val genesis = ByteArray(32)
val connection: ChainConnection = provider.connect(genesis, Responses())
connection.send("""{"jsonrpc":"2.0","id":1,"method":"chainSpec_v1_genesisHash","params":[]}""")
// On teardown:
connection.disconnect()make provider-android-publish-localThat regenerates the Kotlin bindings, cross-compiles the cdylib for every ABI, and publishes to ~/.m2/repository/io/parity/truapi-provider-android/<version>/. It needs Gradle, JDK 17, the Android NDK, and cargo-ndk (cargo install cargo-ndk).
Publishing refuses to run when src/main/jniLibs holds no libtruapi_provider.so: such an AAR resolves fine and then fails at the first ChainProvider() with UnsatisfiedLinkError, which is a much worse failure than a build error. The two steps behind it are available separately:
make provider-kotlin # regenerate the bindings only
make provider-android-jni # cross-compile the cdylib onlyBoth the generated bindings under src/main/kotlin/generated/uniffi/ and the .so files under src/main/jniLibs/ are gitignored build outputs.
make provider-kotlinThat builds the crate with the codegen profile and runs the workspace uniffi-bindgen-cli. The codegen profile is required because uniffi-bindgen scans the cdylib's exported metadata symbols, which the release profile strips — a plain --release build produces a stripped library and no bindings. (make uniffi-kotlin does the same for the host package.)