Quick Start for Conference in Android (Jetpack Compose)
This quick start shows how to add video calling to your Android app.
In this quick start you build a group calling app with Jetpack Compose in four Kotlin files. The app creates a room, joins it, shows every participant's camera, and lets you toggle the mic and the camera. Every code block on this page is a complete file: copy it as-is, and it compiles.
Prerequisites
Before proceeding, ensure that your development environment meets the following requirements:
- Android Studio Narwhal 3 Feature Drop (2025.1.3) or later.
- Android SDK API level 24 or higher (
minSdk = 24). - One or two devices running Android 7.0 or later: ARM phones, or emulators with an
arm64-v8asystem image. Two devices let you see a real call.
You need a VideoSDK account to generate a token. Generate the token on the VideoSDK dashboard.
Getting Started with the Code!
Follow the steps to create the environment necessary to add video calls into your app.
Create new Android Project
In Android Studio, create a Phone and Tablet project with the Empty Activity template. This template already uses Jetpack Compose and Kotlin DSL build files (build.gradle.kts), which is what this guide assumes.

After creating the project, Android Studio automatically starts gradle sync. Ensure that the sync succeeds before you continue.
Integrate Video SDK
The SDK is published on Maven Central, so a new project needs no extra repository. Pick the tab that matches your build files.
- Kotlin DSL
- Groovy
- Make sure
mavenCentral()is listed insettings.gradle.kts(a new project already has it).
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
- Add the versions and libraries to the version catalog.
[versions]
lifecycle = "2.8.7"
okhttp = "4.12.0"
rtcAndroidSdk = "3.0.0"
[libraries]
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
rtc-android-sdk = { module = "live.videosdk:rtc-android-sdk", version.ref = "rtcAndroidSdk" }
- Add the dependencies to the app module.
dependencies {
// VideoSDK
implementation(libs.rtc.android.sdk)
// Used once, to create a room id through the VideoSDK REST API
implementation(libs.okhttp)
// viewModel() inside composables
implementation(libs.androidx.lifecycle.viewmodel.compose)
// ...the dependencies generated by the template stay as they are
}
- Make sure
mavenCentral()is listed insettings.gradle.
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
- Add the dependencies to the app module.
dependencies {
// VideoSDK
implementation "live.videosdk:rtc-android-sdk:3.0.0"
// Used once, to create a room id through the VideoSDK REST API
implementation "com.squareup.okhttp3:okhttp:4.12.0"
// viewModel() inside composables
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7"
// ...your existing dependencies
}
The SDK runs on arm64-v8a and armeabi-v7a devices only. To run the app on an emulator, create the device with an arm64-v8a system image, for example on an Apple silicon Mac. x86 / x86_64 emulator images are not supported.
Add permissions into your project
In app/src/main/AndroidManifest.xml, add the permissions and register the MainApplication class that you create in Step 1. The SDK doesn't add INTERNET or MODIFY_AUDIO_SETTINGS to your app, so declare them here.
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<application
android:name=".MainApplication"
... >
<!-- the MainActivity generated by the template stays as it is -->
</application>
</manifest>
Structure of the project
The whole app is four Kotlin files next to each other in your package. Every file below starts with package live.videosdk.rtc.android.quickstart; replace that line with your own package name. Delete the template's ui/theme folder; it is not needed.
app/src/main/java/<your package>/
├── MainApplication.kt initializes VideoSDK and holds the token
├── RoomViewModel.kt creates, joins, and controls the room
├── ParticipantVideoView.kt renders one participant's camera, and the grid
└── MainActivity.kt Join screen and Room screen
How the pieces fit together: RoomViewModel owns the Room object and exposes plain Compose state (roomId, participants, micEnabled, camEnabled). MainActivity shows the Join screen while roomId is null and the Room screen once it is set. ParticipantVideoView wraps the SDK's VideoView so each participant's camera can be shown in a Compose grid.
Step 1: Initialize VideoSDK
Create MainApplication.kt. It initializes the SDK once, when the process starts, and holds the token from the VideoSDK dashboard. The same token is used to create rooms and to join them.
package live.videosdk.rtc.android.quickstart
import android.app.Application
import live.videosdk.rtc.android.VideoSDK
import live.videosdk.rtc.android.lib.tracing.LogLevel
class MainApplication : Application() {
// Generate a token at https://app.videosdk.live/api-keys and paste it here.
val sampleToken = "YOUR_TOKEN"
override fun onCreate() {
super.onCreate()
// The SDK logs at INFO by default; DEBUG shows more of what the SDK does while you build.
VideoSDK.setLogLevel(LogLevel.DEBUG)
VideoSDK.initialize(applicationContext)
}
}
Register it in AndroidManifest.xml with android:name=".MainApplication" on the <application> tag, as shown in the permissions section above.
The SDK logs at LogLevel.INFO until you call VideoSDK.setLogLevel(). LogLevel.DEBUG adds more detail about connection, join, and media while you build; filter Logcat by the tag VideoSDK. The levels are NONE, ERROR, WARN, INFO, DEBUG, and ALL (adds real-time events, use only for performance debugging). Lower the level or remove the call before you ship.
Step 2: Manage the room
Create RoomViewModel.kt. It is the only file that talks to the SDK:
- Create a room:
createAndJoinRoom()calls the Create Room API with your token and reads theroomIdfrom the response, then joins it. - Join a room:
joinRoom()creates theRoomwithVideoSDK.createRoom(), passing theroomIdand the token, registers aRoomEventListener, and callsjoin()from a coroutine.createRoom()throwsVideoSDKExceptionfor an empty token or room id. SettingroomIdis what switches the UI to the Room screen. Ifjoin()throws aVideoSDKException, for example for an invalid token, the app goes back to the Join screen and shows the error. - Track participants:
onRoomJoinedadds the local participant,onParticipantJoinedandonParticipantLeftkeep the list in sync. Entries are matched by participant id becauseonRoomJoinedfires again after a reconnect. - Media controls:
toggleMic()andtoggleCam()callmuteMic()/unmuteMic()anddisableCam()/enableCam(). These aresuspendfunctions, so they run inviewModelScope. The buttons then showroom.isMicEnabledandroom.isCamEnabled, the state the SDK reports. - Leave:
leaveRoom()detaches the listeners, callsleave()in a coroutine, and resets the state right away, which brings the Join screen back.onRoomLeftruns the same reset when the server ends the call. - Errors:
onErrorreceives aVideoSDKErrorand stores itsmessageso the UI can show it. It can arrive off the main thread, so it switches to the main thread first. A failed action throwsVideoSDKException, which is shown the same way.
package live.videosdk.rtc.android.quickstart
import android.app.Application
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import live.videosdk.rtc.android.Participant
import live.videosdk.rtc.android.Room
import live.videosdk.rtc.android.VideoSDK
import live.videosdk.rtc.android.lib.LeaveReason
import live.videosdk.rtc.android.lib.VideoSDKError
import live.videosdk.rtc.android.lib.VideoSDKException
import live.videosdk.rtc.android.listeners.RoomEventListener
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
class RoomViewModel(application: Application) : AndroidViewModel(application) {
private val token = (application as? MainApplication)?.sampleToken
?: error("Add android:name=\".MainApplication\" to <application> in AndroidManifest.xml")
private var room: Room? = null
// Non-null while we are inside a room. MainActivity switches screens on it.
var roomId by mutableStateOf<String?>(null)
private set
val participants = mutableStateListOf<Participant>()
var micEnabled by mutableStateOf(true)
private set
var camEnabled by mutableStateOf(true)
private set
// Set by the SDK (or a failed network call); MainActivity shows it as a Toast.
var errorMessage by mutableStateOf<String?>(null)
// Creates a room through the VideoSDK REST API, then joins it.
fun createAndJoinRoom() {
if (room != null) return // already joining or joined
viewModelScope.launch {
try {
val roomId = withContext(Dispatchers.IO) {
val request = Request.Builder()
.url("https://api.videosdk.live/v2/rooms")
.header("Authorization", token)
.post("".toRequestBody())
.build()
OkHttpClient().newCall(request).execute().use { response ->
val body = response.body?.string().orEmpty()
check(response.isSuccessful) { "HTTP ${response.code}: $body" }
JSONObject(body).getString("roomId")
}
}
joinRoom(roomId)
} catch (e: Exception) {
errorMessage = "Could not create room: ${e.message}"
}
}
}
// Creates the Room object with the token and joins it.
fun joinRoom(roomId: String) {
if (room != null) return // already joining or joined
val room = try {
VideoSDK.createRoom(
roomId = roomId,
token = token,
participantName = "John Doe",
micEnabled = micEnabled,
camEnabled = camEnabled,
multiStream = false,
preferredProtocol = VideoSDK.PreferredProtocol.UDP_OVER_TCP, // the default
)
} catch (e: VideoSDKException) {
errorMessage = e.message // an empty token or room id
return
}
room.addEventListener(roomEventListener)
// Set these first: onRoomJoined can arrive before join() returns.
this.room = room
this.roomId = roomId
viewModelScope.launch {
try {
room.join()
syncMediaState(room)
} catch (e: VideoSDKException) {
// For example an invalid token. Skipped if the user already left.
if (this@RoomViewModel.room === room) {
room.removeAllEventListeners()
reset()
errorMessage = "Could not join: ${e.message}"
}
}
}
}
private val roomEventListener = object : RoomEventListener() {
override fun onRoomJoined(switchRoomId: String?) {
room?.let { addParticipant(it.localParticipant) }
}
override fun onParticipantJoined(participant: Participant) {
addParticipant(participant)
}
override fun onParticipantLeft(participant: Participant, reason: LeaveReason) {
participants.removeAll { it.id == participant.id }
}
// Also fired by the SDK when the server ends the call.
override fun onRoomLeft(reason: LeaveReason) {
reset()
}
// onError can arrive off the main thread, so switch to it before updating state.
override fun onError(error: VideoSDKError) {
viewModelScope.launch { errorMessage = error.message }
}
}
// onRoomJoined fires again after a reconnect; never list the same participant twice.
private fun addParticipant(participant: Participant) {
if (participants.none { it.id == participant.id }) participants.add(participant)
}
fun toggleMic() {
val room = room ?: return
viewModelScope.launch {
try {
if (room.isMicEnabled) room.muteMic() else room.unmuteMic()
} catch (e: VideoSDKException) {
errorMessage = e.message
}
syncMediaState(room)
}
}
fun toggleCam() {
val room = room ?: return
viewModelScope.launch {
try {
if (room.isCamEnabled) room.disableCam() else room.enableCam()
} catch (e: VideoSDKException) {
errorMessage = e.message
}
syncMediaState(room)
}
}
// Shows what the SDK actually did, for example a camera that could not start.
// Skipped once that room is gone, so the next room starts from the defaults.
private fun syncMediaState(room: Room) {
if (room !== this.room) return
micEnabled = room.isMicEnabled
camEnabled = room.isCamEnabled
}
fun leaveRoom() {
val room = room ?: return
room.removeAllEventListeners() // this Room is finished, ignore anything else it reports
viewModelScope.launch {
// Finish leaving even if the ViewModel is cleared in the meantime.
withContext(NonCancellable) {
try {
room.leave()
} catch (e: VideoSDKException) {
// The join never completed, so there is nothing to leave.
}
}
}
// Reset here instead of waiting for a callback: onRoomLeft never fires if the join itself failed.
reset()
}
// Back to the Join screen with clean state; setting roomId to null switches the UI.
private fun reset() {
room = null
participants.clear()
micEnabled = true
camEnabled = true
roomId = null
}
}
The VideoSDK dashboard and some REST API fields still say "meeting" (for example meetingId). A meeting there is the same thing as a room.
Step 3: Render participants
Create ParticipantVideoView.kt. It contains two composables: ParticipantVideoView shows one participant's camera, and ParticipantsGrid lays them out two per row.
- The participant's video is displayed using
VideoView. To know more aboutVideoView, please visit here. VideoViewis a classic AndroidView. Jetpack Compose cannot host it directly, so it is wrapped inAndroidView.- A
ParticipantEventListenerfollows the participant's camera:onStreamEnabledattaches the new video track,onStreamDisableddetaches it. The listener is registered once per tile in aDisposableEffectand removed when the tile leaves the screen. addEventListenercallsonStreamEnabledright away for the streams the participant already has, so a camera that is already on shows as soon as the tile appears.- Grid items are keyed by participant id, so a tile is never reused for a different participant.
package live.videosdk.rtc.android.quickstart
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import live.videosdk.rtc.android.Participant
import live.videosdk.rtc.android.RTCStream
import live.videosdk.rtc.android.MediaType
import live.videosdk.rtc.android.VideoView
import live.videosdk.rtc.android.listeners.ParticipantEventListener
import videosdk.org.webrtc.VideoTrack
@Composable
fun ParticipantsGrid(participants: List<Participant>, modifier: Modifier = Modifier) {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier.padding(8.dp)
) {
// Keyed by participant id so a tile is never reused for someone else.
items(participants, key = { it.id }) { participant ->
ParticipantVideoView(participant)
}
}
}
@Composable
fun ParticipantVideoView(participant: Participant) {
var videoTrack by remember(participant) { mutableStateOf<VideoTrack?>(null) }
// One listener per tile: follows the participant's camera on/off. Adding it also
// replays the streams the participant already has, so a camera that is on shows at once.
DisposableEffect(participant) {
val listener = object : ParticipantEventListener() {
override fun onStreamEnabled(stream: RTCStream) {
if (stream.kind == MediaType.VIDEO) videoTrack = stream.track as VideoTrack
}
override fun onStreamDisabled(stream: RTCStream) {
if (stream.kind == MediaType.VIDEO) videoTrack = null
}
}
participant.addEventListener(listener)
onDispose { participant.removeEventListener(listener) }
}
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(3f / 4f)
.background(Color.DarkGray)
) {
// VideoView is a classic View, so it is wrapped in AndroidView.
AndroidView(
factory = { context -> VideoView(context) },
update = { view ->
// Re-attach only when the track actually changed.
if (view.tag != videoTrack) {
view.removeTrack()
videoTrack?.let { view.addTrack(it) }
view.tag = videoTrack
}
},
// Detaches the track and frees the renderer.
onRelease = { view -> view.releaseSurfaceViewRenderer() },
modifier = Modifier.fillMaxSize()
)
if (videoTrack == null) {
Text(
text = "Camera off",
color = Color.White,
modifier = Modifier.align(Alignment.Center)
)
}
Text(
text = participant.displayName,
color = Color.White,
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.background(Color(0x99000000))
.padding(4.dp)
)
}
}
Step 4: Build the screens
Replace the template's MainActivity.kt with the file below. It contains:
MainActivity: enables edge-to-edge and sets the Compose content insideMaterialTheme.VideoSDKApp: asks for the camera and microphone permissions on first launch, shows SDK errors as aToast, and switches between the two screens based onviewModel.roomId.JoinScreen: a Create Room button, a text field for an existing room id, and a Join Room button.RoomScreen: the room id, the participants grid, and buttons to toggle the mic, toggle the camera, and leave. The system Back button also leaves the room.
package live.videosdk.rtc.android.quickstart
import android.Manifest
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MaterialTheme {
VideoSDKApp()
}
}
}
}
@Composable
fun VideoSDKApp(viewModel: RoomViewModel = viewModel()) {
val context = LocalContext.current
// Ask for camera and microphone as soon as the app opens.
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { }
LaunchedEffect(Unit) {
permissionLauncher.launch(
arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO)
)
}
// Show SDK and network errors as a Toast.
viewModel.errorMessage?.let { message ->
LaunchedEffect(message) {
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
viewModel.errorMessage = null
}
}
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
val roomId = viewModel.roomId
if (roomId == null) {
JoinScreen(viewModel, Modifier.padding(innerPadding))
} else {
RoomScreen(roomId, viewModel, Modifier.padding(innerPadding))
}
}
}
@Composable
fun JoinScreen(viewModel: RoomViewModel, modifier: Modifier = Modifier) {
var input by rememberSaveable { mutableStateOf("") }
Column(
modifier = modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Button(onClick = { viewModel.createAndJoinRoom() }) {
Text("Create Room")
}
Text("OR", modifier = Modifier.padding(16.dp))
OutlinedTextField(
value = input,
onValueChange = { input = it },
label = { Text("Enter Room ID") }
)
Button(
onClick = { if (input.isNotBlank()) viewModel.joinRoom(input.trim()) },
modifier = Modifier.padding(top = 8.dp)
) {
Text("Join Room")
}
}
}
@Composable
fun RoomScreen(roomId: String, viewModel: RoomViewModel, modifier: Modifier = Modifier) {
// Back button leaves the room and brings the Join screen back.
BackHandler { viewModel.leaveRoom() }
Column(modifier = modifier.fillMaxSize()) {
Text(
text = "Room ID: $roomId",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp)
)
ParticipantsGrid(viewModel.participants, Modifier.weight(1f))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
Button(onClick = { viewModel.toggleMic() }) {
Text(if (viewModel.micEnabled) "Mute Mic" else "Unmute Mic")
}
Button(onClick = { viewModel.toggleCam() }) {
Text(if (viewModel.camEnabled) "Cam Off" else "Cam On")
}
Button(onClick = { viewModel.leaveRoom() }) {
Text("Leave")
}
}
}
}
Run the app
- Run the app on the first device and allow the camera and microphone permissions.
- Tap Create Room. The app creates a room, joins it, and shows your own camera. The room id is printed at the top of the screen.
- Run the app on the second device, type that room id, and tap Join Room. Both cameras now appear on both devices.
- Use Mute Mic, Cam Off, and Leave to control the call.


Final Output
You have built a video calling app in Android with VideoSDK. To explore more features, go through Basic and Advanced features.
Got a Question? Ask us on discord

