<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />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:
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:
| Action | Triggered By | Handler |
|---|---|---|
AcceptCall | Accept button (incoming) | call.accept() + call.join() |
DeclineCall | Decline button (incoming) | call.reject() |
CancelCall | Cancel button (outgoing) | call.reject() + call.leave() |
ToggleCamera | Camera toggle | call.camera.setEnabled() |
ToggleMicrophone | Mic toggle | call.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:
| State | UI Shown |
|---|---|
| Outgoing (caller) | "Calling..." with cancel button |
| Incoming (recipient) | Caller info with accept/reject |
| Accepted | CallContent via onAcceptedContent |
| Rejected/No answer | Cleanup 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
- Explore the Video SDK documentation for more call features
- Add screen sharing
- Implement call recording
- Customize the call UI