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.

Prefer to skip the setup? Add the Stream skill and let your AI agent build your Android video calling app.

This tutorial teaches you how to build a Zoom/WhatsApp-style video calling app with Jetpack Compose and the Stream Video SDK.

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

This page works for both humans and AI coding agents: every step is a file operation or shell command, every step has a verification checkpoint, and the few steps that need a human are explicitly marked.

Choose your path

This tutorial has two paths. In Path A you let your AI agent build the app for you. In Path B you build it yourself, step by step. Both paths end with the same working video call, so pick the one you prefer instead of working through both.

Install the Stream CLI once, then add the skills. This step is required for Path A, not optional — the skills are what give Claude Code, Cursor, or Codex the Android integration patterns and current SDK APIs, so the agent builds against real docs instead of stale training data.

bash
1
2
3
4
5
6
7
8
9
# Install the getstream CLI curl -fsSL https://getstream.io/cli.sh | bash # Install the skills (router, docs, and builder) for your agent. Pick the # target: --universal for Cursor, Codex, and other AGENTS-ecosystem tools, # or --claude for Claude Code. The Android pack installs on demand the first # time it's needed, or add it explicitly: getstream skills --universal getstream skills stream-android --universal

Then ask your agent:

plaintext
1
2
3
4
/stream-android Add Stream Video to my Jetpack Compose app: create and join a call, render the participants, and show the default call controls. Provision credentials with the CLI (create/select my org and app, mint a token), or fall back to the tutorial demo credentials if I'm not logged in.

Where you come in. The agent writes the code, provisions the credentials, and adds the Gradle dependency itself. It hands back to you twice: when getstream init opens your browser to log in and pick an app (new accounts get an organization created in that flow), and when it's time to run the app on a device and grant camera/microphone access.

Path B — Build it manually

Choose this path if you'd rather write the code yourself, or want to understand what the SDK is doing. Follow the steps below.

For AI assistants reading this page: append .md to any Stream docs URL for a clean Markdown version. A condensed index for the Android Video SDK is at https://getstream.io/video/docs/android/llms.txt. Prefer these over parsing HTML.

Important Building Blocks

Four pieces, one mental model:

  • StreamVideo — the low-level client, built with StreamVideoBuilder. Holds the API key, the user, the token, and the websocket connection. Created once, at app launch.
  • Call — a single call, created with client.call(type, id). call.join(create = true) is what actually sets up audio and video.
  • call.state — the observable state of the call: participants, connection status, and more, exposed as StateFlows your Compose UI reads from.
  • VideoTheme + CallContentVideoTheme is the styling context; CallContent is a complete calling screen (grid, controls, header, picture-in-picture) in one composable. Swap parts through its slots, or drop down to ParticipantVideo / VideoRenderer for full control.

You'll get a bare call connected first, then render raw video, then swap in the prebuilt calling UI.

Step 0 — Get your credentials

The code in Step 3 needs four values: an API key, a user token, a user id, and a call id. There are two ways to get them.

Agents: detect the project shape before touching anything, including the CLI (find . -maxdepth 3 -name "settings.gradle*" -o -name "build.gradle*"). The CLI writes into the project directory, so a Gradle project has to exist first. If the directory is empty, stop and ask the human to create the app in Android Studio (Step 1) - don't scaffold it yourself, and don't run getstream init until it's there.

Option 1 — Your own Stream app, via the Stream CLI

The getstream CLI provisions all of it in one flow. Run these from your project directory.

1. Install the CLI (skip if you did this in Path A):

bash
1
curl -fsSL https://getstream.io/cli.sh | bash

2. Initialize the project. This one command authenticates you, lets you create or select an organization and app, and writes the project credentials.

bash
1
getstream init

🧑 Human checkpoint: getstream init opens a browser to authenticate. Agents: run it, then ask the human to finish logging in before continuing. It's required first — env, token, and api commands fail with "stream project is not initialized" until it runs.

3. Grab your API key. getstream env --target android writes it to local.properties (STREAM_API_KEY); copy the value from there (or open the app in the dashboard with getstream open). Paste it into the code in Step 3.

bash
1
getstream env --target android

4. Mint a user token for a user in your app (never-expiring by default; add a TTL for production-like testing):

bash
1
2
getstream token tutorial_user getstream token tutorial_user --ttl 1d

