Video and Audio Calls Integration

Introduction

Video calls are a common feature in chat applications. Stream offers both a Chat SDK and a Video SDK that work together seamlessly.

This guide covers adding video calling to an existing chat app. For the opposite (adding chat to a video app), see the Video SDK documentation.

Permissions

Add camera and microphone permissions to your AndroidManifest.xml:

<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />

Request these permissions at runtime before starting a call.

Setting Up Both SDKs

Both SDKs should use the same API key and user credentials. Here's how to initialize them together:

class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()

        val apiKey = "your_api_key"
        val userId = "user_id"
        val userToken = "user_token"

        // Initialize Chat SDK
        initializeChatClient(apiKey, userId, userToken)

        // Initialize Video SDK
        initializeStreamVideo(apiKey, userId, userToken)
    }

    private fun initializeChatClient(apiKey: String, userId: String, token: String) {
        val offlinePlugin = StreamOfflinePluginFactory(appContext = this)
        val statePlugin = StreamStatePluginFactory(
            config = StatePluginConfig(),
            appContext = this,
        )

        val chatClient = ChatClient.Builder(apiKey, this)
            .withPlugins(offlinePlugin, statePlugin)
            .build()

        val user = User(id = userId)
        chatClient.connectUser(user, token).enqueue()
    }

    private fun initializeStreamVideo(apiKey: String, userId: String, token: String) {
        val streamVideo = StreamVideoBuilder(
            context = this,
            apiKey = apiKey,
            user = io.getstream.video.android.model.User(id = userId),
            token = token,
        ).build()
    }
}

Adding a Call Button (Compose)

Add a video call button to your message list header. Customize the MessagesScreen or create a custom header:

@Composable
fun ChatScreenWithVideo(
    channelId: String,
    onStartCall: (List<String>) -> Unit,
    onBackPressed: () -> Unit,
) {
    val viewModelFactory = MessagesViewModelFactory(
        context = LocalContext.current,
        channelId = channelId,
    )
    val listViewModel = viewModel(
        modelClass = MessageListViewModel::class.java,
        factory = viewModelFactory,
    )

    val channel = listViewModel.channel

    MessagesScreen(
        viewModelFactory = viewModelFactory,
        topBarContent = {
            MessageListHeader(
                channel = channel,
                currentUser = listViewModel.user.collectAsStateWithLifecycle().value,
                connectionState = listViewModel.connectionState.collectAsStateWithLifecycle().value,
                onBackPressed = onBackPressed,
                trailingContent = {
                    // Video call button
                    IconButton(
                        onClick = {
                            val memberIds = channel.members.map { it.user.id }
                            onStartCall(memberIds)
                        }
                    ) {
                        Icon(
                            imageVector = Icons.Default.VideoCall,
                            contentDescription = "Start video call",
                            tint = ChatTheme.colors.primaryAccent,
                        )
                    }
                },
            )
        },
    )
}

Starting a Call

Create a call with the channel members. Setting ring = true sends push notifications to other members:

suspend fun startCall(memberIds: List<String>): Call? {
    val streamVideo = StreamVideo.instance()
    val callId = UUID.randomUUID().toString()

    val call = streamVideo.call(type = "default", id = callId)

    val result = call.create(
        memberIds = memberIds,
        ring = true,
    )

    return result.getOrNull()?.let { call }
}

Handling Ringing Calls

The Video SDK provides RingingCallContent which automatically displays the correct UI based on call state:

  • Outgoing call: Shows "Calling..." with a cancel button (for the caller)
  • Incoming call: Shows caller info with accept/reject buttons (for recipients)

You need to handle call actions (AcceptCall, DeclineCall, CancelCall) in the onCallAction callback:

