Build multi-modal AI applications using our new open-source Vision AI SDK.

Android Video Calling Tutorial

The following tutorial shows you how to quickly build a Video Calling app leveraging Stream's Video API and the Stream Video Android components. The underlying API is very flexible and allows you to build nearly any type of video experience.

This tutorial teaches you how to build a Zoom/Whatsapp-style video calling app.

  • Calls run on Stream's global edge network for optimal latency & reliability.
  • Permissions give you fine-grained control over who can do what.
  • Video quality and codecs are automatically optimized.
  • Powered by Stream's Video Calling API.
  • UI components are fully customizable, as demonstrated in the Android Video Cookbook.

Step 1 - Create a New Project in Android Studio

  1. Create a New Project.
  2. Select Phone & Tablet -> Empty Activity.
  3. Name your project VideoCall.

Note: This tutorial's sample project uses Android Studio Ladybug. The setup steps can vary slightly across Android Studio versions. We recommend using Android Studio Ladybug or newer.

Step 2 - Install the SDK & Setup the Client

The Stream Video SDK has two main artifacts:

  • Core Client: io.getstream:stream-video-android-core: Includes only the core part of the SDK.
  • Compose UI Components: io.getstream:stream-video-android-ui-compose: Includes the core + Compose UI components.

For this tutorial, we'll use the Compose UI Components.

Add the Video Compose SDK dependency to the app/build.gradle.kts file. If you're new to Android, note that there are 2 build.gradle.kts files, you want to open the one located in the app folder.

