Skip to Content
InternalsCall lifecycle

Call lifecycle

The happy path (1:1)

The glare-avoidance rule

Two peers both creating an offer at once is glare - it produces two incompatible PeerConnection states. Viora avoids it with a strict rule derived from join ordering:

The peer that receives a non-empty room-joined snapshot is the offerer. The peer that receives user-joined waits for the offer.

In MainActivity.dispatchSignalingMessage:

"room-joined" -> if (!msg.peers.isNullOrEmpty()) { remotePeerId = msg.peers[0].peerId isOfferer = true webRtcClient?.createOffer { sdp -> send("offer", targetPeerId = remotePeerId, sdp) } } "user-joined" -> { remotePeerId = msg.peerId isOfferer = false // they will offer to us } "offer" -> { remotePeerId = msg.senderPeerId isOfferer = false webRtcClient?.handleRemoteOffer(sdp) { answer -> send("answer", targetPeerId = remotePeerId, answer) } }

isOfferer is later used by ICE recovery: only the offerer re-offers on a dropped route.

ICE candidate buffering

Candidates can arrive before the remote description is set. WebRtcClient buffers them in a CopyOnWriteArrayList and drains them once setRemoteDescription succeeds:

fun addIceCandidate(candidate: IceCandidate) { if (isRemoteDescriptionSet) peerConnection?.addIceCandidate(candidate) else pendingIceCandidates.add(candidate) } // after setRemoteDescription onSetSuccess: isRemoteDescriptionSet = true pendingIceCandidates.forEach { peerConnection?.addIceCandidate(it) } pendingIceCandidates.clear()

Connection-state handling

PeerConnection.PeerConnectionState drives the CallScreen when:

StateUI
NEW / CONNECTING“Connecting…” with the peer’s avatar
CONNECTEDFull call surface; callConnectedAtMs set on first entry → the call timer starts
DISCONNECTEDCall surface stays; iceRecoveryRunnable scheduled
FAILEDICE restart attempt (offerer only)
CLOSEDBack to Home

callConnectedAtMs is set exactly once (on the first CONNECTED) and cleared only when the peer leaves - a brief DISCONNECTED blip doesn’t reset the timer.

Hang up

close() is @Synchronized, disposes every native object, removes all handler callbacks, and detaches the audio sinks. Because a fresh WebRtcClient is built immediately, the Home preview lights back up without an app restart.

Peer leaves first

If the other peer hangs up, the server sends user-left. MainActivity clears remotePeerId, resets the remote* scalars, sets peerLeftState = true, and CallScreen shows a calm “‹name› left” waiting state with the call still technically open - the local user can wait for a re-join or hang up themselves.