@Composable
fun RingingCallScreen(
    call: Call,
    onCallEnded: () -> Unit,
) {
    val scope = rememberCoroutineScope()

    RingingCallContent(
        call = call,
        onBackPressed = onCallEnded,
        onCallAction = { action ->
            scope.launch {
                when (action) {
                    is AcceptCall -> {
                        call.accept()
                        call.join()
                    }
                    is DeclineCall -> {
                        call.reject()
                        onCallEnded()
                    }
                    is CancelCall -> {
                        call.reject()
                        call.leave()
                        onCallEnded()
                    }
                    is ToggleCamera -> call.camera.setEnabled(action.isEnabled)
                    is ToggleMicrophone -> call.microphone.setEnabled(action.isEnabled)
                    else -> Unit
                }
            }
        },
        onAcceptedContent = {
            // Navigate when call disconnects (not immediately after leave())
            val connection by call.state.connection.collectAsStateWithLifecycle()
            LaunchedEffect(connection) {
                if (connection == RealtimeConnection.Disconnected) {
                    onCallEnded()
                }
            }

            CallContent(
                call = call,
                onBackPressed = { call.leave() },
            )
        },
        onRejectedContent = {
            // Call was rejected by all recipients
            LaunchedEffect(Unit) {
                onCallEnded()
            }
        },
        onNoAnswerContent = {
            // No one answered
            LaunchedEffect(Unit) {
                onCallEnded()
            }
        },
    )
}

The onCallAction handler processes button clicks:

ActionTriggered ByHandler
AcceptCallAccept button (incoming)call.accept() + call.join()
DeclineCallDecline button (incoming)call.reject()
CancelCallCancel button (outgoing)call.reject() + call.leave()
ToggleCameraCamera togglecall.camera.setEnabled()
ToggleMicrophoneMic togglecall.microphone.setEnabled()

Observing Ringing Calls

Monitor for ringing calls and navigate to the call screen:

@Composable
fun ObserveRingingCall(
    onRingingCall: (Call) -> Unit,
) {
    val ringingCall by StreamVideo.instance().state.ringingCall.collectAsStateWithLifecycle()

    LaunchedEffect(ringingCall) {
        ringingCall?.let { call ->
            onRingingCall(call)
        }
    }
}

Complete Integration Example

Here's how it all comes together:

@Composable
fun ChatApp() {
    var currentScreen by remember { mutableStateOf<Screen>(Screen.ChannelList) }
    var activeCall by remember { mutableStateOf<Call?>(null) }

    val scope = rememberCoroutineScope()

    // Observe incoming ringing calls
    ObserveRingingCall { call ->
        activeCall = call
        currentScreen = Screen.Call
    }

    ChatTheme {
        VideoTheme {
            when (val screen = currentScreen) {
                Screen.ChannelList -> {
                    ChannelsScreen(
                        onChannelClick = { channel ->
                            currentScreen = Screen.Messages(channel.cid)
                        },
                    )
                }
                is Screen.Messages -> {
                    ChatScreenWithVideo(
                        channelId = screen.channelId,
                        onStartCall = { memberIds ->
                            scope.launch {
                                startCall(memberIds)?.let { call ->
                                    activeCall = call
                                    currentScreen = Screen.Call
                                }
                            }
                        },
                        onBackPressed = { currentScreen = Screen.ChannelList },
                    )
                }
                Screen.Call -> {
                    activeCall?.let { call ->
                        RingingCallScreen(
                            call = call,
                            onCallEnded = {
                                activeCall = null
                                currentScreen = Screen.ChannelList
                            },
                        )
                    }
                }
            }
        }
    }
}

sealed class Screen {
    data object ChannelList : Screen()
    data class Messages(val channelId: String) : Screen()
    data object Call : Screen()
}

The RingingCallContent inside RingingCallScreen handles all call states:

StateUI Shown
Outgoing (caller)"Calling..." with cancel button
Incoming (recipient)Caller info with accept/reject
AcceptedCallContent via onAcceptedContent
Rejected/No answerCleanup via callbacks

Push Notifications

For calls to work when the app is in the background, configure push notifications for both SDKs. See:

Both can share the same Firebase project but need separate notification handling.

Next Steps