5. Pick a call id. Anything URL-safe works, for example my-first-call. Calls are created the first time somebody joins with create = true, so there's nothing to provision up front.

Checkpoint: you have an API key, a token printed by the CLI, the user id you minted it for, and a call id you chose. All four belong to the same app.

Pasting the API key straight into your source is fine for this tutorial — it's a publishable key, not a secret. The user token is the one to be careful with: in production your backend mints it after login and the app fetches it, rather than shipping a hardcoded one.

Option 2 — Pre-filled tutorial credentials, no account

Want to skip account setup entirely? The whole-app code block in Step 3 is filled in for you with working credentials against Stream's shared tutorial environment. Copy it as-is and it runs. Swap in your own credentials from Option 1 before building anything real — tutorial credentials are shared and short-lived.

Step 1 — Create a New Project in Android Studio

  1. Create a New Project.
  2. Select Phone & Tablet → Empty Activity (the Compose one).
  3. Name your project VideoCall.

Note: This tutorial's sample uses Android Studio Ladybug or newer. Steps can vary slightly across versions.

🧑 Human checkpoint: the project is created in Android Studio's New Project wizard — it can't be scaffolded from the CLI. Agents: detect the project shape first (the find command above). If there's no Gradle project, stop and ask the human to create it before continuing — don't scaffold it yourself.

Checkpoint:

bash
1
./gradlew :app:assembleDebug

Step 2 — Install the SDK & Set Up the Project

The Stream Video SDK has two main artifacts:

  • Core Clientio.getstream:stream-video-android-core: the core part of the SDK.
  • Compose UI Componentsio.getstream:stream-video-android-ui-compose: the core + Compose UI components.

For this tutorial, we'll use the Compose UI Components. Add the dependency to the app module's build.gradle.kts (there are two build.gradle.kts files — use the one in the app folder). Replace <latest_version> with the latest release:

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

Make sure compileSdk is set to 35 or newer in the app module's build.gradle.kts:

kotlin
1
2
3
android { compileSdk = 35 }

Use recent Android Gradle Plugin, Kotlin, and Compose compiler versions so the SDK can target compileSdk = 35. 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 }

Apply the Compose plugin in the app module's 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 AndroidManifest.xml, before the application tag:

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

Checkpoint: ./gradlew :app:assembleDebug succeeds and import io.getstream.video.android.core.StreamVideoBuilder resolves. (Sync the project after editing Gradle files.)

Step 3 — Create & Join a Call

To keep this tutorial short, we'll place all the code in MainActivity.kt. For a production app, initialize the client in your Application class or a DI module and use a ViewModel.

Open MainActivity.kt and replace the MainActivity class with the code below. Expand the section for the import statements used throughout this tutorial.

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) } } } } } }

Using Option 1? Paste your four values into apiKey, userToken, userId, and callId. Using Option 2? The block above is pre-filled with working tutorial credentials — copy it as-is.

🧑 Human checkpoint: the camera and microphone prompts are Android system dialogs, and a real device gives you a real camera feed (the emulator only has a virtual scene). Agents: install the app, then ask the human to launch it and allow camera and microphone access.

Checkpoint: the screen reads Call <call-id> has 1 participants and Logcat is free of auth errors. Stuck on Loading...? The call never finished joining — usually the API key and token belong to different apps.

Let's review what we did.

Create a User. You typically sync users via a server-side integration. Guest and anonymous users are also supported.

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 with the API key, user, and 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.

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

Request Runtime Permissions for the camera and microphone before joining.

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

Review the permissions docs to learn more.

Note: When you join a call, the SDK starts a foreground service that keeps the call alive in the background. On Android 13+ this posts an ongoing notification, which requires the POST_NOTIFICATIONS runtime permission. You don't need to handle this yourself — the SDK declares the permission and requests it for you. The call still works if the user denies it; they just won't see the ongoing-call notification.

Join a Call in the onAllPermissionsGranted block. call.join() sets up the audio and video connections.

Define the UI by observing call.state:

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

You'll find all relevant states in call.state and call.state.participants. See Call and Participant state.

Step 4 — Join a Call From the Web

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

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

On your Android device, the text updates to 2 participants. Keep the browser tab open to test changes in the next steps.

Checkpoint: the participant count reads 2.

Step 5 — Render Local & Remote Videos

