Client SDKs/Android
Android
com.usevelo:velo-android is the join half of Velo and nothing else. Suspending functions get you into a room, state flows carry the call, and there is no project-key client anywhere in the library, because an APK can be decompiled.
The package
com.usevelo:velo-android is a Kotlin Android library. Media handling stays in io.livekit:livekit-android, which is an api dependency, so every LiveKit type stays visible to you and VeloRoom is a wrapper rather than a fork. Coroutines come through the same way; OkHttp and kotlinx-serialization are implementation details you do not inherit.
minSdk 24, compiled against SDK 36.- Java 17 source and target compatibility, so JDK 17 or newer to build.
- Kotlin, with
explicitApistyle naming throughout. Every public symbol lives incom.usevelo.velo.
There is no VeloClient, no createToken and no admin surface here, on purpose. A Velo project API key starts with vk_, and an extracted key lets anyone mint tokens, create rooms and burn your credit. A client gets into a room exactly two ways: your backend mints a room token with POST /v1/tokens and sends it to the app, or the app exchanges a room code with POST /v1/codes/{code}/exchange. VeloConnection.assertRoomToken rejects any string starting with vk_ before a socket opens, so a key that reaches the app by mistake fails loudly at the call site.
Install
One coordinate in your module build file.
dependencies {
implementation("com.usevelo:velo-android:0.1.0")
}The media dependency resolves from Maven Central and the AndroidX and Google artifacts from Google, so both repositories must be reachable from your settings file.
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}- livekit-android
- 2.27.0, an
apidependency.VeloRoom.livekitRoomis the realRoom, so screen share, data channels and video renderers are all reachable. - coroutines-android
- 1.10.2, also an
apidependency. The join is a suspending call and the call state is aStateFlow. - okhttp
- 4.12.0, internal. It is the transport for the code exchange only. You may pass your own
OkHttpClientinstead.
Permissions
The library manifest contributes INTERNET and ACCESS_NETWORK_STATE and nothing else. Everything a call needs from the user is left to your app, because only your app knows whether it is publishing video, audio or neither.
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />Declare what you use, then request the dangerous ones before the first capture.
CAMERAandRECORD_AUDIOare dangerous permissions. A manifest entry is a declaration, not consent, andsetCameraEnabled(true)on an ungranted permission fails at the capture rather than at the join.BLUETOOTH_CONNECTis what Android 12, API 31, and above needs for headset routing, and it is requested at runtime too.- Keeping a call alive while the app is backgrounded needs a foreground service of your own, with the
cameraandmicrophoneservice types declared.
Getting a token
An app needs two strings to join: a room token and the url that came with it. A room code is the path that puts nothing secret on the device at all. It is a short string bound to one room and one role, created once on your server, redeemed by the app against the public, unauthenticated exchange endpoint. It is rate limited per code and per client IP, so a leaked code cannot be brute forced into an unlimited seat.
import com.usevelo.velo.VeloRoomCodes
val token = VeloRoomCodes.exchange(
baseUrl = "https://api.usevelo.xyz",
code = "abcdefghjkmnpqrs",
identity = "patient-1187",
)Both are suspending calls. Either way, only token and url reach the media server.
| Field | Type | Required | Description |
|---|---|---|---|
| baseUrl | String | Yes | The API origin, https://api.usevelo.xyz. Trailing slashes are stripped. |
| code | String | Yes | The room code. It is url encoded into POST /v1/codes/{code}/exchange. |
| identity | String? | No | Who the resulting token admits. Ignored when the code was created with a pinned identity, required otherwise. |
| timeoutMillis | Long | No | Defaults to 30000, applied as the OkHttp call, connect and read timeouts. Exceeding it raises VeloError.Timeout. |
| client | OkHttpClient? | No | Your own client, for a shared connection pool, an interceptor or a test. One is built for you when this is null. |
It returns a VeloToken.
- token
- The room token to dial with.
- url
- The Velo media server websocket url.
- expiresAt
- When the token stops working, RFC 3339, as sent.
- role
- Nullable. The template role the code carries.
- publishProfile
- Nullable. The capture and subscribe hints for that role, including maxPublishKbps.
- roomName
- Nullable, and not a server field: decoded on the device from the token claim
video.room, with theprj_namespace prefix stripped. - identity
- Nullable. Decoded from the token claim
sub, falling back to the identity you asked for. This is how you learn the identity an identity-locked code pinned for you.
Nothing is verified by that decode. The token is read, not checked, on the device, so treat both as a convenience for showing who is joining what, never as a trust boundary.
Connecting
VeloConnection.connect is a suspending call that returns a connected VeloRoom. It takes the token and url separately, or a VeloToken straight from the exchange. Pass an Activity or any context; the application context is what is retained.
import com.usevelo.velo.VeloConnection
import com.usevelo.velo.VeloConnectionState
lifecycleScope.launch {
val room = VeloConnection.connect(this@CallActivity, token)
room.setMicrophoneEnabled(true)
room.setCameraEnabled(true)
launch {
room.connectionState.collect { state ->
showBanner(state == VeloConnectionState.RECONNECTING)
}
}
launch {
room.participants.collect { people -> render(people) }
}
}RoomOptionsandConnectOptionsare LiveKit types and both default to empty, so adaptive stream, dynacast and capture defaults are configured where you would expect them.- A failed handshake disconnects and releases the room for you and raises
VeloError.Networkwith the underlying throw as its cause, so a failed join leaves nothing running. - The guard runs before any of that. A blank token, a blank url or a
vk_key raisesVeloError.InvalidArgument.
VeloError.InvalidArgument: VeloConnection.connect was given a Velo project API key
instead of a room token. Project keys must never ship inside an Android app: an APK can
be decompiled. Mint a room token on your own backend with the Velo REST API, or use
VeloRoomCodes.exchange.Call controls
VeloRoom exposes the call as flows you can collect from a lifecycle scope, and the controls as plain calls. Nothing polls: a coroutine inside the room follows the LiveKit event stream and republishes the snapshot.
room.setMicrophoneEnabled(true)
room.setCameraEnabled(false)
room.setSpeakerMuted(false)
room.participants.value.forEach { participant ->
log(participant.identity, participant.isSpeaking, participant.isCameraEnabled)
}
room.release()- connectionState
StateFlowofDISCONNECTED,CONNECTING,CONNECTEDorRECONNECTING.- participants
StateFlowofVeloParticipant, the local participant first, then the remote ones.- disconnectReason
StateFlowof a nullable string, set from the disconnect reason or from the failure message when the connection never came up.- setMicrophoneEnabled
- Suspending, returns the resulting enabled state rather than assuming it took.
- setCameraEnabled
- Suspending, same contract.
- setSpeakerMuted
- Not suspending. Mutes local playback, not your published audio.
- disconnect
- Leaves the room and refreshes both flows.
- release
- Disconnects, cancels the internal scope and frees the native resources. Call it once.
- livekitRoom
- The underlying LiveKit room, for everything this wrapper does not cover.
A VeloParticipant carries identity, sid, name, isLocal, isSpeaking, isMicrophoneEnabled and isCameraEnabled. It is a data class, a snapshot taken when the event fired, so it compares by value and is safe to hand to a list adapter.
Chat and reactions
VeloChat is built from a VeloRoom that is already connected, and it never owns that room. It rides the data channel the room already has, under the topic velo.chat, so there is no extra endpoint and no server round trip. Messages and reactions share one bounded in-memory history, oldest first, historyLimit entries deep and 200 by default, deduplicated by payload id. Sending takes an optional target identity, and with one set the payload is published only to that participant and arrives marked direct. A reaction body is a short name your application chooses, such as clap or raise_hand; the SDK defines no vocabulary and ships no emoji.
import com.usevelo.velo.VeloChat
import com.usevelo.velo.VeloChatKind
val chat = VeloChat(room, historyLimit = 200)
lifecycleScope.launch {
launch { chat.records.collect { history -> render(history) } }
launch {
chat.incoming.collect { record ->
when (record.kind) {
VeloChatKind.REACTION -> flash(record.senderIdentity, record.body)
VeloChatKind.MESSAGE -> notify(record.senderIdentity, record.body)
}
}
}
chat.sendMessage("The nurse will join in a moment")
chat.sendReaction("raise_hand")
chat.sendMessage("Your results are ready", to = "patient-1187")
}
chat.close()records is a StateFlow of the whole history, which is what a list binds to. incoming is a SharedFlow of arrivals only, for the things you do once, such as a toast or a sound. clear() empties the history without detaching, and close() detaches for good: it is a no-op the second time, and sending afterwards raises VeloError.InvalidArgument.
Each VeloChatRecord carries id, kind, body, sentAt, senderIdentity, senderName, local and direct. The sender identity is taken from the participant on the data event rather than from the payload, so a peer cannot claim to be someone else. What you send is echoed into your own history immediately, so a list renders it without a round trip. A message body is 1 to 2000 characters after trimming and a reaction 1 to 64, rejected locally before publishing; a malformed or unknown payload from a remote peer is dropped silently rather than raised, because a peer you do not control decides what arrives.
Errors and disconnects
Every failure the SDK raises is a VeloError, a sealed class, so an exhaustive when over the subtypes needs no catch-all. API failures carry the Velo envelope, { "error": { "code", "message", "field" } }, verbatim.
import com.usevelo.velo.VeloError
try {
val token = VeloRoomCodes.exchange(baseUrl = baseUrl, code = code, identity = identity)
val room = VeloConnection.connect(this@CallActivity, token)
} catch (error: VeloError.Quota) {
showUpgradePrompt(error.message)
} catch (error: VeloError.Permission) {
disableControl(error.permission)
} catch (error: VeloError.Api) {
report(error.status, error.code, error.message)
} catch (error: VeloError.Timeout) {
showMessage("the network is too slow right now")
} catch (error: VeloError.Network) {
showOffline()
} catch (error: VeloError.InvalidArgument) {
crashInDebug(error)
}VeloError.Apiis any non-2xx response and carriesstatus,code,message,fieldand the rawbody.VeloError.Quotais any 402 andVeloError.Permissiona 403 whose code ispermission_denied, withpermissionnaming the missing grant. Both extendApi, so order your catches narrowest first. There is no rate-limit subtype: a 429 arrives asApiwith coderate_limited.VeloError.Networkmeans the request never reached Velo, or the response could not be parsed.VeloError.Timeoutmeans it exceededtimeoutMillis.VeloError.InvalidArgumentis a programming mistake: a blank base url, a blank code, a blank token, or a project key where a room token belongs.- When a response is not a Velo envelope the code becomes
http_{status}and the message is the first 512 characters of the body.
A drop after a successful join is not an exception to catch. It arrives as connectionState moving to RECONNECTING, with disconnectReason filled in if the room gives one up. The media stack retries on its own; show a banner and leave it alone. A room token expires, so a session longer than its TTL needs a fresh token and a fresh connect.
Where to go next
- Your first room walks the four requests that get a call running, from the server side you have to build anyway.
- The REST API is where room creation, token minting and room codes live. Every one of them belongs on your backend.
- Templates and roles explains what the
roleon a token actually fixes: publish sources, the subscribe graph, permissions, priority and capacity. - Authentication is the full account of the two credentials, and why only one of them may ever reach a device.
Was this page useful?