Skip to main content
Version: 3.x.x

Quick Start for Interactive Live Streaming in Android

This quick start adds interactive live streaming to your Android app. A live stream supports up to 100 hosts/co-hosts and 2,000 viewers in real-time.

info

For standard live streaming with 6-7 second latency and playback support, follow this documentation.

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.
  • A mobile device that runs Android 7.0 or later with an ARM (arm64-v8a or armeabi-v7a) processor, or an emulator with an arm64-v8a system image.

Getting Started with the Code!​

Follow the steps to create the environment necessary to add video calls into your app.

Create new Android Project​

For a new project in Android Studio, create a Phone and Tablet Android project with an Empty Activity.

VideoSDK Android Quick Start New Project

caution

After creating the project, Android Studio automatically starts gradle sync. Ensure that the sync succeeds before you continue.

Integrate Video SDK​

settings.gradle.kts
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
  • Add the following dependency in your app's app/build.gradle.kts.
app/build.gradle.kts
dependencies {
implementation("live.videosdk:rtc-android-sdk:3.0.0")

// library to perform Network call to generate a stream id
implementation("com.squareup.okhttp3:okhttp:4.12.0")

// navigation and viewModel() for the Compose screens
implementation("androidx.navigation:navigation-compose:2.7.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")

// other app dependencies
}
important

Android SDK ships its native libraries for arm64-v8a and armeabi-v7a. If you want to run the application in an emulator, choose an arm64-v8a system image when creating a device. x86 / x86_64 emulator images are not supported.

Add permissions into your project​

  • In /app/Manifests/AndroidManifest.xml, add the following permissions. The SDK doesn't add INTERNET or MODIFY_AUDIO_SETTINGS to your app, so declare them here.
AndroidManifest.xml
<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"/>

Structure of the project​

Your project structure should look like this.

Project Structure
app/
├── src/main/
│ ├── java/com/example/app/
│ │ ├── components/
│ │ ├── screens/
│ │ ├── ui/
│ │ │ └── theme/
│ │ ├── model/
│ │ ├── MainApplication.kt
│ │ ├── MainActivity.kt
│ │ └── NetworkClass.kt
| | └── StreamingMode.kt
│ ├── res/
│ └── AndroidManifest.xml
├── build.gradle.kts
└── settings.gradle.kts

App Architecture​

Step 1: Initialize VideoSDK​

  1. Create MainApplication class which will extend the android.app.Application.
  • Create field sampleToken in MainApplication which will hold the generated token from the VideoSDK dashboard. This token is passed to createRoom() and is also used to generate the stream id.
caution

This quick start pastes a temporary token from the VideoSDK Dashboard into the client and lets the user choose the mode. Use this setup only for development. In production, your client must obtain its token from your backend server, and your server must decide whether a participant joins as a host or as an audience member. A token cannot restrict the mode, so any client that holds a valid token can join in SEND_AND_RECV mode. See Authentication and Tokens.

MainApplication.kt
import android.app.Application
import live.videosdk.rtc.android.VideoSDK

class MainApplication: Application() {

val sampleToken = "YOUR_TOKEN" //paste your token here

override fun onCreate() {
super.onCreate()
VideoSDK.initialize(applicationContext)
}
}
  1. Add MainApplication to AndroidManifest.xml
AndroidManifest.xml
<application
android:name=".MainApplication" >
<!-- ... -->
</application>

Step 2: Managing Permissions and Setting Up Navigation​

The MainActivity handles permission requests for the camera and microphone and sets up navigation using Jetpack Compose.

On creation it requests RECORD_AUDIO and CAMERA if they are not granted yet, then sets the content to the MyApp composable, which holds the navigation through NavigationGraph.

The NavigationGraph defines two screens:

  • JoinScreen: For joining or creating a stream.
  • StreamingScreen: For participating in a stream, passing streamId and mode as arguments.
MainActivity.kt
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
checkSelfPermission(REQUESTED_PERMISSIONS[0], PERMISSION_REQ_ID)
checkSelfPermission(REQUESTED_PERMISSIONS[1], PERMISSION_REQ_ID)

setContent {
Videosdk_android_modes_quickstartTheme {
MyApp(this) } }
}

private fun checkSelfPermission(permission: String, requestCode: Int): Boolean {
if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this, REQUESTED_PERMISSIONS, requestCode)
return false
}
return true
}

companion object {
private const val PERMISSION_REQ_ID = 22
private val REQUESTED_PERMISSIONS = arrayOf(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA)
}
}

