The Android SDK is for native Android apps written in Kotlin (or Java). It adds video and audio calls. It speaks the same protocol and fires the same events as the web, React Native and Flutter SDKs, so an Android user can be in a call with all of them.
It runs on the user's phone and holds only a token, never your API key. Your backend makes the token with the TypeScript, Python, Go, Java or PHP SDK. Your app fetches it from your own endpoint and passes it in.
Why a backend, and how to write it: the API key can't live in an app, so one small route on your server makes the token. Build your token endpoint has the full route in TypeScript, Python, Go, Java, PHP and Dart, plus the plain HTTP call for any other language. You don't need Node.js unless your backend already uses it.
The shortest version:
val client = LivqenoClient(applicationContext)
val room = client.connect(token, endpoint, iceServers)
room.enableMicrophone()
room.enableCamera()token, endpoint and iceServers all come from the same token-mint
response. Always pass iceServers: without it there's no relay (TURN), and
calls on mobile data often fail.
Install
The library module is livqeno-rtc, in sdks/android of the repository.
Until it's on Maven Central, include it from source:
// settings.gradle.kts
includeBuild("path/to/sdks/android") {
dependencySubstitution {
substitute(module("io.livqeno:livqeno-rtc")).using(project(":livqeno-rtc"))
}
}
// app/build.gradle.kts
dependencies {
implementation("io.livqeno:livqeno-rtc:0.1.0")
}- minSdk 24 (Android 7.0).
- WebRTC comes from
io.github.webrtc-sdk:android, pulled in for you.
Add permissions
The SDK merges in the permissions Android grants at install time
(INTERNET, audio settings, the call foreground service). The ones the
user has to approve go in your manifest, and you request them at
runtime before publishing:
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <!-- Bluetooth headsets, Android 12+ -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <!-- call notification, Android 13+ -->Without them, enableCamera() and enableMicrophone() throw an
RtcException with CAMERA_PERMISSION_DENIED or
MICROPHONE_PERMISSION_DENIED. See Permissions.
Join a room
val client = LivqenoClient(
applicationContext,
LivqenoClientOptions(
refreshToken = { myBackend.fetchRtcToken(roomId) }, // keeps long calls alive
),
)
lifecycleScope.launch {
val room = client.connect(
token = grant.token,
endpoint = grant.endpoint,
iceServers = grant.iceServers.map { IceServer(it.urls, it.username, it.credential) },
)
room.enableMicrophone()
room.enableCamera()
}connect returns once the server has confirmed the join. People already in
the call are in room.remoteParticipants straight away. To wait until media
is flowing too, call room.waitUntilConnected().
refreshToken is optional but recommended. When the server says the token
is about to expire, the SDK calls it and hands the new token to the live
session, with no reconnect.
Events, as a Flow
Events have the same names as on the web. Kotlin reads them
from a SharedFlow instead of room.on(...):
lifecycleScope.launch {
room.events.collect { event ->
when (event) {
is RoomEvent.ParticipantJoined -> addTile(event.participant)
is RoomEvent.ParticipantLeft -> removeTile(event.participant)
is RoomEvent.TrackSubscribed -> if (event.track.mediaKind == MediaKind.VIDEO) {
event.track.addSink(tileFor(event.participant).renderer)
}
is RoomEvent.TrackMuted -> showMutedIcon(event.participant, event.kind)
is RoomEvent.ActiveSpeakersChanged -> highlight(event.speakers.firstOrNull())
is RoomEvent.ConnectionQualityChanged -> setBadge(event.participant, event.quality)
is RoomEvent.PermissionsChanged -> setCanPublish(event.permissions.publish)
is RoomEvent.LocalTrackMutedByServer -> showMutedByHost()
is RoomEvent.Disconnected -> showCallEnded(event.reason)
else -> Unit
}
}
}Each participant also has its own state as StateFlows
(isSpeakingFlow, audioLevelFlow, connectionQualityFlow) and its own
events, for a tile that only cares about one person.
Show video
Use libwebrtc's SurfaceViewRenderer, set up with the SDK's EGL context:
renderer.init(client.eglBaseContext, null)
track.addSink(renderer) // a RemoteTrack, or your own camera's LocalTrack
// when the tile goes away:
track.removeSink(renderer)
renderer.release()Remote audio plays by itself; there's nothing to attach.
Pause video nobody is looking at
track.setVisible(false) // tile scrolled away: the server stops sending it
track.setVisible(true) // back on screen: it resumes at once
track.setEnabled(false) // pause it on purpose
track.setLayer(VideoLayer.LOW) // pin a qualityHiding waits 500 ms, so fast scrolling doesn't flap. For big rooms, pass
LivqenoClientOptions(autoSubscribe = false): every video then starts paused
until you show it. Audio always flows. Read track.subscriptionState to
show a "paused for bandwidth" badge.
Camera, microphone, screen share
room.enableCamera(CameraOptions(facing = CameraFacing.FRONT))
room.switchCamera()
room.disableCamera()
room.enableMicrophone()
room.localParticipant.tracks.first { it.kind == TrackKind.MICROPHONE }.mute()
room.disableMicrophone()The camera publishes three qualities (simulcast), so each viewer gets the one that fits their screen and network. Qualities nobody is watching are switched off on your phone automatically, saving battery and upload.
Screen sharing uses MediaProjection. Ask the user first, then pass the result in:
val projection = getSystemService(MediaProjectionManager::class.java)
screenShareLauncher.launch(projection.createScreenCaptureIntent())
// in the ActivityResult callback:
LivqenoCallService.start(this, LivqenoCallService.Config(microphone = true, screenShare = true))
room.enableScreenShare(result.data!!)Keep the call running in the background
Android stops the camera and microphone when your app leaves the screen, unless a foreground service is running. The SDK ships one. Start it when the call starts, stop it when the call ends:
LivqenoCallService.start(context, LivqenoCallService.Config(
title = "Call with Ada", camera = true, microphone = true,
))
// …
room.leave()
LivqenoCallService.stop(context)Speaker, earpiece, Bluetooth
val audio = AudioRouter(context)
audio.start() // picks Bluetooth, then a headset, then the speaker
audio.selectRoute(AudioRoute.EARPIECE)
audio.availableRoutes()
audio.stop() // when the call endsPhone-call integration
LivqenoCallManager registers your app with Android's Telecom system as a
self-managed calling app. The phone then treats your call like a real one:
Bluetooth and car buttons work, and an incoming phone call puts yours on
hold. Declare LivqenoConnectionService and MANAGE_OWN_CALLS in your
manifest; the class documentation has the snippet.
val calls = LivqenoCallManager(context, "My App")
calls.register()
LivqenoCallManager.listener = object : LivqenoCallManager.Listener {
override fun onAnswer(callId: String) { joinRoom(callId) }
override fun onHangUp(callId: String) { leaveRoom() }
}
calls.reportIncomingCall(callId = "room_123", displayName = "Ada", video = true)End-to-end encryption
For rooms created with e2ee: true. The media is encrypted on the phone
and only decrypted by the other participants; the server never has the key.
val client = LivqenoClient(context, LivqenoClientOptions(
e2ee = E2eeOptions(passphrase = sharedSecret),
))Everyone in the room must use the same passphrase. Android, web, iOS and
Flutter users can be in the same encrypted call. The SDK won't publish
into an encrypted room unless E2EE is configured, and reports problems as
RoomEvent.E2eeStateChanged (DECRYPT_FAILED usually means a wrong
passphrase).
If your app hands out keys itself, leave out the passphrase and call
room.e2ee!!.setKey(participantId, key, keyIndex) with 32-byte keys.
Errors
Everything that fails throws or emits an RtcException with a code
(RtcErrorCode): the same codes as the web SDK's RTCError, plus the
Android-only CAMERA_PERMISSION_DENIED, MICROPHONE_PERMISSION_DENIED,
SCREEN_SHARE_PERMISSION_DENIED and E2EE_ERROR.
try {
room.enableCamera()
} catch (e: RtcException) {
if (e.code == RtcErrorCode.CAMERA_PERMISSION_DENIED) askForCamera()
}Production
- Reconnecting is automatic: network changes, server restarts and expired
tokens (with
refreshToken) are handled without dropping the call. Callroom.handleNetworkChange()from aConnectivityManagercallback to reconnect sooner. See Reconnection. - Start the foreground service for any call that should survive the app going to the background.
- Chat and live streaming are not in the Android SDK yet. Use the Flutter SDK or the HTTP API for those.
Next steps
- Build the backend endpoint that mints tokens: Quickstart.
- Learn the call concepts (same on every platform): Rooms & participants.
- Every event in detail: RTC events.
- What's on the wire: Signaling protocol.