kotlin
1
2
3
4
5
6
dependencies { // Stream Video Compose SDK implementation("io.getstream:stream-video-android-ui-compose:<latest_version>") // ... }

️ Replace <latest-version> with the version number indicated below. Also, you can check the Releases page.

️ Make sure compileSdk or compileSdkVersion (if you're using an older syntax) is set to 35 or newer in your app/build.gradle.kts file.

kotlin
1
2
3
4
5
android { // ... compileSdk = 35 // ... }

️ Use the latest Android Gradle Plugin, Kotlin, and Compose compiler versions so the SDK can target compileSdk = 35. We recommend matching the versions below in the root build.gradle.kts:

kotlin
1
2
3
4
5
plugins { id("com.android.application") version "8.7.2" apply false id("org.jetbrains.kotlin.android") version "2.0.21" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false }

And apply the Compose plugin in your app/build.gradle.kts:

kotlin
1
2
3
4
5
plugins { id("com.android.application") id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.plugin.compose") }

️ Add the INTERNET permission in the AndroidManifest.xml file, before the application tag:

xml
1
<uses-permission android:name="android.permission.INTERNET" />

️ If you get Compose-related errors when building your project, expand the section below and follow the steps.

️ Make sure you sync the project after doing these changes. Click on the Sync Now button above the file contents.

Step 3 - Create & Join a Call

To keep this tutorial short and easy to understand, we'll place all the code in MainActivity.kt. For a production app, you'd want to initialize the client in your Application class or DI module and use a View Model.

Open MainActivity.kt and replace the MainActivity class with the code below. You can delete the other functions that Android Studio created.

Also, expand the section below to view the import statements used throughout this tutorial. To follow along easily, replace your file's existing import statements.

kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val apiKey = "REPLACE_WITH_API_KEY" val userToken = "REPLACE_WITH_TOKEN" val userId = "REPLACE_WITH_USER_ID" val callId = "REPLACE_WITH_CALL_ID" // Create a user val user = User( id = userId, // any string name = "Tutorial", // name and image are used in the UI image = "https://bit.ly/2TIt8NR", ) // Initialize StreamVideo. For a production app, we recommend adding the client to your Application class or di module. val client = StreamVideoBuilder( context = applicationContext, apiKey = apiKey, geo = GEO.GlobalEdgeNetwork, user = user, token = userToken, ).build() setContent { // Request permissions and join a call, which type is `default` and id is `123`. val call = client.call(type = "default", id = callId) LaunchCallPermissions( call = call, onAllPermissionsGranted = { // All permissions are granted so that we can join the call. val result = call.join(create = true) result.onError { Toast.makeText(applicationContext, it.message, Toast.LENGTH_LONG).show() } } ) // Apply VideoTheme VideoTheme { // Define required properties. val participants by call.state.participants.collectAsState() val connection by call.state.connection.collectAsState() // Render local and remote videos. Box( contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize() ) { if (connection != RealtimeConnection.Connected) { Text("Loading...", fontSize = 30.sp) } else { Text("Call ${call.id} has ${participants.size} participants", fontSize = 30.sp) } } } } } }

To run this sample code, we need a valid user token typically generated by your server-side API for a production app. When a user logs in to your app, you return the user token that gives them access to the call. As you can see in the code snippet, we've generated a user token to make this tutorial easier to follow.

When you run the sample app, it will connect successfully. The text will say, "Call <ID> has 1 participant" (yourself).

Let's review what we did in the code above.

Create a User. First, we create a user instance. You typically sync these users via a server-side integration on your backend. Alternatively, you can also use guest or anonymous users.

kotlin
1
2
3
4
5
val user = User( id = userId, // any string name = "Tutorial", // name and image are used in the UI image = "https://bit.ly/2TIt8NR", )

Initialize the Stream Video Client. Next, we initialize the video client by passing the API Key, user, and user token.

kotlin
1
2
3
4
5
6
7
val client = StreamVideoBuilder( context = applicationContext, apiKey = apiKey, geo = GEO.GlobalEdgeNetwork, user = user, token = userToken, ).build()

Create a Call. After the user and client are created, we create a call.

kotlin
1
val call = client.call("default", callId)

Request Runtime Permissions. Before joining the call, we request a camera and microphone runtime permissions to capture video and audio.

kotlin
1
2
3
4
5
6
LaunchCallPermissions( call = call, onAllPermissionsGranted = { // ... } )

Review the permissions docs to learn more about how you can easily request permissions.

Note: When you join a call, the SDK starts a foreground service that keeps the call alive even if the app goes to the background. On Android 13+ (API 33) this service posts an ongoing notification, which requires the POST_NOTIFICATIONS runtime permission. You don't need to handle this yourself - the SDK declares the permission in its manifest and requests it for you on launch, so no app-side notification code is required. The call still works if the user denies it; they just won't see the ongoing-call notification.

Join a Call. We join a call in the onAllPermissionsGranted block.

kotlin
1
2
3
4
5
6
7
8
9
10
LaunchCallPermissions( call = call, onAllPermissionsGranted = { // All permissions are granted so we can join the call. val result = call.join(create = true) result.onError { Toast.makeText(applicationContext, it.message, Toast.LENGTH_LONG).show() } } )

Video and audio connections will be set up when you use call.join().

Define the UI. Lastly, you can render the UI by observing call.state (participants and connection states).

kotlin
1
2
val participants by call.state.participants.collectAsState() val connection by call.state.connection.collectAsState()

You'll find all relevant states for a call in call.state and call.state.participants. The documentation on Call state and Participant state explains this further.

Step 4 - Join a Call From the Web

Let's join the call from your browser to make this a little more interactive.

For testing you can join the call on our web-app:

On your Android device, you'll see the text update to 2 participants. Keep the browser tab open so you can continue testing changes in the next steps.

Step 5 - Render Local & Remote Videos

In this next step, we will render the local & remote participant video feeds.

In the MainActivity.kt file, replace the code inside VideoTheme with the example below:

kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
VideoTheme { val remoteParticipants by call.state.remoteParticipants.collectAsState() val remoteParticipant = remoteParticipants.firstOrNull() val me by call.state.me.collectAsState() val connection by call.state.connection.collectAsState() var parentSize: IntSize by remember { mutableStateOf(IntSize(0, 0)) } Box( contentAlignment = Alignment.Center, modifier = Modifier .fillMaxSize() .background(VideoTheme.colors.baseSenary) .onSizeChanged { parentSize = it } ) { if (remoteParticipant != null) { ParticipantVideo( modifier = Modifier.fillMaxSize(), call = call, participant = remoteParticipant ) } else { if (connection != RealtimeConnection.Connected) { Text( text = "waiting for a remote participant...", fontSize = 30.sp, color = VideoTheme.colors.basePrimary ) } else { Text( modifier = Modifier.padding(30.dp), text = "Join call ${call.id} in your browser to see the video here", fontSize = 30.sp, color = VideoTheme.colors.basePrimary, textAlign = TextAlign.Center ) } } // floating video UI for the local video participant me?.let { localVideo -> FloatingParticipantVideo( modifier = Modifier.align(Alignment.TopEnd), call = call, participant = localVideo, parentBounds = parentSize ) } } }

When you run the app, you'll see your local video in a floating video element and the video from your browser. The result should look somewhat like this:

Video Tutorial

Let's review the changes we made.

ParticipantVideo renders a participant based on ParticipantState in a call. If the participant's track is not null and is correctly published, it renders the participant's video or a user avatar if no video is to be shown. If you want to use a lower-level component, you can also see the VideoRenderer.

kotlin
1
2
3
4
5
VideoRenderer( modifier = Modifier.weight(1f), call = call, video = remoteVideo )

It only displays the video and doesn't add any other UI elements. The video is lazily loaded and only requested from the video infrastructure if you display it. So, if you have a video call with 200 participants and show only 10 of them, you'll only receive video for 10 participants. This is how software like Zoom and Google Meet make large calls work.

FloatingParticipantVideo renders a draggable display of your own video.

kotlin
1
2
3
4
5
6
FloatingParticipantVideo( modifier = Modifier.align(Alignment.TopEnd), call = call, participant = localVideo, parentBounds = parentSize )

Step 6 - Render a Full Video Calling UI

The above example showed how to use the call state object and Compose to build a basic video UI. For a production version of calling, you'd want a few more UI elements:

  • Indicators of when someone is speaking.
  • Quality of their network.
  • Layout support for >2 participants.
  • Labels for the participant names.
  • Call header and controls.

Stream ships with several Compose components to make this easy. You can customize the components by theming, arguments, and swapping parts. The customization is convenient if you want to quickly build a production-ready calling experience for your app. (If you need more flexibility, many customers use the low-level approach to build a UI from scratch.)

To render a complete calling UI, we'll leverage the CallContent component. The component includes sensible defaults for a call header, video grid, call controls, picture-in-picture, and everything you need to build a video call screen.

Open MainActivity.kt, and update the code inside VideoTheme to use the CallContent. The code will be much smaller since the CallContent handles all the UI logic. Keep the LaunchCallPermissions and call.join() from the earlier step - they still run before this UI.

kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
VideoTheme { // On Android 15+ (targetSdk 35) the app draws edge-to-edge, behind the system bars. Paint // the call background across the whole screen and apply systemBarsPadding so the app bar // (back/leave) sits below the status bar and the controls sit above the navigation bar. Box( modifier = Modifier .fillMaxSize() .background(VideoTheme.colors.baseSheetPrimary) .systemBarsPadding(), ) { CallContent( modifier = Modifier.fillMaxSize(), call = call, onCallAction = { action -> // CallContent routes both its top app bar and its default control row through // onCallAction, so we handle the actions we care about here. LeaveCall disconnects // the call and closes the screen; the rest just toggle the local devices. when (action) { is LeaveCall -> { call.leave() finish() } is ToggleCamera -> call.camera.setEnabled(action.isEnabled) is ToggleMicrophone -> call.microphone.setEnabled(action.isEnabled) is FlipCamera -> call.camera.flip() else -> Unit } }, onBackPressed = { // Leaving the call also stops the call's foreground service. call.leave() finish() }, ) } }

A couple of important details here:

  • Window insets: with targetSdk 35 (Android 15) apps draw edge-to-edge by default, so without insets the app bar renders under the status bar. Wrapping CallContent in a Box that paints VideoTheme.colors.baseSheetPrimary and applies Modifier.systemBarsPadding() moves the bar below the status bar (and lifts the controls above the navigation bar). This mirrors what the SDK's built-in call activity does.
  • Leave button: CallContent shows a leave button in its top app bar, but it does nothing on its own unless you handle the LeaveCall action. Handle LeaveCall in onCallAction (as above) to actually disconnect and close the screen. Since CallContent also routes its default control row (camera, microphone, flip) through the same onCallAction, we handle those actions there too so the controls keep working.

The result will be:

Compose Content

You'll see a more polished video UI when you run your app. It supports reactions, screen sharing, active speaker detection, network quality indicators, etc. The most commonly used UI components are:

  • VideoRenderer: For rendering video and automatically requesting video tracks when needed. Most of the Video components are built on top of this.
  • ParticipantVideo: The participant's video + some UI elements for network quality, reactions, speaking etc.
  • ParticipantsGrid: A grid of participant video elements.
  • FloatingParticipantVideo: A draggable version of the participant video. Typically used for your own video.
  • ControlActions: A set of buttons for controlling your call, such as changing audio and video states.
  • RingingCallContent: UI for displaying incoming and outgoing calls.

The complete list of UI components is available in the docs.

Step 7 - Customize the Calling UI

You can customize the UI by:

  • Building your UI components (the most flexible, build anything).
  • Mixing and matching with Stream's UI Components (speeds up how quickly you can build common video UIs).
  • Theming (basic customization of colors, fonts, etc.).

The example below shows how to swap out the call controls for a custom implementation:

kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
VideoTheme { val isCameraEnabled by call.camera.isEnabled.collectAsState() val isMicrophoneEnabled by call.microphone.isEnabled.collectAsState() CallContent( modifier = Modifier.background(color = Color.White), call = call, onCallAction = { action -> // The custom controls below trigger the camera/microphone actions directly, so the only // action we still need to handle here is LeaveCall (from the leave button in the app bar). if (action is LeaveCall) { call.leave() finish() } }, onBackPressed = { // Leaving the call also stops the call's foreground service. call.leave() finish() }, controlsContent = { ControlActions( call = call, actions = listOf( { ToggleCameraAction( modifier = Modifier.size(52.dp), isCameraEnabled = isCameraEnabled, onCallAction = { call.camera.setEnabled(it.isEnabled) } ) }, { ToggleMicrophoneAction( modifier = Modifier.size(52.dp), isMicrophoneEnabled = isMicrophoneEnabled, onCallAction = { call.microphone.setEnabled(it.isEnabled) } ) }, { FlipCameraAction( modifier = Modifier.size(52.dp), onCallAction = { call.camera.flip() } ) }, { // A clearly tappable leave button in the bottom control row. LeaveCallAction( modifier = Modifier.size(52.dp), onCallAction = { call.leave() finish() } ) }, ) ) } ) }

Note: A custom controlsContent replaces the default control row (including its leave button), so you should add your own LeaveCallAction to the controls as shown above - this gives users an obvious, easy-to-tap hang-up button. LeaveCallAction calls call.leave(), which disconnects the call and stops its foreground service. We also handle LeaveCall in onCallAction so the leave button in the top app bar works too.

Heads-up: While a call is active, Android (or your device's OEM skin) may show its own ongoing-call indicator in the status bar - often a call-duration chip with a red end-call button. That's a system UI element shown because the call runs as a phone-call foreground service; it's separate from your in-app controls and can't be styled or moved by your app.

Stream's Video SDK provides fully polished UI components, allowing you to build a video call quickly and customize them. As you've seen before, you can implement a complete video call screen with CallContent that is composable in Jetpack Compose. The CallContent composable consists of three major parts:

  • appBarContent: Content that calls information or additional actions show.
  • controlsContent: Users can trigger different actions to control a joined call.
  • videoContent: Content rendered when connected to a call successfully.

Theming gives you control over the colors and fonts.

kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
val colors = StreamColors.defaultColors().copy(brandPrimary = Color.Black) val dimens = StreamDimens.defaultDimens().copy(componentHeightM = 52.dp) val typography = StreamTypography.defaultTypography(colors, dimens).copy(titleL = TextStyle()) val shapes = StreamShapes.defaultShapes(dimens).copy(button = CircleShape) VideoTheme( colors = colors, dimens = dimens, typography = typography, shapes = shapes, ) { // .. }

Recap

To recap what we've learned in the Android video calling tutorial:

  • You set up a call: (val call = client.call("default", "123")).
  • The call type ("default" in the above case) controls which features are enabled and how permissions are set.
  • When you join a call, real-time communication is set up for audio & video calling: (call.join())
  • StateFlow objects in call.state and call.state.participants make it easy to build your UI.
  • VideoRenderer is the low-level component that renders video.

We've used Stream's Video Calling API in this tutorial, which means calls run on a global edge network of video servers. Being closer to your users improves the latency and reliability of calls. The Compose SDK enables you to build in-app video calling, audio rooms, and live streaming in days.

We hope you've enjoyed this tutorial. Please feel free to contact us if you have any suggestions or questions.

Samples

If you're interested in learning more use cases of Video SDK with codes, check out the GitHub repositories below:

  • Android Video Chat: Android Video Chat demonstrates a real-time video chat application by utilizing Stream Chat & Video SDKs.
  • Android Video Samples: Provides a collection of samples that utilize modern Android tech stacks and Stream Video SDK for Kotlin and Compose.
  • WhatsApp Clone Compose: The WhatsApp clone project demonstrates modern Android development built with Jetpack Compose and Stream Chat/Video SDK for Compose.
  • Twitch Clone Compose: The Twitch clone project demonstrates modern Android development built with Jetpack Compose and Stream Chat/Video SDK for Compose.
  • Meeting Room Compose: A real-time meeting room app built with Jetpack Compose to demonstrate video communications.
  • Audio Only Demo: A sample implementation of an audio-only caller application with Android Video SDK.

Final Thoughts

In this video app tutorial, we built a fully functioning Android messaging app with our Android SDK component library. We also showed how easy it is to customize the behavior and the style of the Android video app components with minimal code changes.

Both the video SDK for Android and the API have plenty more features available to support more advanced use cases.

Give us feedback!

Did you find this tutorial helpful in getting you up and running with your project? Either good or bad, we're looking for your honest feedback so we can improve.

Start coding for free

No credit card required.
If you're interested in a custom plan or have any questions, please contact us.