@Composable
fun MyApp(context: Context) {
NavigationGraph(context = context)
}

@Composable
fun NavigationGraph(navController: NavHostController = rememberNavController(), context: Context) {
NavHost(navController = navController, startDestination = "join_screen") {
composable("join_screen") { JoinScreen(navController, context) }
composable("stream_screen?streamId={streamId}&mode={mode}") { backStackEntry ->
val streamId = backStackEntry.arguments?.getString("streamId")
val modeStr = backStackEntry.arguments?.getString("mode")
val mode = StreamingMode.valueOf(modeStr ?: StreamingMode.SendAndReceive.name)

streamId?.let {
// viewModel() keeps one StreamViewModel for this screen, across recompositions and rotation.
StreamingScreen(viewModel = viewModel(), navController, streamId, mode, context)
}
}
}
}

Step 3: Creating Reusable Components​

  • Before building the individual screens, create the reusable composable components used throughout the project.
  1. Create a ReusableComponents.kt file to organize all reusable components for the project, such as buttons, spacers, and customizable text.
ReusableComponents.kt
@Composable
fun MyAppButton(task: () -> Unit, buttonName: String) {
Button(onClick = task) {
Text(text = buttonName) }
}

@Composable
fun MySpacer() {
Spacer(modifier = Modifier.fillMaxWidth()
.height(1.dp)
.background(color = Color.Gray)) }

@Composable
fun MyText(text: String, fontSize: TextUnit = 23.sp) {
Text(text = text,
fontSize = fontSize,
fontWeight = FontWeight.Normal,
modifier = Modifier.padding(4.dp),
style = MaterialTheme.typography.bodyMedium.copy(fontSize = 16.sp),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f))
}
  1. Create a ParticipantVideoView.kt file that includes two composable functions: ParticipantVideoView for rendering individual participant video streams, and ParticipantsGrid for displaying a grid of participant videos.
info
  • Here the participant's video is displayed using VideoView. To know more about VideoView, please visit here
  • VideoView is a custom View. Since Jetpack Compose does not natively support traditional View objects, we need to integrate the VideoView into the Compose layout using the AndroidView wrapper.
  • AndroidView() hosts an Android View inside a composable.
ParticipantVideoView.kt
import videosdk.org.webrtc.VideoTrack

@Composable
fun ParticipantVideoView(
participant: Participant
) {
var videoTrack by remember(participant) { mutableStateOf<VideoTrack?>(null) }

// Follows the participant's camera. Adding the listener also replays the streams
// the participant already has, so a camera that is already on shows at once.
DisposableEffect(participant) {
val eventListener = 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(eventListener)
onDispose { participant.removeEventListener(eventListener) }
}

val isVideoEnabled = videoTrack != null

Box(
modifier = Modifier.fillMaxWidth()
.height(200.dp)
.background(if (isVideoEnabled) Color.DarkGray else Color.Gray)
) {
AndroidView(
factory = { context -> VideoView(context) },
update = { videoView ->
// Re-attach only when the track actually changed.
if (videoView.tag != videoTrack) {
videoView.removeTrack()
videoTrack?.let { videoView.addTrack(it) }
videoView.tag = videoTrack
}
},
// Detaches the track and frees the renderer.
onRelease = { videoView -> videoView.releaseSurfaceViewRenderer() },
modifier = Modifier.fillMaxSize()
)

if (!isVideoEnabled) {
Box(
modifier = Modifier.fillMaxSize()
.background(Color.DarkGray),
contentAlignment = Alignment.Center
) {
Text(text = "Camera Off", color = Color.White)
}
}

Box(
modifier = Modifier.align(Alignment.BottomCenter)
.fillMaxWidth()
.background(Color(0x99000000))
.padding(4.dp)
) {
Text(
text = participant.displayName,
color = Color.White,
modifier = Modifier.align(Alignment.Center)
)
}
}
}

@Composable
fun ParticipantsGrid(
participants: List<Participant>,
modifier: Modifier = Modifier
) {
LazyVerticalGrid(columns = GridCells.Fixed(2),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier.fillMaxWidth()
.padding(8.dp)
) {
// Keyed by participant id so a tile is never reused for someone else.
items(participants, key = { it.id }) { participant ->
ParticipantVideoView(participant = participant)
}
}
}

Before proceeding, let's understand the two modes of a Live Stream:

