Skip to content

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 explicitApi style naming throughout. Every public symbol lives in com.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.

kotlinapp/build.gradle.kts
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.

kotlinsettings.gradle.kts
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
    }
}
livekit-android
2.27.0, an api dependency. VeloRoom.livekitRoom is the real Room, so screen share, data channels and video renderers are all reachable.
coroutines-android
1.10.2, also an api dependency. The join is a suspending call and the call state is a StateFlow.
okhttp
4.12.0, internal. It is the transport for the code exchange only. You may pass your own OkHttpClient instead.

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.

xml
<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.

  • CAMERA and RECORD_AUDIO are dangerous permissions. A manifest entry is a declaration, not consent, and setCameraEnabled(true) on an ungranted permission fails at the capture rather than at the join.
  • BLUETOOTH_CONNECT is 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 camera and microphone service 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.

kotlin
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.

VeloRoomCodes.exchange parameters
FieldTypeRequiredDescription
baseUrlStringYesThe API origin, https://api.usevelo.xyz. Trailing slashes are stripped.
codeStringYesThe room code. It is url encoded into POST /v1/codes/{code}/exchange.
identityString?NoWho the resulting token admits. Ignored when the code was created with a pinned identity, required otherwise.
timeoutMillisLongNoDefaults to 30000, applied as the OkHttp call, connect and read timeouts. Exceeding it raises VeloError.Timeout.
clientOkHttpClient?NoYour 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 the prj_ 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.

kotlin
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) }
    }
}
  • RoomOptions and ConnectOptions are 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.Network with 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 raises VeloError.InvalidArgument.
texta project key passed as a token
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.

kotlin
room.setMicrophoneEnabled(true)
room.setCameraEnabled(false)
room.setSpeakerMuted(false)

room.participants.value.forEach { participant ->
    log(participant.identity, participant.isSpeaking, participant.isCameraEnabled)
}

room.release()
connectionState
StateFlow of DISCONNECTED, CONNECTING, CONNECTED or RECONNECTING.
participants
StateFlow of VeloParticipant, the local participant first, then the remote ones.
disconnectReason
StateFlow of 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.

kotlin
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.

kotlin
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.Api is any non-2xx response and carries status, code, message, field and the raw body.
  • VeloError.Quota is any 402 and VeloError.Permission a 403 whose code is permission_denied, with permission naming the missing grant. Both extend Api, so order your catches narrowest first. There is no rate-limit subtype: a 429 arrives as Api with code rate_limited.
  • VeloError.Network means the request never reached Velo, or the response could not be parsed. VeloError.Timeout means it exceeded timeoutMillis.
  • VeloError.InvalidArgument is 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 role on 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?

Edit this page on GitHub