Render the local & remote participant video feeds. In MainActivity.kt, replace the code inside VideoTheme with:

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 ) } } }

Checkpoint: your local video plays in a floating tile at the top right, and the participant you joined with in Step 4 fills the rest of the screen. Black floating tile? Run on a real device — the emulator has no real camera.

Video Tutorial

ParticipantVideo renders a participant from ParticipantState, showing their video or an avatar. The lower-level VideoRenderer displays only the video:

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

Video is lazily loaded and only requested for participants you display — so a 200-participant call showing 10 only pulls 10 streams. This is how software like Zoom and Google Meet make large calls work.

FloatingParticipantVideo renders a draggable display of your own video.

Step 6 — Render a Full Video Calling UI

The example above showed the low-level approach. For a production UI you'd want speaking indicators, network quality, multi-participant layouts, name labels, and a call header and controls. Stream ships these as Compose components.

To render a complete calling UI, use CallContent — it includes sensible defaults for the header, video grid, controls, and picture-in-picture. Update the code inside VideoTheme to use CallContent. Keep the LaunchCallPermissions and call.join() from Step 3 — 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:

  • 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.
  • Leave button: CallContent's app bar shows a leave button, but it does nothing unless you handle the LeaveCall action in onCallAction (as above). Because CallContent also routes its default control row through the same onCallAction, we handle camera/mic/flip there too.

Checkpoint: the call screen shows the participant grid with name labels and network indicators, and the control bar at the bottom toggles your mic and camera. Toggle your mic and watch the indicator update in the browser tab.

Compose Content

The most commonly used UI components are:

The complete list of UI components is in the docs.

Step 7 — Customize the Calling UI

You can customize by building your own components, mixing in Stream's, or theming. The example below swaps 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 add your own LeaveCallAction as shown — it calls call.leave(), which disconnects and stops the foreground service. We also handle LeaveCall in onCallAction so the app bar's leave button works too.

Theming gives you control over 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, ) { // .. }

Checkpoint: the default control bar is replaced by your four buttons, each still toggling the right thing, and the theming changes take effect.

Verify the whole build

Build and install to a running emulator or connected device:

bash
1
./gradlew :app:installDebug

Then launch on a real device (for a real camera feed), grant camera and microphone access, and confirm: the call joins, your local video shows in the floating tile, and joining from the browser (Step 4) adds a second participant. Toggle the mic/camera controls and watch the browser tab update.

Troubleshooting

  • stream project is not initialized — CLI onboarding not run. Run getstream init in the project directory first.
  • Stuck on Loading... / auth errors in Logcatcall.join() never completed. Confirm the API key, token, and user id all belong to the same app; re-mint with getstream token <user_id> if unsure.
  • Black floating video tile — the emulator has no real camera. Run on a physical device.
  • No camera/microphone prompt, or call has video but no audio — permissions denied. LaunchCallPermissions requests them; grant camera and microphone in system settings if you dismissed the dialog.
  • App bar renders under the status bar — missing insets on targetSdk 35. Wrap CallContent in a Box with Modifier.systemBarsPadding() (Step 6).
  • Leave button does nothing — you didn't handle LeaveCall in onCallAction, or a custom controlsContent dropped the default leave button. Handle LeaveCall and add a LeaveCallAction (Step 7).
  • Compose build errors — align Kotlin, Compose compiler, and AGP versions (Step 2 config summary).

Next steps

Samples

More Video SDK use cases with code:

Machine-readable resources

For AI agents and coding assistants working with this SDK:

  • CLI + skills: curl -fsSL https://getstream.io/cli.sh | bash, then getstream skills --universal and getstream skills stream-android --universal for the Android pack (use --claude instead for Claude Code). (Alternative: npx skills add GetStream/agent-skills -s stream.)
  • Provisioning: getstream init (auth + create/select org & app) → getstream env --target android (API key to local.properties) → getstream token <user_id> (mint a token); pick any URL-safe call id
  • Data & config from the CLI: getstream api <Endpoint> --request '{...}'
  • Android integration skill: invoke /stream-android in your agent for setup patterns; /stream-docs searches live SDK docs with citations
  • Docs index for LLMs: https://getstream.io/video/docs/android/llms.txt
  • Markdown endpoints: append .md to any docs URL for a clean, token-efficient version
  • Source of truth for APIs: the SDK repository — check the pinned version's source rather than assuming APIs from training data

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.