ILS Mode Demonstration
  1. SEND_AND_RECV:
  • Designed primarily for the Host or Co-host.
  • Allows sending and receiving media.
  • Hosts can broadcast their audio/video and interact directly with the audience.
  1. RECV_ONLY:
  • Tailored for the Audience.
  • Enables receiving media shared by the Host.
  • Audience members can view and listen but cannot share their own media.
StreamingMode
enum class StreamingMode {
SendAndReceive,
ReceiveOnly
}

Step 4: Creating Joining Screen​

Join screen will serve as a medium to either Create a new Live Stream or join an existing one as a host or an audience member.

We will create a simple UI for the Join screen that includes three action buttons for joining or creating a Live Stream:

  1. Create Stream: Lets the user initiate a new Live Stream in SEND_AND_RECV mode, granting full host controls.
  2. Join as Host: Allows the user to join an existing Live Stream using the provided stream id with the SEND_AND_RECV mode, enabling full host privileges.
  3. Join as Audience: Enables the user to join an existing Live Stream using the provided stream id with the RECV_ONLY mode, allowing view-only access.
JoinScreen.kt
@Composable
fun JoinScreen(
navController: NavController, context: Context
) {
val app = context.applicationContext as MainApplication
val token = app.sampleToken
Box(
modifier = Modifier.fillMaxSize()
.padding(8.dp),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier.padding(4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceEvenly
) {
var input by rememberSaveable { mutableStateOf("") }
CreateStreamBtn(navController, token)
Text(text = "OR")
InputStreamId(input) { updateInput -> input = updateInput }
JoinHostBtn(navController, input)
JoinAudienceBtn(navController, input)
}
}
}


@Composable
fun JoinHostBtn(navController: NavController, streamId: String) {
MyAppButton({
if (streamId.isNotEmpty()) {
navController.navigate("stream_screen?streamId=$streamId&mode=${StreamingMode.SendAndReceive.name}")
}
}, "Join as Host")
}

@Composable
fun JoinAudienceBtn(navController: NavController, streamId: String) {
MyAppButton({
if (streamId.isNotEmpty()) {
navController.navigate("stream_screen?streamId=$streamId&mode=${StreamingMode.ReceiveOnly.name}")
}
}, "Join as Audience")
}

@Composable
fun CreateStreamBtn(navController: NavController, token: String) {
MyAppButton({
NetworkManager.createStreamId(token) { streamId ->
navController.navigate("stream_screen?streamId=$streamId&mode=${StreamingMode.SendAndReceive.name}")
}
}, "Create Stream")
}

@Composable
fun InputStreamId(input: String, onInputChange: (String) -> Unit) {
OutlinedTextField(value = input,
onValueChange = onInputChange,
label = { Text(text = "Enter Stream Id") })
}

Output​

VideoSDK Android Quick Start joining Screen

Step 5: Creating StreamId​

The NetworkManager singleton handles stream creation by making a POST request to the VideoSDK API:

  • Stream Creation: Sends a request to the API with an authorization token to create a stream.
  • Response Handling: Extracts the roomId from the response and calls the onStreamIdCreated callback on the main thread.
NetworkManager
object NetworkManager {
private val client = OkHttpClient()

fun createStreamId(token: String, onStreamIdCreated: (String) -> Unit) {
val request = Request.Builder()
.url("https://api.videosdk.live/v2/rooms")
.header("Authorization", token)
.post("".toRequestBody())
.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
val body = it.body?.string().orEmpty()
if (!it.isSuccessful) {
Log.d("TAG", "onError: HTTP ${it.code} $body")
return
}
try {
val streamId = JSONObject(body).getString("roomId")
// OkHttp answers on a background thread; navigate on the main thread.
Handler(Looper.getMainLooper()).post { onStreamIdCreated(streamId) }
} catch (e: JSONException) {
e.printStackTrace()
}
}
}

override fun onFailure(call: Call, e: IOException) {
e.printStackTrace()
Log.d("TAG", "onError: $e")
}
})
}
}
note

A stream here is the same thing as a room.

Step 6: Creating Streaming Screen​

We will create a simple Streaming screen UI with two main sections:

  1. Header: Displays the stream ID and current streaming mode at the top of the screen for reference.
  2. ParticipantsGrid: Shows the list of participants in a grid layout, adjusting dynamically.
note

In Step 3, we created the ParticipantsGrid component.

