Android SDK

Realtime

pb.realtime (studio.palbase:palbase-realtime) is the project's channels: what members broadcast on them, and a state each channel's members share. The channels go over the project's one realtime socket, pb.socket, which every module that needs one shares. It opens the first time a channel is joined and never before, so an app that only signs in or captures analytics opens none.

Note: Your own backend's realtime channels are its gateways, joined through the generated client: typed events, and messages the backend answers with an ack (Gateways). This page is the socket under them, and the channels the project declares by name.

Quick example

import studio.palbase.realtime.realtime
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put

val room = pb.realtime.channel("room:42")

val stop = room.onBroadcast("message") { payload ->
    println("${payload["from"]}: ${payload["text"]}")
}
room.onRefused { refusal -> println("refused: $refusal") }   // {event, reason}

room.join()   // throws the server's reason when it does not let the user in
room.broadcast("message", buildJsonObject { put("from", "ada"); put("text", "hello") })

// later:
stop()
room.leave()

Channels

pb.realtime.channel(name) is the channel of that name. Names are :-separated segments.

MemberWhat it does
join()Joins, and returns once the server let the user in. Throws PalbaseError when it did not (Who may join).
leave()Leaves the channel.
onBroadcast(event) { payload -> }Listens to one broadcast event, a member's or the platform's. Returns what stops it.
broadcast(event, payload)Sends an event to the channel's other members; the sender does not hear its own. The server says nothing of one it takes.
onRefused { refusal -> }Listens to the server refusing a broadcast: {event, reason}, with reasons such as forbidden and rate_limited.
entriesThe channel's state as the server last told it: a Map<String, RealtimeChannel.Entry>, each a value and its life.
onState { entries -> }Called with the whole state each time it changes. Returns what stops it.
set(key, value, life)Writes one entry, and returns once the server took it; a refusal is thrown. The state changes when the server says so (its next change), for every member alike.
deleteOwn(key)Removes an entry this membership wrote, as set does. Every other member's entry stays theirs.

A channel's state is a keyed map that outlives the connections reading it. A Life.DURABLE entry (the default) stays until it is removed. A Life.EPHEMERAL one is its writer's presence, gone when the writer leaves:

import kotlinx.serialization.json.JsonPrimitive

room.onState { entries ->
    val here = entries.filterValues { it.life == RealtimeChannel.Life.EPHEMERAL }.keys
    println("in the room: $here")
}
room.join()
room.set("presence:${pb.auth.userId.value}", JsonPrimitive("online"), RealtimeChannel.Life.EPHEMERAL)

The state arrives whole as a snapshot when the channel is joined, then as changes. A change that does not follow from the state held means something was missed, and the client asks for the whole state again. While the channel is not joined, entries is empty. Listeners are called on the socket's thread.

Who may join

A channel name is a string a client chooses, so proving who you are says nothing about which channels are yours. The project declares its channels, and a channel that no declaration matches belongs to no one. A server declares them with PUT /realtime/channels and the secret key, or from a terminal:

palbase realtime:channels:set --file channels.json
{
  "channels": [
    { "pattern": "user:{uid}", "kind": "owner" },
    { "pattern": "room:{id}", "kind": "public", "publish": true, "state_read": true, "state_write": true }
  ]
}
kindWho joins
ownerThe pattern's one {name} segment is the joining user: user:{uid} is joined by that user alone, who may do everything on it.
publicAny signed-in user of the project, with the rights the declaration gives: publish (broadcast), state_read, state_write.

Patterns match in their order, and the first one decides. A declared channel is joined only by a signed-in user, whose access token goes with each join. See Channels.

A refused join throws PalbaseError with the code join_refused and the server's reply in its body: unauthorized, forbidden, invalid_topic, rate_limited, too_many_channels, state_budget or unavailable. Only unavailable is worth trying again. A join that got no answer at all is join_failed.

The socket

pb.socket.status is the socket's connection as a StateFlow, and pb.socket.statusChanges every change in order:

RealtimeSocket.ConnectionStateWhat it means
IDLENever opened: nothing has joined a channel yet.
CONNECTINGBeing opened.
CONNECTEDOpen, and the channels wanted are joined on it.
RECONNECTINGDropped (the network went, a heartbeat was not answered), and opened again after a pause.
DISCONNECTEDClosed by the client: the user signed out, or no channel is wanted any more.
@Composable
fun Connection() {
    val status by pb.socket.status.collectAsState()
    if (status == RealtimeSocket.ConnectionState.RECONNECTING) Text("Reconnecting…")
}

The socket sends a heartbeat every 25 seconds. A socket that drops is opened again after 0.5, 1, 2, 5, 10, then every 30 seconds, each pause jittered, and every channel still wanted is joined again. A broadcast sent while the socket was down is not delivered: broadcasts are delivered at most once. A channel's state comes back whole with the snapshot of the new join. pb.socket.reconnects counts the reopens.

One socket, one user

The socket opens at wss://<ref>.palbase.studio/realtime/v1/websocket, the project's address. The publishable key goes in its x-api-key header, never in the address. When the session is bound to the device, the user's token and a proof signed by the device's key go there instead. Who the user is, is the access token each join carries, and a refreshed token is pushed to the joined channels without leaving them.

The socket is one person's. When the user signs out, someone else signs in, or the session ends, the client closes it, and every channel with it. Join again for the user who is there now:

import studio.palbase.session.AuthEvent

suspend fun joinMine() {
    val user = pb.auth.userId.value ?: return
    val mine = pb.realtime.channel("user:$user")
    mine.onBroadcast("note") { payload -> println("for me: $payload") }
    mine.join()
}

// scope: a CoroutineScope of the app's own.
pb.auth.onChange { event, _ ->
    if (event == AuthEvent.SIGNED_IN) scope.launch { joinMine() }
}

pb.socket.disconnect() closes the socket. The next join opens a new one.

Limits

The project holds its clients to limits the platform sets and the project may lower (PUT /realtime/limits from a server, or palbase realtime:limits:set): connections open at once, broadcasts and state writes per second, joins per second, channels per connection. A frame over 1 MiB closes the socket. A project over its connection limit, or whose realtime quota is used up, is refused a new socket.