Two-Way Calling
Pre-requisite
- The user has to be signed in to perform the following operations.
- The camera can take a call right now —
device.canInitiateCall(). This is the gate to put on your call button: it checks hardware support and that the camera is activated, online, not in privacy mode and not security-armed.device.supportsTwoWayCalling()answers only the hardware half (clusterVideoCall,0xFC1F, with attribute0xFC1F:0x00).CAMERAandRECORD_AUDIOare granted — the first is needed to send the phone’s video, the second to send its audio.- A
LiveStreamClientfor the camera is connected.
Call state
A call has two halves: a call record on the backend (initiateVideoCall / updateVideoCallStatus on deviceServices) and the media on the stream (LiveStreamClient). client.callState (StateFlow<CallState?>, null before any call activity) publishes where the call is — CallState: Calling, Accepted, OnCall, Disconnected (Missed is defined but never produced by the stream) — with helpers isCalling(), isAccepted(), isDisconnected().
viewModelScope.launch {
client.callState.collectLatest { state ->
// Drive the call UI from the state
}
}
Two more helpers exist, both with sharp edges. atleast(state) compares declaration order, which is Disconnected < Calling < Accepted < OnCall < Missed — Disconnected is the lowest value, not a terminal one, so atleast tells you how far a live call has progressed and nothing about whether it ended. isError() is true for OnCall as well as Disconnected, despite the name.
Because callState is a StateFlow, the last value of a finished call is replayed to the next collector. Clear it when a call ends, or the following call appears to start already disconnected:
client.clearCallStatus() // resets callState to null (its initial value)
Placing a call
The backend record comes first, because the stream needs the eventId it returns.
device(required): The device object on which the operation has to be performed.request(required): The request object containing the start time of the video call.
val request = InitiateVideoCallRequest(startTime = System.currentTimeMillis())
InstaVision.deviceServices.initiateVideoCall(
device = device,
request = request,
onSuccess = { eventId ->
// The id of the video call event
viewModelScope.launch {
client.hasLoadedStream.first { it }
client.initiateCall(eventId)
}
},
onError = { error ->
// The error object contains the error code and message
},
)
Two things about that snippet are load-bearing.
initiateCall is a fire-and-forget message on the stream’s data channel. It returns Unit, and if the data channel is not open yet the message is dropped silently — no exception, no error callback, and callState never moves. Waiting on hasLoadedStream first is what makes it arrive.
initiateVideoCall wakes a sleeping battery camera before sending the request, so onSuccess can take several seconds on those cameras. Show a pending state rather than assuming it returns immediately.
The camera then answers on callState. There is no ringing timeout in the SDK and Missed is never emitted, so an unanswered call stays in Calling indefinitely — run your own timer while the state is Calling and end the call yourself when it expires.
Answering a call
A camera raises a call as an event tagged EventTag.CALL, delivered over Realtime Events and as a push notification. The event’s id is the eventId for the rest of the call — there is no initiateVideoCall on this path, the record already exists.
Report the answer to the backend first, then tell the stream. acceptCall is subject to the same silent drop as initiateCall, so it too waits for the stream to load:
viewModelScope.launch {
client.hasLoadedStream.first { it }
InstaVision.deviceServices.updateVideoCallStatus(
device = device,
eventId = eventId,
request = UpdateVideoCallStatusRequest(status = CallState.Accepted.name),
onSuccess = {
client.acceptCall(eventId)
client.enableMic(true)
client.enableSpeaker(true)
},
onError = { error ->
// The error object contains the error code and message
},
)
}
Send the phone’s video
The camera preview for the local video is published as client.cameraViewRenderer (StateFlow<TextureViewRenderer?> — null on cameras without calling and after close()); attach it to your layout, then start capture.
client.startCapturingLocalVideo(cameraFacingFront = true)
// stop / start sending video, releasing the capturer when disabled
client.toggleUpstreamVideo(cameraFacingFront = true, enable = false)
client.stopCapturingLocalVideo()
To flip between the front and back camera, call startCapturingLocalVideo again with the other value — it stops and releases the running capturer before starting the new one, so there is no need to stop it yourself.
Audio follows the microphone controls on Audio Interactions; keep InstaVision.setAppInForeground(...) up to date or the microphone is released in the background.
End the call
Ending is the same for an outgoing and an answered call: stop the media, clear the state, then close the record.
client.disconnectCall()
client.clearCallStatus()
disconnectCall() already stops local video capture, so there is no need to call stopCapturingLocalVideo() after it.
Then report the outcome. status takes a CallState name — CallState.Accepted.name when the user answers, CallState.Disconnected.name when the call ends — and endTime gives the call record its duration.
device(required): The device object on which the operation has to be performed.eventId(required): The id of the video call event.request(required): The request object containing the new status of the video call, and optionally the time the call ended.
val request = UpdateVideoCallStatusRequest(
status = CallState.Disconnected.name,
endTime = System.currentTimeMillis(),
)
InstaVision.deviceServices.updateVideoCallStatus(
device = device,
eventId = "eventId",
request = request,
onSuccess = {
// The video call status was updated successfully
},
onError = { error ->
// The error object contains the error code and message
},
)
Like initiateVideoCall, this request wakes a sleeping camera first.
Closing the client with close() is not a hang-up: it releases the camera and the renderer but tells the backend nothing, and the call record stays open until you send this status.