StreamingScreen.kt
@Composable
fun StreamingScreen(viewModel: StreamViewModel, navController: NavController,
streamId: String,
mode: StreamingMode,
context: Context
) {
val app = context.applicationContext as MainApplication
val isStreamLeft = viewModel.isStreamLeft
val currentMode = viewModel.currentMode
val isJoined = viewModel.isJoined

LaunchedEffect(isStreamLeft) {
if (isStreamLeft) {
navController.navigate("join_screen") }
}

// Leave the stream on Back, so the room is not left running in the background.
BackHandler(enabled = isJoined) { viewModel.leaveStream() }

Column(modifier = Modifier.fillMaxSize()) {
Header(streamId, currentMode)
MySpacer()
ParticipantsGrid(
participants = viewModel.currentParticipants,
modifier = Modifier.weight(1f)
)
MySpacer()
MediaControlButtons(
onJoinClick = { viewModel.initStream(app.sampleToken, streamId, mode)},
onMicClick = { viewModel.toggleMic() },
onCamClick = { viewModel.toggleCam()},
onModeToggleClick = { viewModel.toggleMode() },
showMediaControls = currentMode == StreamingMode.SendAndReceive,
currentMode = currentMode,
onLeaveClick = { viewModel.leaveStream() },
isJoined = isJoined )
}
}
@Composable
fun Header(streamId: String, mode: StreamingMode) {
val context = LocalContext.current
val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager

Box(modifier = Modifier.fillMaxWidth()
.padding(8.dp),
contentAlignment = Alignment.TopStart
) {
Row( modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
MyText("StreamID: ", 28.sp)
MyText(streamId, 25.sp)
IconButton( onClick = {
val clip = ClipData.newPlainText("Stream ID", streamId)
clipboardManager.setPrimaryClip(clip)
})
{
//get baseline_content_copy_24 icon from Vector Asset
Icon( painter = painterResource(R.drawable.baseline_content_copy_24),
contentDescription = "Copy Stream ID",
modifier = Modifier.size(40.dp).padding(start = 8.dp)
)
}
}
Row {
MyText("Mode: ", 20.sp)
MyText(mode.name, 18.sp)
}
}
}
}
}
//...

Step 7: Implementing Mode Switch and Media Toggles​

Implement interactive controls to leave the stream, toggle microphone and camera, and switch between Host (SEND_AND_RECV) and Audience (RECV_ONLY) modes.

