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.
Path A — Let your AI agent build it (recommended)
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.
123456789# 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:
1234/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
.mdto any Stream docs URL for a clean Markdown version. A condensed index for the Android Video SDK is athttps://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 withStreamVideoBuilder. Holds the API key, the user, the token, and the websocket connection. Created once, at app launch.Call— a single call, created withclient.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 asStateFlows your Compose UI reads from.VideoTheme+CallContent—VideoThemeis the styling context;CallContentis a complete calling screen (grid, controls, header, picture-in-picture) in one composable. Swap parts through its slots, or drop down toParticipantVideo/VideoRendererfor 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 rungetstream inituntil 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):
1curl -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.
1getstream 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.
1getstream 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):
12getstream 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
- Create a New Project.
- Select Phone & Tablet → Empty Activity (the Compose one).
- 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:
1./gradlew :app:assembleDebug
Step 2 — Install the SDK & Set Up the Project
The Stream Video SDK has two main artifacts:
- Core Client —
io.getstream:stream-video-android-core: the core part of the SDK. - Compose UI Components —
io.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:
1234dependencies { // 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:
123android { 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:
12345plugins { 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:
12345plugins { 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:
1<uses-permission android:name="android.permission.INTERNET" />
If you get Compose-related errors when building, add these to the app/build.gradle.kts, if needed:
1234567891011dependencies { // Jetpack Compose (skip if already added by Android Studio) implementation(platform("androidx.compose:compose-bom:2024.09.02")) implementation("androidx.activity:activity-compose:1.9.0") implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-tooling") implementation("androidx.compose.runtime:runtime") implementation("androidx.compose.foundation:foundation") implementation("androidx.compose.material:material") implementation("androidx.compose.material3:material3") }
12345678android { buildFeatures { compose = true } composeOptions { kotlinCompilerExtensionVersion = "1.7.5" } }
For Compose Compiler 1.7.5, use Kotlin 2.0.21 and apply org.jetbrains.kotlin.plugin.compose at both the root and module level as shown earlier. See the compatibility table for other release trains.
✅ 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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354import android.os.Bundle import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Text import androidx.compose.runtime.collectAsState 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.layout.onSizeChanged import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.lifecycleScope import io.getstream.video.android.compose.permission.LaunchCallPermissions import io.getstream.video.android.compose.theme.StreamColors import io.getstream.video.android.compose.theme.StreamDimens import io.getstream.video.android.compose.theme.StreamShapes import io.getstream.video.android.compose.theme.StreamTypography import io.getstream.video.android.compose.theme.VideoTheme import io.getstream.video.android.compose.theme.VideoTheme.colors import io.getstream.video.android.compose.theme.VideoTheme.dimens import io.getstream.video.android.compose.ui.components.call.activecall.CallContent import io.getstream.video.android.compose.ui.components.call.controls.ControlActions import io.getstream.video.android.compose.ui.components.call.controls.actions.FlipCameraAction import io.getstream.video.android.compose.ui.components.call.controls.actions.LeaveCallAction import io.getstream.video.android.compose.ui.components.call.controls.actions.ToggleCameraAction import io.getstream.video.android.compose.ui.components.call.controls.actions.ToggleMicrophoneAction import io.getstream.video.android.compose.ui.components.call.renderer.FloatingParticipantVideo import io.getstream.video.android.compose.ui.components.call.renderer.ParticipantVideo import io.getstream.video.android.compose.ui.components.video.VideoRenderer import io.getstream.video.android.core.GEO import io.getstream.video.android.core.RealtimeConnection import io.getstream.video.android.core.StreamVideoBuilder import io.getstream.video.android.core.call.state.FlipCamera import io.getstream.video.android.core.call.state.LeaveCall import io.getstream.video.android.core.call.state.ToggleCamera import io.getstream.video.android.core.call.state.ToggleMicrophone import io.getstream.video.android.model.User import kotlinx.coroutines.launch
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960class 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.
12345val 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.
1234567val client = StreamVideoBuilder( context = applicationContext, apiKey = apiKey, geo = GEO.GlobalEdgeNetwork, user = user, token = userToken, ).build()
Create a Call.
1val call = client.call("default", callId)
Request Runtime Permissions for the camera and microphone before joining.
123456LaunchCallPermissions( 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_NOTIFICATIONSruntime 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:
12val 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.
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:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849VideoTheme { 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.

ParticipantVideo renders a participant from ParticipantState, showing their video or an avatar. The lower-level VideoRenderer displays only the video:
12345VideoRenderer( 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.
123456789101112131415161718192021222324252627282930313233343536VideoTheme { // 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
targetSdk35 (Android 15) apps draw edge-to-edge by default, so without insets the app bar renders under the status bar. WrappingCallContentin aBoxthat paintsVideoTheme.colors.baseSheetPrimaryand appliesModifier.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 theLeaveCallaction inonCallAction(as above). BecauseCallContentalso routes its default control row through the sameonCallAction, 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.

The most commonly used UI components are:
- VideoRenderer: renders video and requests tracks when needed. Most components build on it.
- ParticipantVideo: a participant's video plus network quality, reactions, speaking indicators.
- ParticipantsGrid: a grid of participant videos.
- FloatingParticipantVideo: a draggable participant video, typically your own.
- ControlActions: buttons for controlling the call.
- RingingCallContent: incoming and outgoing call UI.
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:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859VideoTheme { 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
controlsContentreplaces the default control row (including its leave button), so add your ownLeaveCallActionas shown — it callscall.leave(), which disconnects and stops the foreground service. We also handleLeaveCallinonCallActionso the app bar's leave button works too.
Theming gives you control over colors and fonts:
12345678910111213val 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:
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. Rungetstream initin the project directory first.- Stuck on
Loading.../ auth errors in Logcat —call.join()never completed. Confirm the API key, token, and user id all belong to the same app; re-mint withgetstream 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.
LaunchCallPermissionsrequests them; grant camera and microphone in system settings if you dismissed the dialog. - App bar renders under the status bar — missing insets on
targetSdk35. WrapCallContentin aBoxwithModifier.systemBarsPadding()(Step 6). - Leave button does nothing — you didn't handle
LeaveCallinonCallAction, or a customcontrolsContentdropped the default leave button. HandleLeaveCalland add aLeaveCallAction(Step 7). - Compose build errors — align Kotlin, Compose compiler, and AGP versions (Step 2 config summary).
Next steps
- Audio rooms — build a Twitter-Spaces-style experience: audio room tutorial
- Livestreaming — broadcast to many viewers: livestreaming tutorial
- Chat + video — combine calling with messaging: chat with video guide
- Call and participant state — the full state model: guide
Samples
More Video SDK use cases with code:
- Android Video Chat: real-time video chat with Stream Chat & Video SDKs.
- Android Video Samples: a collection of samples using modern Android tech and the Stream Video SDK.
- WhatsApp Clone Compose: Jetpack Compose + Stream Chat/Video.
- Twitch Clone Compose: Jetpack Compose + Stream Chat/Video.
- Meeting Room Compose: a real-time meeting room app.
- Audio Only Demo: an audio-only caller app.
Machine-readable resources
For AI agents and coding assistants working with this SDK:
- CLI + skills:
curl -fsSL https://getstream.io/cli.sh | bash, thengetstream skills --universalandgetstream skills stream-android --universalfor the Android pack (use--claudeinstead 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 tolocal.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-androidin your agent for setup patterns;/stream-docssearches live SDK docs with citations - Docs index for LLMs:
https://getstream.io/video/docs/android/llms.txt - Markdown endpoints: append
.mdto 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

