API overview
API overview, Kotlin
Generated Kotlin is what a Kotlin engineer would write by hand: data classes, sealed hierarchies, suspend functions, Flow, and StateFlows you read with collectAsState(). The runtime needs only the standard library and kotlinx-coroutines.
undra bindgen writes a Gradle module (src/main/kotlin/<package>/*.kt, a build.gradle.kts and a .gitignore) that depends only on undra-runtime. The shapes below come from the playground's generated code and the specification (§10.2).
Reference. Every declaration the playground generates, with its docs, is in the Kotlin reference.
Loading the core#
Load the core once per process, before any store is created, typically in Application.onCreate, through the entry generated for it: Undra and the core's namespace ([core] namespace in undra.toml, by default the core's package name), here UndraPlaygroundCore. It checks that the native library was built from the same schema as the bindings.
class UndraApp : Application() {
override fun onCreate() {
super.onCreate()
UndraPlaygroundCore.load(
LoadOptions(adapters = mapOf(StandardPorts.Http.PORT_ID to myHttp.portImpl())),
)
}
}The core is libplayground_core.so, named after its namespace, which undra build --platform android writes to build/android/jniLibs/<abi>/; it registers its natives on the bindings' own UndraCoreNative, so two cores load side by side. On Android the runtime installs the adapters that need nothing from the platform (Clock, Rng, Log, Timer); pass the others you want to replace. After loading, every generated type defaults to its entry's core: Todos() is Todos(ctx = UndraPlaygroundCore.core). Before the core is loaded, or after it was closed, that is a closed placeholder: constructors and calls on it fail with UndraCallError.Unavailable instead of crashing, and the first use logs what to do.
Type mapping#
| Rust | Kotlin |
|---|---|
bool, f32, f64 | Boolean, Float, Double |
i8 … i64 | Byte, Short, Int, Long |
u8 … u64 | UByte, UShort, UInt, ULong |
String, Bytes | String, ByteArray |
Duration, Timestamp, Uuid | Duration, Timestamp, UUID |
Option<T>, Vec<T> | T?, List<T> |
Map<K, V> | Map<K, V> |
| record, enum, error | data class, enum class or sealed interface, sealed class |
Names follow the platform: Rust snake_case becomes camelCase for functions and properties and PascalCase for types. Unit enum variants become UPPER_SNAKE constants; data enum variants become PascalCase classes. Undra does not support Option<Option<T>> because Kotlin cannot express nested optionality (E0063).
Records#
/** One item of the to-do list. */
data class Todo(
val id: UUID,
val title: String,
val done: Boolean,
) : UndraRecordEnums#
A unit enum is an enum class with its wire index. A data enum is a sealed interface whose variants are data classes, data objects for the ones without fields.
enum class Filter(val index: UShort) : UndraEnum {
ALL(0u), ACTIVE(1u), DONE(2u);
}
sealed interface Figure : UndraEnum {
data class Circle(val radius: Double) : Figure
data class Rect(val width: Double, val height: Double) : Figure
data class Label(val value: String) : Figure
data object Empty : Figure
}when (figure) over a sealed interface is exhaustive, so adding a variant in Rust is a compile error in every when that forgot it.
Errors#
A #[undra::error] becomes a sealed class extending UndraException, with the message from #[error("…")]. A generated call that can fail throws exactly one of three things: its own error, thrown as the sealed class itself, so catch (e: TodoError) and when (e) work; CancellationException, when the calling coroutine was cancelled; or UndraCallError, for anything else that went wrong with the call. The method's @throws documentation names its error.
sealed class TodoError(message: String) : UndraException(message) {
data object EmptyTitle : TodoError("the title cannot be empty")
// a variant wrapping another error carries it: data class Http(val cause: HttpError)
}
viewModelScope.launch {
try {
store.add(draft)
} catch (e: TodoError) {
problem = when (e) {
TodoError.EmptyTitle -> "Give it a title first."
}
} catch (e: UndraCallError) {
problem = e.message // Panicked, Refused, Unavailable, …
} // a CancellationException ends the coroutine as usual
}UndraCallError is a sealed class under UndraException, so one catch (e: UndraException) shows either kind of failure to the user, and e.message reads well in a dialog. Every failure of the call is a typed value: no reply status, lost connection or undecodable byte reaches you as a bare UndraException.
| Case | When |
|---|---|
CancelledByCore | The core cancelled the call: a restore replaced the object it ran on, or the core shut down. Not a CancellationException, because your coroutine was not cancelled and the write did not land. |
Panicked(panicMessage, backtrace) | The core panicked while running the call. It caught the panic and keeps working. |
Refused(reason) | The core would not run the call: the object was closed or replaced by a restore, the call came from inside one of the core's callbacks (E_REENTRANT), or its arguments did not decode. |
Unavailable(transport) | The core cannot be reached: it was closed or never loaded, or the remote connection closed or timed out. transport is an UndraTransportException whose reason says which: CONNECTION_LOST, CLOSED, TIMEOUT or INTERRUPTED. While undra dev is reconnecting, every call fails with it at once (CONNECTION_LOST); so does every call on a core that a rebuild closed for good (transport.cause is an UndraSessionLostException). |
Malformed(detail) | The core answered with something the bindings cannot read. After a successful schema check this is a bug in Undra; report it with the text. |
Commands report instead of throwing. A synchronous method that returns nothing and has no error type, such as store.setFilter(f), stays non-throwing, so a Compose onClick calls it directly. If it fails, the runtime logs the failure at error level, passes an UndraUnhandledError to LoadOptions.onError and returns. A store's flows change only from the core's change-sets, so a refused command leaves the screen showing exactly what the core holds. A value the wire cannot represent is reported as Malformed, not thrown into the handler. A store that cannot decode a change from the core skips it and reports it the same way, and so does a malformed change-set (dropped whole) or a port implementation that failed.
UndraPlaygroundCore.load(
LoadOptions(
onError = { unhandled ->
// "Todos.toggle failed: the Undra core refused the call: …"
Log.w("app", "${unhandled.operation} failed: ${unhandled.error.message}")
},
),
)The handler runs on the thread that made the call (the main thread for a click), so keep it short, and it must not call into Undra. To crash at the failing line in debug builds, install onError = { throw AssertionError(it) }.
A lost connection is not an onError event. Over undra dev, a command tapped while the dev server is away fails with Unavailable too, and the connection state (connectionState on the core load returned, or LoadOptions.onConnectionChange) already says so, once. A failure that is the connection being down (CONNECTION_LOST) is therefore logged at warning level and not handed to onError; a crash reporter would otherwise get one event per tap for as long as the laptop sleeps. Everything else Unavailable is still reported: a command on a core you closed yourself, and a timeout.
Objects and functions#
An object is a class holding a handle into the core. Sync methods that return a value are plain functions, async methods are suspend, a sync method that returns nothing is a command (it reports instead of throwing, see Errors), and stream methods return a Flow that fails with the same errors while you collect it. A free Rust function is a top-level Kotlin function that takes the core as its last, defaulted parameter.
class Probe private constructor(core: UndraCore, handle: Long) : UndraObject(core, handle) {
fun counters(): ProbeCounters // sync
suspend fun wait(ms: UInt): UInt // async, cancellable
fun ticks(count: UInt): Flow<UInt> // stream
fun reset() // a command
companion object { fun create(ctx: UndraCore = UndraPlaygroundCore.core): Probe }
}
fun add(a: Int, b: Int, ctx: UndraCore = UndraPlaygroundCore.core): Int
suspend fun createRemoteTodo(list: String, title: String, ctx: UndraCore = UndraPlaygroundCore.core): RemoteTodoCancelling the coroutine cancels the call in the core, and a Flow applies backpressure: the runtime grants credit as your collector consumes items. The runtime contains core panics at the boundary: a bug in the core fails the call with Panicked, not a crash.
Stores#
A store extends UndraStore. Every signal is a read-only StateFlow; computed signals are updated by the core. Read them in Compose with collectAsState(), no extra module needed.
class Todos : UndraStore {
val todos: StateFlow<List<Todo>>
val filter: StateFlow<Filter>
val visible: StateFlow<List<Todo>> // computed by the core
val remaining: StateFlow<UInt> // computed by the core
constructor(ctx: UndraCore = UndraPlaygroundCore.core)
fun setFilter(f: Filter) // a command
suspend fun add(title: String): Todo // throws TodoError
}@Composable
fun TodosScreen(vm: TodosViewModel = viewModel()) {
val visible by vm.store.visible.collectAsState()
val remaining by vm.store.remaining.collectAsState()
Text("$remaining left")
LazyColumn {
// the list is keyed by id in the core, so Compose moves and animates rows
items(visible, key = { it.id }) { todo -> TodoRow(todo) }
}
}Hold on to the store. The runtime's mirror holds a store weakly. A StateFlow on its own does not keep the store, and the core object behind it, alive. Keep the store in a ViewModel and close it in onCleared().
Query handles#
A #[undra::query] becomes a store with five StateFlows (data, status, error, fetching, updatedAt) plus refetch() and invalidate(). Constructing it observes the query and fetches when the data is missing or stale.
val inbox = RemoteTodosQueryHandle.create("inbox")
val items by inbox.data.collectAsState() // List<RemoteTodo>?
val busy by inbox.fetching.collectAsState()See Queries & mutations.
Ports#
The ten standard ports are implemented in dev.undra.runtime.adapters and, for Android, in the android-adapters module (HttpURLConnection, files, an Android Keystore key, ConnectivityManager, activity lifecycle callbacks and so on; AndroidPlatformDefaults.install(core, context) registers them all) and are not generated into your module. You replace one by passing an implementation in LoadOptions.adapters, keyed by its port id, or by registering it after the load with core.registerPort. A port you define yourself becomes an interface:
interface Http : UndraPort {
suspend fun request(req: HttpRequest): HttpResponse // throws HttpError
}See Ports & adapters for the defaults. Android-specific adapters live in the android-adapters module, not in the runtime.
Threading and lifetime#
- Signals are updated on
Dispatchers.Main.immediate, in one hop per transaction. On a JVM without Android the runtime falls back to a single-thread executor, which is what the JVM test suite uses. UndraStoreandUndraObjectimplementAutoCloseable. Callclose()to release the handle (ause { }block works); aCleanerreleases leaked handles as a backstop.UndraCore.stats()exposes live handle counts.- Over JNI, callbacks arrive from core threads and are decoded immediately; you never see a raw buffer.