StreamingScreen.kt
@Composable
fun MediaControlButtons(
onJoinClick: () -> Unit,
onMicClick: () -> Unit,
onCamClick: () -> Unit,
onModeToggleClick: () -> Unit,
onLeaveClick: () -> Unit,
showMediaControls: Boolean = true,
currentMode: StreamingMode,
isJoined: Boolean
) {
Column( modifier = Modifier.fillMaxWidth()
.padding(6.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row( modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
if (!isJoined) {
MyAppButton(onJoinClick, "Join")
}
if (showMediaControls && isJoined) { // Only show media controls if joined
MyAppButton(onMicClick, "ToggleMic")
MyAppButton(onCamClick, "ToggleCam")
}
}

Row( modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
if (isJoined) {
MyAppButton(onLeaveClick, "Leave")
MyAppButton(
task = onModeToggleClick,
buttonName = if (currentMode == StreamingMode.SendAndReceive)
"Switch to Audience"
else
"Switch to Host"
)
}
}
}
}

Step 8: Setting Up StreamViewModel​

The StreamViewModel manages the core functionality of Interactive Live Streaming (ILS), serving as the central state manager:

  1. Stream State Management

    • Handles stream initialization with appropriate mode (SEND_AND_RECV for hosts, RECV_ONLY for the audience)
    • Tracks stream lifecycle (joined, left, current mode)
  2. Media Control Integration

    • Provides microphone toggle (mute/unmute)
    • Controls camera state (enable/disable)
  3. Mode Switching

    • Switches between host (SEND_AND_RECV) and audience (RECV_ONLY) modes with changeMode(). The SDK turns the mic and camera off when you switch to RECV_ONLY.
  4. Coroutines

    • join(), leave(), changeMode() and the media toggles are suspend functions, so the view model calls them from viewModelScope and catches VideoSDKException.
StreamViewModel
class StreamViewModel : ViewModel() {
private var room: Room? = null
private var micEnabled by mutableStateOf(true)
private var camEnabled by mutableStateOf(true)
var isConferenceMode by mutableStateOf(true)
val currentParticipants = mutableStateListOf<Participant>()

var localParticipantId by mutableStateOf("")
private set

var isStreamLeft by mutableStateOf(false)
private set

var currentMode by mutableStateOf(StreamingMode.SendAndReceive)
private set

var isJoined by mutableStateOf(false)
private set

fun initStream(token: String, streamId: String, mode: StreamingMode) {
if (room != null) return // already joining or joined
currentMode = mode
val room = try {
if (mode == StreamingMode.SendAndReceive) {
VideoSDK.createRoom(
roomId = streamId,
token = token,
participantName = "John Doe",
micEnabled = micEnabled,
camEnabled = camEnabled,
)
} else {
// The audience does not publish, so it joins with the mic and camera off.
VideoSDK.createRoom(
roomId = streamId,
token = token,
participantName = "John Doe",
micEnabled = false,
camEnabled = false,
mode = Mode.RECV_ONLY,
)
}
} catch (e: VideoSDKException) {
// thrown for an empty token or stream id
Log.e("StreamViewModel", "init failed: ${e.code} ${e.message}")
return
}
room.addEventListener(roomEventListener)
this.room = room
isJoined = true
viewModelScope.launch {
try {
room.join()
} catch (e: VideoSDKException) {
Log.e("StreamViewModel", "join failed: ${e.code} ${e.message}")
room.removeAllEventListeners()
this@StreamViewModel.room = null
isJoined = false
}
}
}

private val roomEventListener: RoomEventListener = object : RoomEventListener() {
override fun onRoomJoined(switchRoomId: String?) {
room?.let {
if (it.localParticipant.mode != Mode.RECV_ONLY) {
if (!currentParticipants.contains(it.localParticipant)) {
currentParticipants.add(it.localParticipant)
}
}
localParticipantId = it.localParticipant.id
}
}

override fun onRoomLeft(reason: LeaveReason) {
currentParticipants.clear()
room = null
isStreamLeft = true
}

override fun onParticipantJoined(participant: Participant) {
if (participant.mode != Mode.RECV_ONLY) {
currentParticipants.add(participant)
}
}

override fun onParticipantLeft(participant: Participant, reason: LeaveReason) {
currentParticipants.remove(participant)
}

override fun onParticipantModeChanged(participantId: String, mode: Mode) {
val participant = if (room?.localParticipant?.id == participantId) {
room?.localParticipant
} else {
room?.participants?.get(participantId)
} ?: return

when (mode) {
Mode.RECV_ONLY -> currentParticipants.remove(participant)
Mode.SEND_AND_RECV -> {
if (!currentParticipants.contains(participant)) {
currentParticipants.add(participant)
}
}
else -> {}
}
}
}

fun toggleMic() {
val room = room ?: return
viewModelScope.launch {
try {
if (room.isMicEnabled) room.muteMic() else room.unmuteMic()
} catch (e: VideoSDKException) {
Log.e("StreamViewModel", "toggleMic failed: ${e.message}")
}
}
}

fun toggleCam() {
val room = room ?: return
viewModelScope.launch {
try {
if (room.isCamEnabled) room.disableCam() else room.enableCam()
} catch (e: VideoSDKException) {
Log.e("StreamViewModel", "toggleCam failed: ${e.message}")
}
}
}

fun leaveStream() {
val room = room ?: return
currentParticipants.clear()
room.removeAllEventListeners()
this.room = null
isStreamLeft = true
isJoined = false
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.
}
}
}
}

fun toggleMode() {
val room = room ?: return
val newMode = if (currentMode == StreamingMode.SendAndReceive) {
StreamingMode.ReceiveOnly
} else {
StreamingMode.SendAndReceive
}
viewModelScope.launch {
try {
// The SDK turns your mic and camera off when you switch to RECV_ONLY,
// so don't call disableCam() or enableCam() around changeMode().
room.changeMode(
if (newMode == StreamingMode.SendAndReceive) Mode.SEND_AND_RECV else Mode.RECV_ONLY
)
} catch (e: VideoSDKException) {
Log.e("StreamViewModel", "changeMode failed: ${e.message}")
return@launch
}
isConferenceMode = newMode == StreamingMode.SendAndReceive

// Rebuild the grid: every host, plus you if you are now a host.
currentParticipants.clear()
room.participants.values.forEach { participant ->
if (participant.mode != Mode.RECV_ONLY) {
currentParticipants.add(participant)
}
}
if (newMode == StreamingMode.SendAndReceive &&
!currentParticipants.contains(room.localParticipant)
) {
currentParticipants.add(room.localParticipant)
}
currentMode = newMode
}
}
}
VideoSDK Android Quick Start Stream Screen

Final Output​

You have built an interactive live streaming app in Android with VideoSDK. To explore more features, go through Basic and Advanced features.

Got a Question? Ask us on discord