@streaming-cdn/rtc-react-native
v1.7.3
Published
This package is the supported React Native adapter. It contains the media and signaling adapter that was missing from the 1.3.0 delivery.
Downloads
1,656
Readme
RTC React Native / Expo adapter 1.7.3
This package is the supported React Native adapter. It contains the media and signaling adapter that was missing from the 1.3.0 delivery.
Scope
The adapter provides the media core, signaling, and the native call layer.
| Capability | API |
| --- | --- |
| Media, signaling, quality control, telemetry | ReactNativeRtcClient, ReactNativeIncomingClient |
| Audio output routing (speaker / earpiece / Bluetooth / wired) | client.setAudioOutput(), RtcAudioRouting |
| Ringtone and ringback | RtcCallTones |
| Background and locked-screen call delivery | RtcNativeIncomingCalls |
The call layer ships native Android and iOS modules, so applications upgrading
from 1.3.7 or earlier must rebuild: Expo projects run expo prebuild followed
by a Development Build or EAS Build, and bare React Native projects reinstall
pods and rebuild. Until the rebuild happens, every native call fails with
RtcNativeModuleUnavailableError rather than crashing, and
RtcAudioRouting.isSupported() reports false.
Call audio routing
WebRTC on Android renders to the earpiece by default, which makes a video call
inaudible at arm's length. join() now enters the communication audio mode and
selects a sensible default — the speaker for video and meeting, the earpiece
for voice — while a connected headset or Bluetooth device takes precedence
over both.
const { current, available } = await client.getAudioOutputs();
await client.setAudioOutput("speaker"); // "earpiece" | "speaker" | "bluetooth" | "wired"
client.addEventListener("audiooutputchanged", ({ detail }) => {
renderAudioButton(detail.current, detail.available);
});Routing is released automatically by leave().
Ringtone and ringback
import { RtcCallTones } from "@streaming-cdn/rtc-react-native";
RtcCallTones.startIncoming(); // system ringtone plus vibration
RtcCallTones.startIncoming({ uri, vibrate: false }); // custom ringtone
RtcCallTones.startRingback(); // caller-side ringback
RtcCallTones.stop(); // idempotentSilent and vibrate-only ringer profiles are honoured on Android. On iOS, CallKit
plays the ringtone itself when the system call UI is used, so startIncoming
covers in-application ringing while startRingback covers the caller side that
CallKit never provides.
Background and locked-screen calls
A JavaScript WebSocket stops delivering once the CPU sleeps, so background calls must arrive as a high-priority push handled natively. Android presents a full-screen intent notification; iOS uses PushKit with CallKit.
import { RtcNativeIncomingCalls } from "@streaming-cdn/rtc-react-native";
await RtcNativeIncomingCalls.configure({ displayName: "Family Care" });
// Send this to your backend so it can address pushes to the device.
const token = await RtcNativeIncomingCalls.getPushToken();
RtcNativeIncomingCalls.addPushTokenListener((rotated) => saveToken(rotated));
RtcNativeIncomingCalls.addIncomingCallListener((call) => showCall(call));
RtcNativeIncomingCalls.addActionListener((action) => {
// Only fires for actions the USER took in the system call UI. Answering
// through the client below does not come back here.
if (action.type === "accepted") acceptCall(action.callId);
if (action.type === "rejected") rejectCall(action.callId); // declined while ringing
if (action.type === "ended") hangUp(action.callId); // hung up after answering
});
// Answering from the lock screen cold-starts the application.
const initial = await RtcNativeIncomingCalls.getInitialCall();
if (initial) showCall(initial);Any callId works on iOS: CallKit identifies calls by UUID, and the SDK maps
a non-UUID id (such as the platform's rtc_call_...) to a stable UUID that is
the same at registration and at endCall().
A call answered on the lock screen comes back from getInitialCall() with
answered: true, so join it instead of ringing again. A call that is no longer
live — it rang out, was declined on the lock screen, or was ended — is never
returned or replayed, so opening the application minutes after a missed call
does not show a phantom incoming call. An answered call is held for 90 seconds
after the answer, long enough for the application it started to pick it up and
short enough that a process which stays alive for days (a foreground service,
with the Activity destroyed and recreated underneath it) never rejoins a call
that finished hours ago. Both platforms behave identically from 1.4.1; before
that, only iOS applied any of it.
Native rebuild required (1.7.0). Two native fixes, so a host application
must rebuild; an OTA JavaScript update does not carry them. Android: a second
delivery of a call this device already has — an FCM redelivery, a device with
two registered tokens after a reinstall, or the app calling
reportIncomingCall() for the socket copy of a call the push already
delivered — is now ignored: the ringing notification is not posted again over
the live call, the ring timeout is not re-armed, and a call the user answered
stays answered. Before this the redelivery reset the answered flag, and 45
seconds later the ring timeout ended the call the user was sitting in with
calldismissed { reason: "unanswered" }. iOS: a call retained natively (a
VoIP push, or one reported while no call listener existed) is no longer
flushed by the first listener of an unrelated event. It is replayed to
addIncomingCallListener only once that listener exists — including one
registered after addPushTokenListener or addActionListener — and otherwise
stays available to getInitialCall() until it is taken or its window passes,
exactly as on Android. An application whose bootstrap registers the call
listener a tick after the others (after an await, or in a screen's effect)
previously opened to nothing after a lock-screen answer. Each call is delivered
once: whichever of the listener or getInitialCall() reads it first takes it.
Android requires a Firebase messaging service in the host application. Firebase stays an optional dependency that way, while the SDK posts the notification itself from the push receiver, so the call reaches the lock screen even when the application process is dead and no JavaScript is running:
class CallMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) =
StreamingCdnCallPush.setToken(applicationContext, token)
override fun onMessageReceived(message: RemoteMessage) =
StreamingCdnCallPush.deliverCall(applicationContext, message.data)
}Register it in your AndroidManifest.xml, and send a data-only message so
Firebase does not display its own notification instead:
<service android:name=".CallMessagingService" android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>The push payload needs callId, and optionally callerName, callerId,
mode, and a ring window: expiresAt (ISO 8601, which the platform's own
push sender includes) or ringTimeoutMs (10 000–120 000, default 45 000 — the
platform's invitation window). Android honours expiresAt from 1.4.1; before
that it read ringTimeoutMs only, so a pushed call always used the 45 000 ms
default. deliverCall shows the notification with Accept
and Decline actions immediately; the call and any button press are buffered and
replayed to JavaScript once the application starts, so a call answered from the
lock screen arrives through getInitialCall() and addActionListener.
Ending a call that is still ringing on a device whose JavaScript is not
running. On Android, send a data-only push with type: "call_dismiss",
callId, and an optional reason (answered_elsewhere, cancelled); the
notification is cancelled natively and addCallDismissedListener fires if
JavaScript is up. On iOS this cannot be a VoIP push: since iOS 13, every VoIP
push must report a new incoming call to CallKit, or the system terminates the
application and, on repeat, stops delivering VoIP pushes to it — so there is no
way to carry "end this call" over PushKit, and the SDK deliberately does not try.
Instead, the iOS side ends an unanswered call itself when the ring window
elapses (reported to CallKit as unanswered, addCallDismissedListener fires
with reason: "unanswered"), and a call whose JavaScript is running is ended
through signalling the moment the platform reports it answered elsewhere,
cancelled, or ended. Keep cancellation off the VoIP channel; forward the
platform's expiresAt in the push so the device rings for exactly the
invitation window.
The FCM token reaches JavaScript through addPushTokenListener once
setToken has been called with a context, and is replayed if the listener
subscribes later.
Answering in your own UI
You do not have to make users answer on the system call screen. An application with its own answer UI (a full-screen emergency button, say) keeps working, as long as the system is told the call was answered — otherwise it keeps ringing over your screen and the platform logs the call as missed even though it was answered and held.
accept() on the client does this for you:
await client.accept(callId); // answers the call AND the system call UIIf you answer without going through the client, do it yourself:
await RtcNativeIncomingCalls.answerCall(callId);It resolves true when the system call UI moved to answered, and false when the
platform does not know the call — including any integration that never reports calls to
the system UI, so it is safe to call unconditionally. On iOS it requests a
CXAnswerCallAction through CXCallController, which is the only way an incoming
CallKit call reaches the connected state; CXProvider has no method that connects an
incoming call. reportCallConnected() connects an outgoing call, and before 1.4.0 it
was wrongly used for both.
Two consequences of a genuinely answered call, both correct and both new:
- The system holds the call for the whole conversation. The provider is configured for
one call at a time, so a second incoming call while one is up will not ring. Ending
the call frees the slot;
end(),reject(),busy()andcancel()all dismiss the system call UI now, so a call cannot be left on screen after it is over. - On iOS the call appears in the Phone app's Recents as an answered call. Pass
includesCallsInRecents: falsetoconfigure()if your application should not write to the user's call history at all.
Install
Extract streaming-cdn-rtc-react-native.zip, then install the extracted
directory and its native WebRTC peer dependency:
npm install ./path/to/streaming-cdn-rtc-react-native react-native-webrtcBare React Native applications can rebuild normally. Expo applications must
use expo prebuild followed by a Development Build or EAS Build. Expo Go
cannot load the required native WebRTC runtime.
Add the plugin to app.json when using Expo:
{ "expo": { "plugins": ["@streaming-cdn/rtc-react-native"] } }The Server API key remains on your backend. The app receives only a short-lived client token or participant credential.
Receive-only viewer
const client = new ReactNativeRtcClient({
credential,
mode: "video",
publishAudio: false,
publishVideo: false,
receiveAudio: true,
receiveVideo: true,
quality: "low",
preferredVideoCodec: "h264",
});
await client.join();
await client.setQuality("medium");Quality statistics and telemetry
The adapter reads RTCPeerConnection.getStats() internally. Applications do
not need to intercept setRemoteDescription, retain a private peer-connection
reference, or build a separate telemetry loop.
const client = new ReactNativeRtcClient({
credential, // includes telemetryToken and telemetryEndpoint
mode: "video",
statsInterval: 5000,
telemetryInterval: 30000,
autoReportStats: true,
quality: "auto",
adaptiveQuality: true,
onEvent: ({ type, detail }) => {
if (type === "stats") renderNetworkQuality(detail.latestQuality);
if (type === "telemetryerror") reportClientError(detail.message);
},
});
await client.join();
const state = client.getStats();
const sample = await client.collectQualitySample();
await client.reportQuality(sample); // optional immediate reportstats is emitted every statsInterval milliseconds. Automatic server
reports are rate-limited by telemetryInterval; the default is 30 seconds and
a final sample is sent when leaving. Each sample can include RTT, jitter,
interval and cumulative packet loss, inbound/outbound bitrate, resolution,
frame rate, dropped frames, freezes, codec, candidate type, and transport.
getStats() returns the latest normalized client state synchronously.
collectQualitySample() performs a fresh native WebRTC stats read.
reportQuality() sends a supplied sample using only the participant-scoped
telemetry credential. It does not use or expose the Server API key.
Video calls use auto quality unless another value is supplied. Automatic
quality starts at medium and moves one tier at a time in either direction:
- The first
warmupSamplessamples (3 by default, so roughly the first 15 seconds at the defaultstatsInterval) never trigger a downgrade. Bandwidth estimation has not converged yet, and treating that ramp as congestion used to pin calls to the lowest tier for their whole duration. - A downgrade needs either one critical sample (heavy loss, extreme latency, or encoder CPU pressure) or two consecutive poor samples.
- An upgrade needs six consecutive healthy samples. Healthy means low loss, low
round-trip time, no active
qualityLimitationReason, and an outgoing bitrate estimate that sustains the current tier.qualityLimitationReason: "none"is a healthy value.
Each tier pairs a bitrate with an absolute output height, and the encoder scale factor is computed from the device's own capture height, so the same tier produces the same resolution on every handset:
| Tier | Output height | Max bitrate | Max frame rate |
| --- | --- | --- | --- |
| low | 360p | 600 kbps | 20 |
| medium | 540p | 1.2 Mbps | 24 |
| high | 720p | 1.8 Mbps | 30 |
Thresholds are adjustable when the defaults do not suit a network profile:
const client = new ReactNativeRtcClient({
credential,
mode: "video",
quality: "auto",
adaptiveQuality: true,
adaptiveQualityThresholds: { warmupSamples: 5, goodSamplesToStepUp: 4 },
});transport: "mesh" (the default) opens one connection per pair; transport:
"sfu" sends media once to the platform SFU with a three-layer simulcast
uplink and pulls each remote participant from it; transport: "auto" picks
the SFU for meeting mode and the mesh for calls. The credential, events and
API are identical either way. On the SFU transport,
client.setRemoteQuality(participantId, "low" | "medium" | "high") switches
which simulcast layer this client receives for one participant.
simulcast: false publishes a single video encoding instead of the
three-layer ladder — the right call on phones, whose CPU and cellular uplink
cannot carry three encoders. The quality ladder still shapes that single
encoding; subscribers simply lose per-viewer layer selection for this
publisher. Mesh transport ignores the option. Defaults to true.
The effective quality is a ceiling, not an instruction. Calls are a peer mesh,
so every participant has its own connection with its own link: each connection
settles at or below the ceiling on its own evidence, and one participant on a
poor network no longer decides what everybody else receives. A new connection
starts one step below the ceiling and proves itself upward. Listen for
peerquality ({ participantId, quality }) when one connection's tier moves.
Listen for qualitychange to show the effective level, and for
qualityencodingignored to detect an encoder that accepted the bitrate cap but
ignored scaleResolutionDownBy — that combination sends capture resolution
under a low cap and freezes continuously. Use
setQuality("low" | "medium" | "high" | "audio" | "auto") to override the tier,
and setPreferredVideoCodec("vp8" | "h264" | "auto") when a device performs
better with a specific hardware codec.
When a carrier-selected direct path is connected but performs poorly, compare it with a relay-only test before blaming the encoder:
const client = new ReactNativeRtcClient({
credential,
mode: "video",
quality: "auto",
iceTransportPolicy: "relay", // diagnostic or managed-route policy
});The default is all, which allows direct and relay candidates. relay requires
valid relay credentials, can increase usage cost, and should not be enabled
globally without comparing RTT, loss, and selected candidate type.
Render stream.toURL() with the exported RTCView. Signaling reconnects with
heartbeat monitoring and exponential backoff. A still-valid call invitation
created during a signaling outage is replayed after reconnection.
Bind the media client to every call you answer or place. This is required,
not a convenience. Call incoming.attachMediaClient(callId, client) as soon
as accept() (or startCall()) resolves and before client.join(). The 70 s
connect watchdog that accept() arms (and that the caller arms on accepted)
is disarmed only by the attached client's mediaconnected; a call with no
media client attached has no way to report that media came up. On 1.4.0 to
1.7.0 such a call was released at 70 s and the system call screen closed with
reason unanswered; on 1.7.1 the SDK also sent end, hanging up both sides of
a call that was working. From 1.7.2 the SDK instead emits
callunattached { id, expiresAt, message } once, logs a console warning,
reports media_unattached to the platform (the call record then names the
application that never attached), and leaves the call alone: nothing is
ended or released, the system call UI is not touched, and you must
end(callId) yourself. Attaching after that warning
re-arms the watchdog for the attached client, so a call whose media never
comes up is still freed from there. Terminal call states stop local
tracks and leave the room automatically for an attached client. Applications
that do not use the incoming-call adapter must call client.leave()
themselves. client.destroy() is an idempotent teardown alias. The
incoming-call client also exposes destroy() as an alias for disconnect().
leave() may land while join() is still in flight — the caller cancelled
while the OS permission dialog was up, the user hung up, or the incoming-call
watchdog fired. The join is then aborted: the tracks the dialog handed over
are stopped, call audio stays off, no signaling socket, SFU session or stats
timer is started, and join() rejects with RtcJoinAbortedError
(error.code === "join_aborted"). A newer join() on the same client
supersedes an older one the same way. Handle the rejection where you call
join(); nothing is left running for the next call to trip over:
client.join().catch((error) => {
if (error instanceof RtcJoinAbortedError) return; // the call was released meanwhile
if (error instanceof RtcJoinTimeoutError) {
// error.code: "media_acquire_timeout" (camera/microphone never came back —
// typically a permission dialog nobody answered) or "audio_route_timeout"
// (the native audio route never answered); error.elapsedMs since join().
showError(`Could not start the call (${error.code} after ${error.elapsedMs} ms)`);
return;
}
showError(error);
});join() no longer waits for ever (1.7.1). The camera/microphone request
is bounded by mediaAcquireTimeoutMs (default 20 000) and the native call
audio route by audioRouteTimeoutMs (default 5 000); 0 disables either. On
the deadline join() rejects with RtcJoinTimeoutError (code,
elapsedMs) and the client is left as after leave(): tracks the permission
dialog hands over afterwards are stopped, and an audio mode that latches late
is turned off again. Before 1.7.1 neither step had a timeout, so a join stuck
on the permission dialog outlived every watchdog (the incoming-call watchdog
only reaches a media client that was attached — attach before join(), which
1.5.0 and later allow, or call media.leave() when you give up).
The connect phase: what connected means, and when to give up
connected on the media client means the signaling room was joined — the
server answered join(). It says nothing about media. A call with a
connected media client and no audio or video is exactly the case this
section exists for, so drive your call UI from the events below, not from
connected.
| Step | Event | What it proves |
| --- | --- | --- |
| join() resolved | connected { resumed, elapsedMs, steps } | The signaling room accepted this device. Signaling only. elapsedMs is ms since join(); steps.localStreamMs / audioRouteMs / connectedMs are when each step of join() completed, also since join() (null for a step that was skipped). |
| The other side is in the room | participantjoined (initial: true when they were there first) | Both devices are in the same room. Still no media. |
| Offer / answer / ICE exchanged | negotiation { participantId, phase, at } | Which step of the exchange was reached, at ms after join(). Phases: offer-created, offer-sent, offer-received, answer-created, answer-sent, answer-received, remote-description-set, ice-gathering-complete, ice-candidate-sent, ice-candidate-received. |
| ICE moves | icestate { participantId, state }, peerconnectionstate { participant, state } | The transport is checking, connected, failed... |
| Remote SDP applied | remotestream { participant, stream, track } | The peer's description was set and a receiver exists for that track. Not media: the binding this SDK requires fires it inside setRemoteDescription, while ICE may still be new; it can fire again for the same track on renegotiation. Do not count it, and do not treat it as "connected". |
| Media reached this device | mediaconnected { participantId, elapsedMs, first, transportConnected, inboundBytes, inboundPackets } | That peer's transport is up (connectionState connected, or ICE connected/completed) and, when the peer sends this device anything, inbound RTP has been counted on that connection (bytesReceived or packetsReceived above zero). A peer that sends nothing (no remote track) counts on the transport alone. Once per peer; first: true for the first peer of the phase. This is "call connected". Read within about half a second of the first packet. |
| A kind of media started | remotetrackactive { participantId, kind } | Inbound data for that participant's audio or video first arrived. Once per participant and kind. |
| Nobody else came | peerwaittimeout { waitedMs } | peerWaitTimeoutMs (default 0 = off) elapsed after join() with no remote participant present. Fired once; the client stays. |
| Nothing arrived in time | connecttimeout { elapsedMs, stage, joined, waitedForPeerMs, sinceFirstPeerMs, peers: [{ participantId, connectionState, iceState, signalingState, lastNegotiationPhase, transportConnected, inboundBytes, inboundPackets }], participantCount } | connectTimeoutMs (default 30 000; 0 disables) elapsed with no mediaconnected, counted from join() or the first remote participant being present, whichever is later. stage is waiting_for_peer (nobody else was ever present; waitedForPeerMs / sinceFirstPeerMs are null) or negotiating (a peer was present waitedForPeerMs after join(), and sinceFirstPeerMs have passed since). joined: false means the room was never joined — the stall is local (permission dialog, socket), not the other side. At most once per stage: a peer that arrives after a waiting_for_peer timeout gets its own budget and, if it fails too, its own negotiating timeout. The client stays in the room — see the budget. |
client.getConnectionDiagnostics() returns the same snapshot at any moment
(elapsedMs, stage, joined, waitedForPeerMs, sinceFirstPeerMs,
peers, participantCount), plus joinedAfterMs and joinSteps (the
connected timings), mediaConnected, mediaConnectedAfterMs,
mediaConnectedParticipantId, connectTimeoutFired and lastConnectTimeout
(the connecttimeout payload once it has fired). When connecttimeout
fires, read joined first: false is a local problem; true with
stage: "waiting_for_peer" is the other side never arriving; otherwise
peers[].lastNegotiationPhase, iceState, transportConnected and
inboundBytes say how far the link got.
A signaling reconnect starts the connect phase over on the mesh. When the
socket drops (reconnecting), every mesh link is rebuilt, so the clock,
mediaconnected (once per peer per phase, first: true again for the first
one) and connecttimeout are re-armed with it, and getConnectionDiagnostics()
reports the rebuilt links rather than the first join's outcome. at and
elapsedMs count from the start of the current phase — join(), or the
reconnecting event that began the rebuild. On the SFU transport the uplink is
negotiated over HTTP and outlives the socket, so its connect phase is kept: no
second mediaconnected for media that never dropped, and the diagnostics keep
reporting it.
The participant list is kept, not replayed: peers who were already announced
get no second participantjoined, and whoever left while the socket was down
is announced with participantleft when the rejoin roster arrives. On the SFU
transport the device re-announces its own tracks on rejoin, so the others
subscribe again.
The budget
The two sides of a call do not join at the same moment: the callee accepts, then fetches credentials and joins; the caller learns of the accept from a broadcast, then joins. Either can be first, and a device that hangs up because the other one is still on its way ends a call that would have worked.
| Stage | Budget | Where it is measured |
| --- | --- | --- |
| Join the signaling room | 20 s | join() rejects at 15 s on the socket, at mediaAcquireTimeoutMs (20 s) on the camera/microphone and at audioRouteTimeoutMs (5 s) on the audio route; the socket budget also covers the first offer when this device is the offerer |
| Wait for the other side to appear | at least 20 s, without hanging up | participantjoined; peerWaitTimeoutMs reports it for you |
| Negotiate and connect media | 15 s | negotiation → mediaconnected; connectTimeoutMs counts from here (from the first remote participant), not from join() |
| Give up | no earlier than 45 s after join() | connecttimeout is the signal to show "still connecting"; the incoming-call client releases an accepted call whose media never came up about 70 s after accept() (a 60 s grace plus a 10 s margin) and ends it on the server. This presupposes an attached media client: a call that was never attached gets callunattached instead and is left standing (1.7.2) |
When connecttimeout fires, show the user that the call is still connecting
and render peers somewhere a support ticket can quote — lastNegotiationPhase
and iceState per peer are what distinguishes "the other side never joined"
from "offer sent, no answer" from "answered, ICE never connected". Hang up on
your own schedule, not before 45 s.
With ReactNativeIncomingClient, attachMediaClient(callId, media) reports
this phase to the platform by call id (media_joined, media_connected,
media_timeout, and from 1.7.2 media_unattached for a call that reached
the watchdog with nothing attached — message types and timings only, never
SDP or candidates),
and the call watchdog armed by accept() now stands until mediaconnected
rather than until the media client is attached. On expiry it emits
callexpired { id, expiresAt, ended: true }, ends the call on the server
(so the other side's call client receives callupdated: ended and the call
record does not stop at "accepted"), leaves the media room and releases the
call. Until 1.7.1 the release was local only. All of this presupposes
attachMediaClient(): only the attached client's mediaconnected can disarm
the watchdog. A call this device answered or placed with no media client
attached is not ended (1.7.2): at 70 s the SDK emits callunattached with a
console warning and leaves the call to the application. On 1.7.1 that call
was ended on the server, and on 1.4.0 to 1.7.0 released locally with the
system call screen closed as unanswered — in every case, including calls
whose media was fine. A ring window that ran out
unanswered (ended: false) is swept by the server itself; nothing is sent —
and neither is anything sent by a device that merely heard accepted for a
call it did not place or answer (the caller's or the answerer's other phone):
it holds the same 70 s watchdog and releases locally with ended: false,
because an end from it would hang up a call running fine on the phone next
to it.
send_dropped: a signaling message produced while the socket was not open is
announced as signalingerror { code: "send_dropped", messageType } rather than
lost in silence. Nothing throws; the reconnect path rebuilds every peer and
re-offers.
Abnormal signaling closes are reported to the service as operational
diagnostics without tokens or media content. The SDK reconnects with backoff,
refreshes the client identity, and reports recovery duration. Set
reportSignalingDiagnostics: false only when the host application has an
equivalent diagnostics pipeline.
Stable event contract
Web and React Native use the same public event names and detail objects. The adapter never exposes raw signaling event names as application events:
Every event either client emits, by name:
Incoming-call client (ReactNativeIncomingClient)
| Event | Detail |
| --- | --- |
| incomingcall | The call object (id, mode, status, caller, and optional metadata) |
| callupdated | { id, status, actorExternalId, answeredDeviceId? } — actorExternalId is who acted, null when the platform does not say; answeredDeviceId (accepted only, when the platform sends it) is the actor's device. When the actor is this user on another device (1.7.1), the SDK emits status: "answered_elsewhere" (plus reason: "answered_elsewhere"), dismisses the system ring the way it does for another recipient, and arms no watchdog — that device is not in the call. A call this client accepted itself is never treated that way |
| callrestored | A still-ringing invitation replayed after a signaling reconnect (same detail as incomingcall) |
| outgoingcall / callaccepted / callrejected | The corresponding API result |
| callexpired | { id, expiresAt, ended } — the client released a call by itself: an unanswered ring or a call this device only heard accepted for (ended: false; nothing sent, the server sweeps a missed ring), or a call this device answered or placed whose attached media client reported no mediaconnected within the grace (ended: true; the SDK also sent end, so the other side hears ended) |
| callunattached | { id, expiresAt, message } — a call this device answered or placed reached the 70 s watchdog with no media client ever attached (1.7.2). Nothing is ended or released and the system call UI is untouched, because the SDK cannot tell this call from a working one. This is an integration error, not a failed call: attach the media client (attachMediaClient() before join()); until then end the call yourself |
| callreleased | { id } after release(callId) |
| presence | { users, scope, roomId? } — the complete online list for this socket's scope, kept current by the SDK; render users on every event |
| presencedelta | { change: "joined" \| "updated" \| "left", scope, roomId?, user, timestamp } — the single change that produced the presence event just after it |
| signalingconnected | { resumed } — the presence socket is open |
| signalingdisconnected | { code, reason, wasClean, reconnecting, attempt } |
| signalingerror | { message, reconnecting } |
| signal | { signal, message } — any presence-socket frame that is not one of the events above (ready, rtc.ack, a platform error envelope, a type added later), with its raw type in signal; never delivered under the raw name |
| tokenrefreshed | { reason, expiresAt } |
| tokenerror | { message } — the tokenProvider failed; the reconnect keeps retrying |
| diagnosticserror | { message } — a non-fatal signaling diagnostics upload failure |
| mediaerror | { message } — the attached media client threw while being torn down |
Media client (ReactNativeRtcClient)
| Event | Detail |
| --- | --- |
| localstream | { stream } — the camera/microphone stream is open |
| connected | { resumed, elapsedMs, steps } — the signaling room was joined; not media (see the connect phase above); timed from join() |
| reconnecting | { code, attempt } — the signaling socket dropped; mesh peers are rebuilt on reconnect and the connect phase starts over (mediaconnected / connecttimeout fire again for the rebuilt links); the SFU uplink outlives the socket and keeps its phase |
| participantjoined | The participant object; initial: true for peers already in the room when this device joined. Not repeated for an already-announced peer after a reconnect |
| participantleft | The participant object — also for whoever left while the signaling socket was down, once the rejoin roster arrives |
| negotiation | { participantId, phase, at } — one step of the offer/answer/ICE exchange (at = ms since join()) |
| icestate | { participantId, state } on every ICE connection state change |
| peerconnectionstate | { participant, state } |
| mediaconnected | { participantId, elapsedMs, first, transportConnected, inboundBytes, inboundPackets } — that peer's transport is up and its data has been counted arriving (transport alone for a peer that sends nothing); once per peer, self (1.7.3: this participant's own SFU uplink; ignored by the call watchdog) |
| remotetrackactive | { participantId, kind } — inbound data for that participant's audio / video first arrived; once per participant and kind |
| peerwaittimeout | { waitedMs } — peerWaitTimeoutMs elapsed after join() with nobody else present; fired once, the client stays |
| connecttimeout | { elapsedMs, stage, joined, waitedForPeerMs, sinceFirstPeerMs, peers, participantCount } — connectTimeoutMs elapsed with no mediaconnected, counted from the later of join() and the first remote participant; at most once per stage, the client stays |
| remotestream | { participant, stream, track } — the remote SDP was applied and a receiver exists; not media (see the connect phase) |
| stats | Current client state and latestQuality |
| telemetryerror | { message }; refresh the participant credential when expired |
| qualitychange | Requested/effective quality and whether the change was manual or network-driven |
| peerquality | { participantId, quality } when one connection's tier moves on its own evidence |
| qualityencodingignored | { quality, participantId, encodings } — the sender ignored a resolution scale |
| qualityerror | { message } — applying a quality tier failed |
| codecchange | { preferredVideoCodec } |
| audiooutputchanged | The audio route state (RtcAudioRouteState) |
| audioroutingerror | { message } — entering the call audio mode failed; the call continues |
| signalingerror | { message }, or { code: "send_dropped", messageType, message } when a message was dropped because the socket was not open |
| chatupdate | The room chat message |
| control | The room control message |
| listenererror | { eventType, error } — one of your listeners (or onEvent) threw while handling eventType. Both clients emit it. See below |
A throwing listener never crashes the application and never stops the SDK.
An exception from an addEventListener callback or from onEvent is caught,
the SDK state transition that emitted the event completes, and the error is
reported as listenererror { eventType, error } on the same client. When
nothing listens to listenererror it goes to console.error instead. It is
never rethrown: on a React Native release build an exception escaping a
microtask is a fatal uncaught error, and a bug in a stats handler (say,
rttMs.toFixed() on the first, null sample) would have taken the application
down on every tick. Wire listenererror to your error reporting:
client.addEventListener("listenererror", ({ detail }) => {
reportClientError(`RTC ${detail.eventType} listener threw`, detail.error);
});With the SFU transport there is one uplink and no peer to attribute it to:
negotiation, icestate, peerconnectionstate and the uplink's own
mediaconnected are reported under this participant's own participantId
(the same attribution the web SDK uses). The uplink counts on its transport
alone while nobody sends to this device, and on its inbound counters once a
subscription exists; each remote participant reports mediaconnected and
remotetrackactive when data attributed to its tracks (by the stats row's
trackIdentifier, or by mid) is first counted — not when its track event
fires.
const incoming = new ReactNativeIncomingClient({
tokenProvider: async () => {
const response = await fetch(`${YOUR_BACKEND}/api/rtc/client-token`, {
headers: { authorization: `Bearer ${yourApplicationSession}` }
});
if (!response.ok) throw new Error("Unable to refresh RTC identity");
return response.json(); // { token, expiresAt }
},
signalingUrl,
onEvent: ({ type, detail }) => {
if (type === "incomingcall") showIncomingCall(detail);
if (type === "callupdated" && detail.status === "accepted") startMedia(detail.id);
}
});
incoming.connect();tokenProvider is the production integration. It runs before the first connection, before each signaling reconnect, before token expiry, and once after an API 401. The application backend keeps the long-lived Server API key and exchanges it for a short-lived user/device identity. Never bundle the Server API key in React Native, Expo constants, native resources, or remote configuration.
Foreground and background calls
Do not disconnect an active call merely because the application enters the background. Presence-only signaling may disconnect when the application's push service is responsible for waking it again, but an accepted call has a different lifecycle.
- Android ongoing calls require a visible foreground-service notification and the service types/permissions that match camera and microphone use. Start while-in-use media access while the application is eligible; Android restricts starting camera or microphone access from the background. See the Android foreground service type reference.
- iOS incoming VoIP calls use PushKit to wake the application and CallKit for the
system call lifecycle. The application owns its
AVAudioSession; background video remains subject to iOS camera restrictions, so maintain audio and show a video-paused state when necessary. See PushKit and VoIP calling with CallKit. - Resume the SDK statistics and diagnostics UI on foreground return. Platform restrictions cannot be bypassed by the JavaScript adapter.
Where call actions go: callActionsUrl
Accept, reject, busy, end and cancel are not local actions: the call's state
lives in a record on the platform, and pressing Accept must (1) update that
record, (2) notify the caller, and (3) stop the ringing on the callee's other
devices. The button therefore sends one authenticated HTTP request — and it
cannot ride the signaling socket, because when a push notification wakes the
app there is no socket yet. callActionsUrl is the origin those requests go
to. It applies ONLY to the incoming-call client's call actions; no other SDK
API reads it.
Caller Platform Callee device
| create call (server API) | |
|-------------------------->| call record: "ringing" |
| ringback tone |---- push / signaling notify ------>| incoming-call UI shows
| | | (no connection exists yet)
| | | user taps Accept / Reject
| |<== POST {callActionsUrl}/v1/rtc/client/calls/:id/accept
| | the server arbitrates: accepted,
| | or "already cancelled" if the
| | caller hung up first
|<-- "accepted" notify -----|--- stop ringing other devices ---->|
| | | connect signaling (signalingUrl)
|<================== media session establishes ================>|Resolution order: callActionsUrl -> signalingUrl's origin -> the page's own
origin. In practice:
| Scenario | Needed? |
| --- | --- |
| Web app served from the platform's own domain | No — the page origin is used |
| Your own website (different domain) embedding the incoming-call UI | Yes — set it to the platform origin |
| signalingUrl already provided | No — its origin is reused |
| React Native / non-browser | Provide signalingUrl or callActionsUrl (there is no page origin to fall back to) |
apiBaseUrl is the deprecated former name; it is still accepted as an alias
so existing integrations keep working.
1.7.3 notes
Native code changed on both platforms (Android and iOS): an OTA bundle of 1.7.3 runs on a 1.7.x binary with the JavaScript changes below only; the Android and iOS changes need a rebuild.
- The call watchdog waits for the other party's media on the SFU
transport. With
transport: "sfu"the client's own uplink comes up within a second whether or not anyone else ever joins, and it was reported asmediaconnected(first: true) — which disarmed the 70 s watchdog, so an accepted call with no other party sat in the call slot until the application hung up.mediaconnectednow carriesself: truefor this participant's own uplink; the incoming-call watchdog ignores it and waits for a remote participant'smediaconnected, exactly as on the mesh transport.getConnectionDiagnostics()gainsmediaConnectedSelf. The media client's ownconnecttimeoutis unchanged. - Frames that are not public events arrive as one
signalevent. The platform'sready,rtc.ackanderrorframes on the presence socket were delivered under their raw type — an application listening to"error"for its own purposes received server frames. They now arrive assignal { signal, message }(see the event table); the client keeps readingreadyitself. - Android: the retained call survives process death. The push
receiver posts the notification, the OS kills the background process,
and the user's Accept ran in a fresh process that knew nothing of the
call:
getInitialCall()returned null and the caller's name and mode were lost. The retained call is now also kept on disk (written when shown, updated when answered, cleared when it ends) and restored under the same liveness rule. - Android:
ringtoneUriis honoured. It was documented as an Android option and read on iOS only. A custom ringtone now gets its own notification channel (a channel's sound is fixed at creation), remembered for the push receiver's process; the type doc says what each platform does with it. - iOS:
configure()is idempotent. Calling it again (from a screen's mount, after a settings change) used to create a secondCXProviderand a secondPKPushRegistrywith the same delegate — every VoIP push was delivered twice and the secondreportNewIncomingCallfor the same UUID failed. The existing provider takes the new configuration; the registry is created once; the provider is invalidated on release.
1.7.2 notes
JavaScript-only release — no native code changed. android/, ios/ and
the podspec are byte-identical to 1.7.1, so an OTA bundle of 1.7.2 runs on a
1.7.0 / 1.7.1 binary and, with the same limits as 1.7.1, on a 1.4.1 binary.
- A call with no media client attached is no longer ended by the
watchdog.
attachMediaClient()is required (it always was: only the attached client'smediaconnectedcan disarm the 70 s watchdog), but an application that never called it had every answered call released at 70 s since 1.4.0, and from 1.7.1 also ended on the server — working calls included (letter of 2026-09-17). From 1.7.2 the watchdog, on a call this device answered or placed that never had a media client attached, emitscallunattached { id, expiresAt, message }once, logs a console warning, and does nothing else: noend, no release, no native end-call. Calls with an attached client keep the full 1.7.1 behaviour, and so does a placed call that nobody answers (its ring window still releases it). Attaching a media client aftercallunattachedre-arms the watchdog for that client: if its media never comes up the call getscallexpiredand is released 70 s later as on 1.7.1, and if media is already running the catch-up on the client's diagnostics disarms it at once. The web SDK carries the same rule from 1.9.2, released the same day. - The platform records it. The same firing reports
media_unattachedthrough the diagnostics channel that carriesmedia_joined,media_connectedandmedia_timeout— call id,elapsedMsandsinceAcceptSeconds(measured from the accept, not from the last time anacceptedecho reset the timer), and the reporting build — so a tenant's call record shows which application never attached. It is the one report a never-attached client ever makes; the platform accepts it from the same deployment that ships 1.7.2.
1.7.1 notes
JavaScript-only release — no native code changed. The JavaScript in this
release calls exactly the native surface 1.7.0 exports, and that surface has
been the same since 1.4.1, so an OTA bundle of 1.7.1 runs on a 1.7.0
binary and on a 1.4.1 binary (1.5.0 and 1.6.0 shipped the same native
code as 1.4.1) with every fix below active. What still needs a rebuild is
unchanged from 1.7.0: the Android redelivery fix and the iOS retained-call
replay fix are native and only work on a 1.7.0 binary; includesCallsInRecents
works on 1.4.1 native (it was always read there). Every item below is one the
2026-09-14 reply to the integrator (section 四) committed to.
mediaconnectedmeans data arrived (四 #1). It used to fire on the peer connection'strackevent too, which the binding this SDK requires dispatches insidesetRemoteDescription— so it could fire with ICE stillnew, disarm the connect timeout and the incoming-call watchdog, and leave a link that never came up with nothing watching it. It now requires the peer's transport to be up (connectionStateconnected, or ICE connected/completed) and inbound RTP counted on that connection; a peer that sends nothing counts on the transport alone. The inbound counters are read every 500 ms while a transport is up and its media has not arrived, so the event lands within about half a second of the first packet. The detail gainstransportConnected,inboundBytes,inboundPackets; new eventremotetrackactive { participantId, kind }once per participant and kind; every peer snapshot (connecttimeout,getConnectionDiagnostics(),media_timeout) carriestransportConnected,inboundBytes,inboundPackets.remotestreamis unchanged in behaviour and documented for what it is: the remote SDP was applied. The track'sunmuteis deliberately not used on this platform: the binding fires it right after the remote description is applied, before any packet, and audio tracks have no mute detection at all. The SFU transport follows the same rule (the uplink counts on its transport while nobody sends to this device).connectTimeoutMsis anchored at the first peer (四 #2). It counted fromjoin(), so a peer that joined 25 s later had 5 s to negotiate — the same flaw as the integrator's own wall clock, which we had told them it replaced. It now counts fromjoin()or from the first remote participant being present, whichever is later;connecttimeoutgainsstage(waiting_for_peer/negotiating),joined(四 #6),waitedForPeerMs,sinceFirstPeerMs, and fires at most once per stage. New optionpeerWaitTimeoutMs(default 0 = off) emitspeerwaittimeout { waitedMs }once when nobody else has joined. With the anchor fixed,30000is again a reasonableconnectTimeoutMs.- The incoming-call watchdog ends the call on the server (四 #3). When
the 70 s grace runs out on an answered call,
callexpiredis followed by theendaction, so the other side's call client receivescallupdated: endedand the call record does not stop at "accepted".callexpiredgainsended: true|false. Only a device that answered (accept()) or placed (startCall()) the call sendsend; an unanswered ring window (false) is left to the server's sweep, and a device that only heardacceptedfor a call it is not in (false) sends nothing — anendfrom the caller's other phone would hang up the live call. Applications that calledend(callId)oncallexpiredthemselves can keep doing so (the secondendis refused harmlessly) or stop. join()has deadlines (四 #4).mediaAcquireTimeoutMs(default 20 000) bounds the camera/microphone request andaudioRouteTimeoutMs(default 5 000) the native audio route; on expiryjoin()rejects withRtcJoinTimeoutError(code: "media_acquire_timeout" | "audio_route_timeout",elapsedMs), the client is left as afterleave(), and whatever the late step hands over afterwards is released (the join-generation rule from 1.7.0's rn-1).0disables either.connectedandmedia_joinedcarry timings (四 #5).connectedis{ resumed, elapsedMs, steps: { localStreamMs, audioRouteMs, connectedMs } }, each sincejoin(); the incoming-call client'smedia_joineddiagnostic carries the sameelapsedMsandsteps(also when attached late), so the platform can say where a slow join spent its time.getConnectionDiagnostics()gainsjoinedAfterMsandjoinSteps.- Answered on another of this user's devices (一 #2, 四 #7 client side).
A
callupdated: acceptedwhoseactorExternalIdis this user, for a call this client did not accept itself and whoseansweredDeviceIdis not this device, is now treated as answered elsewhere: the system ring is dismissed through the same path as for another recipient,callupdatedis emitted withstatus: "answered_elsewhere"andreason: "answered_elsewhere", and no watchdog is armed (1.7.0 armed 70 s and let the native ring window run out on its own). Anacceptedthat lands while this device's ownaccept()is still in flight is never that — the platform broadcasts before it answers the request, and during a token rotation the echo names a device id the socket does not carry yet; if thataccept()then fails (409, the other device won), the update is applied at that point. The SDK learns who it is from the token's claims and from the socket'sreadyframe; the platform-side change that sendsacceptedto the actor's other devices withansweredDeviceIdis in the same platform release. ThedeviceIdin your client token must be a per-device value for this to tell devices apart (see the reply, question 1). - The SDK names its version (四 #12):
clientVersion(react-native/1.7.1, exported asclientVersion;versionstays the bare semver) on the signalingrtc.room.joinmessage, on every telemetry sample and on every signaling diagnostic, so the platform's participant record says which version a call ran — including a call whose media client never reached the room, sincemedia_timeoutcarries it too. - Tests that pinned the old
mediaconnectedwere flipped, not deleted:tests/rtc-react-native-connect-phase.test.ts("fires mediaconnected once per peer …" now asserts that a track alone does not count and that inbound bytes do) and the SFU case intests/rtc-react-native-lifecycle.test.ts("keeps the connect phase on the SFU transport …" now feeds inbound stats before expecting the remote'smediaconnected); theconnecttimeoutpeer-snapshot shapes in both that file andtests/rtc-react-native-sfu.test.tsgained the three evidence fields. The letter's scenarios are reproduced intests/rtc-react-native-connect-evidence.test.ts. - 1.7.0 notes, two corrections (四 #11): the listener-error item said "1.5.0 rethrew"; the rethrow dates from 1.4.0, when listener isolation was introduced, so 1.4.1 carries the same crash risk. The rebuild paragraph said "a device still on a 1.6.0 binary"; 1.5.0 and 1.6.0 shipped no native changes, so that is any device on a 1.4.1, 1.5.0 or 1.6.0 binary. Both are corrected below.
1.7.0 notes
Requires an app rebuild: this release changes native code on both platforms
(Kotlin and Swift). An OTA JavaScript bundle carries the JavaScript fixes
below but not the two native ones; a device still on a 1.4.1, 1.5.0 or 1.6.0
binary (the three ship the same native code) keeps the old redelivery and
lock-screen replay behaviour until it is rebuilt. Bare React
Native applications rebuild normally; Expo applications run expo prebuild
followed by a Development Build or EAS Build. From the second global scan
(2026-09-14):
- Android: redelivery of a live call is ignored (rn-5, native). An FCM
redelivery, a second registered token, or
reportIncomingCall()for the socket copy of a pushed call no longer re-posts the ringing notification, re-arms the ring timeout, or un-answers a call the user is sitting in. See Background and locked-screen calls. - iOS: a natively retained call waits for its listener (rn-v1, native).
The call is replayed to
addIncomingCallListeneronce that listener exists — even one registered after the token or action listener — and otherwise stays available togetInitialCall()for its window, as on Android. It is never flushed by a listener of another event. leave()during a pendingjoin()aborts the join (rn-1). The tracks the permission dialog handed over are stopped, call audio is not re-latched, no socket, SFU session or stats timer is started, andjoin()rejects withRtcJoinAbortedError(code: "join_aborted") — previously it resolved with the camera and microphone left hot, telemetry ticking on a disconnected client and, on the SFU transport, an orphaned uplink. A newerjoin()on the same client supersedes an older one the same way. Catch the rejection where you calljoin()(the example does).includesCallsInRecentsreaches CallKit (rn-2).configure()forwards it; it was documented and read natively but never sent, so every call landed in the Phone app's Recents regardless.- SFU rejoin re-announces this device's tracks and prunes participants who
left while the socket was down (rn-3). Without it the device came back as a
participant with no audio or video for the rest of the meeting, and
participantCount/getRemoteStreams()kept ghosts. - A signaling reconnect starts the connect phase over on the mesh (rn-4):
mediaconnectedandconnecttimeoutfire honestly for the rebuilt links (the SFU uplink outlives the socket and keeps its phase),getConnectionDiagnostics()no longer reports the first join's media on a connection readingnew, and the incoming-call watchdog'smedia_connectedis reported per phase. The roster replay no longer duplicatesparticipantjoinedfor peers who never left; whoever left meanwhile gets aparticipantleft. - Listener errors are
listenererror, not a crash (rn-6). Since 1.4.0 (listener isolation) a throwing listener was rethrown out of band, which on a React Native release build is a fatal uncaught error — 1.4.1 has the same risk. See the stable event contract. - The React Native example shows "Connected" on
mediaconnected, handlesconnecttimeout, and catches an aborted join (ops-11).
1.6.0 notes
JavaScript-only release — no native code changed, so an OTA bundle carries all
of it. Ship it after the platform release that accepts it: a 1.6.0 client
against an older service fails every SFU publish (rtc_sfu_tracks_required).
- Presence deltas. The incoming-call client opens its signaling socket
with
?presence=delta: the service sends the online list once and then one smallpresence.deltaframe per arrival or departure, instead of the whole list to every device on every change. The SDK folds those into the list it keeps and still emitspresence { users }on each change, so nothing in the application changes; the change itself is available aspresencedelta. A meeting participant's socket now lists only that meeting's participants. - SFU wire vocabulary. The
/v1/rtc/sfu/*request and reply bodies use the platform's own field names (publish,subscribe,layers,mids,description,layer: high | medium | low). Public API paths are unchanged.
1.5.0 notes
JavaScript-only release — no native code changed, so an OTA bundle carries all of it and no app rebuild is needed (a device still on a binary older than 1.4.1 keeps needing that rebuild for the native fixes). Prompted by two accepted calls on 2026-09-11 that never reached media, and left no trace of why.
mediaconnectedis the "call connected" signal.{ participantId, elapsedMs, first }, once per peer, the first time its connection readsconnectedor its first remote track arrives. (Corrected in 1.7.1: the track condition fired before any data flowed; it now needs the transport up and inbound data counted. See the 1.7.1 notes.)connectedwas — and is — the signaling room join; the docs that taught it as "call connected" are corrected in this README.negotiationandicestateevents report every step of the offer/answer/ICE exchange per peer, timed fromjoin(), so a stuck join is diagnosable from the client's own events: offer created/sent/received, answer created/sent/received, remote description set, ICE gathering complete, candidates sent/received.connectTimeoutMsoption andconnecttimeoutevent. Default 30 s fromjoin()(1.7.1: from the first remote participant when that is later),0disables. Fired once, with every peer's connection, ICE and signaling state and the last negotiation phase reached. The client does not leave on its own; the budget above says when to.getConnectionDiagnostics()returns the same snapshot on demand.participantjoinednow fires for peers already in the room, markedinitial: true. The second device to arrive got no event at all for the one already there.- A dropped signaling message is announced as
signalingerror { code: "send_dropped", messageType }instead of vanishing. - The final quality sample reads the peer states as they were before
teardown, and a
failedpeer is sampled before it is removed and reported past the telemetry throttle. The last sample of every call used to readclosed(orconnectedwith zero bitrate) regardless of what happened. - Incoming-call client: attaching a media client no longer disarms the
connect watchdog. Applications attach right after
join()resolves, which is the signaling reply — the very moment the unguarded media phase begins. The watchdog armed byaccept()now stands untilmediaconnectedand, on expiry (about 70 s afteraccept(): a 60 s grace plus a 10 s margin), still emitscallexpiredand releases the call, media client and all (1.7.1: and ends it on the server — until then the release was local only). An application that never attaches a media client therefore had every answered call released at 70 s; see 1.7.2 notes. - Incoming-call client: the media connect phase is reported to the platform
by call id through the existing diagnostics path —
media_joined,media_connected{ elapsedMs },media_timeout(theconnecttimeoutpayload). Message types and timings only; never SDP or candidates.reportSignalingDiagnostics: falseturns it off with the rest. - The event table above now lists every event both clients emit. It documented 16 of them.
1.4.0 notes
- Answering in your own UI now actually answers the call on iOS. An application that
answers on its own screen rather than the CallKit banner had no way to tell CallKit,
so the banner and ringtone kept going and the Phone app recorded the answered call as
missed.
accept()now answers the system call too, andRtcNativeIncomingCalls.answerCall(callId)does it directly. On iOS this requests aCXAnswerCallActionthroughCXCallController—CXProviderhas no method that connects an incoming call. reportCallConnected()is direction-aware. It kept reporting an outgoing connect for every call, which the system ignores for an incoming call, and it cancelled the ring timeout first — so an incoming call it had not connected was left ringing with no timeout at all. It now answers an incoming call and reports the connect only for calls registered as outgoing.- Every in-app call action reaches the system call UI.
reject(),busy(),cancel()andend()now dismiss it instead of waiting for the server to echo a terminal status, which never arrives when the socket is down. - Hanging up an answered call reports
ended, notrejected. iOS called every end a decline, so an application following this README posted a reject for a finished call. Android already reported the two separately. - New
includesCallsInRecentsoption onconfigure()(iOS), and a fix forreportIncomingCall()hanging forever whenconfigure()was never called. - A finished call can no longer take the phone feature with it. An audit of every
post-call teardown path found several ways a single call could leave this device
unable to place or receive another one for the life of the process, because
startCall()refuses while any call id is still held:- Hanging up now releases the call locally even when the server call fails.
end(),reject(),busy()andcancel()released only after the request succeeded, so hanging up while offline — or after the server had already swept the call, which answers 409 — stranded the id permanently. Local liveness never depends on a network round trip now. - An outgoing call that nobody answers releases itself. It was the one path with no self-release at all: the server sweeps the call to missed without telling the caller, so calling someone who does not pick up disabled calling until restart.
- An accepted call whose media never comes up releases itself. Accepting used to
clear the only watchdog, so a failed
join()held the call slot for good. The watchdog now disarms when a media client is actually attached, and re-arms if it detaches. An application that never attaches a media client therefore had every answered call released at 70 s; see 1.7.2 notes. - Your other signed-in devices are no longer locked out when you answer a call on
one of them. They took the call lock from the
acceptedbroadcast and cleared their own watchdog, but are never told the call ended. - A failed
join()rolls back, andleave()always runs to completion. A camera or microphone left open, or the call audio mode left latched, is what makes the next call fail with a black frame or no audio; one throwing teardown step used to strand every step after it, including stopping the tracks. - An application event listener that throws no longer aborts an SDK state
transition. The error still surfaces, out of band (superseded: it is now
reported as
listenererror, see the unreleased notes — the rethrow was fatal on release builds).
- Hanging up now releases the call locally even when the server call fails.
- Requires an app rebuild: this release changes native code.
1.4.1 notes
- Android: a finished call is no longer replayed as a live incoming call. This is the 1.3.26 iOS
