diff --git a/calls/react-native/events.mdx b/calls/react-native/events.mdx index af039027a..49ce91bf5 100644 --- a/calls/react-native/events.mdx +++ b/calls/react-native/events.mdx @@ -103,7 +103,8 @@ CometChatCalls.addEventListener('onCameraFacingChanged', (facing) => { | Event | Payload | Description | |-------|---------|-------------| -| `onParticipantJoined` | Participant | A participant joined | +| `onParticipantConnected` | Participant | A participant's device prepared the session without joining. On the caller's prepared session this is the callee ringing | +| `onParticipantJoined` | Participant | A participant joined. On the caller's prepared session this is the callee answering: join now | | `onParticipantLeft` | Participant | A participant left | | `onParticipantAudioMuted` | Participant | A participant muted their audio | | `onParticipantAudioUnmuted` | Participant | A participant unmuted their audio | @@ -252,8 +253,81 @@ function useCallEvents(onCallEnd: () => void) { export default useCallEvents; ``` +## Events on a prepared session + +`CometChatCalls.getActiveSession()` returns the session as soon as it is **prepared** — which is what `initiateCall` does on the caller and what the Push SDK's ring does on the callee — before anyone has joined. Listening on it is what lets a caller show "Ringing…" and detect the answer without joining first. + +```ts +const session = CometChatCalls.getActiveSession(); // null when no session is prepared or joined +const me = CometChatCalls.getLoggedInUser()?.uid; + +session?.addEventListener('onParticipantConnected', p => { + if (p.uid !== me) setStatus('Ringing…'); // the callee's device got the push +}); +session?.addEventListener('onParticipantJoined', p => { + if (p.uid !== me) join(); // the callee answered +}); +``` + +| Event | On a prepared session it means | +|-------|-------------------------------| +| `onParticipantConnected` | The other device prepared the session: it is ringing there | +| `onParticipantJoined` | The other side answered | +| `onSessionTimedOut` | Nobody joined in time | +| `onConnectionClosed` | The call ended before anyone joined (declined, cancelled from another device, or the room went away) | + + +**A prepared session has no roster.** It receives `onParticipantLeft` but never `onParticipantJoined` for participants who were already there, because there are none. `onParticipantConnected` is the only caller-side signal that the callee's device has been reached. + + +--- + +## Incoming call events + +On React Native these come from the Push Notifications SDK, not the Calls SDK. They report a call for the logged-in user starting and ending, whether it arrived by push while the app was killed or while it was running. + +```ts +import { CometChatVoipNotifications } from '@cometchat/push-notifications-voip-react-native'; + +const offRinging = CometChatVoipNotifications.onCallRinging(call => { /* ringing */ }); +const offAccepted = CometChatVoipNotifications.onCallAccepted(call => { /* answered here */ }); +const offEnded = CometChatVoipNotifications.onCallEnded(e => { /* e.reason */ }); +const offRingEnded = CometChatVoipNotifications.onRingEnded(e => { /* the ring stopped */ }); +``` + +| Event | Payload | Description | +|-------|---------|-------------| +| `onCallRinging` | `PNCallInfo` | A call for the logged-in user is ringing | +| `onCallAccepted` | `PNCallInfo` | The user answered; open your call screen | +| `onCallEnded` | `PNCallEndEvent` | The call ended. Caller: `rejected`, `busy`, `unanswered`, `cancelled`. Callee still ringing: `cancelled`, `ongoing` (answered elsewhere), `rejected` / `busy` (declined here), `unanswered` | +| `onRingEnded` | `PNRingEndEvent` | The ring stopped, whatever happened to the call | + +Each returns an unsubscribe function. Subscribing late is safe: a call answered before your component mounted is held and delivered to the first subscriber. See [Ringing](/calls/react-native/ringing). + +--- + +## Login events + +`CometChatCalls.addLoginListener` reports the Calls SDK login state. The Push SDK uses it to keep the identity its ring gates read in step with the logged-in user; your app can use it to gate call UI. + +```ts +CometChatCalls.addLoginListener('my-app', { + onLoginSuccess: user => console.log('logged in as', user.uid), + onLogoutSuccess: () => console.log('logged out'), +}); + +CometChatCalls.removeLoginListener('my-app'); +``` + + +Listener ids are keys: registering the same id twice replaces the earlier listener. Remove it when the screen goes away. The `User` the Calls SDK hands back is a plain object with a `uid` field, not a class with `getUid()`. + + +--- + ## Related Documentation +- [Ringing](/calls/react-native/ringing) - Start, answer and end calls - [Actions](/calls/react-native/actions) - Control the call programmatically - [Session Settings](/calls/react-native/session-settings) - Configure event listeners - [Participant Management](/calls/react-native/participant-management) - Manage participants diff --git a/calls/react-native/migrating-from-legacy-ringing.mdx b/calls/react-native/migrating-from-legacy-ringing.mdx new file mode 100644 index 000000000..03a7353f4 --- /dev/null +++ b/calls/react-native/migrating-from-legacy-ringing.mdx @@ -0,0 +1,295 @@ +--- +title: "Migrating from Legacy Ringing" +sidebarTitle: "Migrating from Legacy Ringing" +sdk_version: "5.x" +description: "Move a React Native app from Chat SDK call signalling and Push Notifications SDK 1.0 VoIP to ringing with the Calls SDK 5.1 and the Push Notifications SDK 2.0: what to add, what to delete, and how each method maps." +--- + +Legacy ringing signalled calls through the Chat SDK: `CometChat.initiateCall` created the call, a `CallListener` on the WebSocket told the callee, `acceptCall` and `rejectCall` answered, and the app rang the device itself — usually with `react-native-callkeep` and `react-native-voip-push-notification` wired up by hand. Ringing now runs on the Calls SDK and the Push Notifications SDK's VoIP package. The Chat SDK's calling methods are deprecated. + +This page is the diff. For the full description of the current flow see [Ringing](/calls/react-native/ringing); for the push side see [Push ringing](/calls/react-native/voip-calling). + +## Why it changed + +| Legacy | Now | +|--------|-----| +| Incoming calls arrived on the Chat WebSocket, so a killed or backgrounded app needed a separate push + CallKeep integration to ring | One path: the push rings in every app state, with the app killed included | +| Four pieces of app code rang the device (listener, overlay, ringtone, busy check) | The Push SDK rings, draws the incoming screen, handles busy, timeout and missed calls | +| `acceptCall` / `rejectCall` / `endCall` were HTTP calls your app had to sequence with the session | Answer = join. Decline, timeout and busy are sent by the Push SDK through a durable queue | +| Caller only knew "accepted" or "rejected" | Caller sees "Ringing…" (the callee's device was reached), "answered", and the reason a call ended (`rejected`, `busy`, `unanswered`, `cancelled`) | +| Chat SDK required for calling | Chat SDK needed for chat only; a calls-only app works | + +## Before and after + +```mermaid +sequenceDiagram + participant A as Caller + participant Chat as Chat SDK (WebSocket) + participant B as Callee + rect rgb(240,240,240) + Note over A,B: Legacy ringing + A->>Chat: CometChat.initiateCall(call) + Chat-->>B: CallListener.onIncomingCallReceived + B->>B: App shows its own incoming UI, CallKeep, ringtone + B->>Chat: CometChat.acceptCall(sessionId) + Chat-->>A: CallListener.onOutgoingCallAccepted + A->>A: start session + B->>B: start session + A->>Chat: CometChat.endCall(sessionId) + Chat-->>B: onCallEndedMessageReceived + end +``` + +```mermaid +sequenceDiagram + participant A as Caller + participant BE as Backend + participant P as Callee · Push SDK + participant C as Callee · Calls SDK + rect rgb(232,244,236) + Note over A,C: Ringing now + A->>BE: CometChatCalls.initiateCall(uid, options) + BE-->>P: push (initiated) + P->>P: rings, shows incoming screen / CallKit + P->>C: connect(sessionId) + C-->>A: onParticipantConnected ("Ringing…") + P->>C: Answer → onCallAccepted → mount the call component + C-->>A: onParticipantJoined → caller joins + A->>A: session.leave() to hang up + end +``` + +## Step 1: Update dependencies + +```bash +npm install @cometchat/calls-sdk-react-native@^5.1.0 \ + @cometchat/push-notifications-react-native@^2.0.0 \ + @cometchat/push-notifications-voip-react-native@^2.0.0 +cd ios && pod install +``` + +- `minSdkVersion` 26 or higher on Android, iOS 13+. +- Keep `@react-native-firebase/messaging` and `google-services.json`. +- Remove `react-native-callkeep` and `react-native-voip-push-notification` if nothing else uses them; the VoIP package registers with PushKit and CallKit itself. + + +`callMode` defaults to `'AUTO'`, which is what this page assumes. Installing the VoIP package is the opt-in: the core package detects it and installs the ringer. + + +## Step 2: Replace the push configuration + + + +```ts +await CometChatPushNotifications.init({ + appId, region, + notificationSmallIcon: 'ic_notification', + voIPEnabled: true, +}); + +CometChatPushNotifications.setOnCallAnsweredHandler(async callInfo => { + await CometChat.acceptCall(callInfo.sessionId); + navigate('Call', { sessionId: callInfo.sessionId }); +}); +``` + + +```ts +await CometChatPushNotifications.init({ + appId, region, + fcmProviderId: FCM_PROVIDER_ID, + apnsProviderId: APNS_PROVIDER_ID, + notificationSmallIcon: 'ic_notification', + callKitName: 'Acme', +}); + +await CometChatVoipNotifications.init(); // no config: the core already has it + +CometChatVoipNotifications.onCallAccepted(call => { + navigate('Call', { sessionId: call.sessionId }); // no acceptCall to make +}); +``` + + + + +A **calls-only** app passes its configuration to `CometChatVoipNotifications.init(config)` instead and never calls the core's `init`. An app that does both must not pass a config to the VoIP `init()` — that re-runs the core initialisation and drops every chat option. + + +If your Chat and Calls SDKs point at a staging or dedicated deployment, pass the same host as `clientHost`. Otherwise the push token registers on the default host and no push ever arrives. + +## Step 3: Make `init()` the first thing you await + +```ts +// index.js / App.tsx +await CometChatVoipNotifications.init(); // FIRST +await CometChatUIKit.init(uiKitSettings); // then everything else +await restoreSessionAndLogin(); +``` + +A cold launch caused by a call push has a ring window measured from the caller's side. Anything awaited before `init()` — a stored-credentials read, a remote config fetch, a login — spends that window, and the call is lost before the ring is wired up. + +## Step 4: Register the background task and delete the token code + +```js index.js +import { registerBackgroundCallTask } from '@cometchat/push-notifications-voip-react-native'; +registerBackgroundCallTask(); +``` + +The Push SDK registers the token on login and unregisters it on logout by itself. Remove: + +- `messaging().getToken()` + `registerPushToken(...)` after login +- `VoipPushNotification.addEventListener('register', …)` and the APNs VoIP token plumbing +- `unregisterPushToken(...)` before logout — replaced by `CometChatPushNotifications.unregister()`, which must run **before** `logout()` + +## Step 5: Delete the app-side ringing + +Everything in this list is now done by the Push SDK. Remove it, or it will fight the SDK's ring (two ringtones, two screens, a busy reject sent on top of the SDK's). + +- `CometChat.addCallListener(...)` and its `onIncomingCallReceived` handler +- The incoming-call modal, overlay or screen you rendered from it +- Your own ringtone or sound player for incoming calls +- The "already in a call → reject with busy" check +- `RNCallKeep.setup`, `displayIncomingCall`, `answerCall`/`endCall` event wiring, `VoipPushNotification` handlers, and the `READ_PHONE_STATE` / `MANAGE_OWN_CALLS` / `ANSWER_PHONE_CALLS` permission chain +- Any foreground check around call pushes + +Keep: `RECORD_AUDIO`, `CAMERA` and `POST_NOTIFICATIONS` in the manifest, and `NSMicrophoneUsageDescription`, `NSCameraUsageDescription` and the `voip` background mode in `Info.plist`. The SDKs request them but do not declare them. + +## Step 6: Rewrite the call code + +| Legacy | Now | Notes | +|--------|-----|-------| +| `CometChat.initiateCall(new CometChat.Call(uid, type, RECEIVER_TYPE_USER))` | `CometChatCalls.initiateCall(uid, { type, receiverType, sessionSettings })` | Set `sessionSettings.sessionType`, or an audio call opens the camera | +| `CometChat.initiateCall(call, timeoutSeconds)` | No client timeout. The ring window is 45 s on both sides (or the push's `ringTimeout`) | Run your own 45 s timer on the caller if you want the screen to close on its own | +| `CallListener.onIncomingCallReceived(call)` | Nothing to write; the Push SDK rings. For your own state use `CometChatVoipNotifications.onCallRinging` | Fires whether the call came by push on a killed process or on a live one | +| `CallListener.onOutgoingCallAccepted(call)` | `session.addEventListener('onParticipantJoined', …)` on the session `initiateCall` prepared, via `CometChatCalls.getActiveSession()` | New in-between event: `onParticipantConnected` = the callee's device is ringing | +| `CallListener.onOutgoingCallRejected(call)` | `CometChatVoipNotifications.onCallEnded` with reason `rejected` or `busy` | | +| `CallListener.onIncomingCallCancelled(call)` | `onCallEnded` with `cancelled`; the Push SDK has already stopped the ring | | +| `CallListener.onCallEndedMessageReceived(call)` | `onSessionLeft` / `onConnectionClosed` on the joined session; `onCallEnded` with `unanswered` while ringing | | +| `CometChat.acceptCall(sessionId)` then start the session | `onCallAccepted` fires; mount the call component | The Push SDK asked for mic/camera before opening your screen | +| `CometChat.rejectCall(sessionId, CALL_STATUS_REJECTED)` | Push SDK Decline button. Own incoming screen: `CometChatVoipNotifications.declineCall(sessionId)` | Sends `busy` automatically when in another call | +| `CometChat.rejectCall(sessionId, CALL_STATUS_BUSY)` | Nothing; the Push SDK decides busy | | +| `CometChat.rejectCall(sessionId, CALL_STATUS_CANCELLED)` | `CometChatCalls.endCall(sessionId, 'cancelled')` | | +| `CometChatCalls.startSession(...)` / `joinSession(...)` | `CometChatCalls.connect(sessionId)` then `` | The session is already prepared, so the join is near-instant | +| `session.leaveSession()` + `CometChat.endCall(sessionId)` | `session.leave()` | No status to send on a hang-up; the room tells the other side and the backend logs it | +| `CometChat.getActiveCall()` / `clearActiveCall()` | `CometChatCalls.getActiveSession()` (null when no session is prepared or joined) | | +| `CometChat.logout()` | `CometChatCalls.logout()` as well (the UI Kit's `CometChatUIKit.logout` does both), after `CometChatPushNotifications.unregister()` | Otherwise the Calls SDK stays signed in as the old user and a ring is not stopped | + +### Caller screen, before and after + + + +```ts +CometChat.addCallListener(LISTENER_ID, new CometChat.CallListener({ + onOutgoingCallAccepted: call => startSession(call.getSessionId()), + onOutgoingCallRejected: () => close('rejected'), + onIncomingCallCancelled: () => close('cancelled'), +})); + +const call = await CometChat.initiateCall( + new CometChat.Call(uid, CometChat.CALL_TYPE.AUDIO, CometChat.RECEIVER_TYPE.USER), +); +setStatus('Calling…'); +``` + + +```ts +const result = await CometChatCalls.initiateCall(uid, { + type: 'audio', + receiverType: 'user', + sessionSettings: { sessionType: 'voice' }, +}); +if (!result.isSuccess) return showError(result.error); + +const session = CometChatCalls.getActiveSession(); +const me = CometChatCalls.getLoggedInUser()?.uid; +setStatus('Calling…'); + +session?.addEventListener('onParticipantConnected', p => { + if (p.uid !== me) setStatus('Ringing…'); // new: the callee's device was reached +}); +session?.addEventListener('onParticipantJoined', p => { + if (p.uid !== me) joinCall(result.value.sessionId); +}); + +CometChatVoipNotifications.onCallEnded(e => close(e.reason)); // rejected / busy / unanswered +``` + + + +### Callee screen, before and after + + + +```ts +CometChat.addCallListener(LISTENER_ID, new CometChat.CallListener({ + onIncomingCallReceived: async call => { + if (inACall) return CometChat.rejectCall(call.getSessionId(), CometChat.CALL_STATUS.BUSY); + playRingtone(); + RNCallKeep.displayIncomingCall(call.getSessionId(), call.getSender().getName()); + showIncomingModal(call); + }, + onIncomingCallCancelled: () => { stopRingtone(); hideIncomingModal(); }, +})); + +async function onAnswerPressed(call) { + stopRingtone(); + await CometChat.acceptCall(call.getSessionId()); + startSession(call.getSessionId()); +} +``` + + +```ts +// Nothing rings here any more: the Push SDK does, in every app state. +CometChatVoipNotifications.onCallAccepted(call => { + navigate('Call', { sessionId: call.sessionId }); +}); +``` + + + +## Step 7: Change the logout + + + +```ts +await CometChat.logout(); +``` + + +```ts +await CometChatPushNotifications.unregister(); // needs the credential; must come first +CometChatVoipNotifications.clearIdentity(); +await CometChatUIKit.logout(); // logs out Chat and Calls +``` + + + +`unregister()` after `logout()` cannot authenticate the delete, so the token stays registered and the device goes on receiving the departed user's call pushes. Logging out of the Chat SDK alone leaves the Calls SDK signed in, so a ring keeps playing for the old user. + +## If you drew your own incoming-call screen + +You can keep it. Subscribe to `onCallRinging` instead of `CallListener.onIncomingCallReceived`, close it from `onRingEnded`, and answer and decline through `CometChatVoipNotifications.answerCall` / `declineCall`. Tell the SDK your screen is up with `notifyCallScreenOpen(sessionId)` so it does not also show its own. See [Your own incoming-call screen](/calls/react-native/voip-calling#your-own-incoming-call-screen). + +This replaces the in-app screen only. A push that arrives while your app is killed still rings with the SDK's native screen on Android and CallKit on iOS — neither can be drawn from JavaScript that is not running. + +## What users will notice + +- Calls now ring with the app killed, and from the lock screen. +- The caller sees "Ringing…" once the callee's device has actually been reached, and a reason when a call ends. +- A second call rings over a live one instead of being silently rejected. +- Missed calls appear as their own notifications. + +## Checklist + +- [ ] Calls SDK 5.1, Push SDK 2.0 core **and** VoIP packages installed; CallKeep and VoIP-push libraries removed +- [ ] `CometChatVoipNotifications.init()` is the first `await` on startup, and the chat app passes it **no** config +- [ ] `registerBackgroundCallTask()` called at module scope in `index.js` +- [ ] All `CometChat.addCallListener` call handling, app-side ringtones and busy checks deleted +- [ ] Caller listens on the prepared session for `onParticipantConnected` / `onParticipantJoined` +- [ ] `onCallAccepted` subscribed before the first call can arrive +- [ ] Hang-up uses `session.leave()`, cancel uses `endCall(sessionId, 'cancelled')` +- [ ] Logout unregisters push first, then clears the identity, then logs out +- [ ] `ensureRingReady()` checked after sign-in; `NSMicrophoneUsageDescription` present +- [ ] Staging or dedicated host passed to the Push SDK as well diff --git a/calls/react-native/migration-guide-v5.mdx b/calls/react-native/migration-guide-v5.mdx index dbc226733..ac9e31556 100644 --- a/calls/react-native/migration-guide-v5.mdx +++ b/calls/react-native/migration-guide-v5.mdx @@ -228,7 +228,7 @@ CometChatCalls.unmuteAudio(); // unmute | v4 Method | v5 Method | |---|---| -| `CometChatCalls.endSession()` | `CometChatCalls.leaveSession()` | +| `CometChatCalls.endSession()` | `session.leave()` | | `CometChatCalls.switchCamera()` | `CometChatCalls.switchCamera()` | | `CometChatCalls.muteAudio(true)` | `CometChatCalls.muteAudio()` | | `CometChatCalls.muteAudio(false)` | `CometChatCalls.unmuteAudio()` | @@ -355,6 +355,21 @@ CometChatCalls.addEventListener("onCallLayoutChanged", (layoutType) => { }); | `CometChatCalls.CallSettingsBuilder` | Pass plain `SessionSettings` object to `` | | `CometChatCalls.OngoingCallListener` | `CometChatCalls.addEventListener()` | | `CometChatCalls.startSession()` | Render `` (no `joinSession` in React Native) | -| `CometChatCalls.endSession()` | `CometChatCalls.leaveSession()` | +| `CometChatCalls.endSession()` | `session.leave()` | | `RTCRecordingInfo` | `onRecordingStarted` / `onRecordingStopped` events | | `CallSwitchRequestInfo` | *Removed (no replacement)* | + +--- + +## 5.0 to 5.1 + +5.1 moves ringing onto the Calls SDK and the Push Notifications SDK, and deprecates two more things: + +| Deprecated in 5.1 | Replacement | +|-------------------|-------------| +| `CometChatCalls.leaveSession()` | `session.leave()` | +| Chat SDK calling: `CometChat.initiateCall`, `acceptCall`, `rejectCall`, `endCall`, `getActiveCall`, `clearActiveCall`, `addCallListener`, `CallListener` | `CometChatCalls.initiateCall`, `endCall(sessionId, status)`, the Push Notifications SDK's VoIP package | + +New in 5.1: `CometChatCalls.initiateCall`, `endCall(sessionId, status)`, `connect`, `getActiveSession`, `addLoginListener` / `LoginListener`, and `onParticipantConnected` on a prepared session. + +The step-by-step move from Chat SDK signalling is on [Migrating from Legacy Ringing](/calls/react-native/migrating-from-legacy-ringing). diff --git a/calls/react-native/ringing.mdx b/calls/react-native/ringing.mdx index 14f08d44c..3853f99aa 100644 --- a/calls/react-native/ringing.mdx +++ b/calls/react-native/ringing.mdx @@ -2,456 +2,385 @@ title: "Ringing" sidebarTitle: "Ringing" sdk_version: "5.x" -description: "Configure CometChat Calls SDK v5 ringing on React Native for incoming calls, outgoing calls, call alerts, and accept or reject flows." +description: "Ring users on React Native with the CometChat Calls SDK and the Push Notifications SDK: start a call, show it ringing, answer, decline, cancel and end it, with the app in any state." --- - - **Quick Reference for AI Agents & Developers** +Ringing lets one user call another and lets the callee answer or decline, whether the callee's app is in the foreground, in the background or not running at all. - ```tsx theme={null} - import { CometChat } from '@cometchat/chat-sdk-react-native'; - import { CometChatCalls } from '@cometchat/calls-sdk-react-native'; +Two SDKs do the work: - // Chat SDK = signaling. Calls SDK = media. - const call = new CometChat.Call(uid, CometChat.CALL_TYPE.VIDEO, CometChat.RECEIVER_TYPE.USER); - await CometChat.initiateCall(call); // ring them +| SDK | Owns | +|-----|------| +| **Calls SDK** (`@cometchat/calls-sdk-react-native` 5.1+) | Creating the call, the media session, answering, declining, cancelling, ending, and the call events your code listens to | +| **Push Notifications SDK, VoIP package** (`@cometchat/push-notifications-voip-react-native` 2.0+) | Ringing the callee's device: the call notification, the full-screen incoming-call screen on Android, CallKit on iOS, the ringtone, the ring timeout, missed calls and busy handling | - CometChat.addCallListener('LISTENER_ID', new CometChat.CallListener({ - onIncomingCallReceived: (call) => { /* show your accept/reject UI */ }, - })); + +**Coming from the earlier ringing flow?** The Chat SDK's calling methods (`CometChat.initiateCall`, `acceptCall`, `rejectCall`, `endCall`, `CallListener`) are deprecated and are replaced by what this page describes. See [Migrating from legacy ringing](/calls/react-native/migrating-from-legacy-ringing) for a method-by-method mapping. + - const accepted = await CometChat.acceptCall(sessionId); // on accept - const { token } = await CometChatCalls.generateToken(accepted.getSessionId()); - // → render - ``` +## How ringing works + +The caller creates the call with the Calls SDK. The CometChat backend sends a push to the callee. The Push SDK rings the callee's device, and when the callee answers, your call screen joins the session. The caller's app learns that the callee is ringing and has answered from the Calls SDK, without joining the session first. + +```mermaid +sequenceDiagram + participant A as Caller app + participant BE as CometChat backend + participant P as Callee · Push SDK + participant C as Callee · Calls SDK + + A->>BE: CometChatCalls.initiateCall(receiverId, options) + Note over A: Session prepared, not joined ("Calling…") + BE-->>P: Push (callAction = initiated) + P->>P: Ring: notification, full-screen screen / CallKit, ringtone + P->>C: connect(sessionId) — pre-warms the session + C-->>A: onParticipantConnected → "Ringing…" + P->>P: Answer → microphone/camera prompt → onCallAccepted + C->>C: Mount CometChatCalls.Component with the call token + C-->>A: onParticipantJoined → caller joins too + Note over A,C: In call. Hang up with session.leave() +``` - * **Two teardowns**: `CometChat.removeCallListener(id)` **and** the Calls-SDK listener + `leaveSession()` - * Unanswered calls cancel after **45s** by default — `initiateCall(call, timeoutInSeconds)` to change it - * ⚠️ This rings the callee **only while their app is open** — waking a closed app needs [VoIP Calling](/calls/react-native/voip-calling) - * Testing ringing needs **two live clients** on separate devices - +What each side sees: -Implement incoming and outgoing call notifications using the CometChat Chat SDK's calling features. The Calls SDK handles the actual call session, while the Chat SDK manages call signaling. +| Stage | Caller | Callee | +|-------|--------|--------| +| Call created | `initiateCall` resolves; show "Calling…" | Push arrives; the Push SDK rings | +| Callee's device reached | `onParticipantConnected` → show "Ringing…" | Ringing | +| Callee answers | `onParticipantJoined` → mount the call component | `onCallAccepted` → mount the call component | +| Callee declines / is busy / does not answer | `onCallEnded` with reason `rejected` / `busy` / `unanswered` | Ring stops; missed call if unanswered | +| Caller cancels | — (you ended it) | Ring stops; missed call | +| Either side hangs up | `onParticipantLeft` / `onSessionLeft` | same | + +## Prerequisites + +- [Calls SDK set up](/calls/react-native/setup) and the user [logged in](/calls/react-native/authentication). +- Push notifications configured with the Push Notifications SDK 2.0. The whole push side of ringing is on the [Push ringing](/calls/react-native/voip-calling) page; this page assumes it is done. +- Microphone and camera permissions declared: `RECORD_AUDIO` and `CAMERA` in `AndroidManifest.xml`, `NSMicrophoneUsageDescription` and `NSCameraUsageDescription` in `Info.plist`. The SDKs request them at runtime but do not declare them. +- `minSdkVersion 26` on Android, iOS 13+. + +```json package.json +{ + "dependencies": { + "@cometchat/calls-sdk-react-native": "^5.1.0", + "@cometchat/push-notifications-react-native": "^2.0.0", + "@cometchat/push-notifications-voip-react-native": "^2.0.0" + } +} +``` + + +Send call pushes at **high** FCM priority. At normal priority the call still rings, but Android refuses the Push SDK's foreground service, so the process can be killed between the ring and the answer. + -Ringing functionality requires the CometChat Chat SDK (`@cometchat/chat-sdk-react-native`) for call signaling. The Calls SDK is used for the actual call session. +**A missing `NSMicrophoneUsageDescription` crashes the app mid-ring on iOS.** The system terminates the process the moment the SDK touches the microphone, which looks exactly like the ring expiring on its own. Declare both usage strings before testing. -## Prerequisites +## Start a call -1. CometChat Chat SDK integrated -2. CometChat Calls SDK integrated -3. Push notifications configured (for background calls) +`CometChatCalls.initiateCall` creates the call on the backend, which rings the callee, and prepares the caller's media session. The caller does **not** join yet. -## Initiate a Call +```ts +import { CometChatCalls } from '@cometchat/calls-sdk-react-native'; -Use the Chat SDK to initiate a call: +const result = await CometChatCalls.initiateCall(receiverUid, { + type: 'audio', // or 'video' + receiverType: 'user', + sessionSettings: { sessionType: 'voice' }, // 'video' for a video call +}); -```tsx -import { CometChat } from '@cometchat/chat-sdk-react-native'; - -async function initiateCall(receiverId: string, callType: string, receiverType: string) { - const call = new CometChat.Call(receiverId, callType, receiverType); - - try { - const outgoingCall = await CometChat.initiateCall(call); - console.log('Call initiated:', outgoingCall); - return outgoingCall; - } catch (error) { - console.error('Error initiating call:', error); - throw error; - } +if (result.isSuccess) { + const call = result.value; + console.log(`Calling ${call.receiver}, session ${call.sessionId}`); + showOutgoingScreen(call); + listenOnPreparedSession(call); +} else { + console.error('Could not start the call:', result.error); } +``` -// Initiate a video call to a user -initiateCall('user_uid', CometChat.CALL_TYPE.VIDEO, CometChat.RECEIVER_TYPE.USER); +| Parameter | Description | +|-----------|-------------| +| `receiverUid` | UID of the user to call | +| `options.type` | `'audio'` or `'video'` | +| `options.receiverType` | `'user'` | +| `options.sessionSettings` | The settings the caller's session is prepared with. See the warning below. | -// Initiate an audio call to a group -initiateCall('group_guid', CometChat.CALL_TYPE.AUDIO, CometChat.RECEIVER_TYPE.GROUP); -``` +`InitiatedCall` gives you `sessionId`, `messageId` (the id of the call message in the conversation), `sender`, `receiver`, `receiverType`, `type`, `status`, `initiatedAt`, `conversationId` and `session` — the session already being prepared for this call. -By default, an unanswered call automatically cancels after 45 seconds. You can customize this duration by passing an optional `timeout` parameter (in seconds) as the second argument to `initiateCall()`. + +**Always set the session type in `sessionSettings`.** The Calls SDK freezes the session settings when it prepares the session and its default is video, so an audio call started without `sessionType: 'voice'` opens the caller's camera. The settings you pass here are the ones the caller later joins with. + -```tsx -import { CometChat } from '@cometchat/chat-sdk-react-native'; + +`initiateCall` resolves while the prepare is still in flight (`session.prepareStatus === 'pending'`). Await `session.settled` or read `session.prepareStatus` later for the outcome. **A failed prepare does not fail the call** — by then the call exists on the server and the callee is already ringing — and mounting the call component still works, falling back to a normal direct join. + -// Initiate a video call to a user with a 60-second timeout -const userCall = new CometChat.Call('user_uid', CometChat.CALL_TYPE.VIDEO, CometChat.RECEIVER_TYPE.USER); + +There is no client-side ring timeout. Both sides stop at the ring window the backend sends in the push (45 seconds by default). A caller that wants its own screen to time out can end the call as `unanswered` after the same window, as shown under [Ring timeout](#ring-timeout). + -CometChat.initiateCall(userCall, 60).then( - (outgoingCall) => { - console.log('Call initiated with 60s timeout:', outgoingCall); - }, - (error) => { - console.error('Call initiation failed:', error); - } -); +## Show "Ringing…" and detect the answer -// Initiate an audio call to a group with a 60-second timeout -const groupCall = new CometChat.Call('group_guid', CometChat.CALL_TYPE.AUDIO, CometChat.RECEIVER_TYPE.GROUP); +The caller stays on the session that `initiateCall` prepared and listens to it. Do not join yet: joining only when the callee has joined keeps the caller's media off until there is someone to talk to, and it is what makes the two events below arrive. -CometChat.initiateCall(groupCall, 60).then( - (outgoingCall) => { - console.log('Call initiated with 60s timeout:', outgoingCall); - }, - (error) => { - console.error('Call initiation failed:', error); - } -); +```ts +function listenOnPreparedSession(call) { + const session = CometChatCalls.getActiveSession(); + if (!session) return; + const me = CometChatCalls.getLoggedInUser()?.uid; + + const offConnected = session.addEventListener('onParticipantConnected', participant => { + // The callee's device received the push and is ringing. + if (participant.uid === me) return; + setStatus('Ringing…'); + }); + + const offJoined = session.addEventListener('onParticipantJoined', participant => { + // The callee answered: now the caller joins. + if (participant.uid === me) return; + joinCall(call.sessionId); + }); + + // The prepared session closed before anyone joined: the call is over. + const offClosed = session.addEventListener('onConnectionClosed', closeOutgoingScreen); + const offTimedOut = session.addEventListener('onSessionTimedOut', closeOutgoingScreen); + + return () => [offConnected, offJoined, offClosed, offTimedOut].forEach(off => off()); +} ``` -| Parameter | Type | Description | -| ---------- | -------- | --------------------------------------------------------------------------------------------------------------- | -| `call` | `Call` | The call object created with receiver ID, call type, and receiver type. | -| `timeout` | `number` | Optional. The ringing duration in seconds before an unanswered call is automatically cancelled. Default: `45`. | +| Event | On the prepared session it means | +|-------|----------------------------------| +| `onParticipantConnected(callee)` | The callee's phone got the push and the Push SDK pre-warmed the session: it is ringing there | +| `onParticipantJoined(callee)` | The callee answered. Join now | +| `onConnectionClosed` / `onSessionTimedOut` | The call ended before anyone joined (declined, cancelled from another device, or the session died) | -## Listen for Incoming Calls +`addEventListener` returns an unsubscribe function. Call it when your screen unmounts; listeners survive the session otherwise. -Register a call listener to receive incoming calls: + +**A prepared session has no roster.** A ring-only connection receives `onParticipantLeft` but never `onParticipantJoined` for participants who were already there, because there are none. `onParticipantConnected` is the only caller-side signal that the callee's device has been reached. + -```tsx -import { CometChat } from '@cometchat/chat-sdk-react-native'; - -const listenerId = 'unique_listener_id'; - -CometChat.addCallListener( - listenerId, - new CometChat.CallListener({ - onIncomingCallReceived: (call) => { - console.log('Incoming call:', call); - // Show incoming call UI - }, - onOutgoingCallAccepted: (call) => { - console.log('Call accepted:', call); - // Start the call session - }, - onOutgoingCallRejected: (call) => { - console.log('Call rejected:', call); - // Handle rejection - }, - onIncomingCallCancelled: (call) => { - console.log('Call cancelled:', call); - // Dismiss incoming call UI - }, - onCallEndedMessageReceived: (call) => { - console.log('Call ended:', call); - // Handle call end - }, - }) -); - -// Remove listener when done -CometChat.removeCallListener(listenerId); + +**The caller only shows "Ringing…" when the call was delivered by push.** A call delivered over the websocket to an app that is already in the foreground makes no pre-join on the callee's side, so no `onParticipantConnected` ever reaches the caller and the screen stays on "Calling…". + + +## Listen for calls starting and ending + +On React Native the Push SDK — not the Calls SDK — is where an incoming call announces itself and its end. It fires even when nothing of yours was running when the push arrived, because it buffers natively and drains the buffer at `init()`. + +```ts +import { CometChatVoipNotifications } from '@cometchat/push-notifications-voip-react-native'; + +// A call for this user is ringing. The Push SDK already shows the ring; +// use this for your own state (badge, analytics, refreshing a list). +const offRinging = CometChatVoipNotifications.onCallRinging(call => { + console.log('ringing', call.sessionId, 'from', call.callerUid); +}); + +// The user answered — open your call screen. +const offAccepted = CometChatVoipNotifications.onCallAccepted(call => { + openCallScreen(call.sessionId, call.callType); +}); + +// The call ended. On the caller: "rejected", "busy", "cancelled" or "unanswered". +const offEnded = CometChatVoipNotifications.onCallEnded(event => { + if (event.sessionId === currentSessionId) closeOutgoingScreen(event.reason); +}); + +// The ring itself stopped, whatever happened to the call. +const offRingEnded = CometChatVoipNotifications.onRingEnded(event => { + dismissIncomingUi(event.sessionId); +}); + +// When the screen goes away: +[offRinging, offAccepted, offEnded, offRingEnded].forEach(off => off()); ``` -## Accept a Call +| Who | `onCallEnded` reason | +|-----|----------------------| +| Caller | `rejected` (callee declined), `busy` (callee was in another call), `unanswered` (nobody answered), `cancelled` (you cancelled from another device) | +| Callee, still ringing | `cancelled` (caller gave up), `ongoing` (answered on another device), `rejected` / `busy` (declined here), `unanswered` (ring timed out) | +| Callee, answered | Nothing; the joined session reports the end through `onSessionLeft` / `onConnectionClosed` | + + +Each `on…` method returns an unsubscribe function, and subscribing late is safe: a call that was answered before your component mounted is held and delivered to the first subscriber. + + +## Answer a call -Accept an incoming call: +The Push SDK rings and, on **Answer**, asks for the microphone (and the camera for a video call), brings your app to the front and emits `onCallAccepted`. There is no separate "accept" call to make. ```tsx -async function acceptCall(sessionId: string) { - try { - const call = await CometChat.acceptCall(sessionId); - console.log('Call accepted:', call); - // Start the call session with Calls SDK - return call; - } catch (error) { - console.error('Error accepting call:', error); - throw error; - } +function App() { + const [activeCall, setActiveCall] = useState(null); + + useEffect(() => CometChatVoipNotifications.onCallAccepted(call => { + setActiveCall(call); // call.sessionId, callerUid, callerName, callType + }), []); + + if (activeCall) return ; + return ; } ``` -## Reject a Call +`PNCallInfo` carries `sessionId`, `callerUid`, `callerName`, `callerAvatar`, `callType`, `receiverId`, `receiverType`, `conversationId`, `source` and `expiresAtMs`. -Reject an incoming call: + +**Every field of a call payload arrives from Android as a string.** `isPeerCall` is `"true"` / `"false"`, not a boolean, and `expiresAtMs` is a string of digits. Compare against strings or coerce before use. + + + +**On iOS, a programmatic answer from a cold or background launch is refused by CallKit.** The user's own tap on the CallKit screen is the only thing that can answer a call in that state, which also means automated cold-launch answer tests need a real tap. + + +## Join the session + +Both sides join the same way: mount the Calls SDK's call component with the token of the prepared session. Because the session was prepared while ringing, the join is near-instant. ```tsx -async function rejectCall(sessionId: string, status: string) { - try { - const call = await CometChat.rejectCall(sessionId, status); - console.log('Call rejected:', call); - return call; - } catch (error) { - console.error('Error rejecting call:', error); - throw error; - } +import { CometChatCalls } from '@cometchat/calls-sdk-react-native'; + +function CallScreen({ sessionId }) { + const [callToken, setCallToken] = useState(null); + + useEffect(() => { + (async () => { + // Hands back the session already prepared for this call, without minting a second token. + const session = await CometChatCalls.connect(sessionId, { sessionType: 'voice' }); + setCallToken(session.callToken); + })(); + }, [sessionId]); + + if (!callToken) return ; + return goBack()} />; } +``` + +See [Join Session](/calls/react-native/join-session) for the component, [Session Settings](/calls/react-native/session-settings) for layouts and controls, and [Events](/calls/react-native/events) for everything the joined session reports. + +## Decline a call -// Reject with status -rejectCall(sessionId, CometChat.CALL_STATUS.REJECTED); +Declining is handled by the Push SDK: the Decline button on the notification, on its full-screen incoming-call screen and on CallKit sends `endCall(sessionId, 'rejected')` through a queue that survives the process dying, and sends `'busy'` instead when the user is already in another call. The caller receives `onCallEnded` with that reason. -// Mark as busy -rejectCall(sessionId, CometChat.CALL_STATUS.BUSY); +You only write decline code when you draw your own incoming-call screen: + +```ts +CometChatVoipNotifications.declineCall(sessionId); ``` -## End a Call +Without the Push SDK's VoIP package, decline through the Calls SDK directly: -End an ongoing call: +```ts +await CometChatCalls.endCall(sessionId, 'rejected'); +``` -```tsx -async function endCall(sessionId: string) { - try { - // End the call session - CometChatCalls.leaveSession(); - - // Notify other participants via Chat SDK - const call = await CometChat.endCall(sessionId); - console.log('Call ended:', call); - return call; - } catch (error) { - console.error('Error ending call:', error); - throw error; - } + +**Android can decline from a fully killed app.** Register the background task so the decline survives the process going away: + +```ts +import { registerBackgroundCallTask } from '@cometchat/push-notifications-voip-react-native'; +registerBackgroundCallTask(); // in index.js, at module scope +``` + + +## Cancel a call + +The caller ends a call that is still ringing with `endCall` and the `cancelled` status. The callee's ring stops and a missed call is posted there. + +```ts +async function cancelOutgoingCall(sessionId: string) { + const result = await CometChatCalls.endCall(sessionId, 'cancelled'); + if (!result.isSuccess) console.error('Cancel failed:', result.error); + closeOutgoingScreen(); // close locally either way } ``` -## Start Call Session After Accept +Disable the End button after the first tap, and ignore an `onParticipantJoined` that arrives after you cancelled: the cancel wins. -After accepting a call, start the Calls SDK session: +## Ring timeout -```tsx -import { CometChatCalls } from '@cometchat/calls-sdk-react-native'; +The callee's Push SDK ends an unanswered 1:1 call as `unanswered` when its ring window closes (45 seconds unless the push carries a `ringTimeout`). To close the caller's screen at the same moment even if that signal is late, run your own timer from `initiateCall` and end the call with the same status: -async function startCallSession(call: any) { - const sessionId = call.getSessionId(); - - try { - // Generate token for the session - const { token } = await CometChatCalls.generateToken(sessionId); - - // Create session settings - const isAudioOnly = call.getType() === CometChat.CALL_TYPE.AUDIO; - - const sessionSettings = { - sessionType: isAudioOnly ? 'VOICE' : 'VIDEO', - } as const; - - // Subscribe to session events; addEventListener returns an unsubscribe function - const unsubscribe = CometChatCalls.addEventListener('onSessionLeft', () => { - console.log('Call ended'); - }); - - return { token, sessionSettings, unsubscribe }; - } catch (error) { - console.error('Error starting call session:', error); - throw error; +```ts +const timer = setTimeout(async () => { + if (!answered) { + await CometChatCalls.endCall(sessionId, 'unanswered'); + closeOutgoingScreen(); } -} +}, 45_000); + +// clear it on answer, cancel and unmount ``` -## Complete Example +## End a call -```tsx -import React, { useState, useEffect, useCallback } from 'react'; -import { View, Text, TouchableOpacity, StyleSheet, Modal } from 'react-native'; -import { CometChat } from '@cometchat/chat-sdk-react-native'; -import { CometChatCalls } from '@cometchat/calls-sdk-react-native'; +Hang up by leaving the session. The room tells the other side, which receives `onParticipantLeft` and then `onSessionLeft` or `onConnectionClosed`. No status needs to be sent and the call is logged by the backend. -interface Call { - getSessionId: () => string; - getType: () => string; - getSender: () => { getName: () => string }; +```ts +async function hangUp() { + await CometChatCalls.getActiveSession()?.leave(); + goBack(); } +``` -function CallManager({ children }: { children: React.ReactNode }) { - const [incomingCall, setIncomingCall] = useState(null); - const [activeCall, setActiveCall] = useState<{ - token: string; - sessionSettings: any; - } | null>(null); +The other participant: - useEffect(() => { - const listenerId = 'call_manager_listener'; - - CometChat.addCallListener( - listenerId, - new CometChat.CallListener({ - onIncomingCallReceived: (call: Call) => { - setIncomingCall(call); - }, - onOutgoingCallAccepted: async (call: Call) => { - await startSession(call); - }, - onOutgoingCallRejected: () => { - setActiveCall(null); - }, - onIncomingCallCancelled: () => { - setIncomingCall(null); - }, - onCallEndedMessageReceived: () => { - setActiveCall(null); - setIncomingCall(null); - }, - }) - ); - - return () => { - CometChat.removeCallListener(listenerId); - }; - }, []); - - const startSession = async (call: Call) => { - try { - const sessionId = call.getSessionId(); - const { token } = await CometChatCalls.generateToken(sessionId); - - const isAudioOnly = call.getType() === CometChat.CALL_TYPE.AUDIO; - - const sessionSettings = { - sessionType: isAudioOnly ? 'VOICE' : 'VIDEO', - } as const; - - // Clear active call when the session is left - CometChatCalls.addEventListener('onSessionLeft', () => { - setActiveCall(null); - }); - - setActiveCall({ token, sessionSettings }); - setIncomingCall(null); - } catch (error) { - console.error('Error starting session:', error); - } - }; - - const handleAccept = async () => { - if (!incomingCall) return; - - try { - const call = await CometChat.acceptCall(incomingCall.getSessionId()); - await startSession(call); - } catch (error) { - console.error('Error accepting call:', error); - } - }; - - const handleReject = async () => { - if (!incomingCall) return; - - try { - await CometChat.rejectCall( - incomingCall.getSessionId(), - CometChat.CALL_STATUS.REJECTED - ); - setIncomingCall(null); - } catch (error) { - console.error('Error rejecting call:', error); - } - }; - - const handleEndCall = async () => { - if (!activeCall) return; - - CometChatCalls.leaveSession(); - setActiveCall(null); - }; - - return ( - - {children} - - {/* Incoming Call Modal */} - - - - - {incomingCall?.getSender().getName()} - - - Incoming {incomingCall?.getType()} call - - - - Decline - - - Accept - - - - - - - {/* Active Call */} - {activeCall && ( - - - - - - )} - - ); -} +```ts +const session = CometChatCalls.getActiveSession(); +session?.addEventListener('onSessionLeft', goBack); +session?.addEventListener('onConnectionClosed', goBack); +``` -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - modalOverlay: { - flex: 1, - backgroundColor: 'rgba(0, 0, 0, 0.8)', - justifyContent: 'center', - alignItems: 'center', - }, - incomingCallCard: { - backgroundColor: '#1a1a1a', - borderRadius: 20, - padding: 30, - alignItems: 'center', - width: '80%', - }, - callerName: { - color: '#fff', - fontSize: 24, - fontWeight: '600', - marginBottom: 8, - }, - callType: { - color: '#999', - fontSize: 16, - marginBottom: 30, - }, - callActions: { - flexDirection: 'row', - gap: 20, - }, - callButton: { - paddingHorizontal: 30, - paddingVertical: 15, - borderRadius: 30, - }, - rejectButton: { - backgroundColor: '#ff4444', - }, - acceptButton: { - backgroundColor: '#22c55e', - }, - buttonText: { - color: '#fff', - fontSize: 16, - fontWeight: '600', - }, - callContainer: { - flex: 1, - backgroundColor: '#000', - }, -}); + +`leaveSession()` is deprecated in 5.1; use `session.leave()`. Leave only the session your screen owns: after answering a second call over a live one, the first screen's clean-up must not leave the new session. + + + +`leave()` takes the Calls SDK through a re-initialisation. A `connect()` issued inside that window never resolves, so wait for the leave to settle before preparing the next session — which is what the Push SDK does for you when a second call is answered over a live one. + -export default CallManager; +## Second call while in a call + +Handled by the Push SDK, the same way a phone does it. A call that arrives while the user is in another call rings over it with "Answering ends your current call". **Answer** leaves the live call, prepares the new one and emits `onCallAccepted` again for the new session — open a fresh screen for it. **Decline** sends `busy` and keeps the live call. A call that arrives while another is already ringing, or while the user's own outgoing call is ringing, is declined as `busy` without ringing. + +## Group calls + +`initiateCall` is for 1:1 calls. A group call is a session every member joins with a session id you share with them (for example in a custom message). If a group call rings through the push, declining it or letting it ring out sends nothing: the call carries on for the other members, and the decline is local to that device. + +## Logout + +Log out of the Calls SDK when the user signs out (`CometChatCalls.logout`, or `CometChatUIKit.logout` when you use the UI Kit), and unregister push **before** the session goes away: + +```ts +await CometChatPushNotifications.unregister(); // needs the credential; must come first +CometChatVoipNotifications.clearIdentity(); +await CometChatUIKit.logout(); ``` -## Related Documentation +`unregister()` deletes the token from the backend and needs the logged-in credential, so after `logout()` there is nothing left to authenticate the delete. Skipping it leaves the device receiving the departed user's call pushes. + + +The Push SDK keeps the ring identity in step with the Calls SDK's login listener: logging in writes the uid, logging out clears it. An app that logs the **Chat** SDK in and out without `CometChatCalls.login` / `logout` must call `clearIdentity()` itself, as above. + + +## Call status values + +| Status | Meaning | Sent by | +|--------|---------|---------| +| `initiated` | Created, ringing | Backend, on `initiateCall` | +| `ongoing` | Answered | Backend, when the callee joins | +| `rejected` | Callee declined | Callee's Push SDK | +| `busy` | Callee was in another call | Callee's Push SDK | +| `cancelled` | Caller ended it before an answer | Caller, `endCall` | +| `unanswered` | Nobody answered in the ring window | Callee's Push SDK (and optionally the caller's timer) | +| `ended` | Hung up after being answered | Backend, when the session ends | +| `missed` | How an unanswered or cancelled incoming call appears in call logs | Call logs | + +## Related -- [VoIP Calling](/calls/react-native/voip-calling) - Push notifications for calls -- [Join Session](/calls/react-native/join-session) - Start call sessions -- [Background Handling](/calls/react-native/background-handling) - Handle background calls +- [Push ringing](/calls/react-native/voip-calling): dependencies, `PNConfiguration`, the Firebase and PushKit setup, your own incoming-call screen, readiness checks and what happens when the app is killed. +- [Migrating from legacy ringing](/calls/react-native/migrating-from-legacy-ringing). +- [Events](/calls/react-native/events), [Actions](/calls/react-native/actions), [Call Logs](/calls/react-native/call-logs). diff --git a/calls/react-native/setup.mdx b/calls/react-native/setup.mdx index e6168a986..dba989b19 100644 --- a/calls/react-native/setup.mdx +++ b/calls/react-native/setup.mdx @@ -23,7 +23,7 @@ Use this Calls SDK directly only if you need custom call UI or advanced control. ```bash theme={null} # The SDK plus its six REQUIRED peer dependencies — versions are pinned; do not float them - npm install @cometchat/calls-sdk-react-native@5.0.5 + npm install @cometchat/calls-sdk-react-native@5.1.0 npm install @react-native-async-storage/async-storage@^2.1.2 react-native-background-timer@^2.4.1 \ react-native-performance@^5.1.2 react-native-svg@^15.12.0 react-native-url-polyfill@2.0.0 \ react-native-webrtc@124.0.7 @@ -51,15 +51,28 @@ This guide walks you through installing the CometChat Calls SDK and configuring ### Using npm ```bash -npm install @cometchat/calls-sdk-react-native@5.0.5 +npm install @cometchat/calls-sdk-react-native@5.1.0 ``` ### Using Yarn ```bash -yarn add @cometchat/calls-sdk-react-native@5.0.5 +yarn add @cometchat/calls-sdk-react-native@5.1.0 ``` + + **Ringing users?** Incoming calls ring through the Push Notifications SDK's VoIP package. Install + `@cometchat/push-notifications-react-native` and `@cometchat/push-notifications-voip-react-native` + as well, and follow [Push Ringing](/calls/react-native/voip-calling). The Calls SDK alone can start, + join and end calls, but it cannot ring a device that is not running your app. + + + + **Initialise once, with the final hosts.** The SDK keeps the hosts from its first `init()`; a later + `init()` with different hosts is ignored for the rest of the process. If you use the CometChat UI Kit, + it initialises the Calls SDK for you from `UIKitSettings`, so do not call `init()` again yourself. + + ## Install Required Dependencies The Calls SDK relies on the following packages. Install them alongside the SDK: diff --git a/calls/react-native/troubleshooting.mdx b/calls/react-native/troubleshooting.mdx index 23d7685dd..5f27c7872 100644 --- a/calls/react-native/troubleshooting.mdx +++ b/calls/react-native/troubleshooting.mdx @@ -62,3 +62,18 @@ sdk_version: "5.x" | --- | --- | --- | | PiP not working on iOS | iOS version below 15 or missing entitlement | Ensure iOS 15+ and PiP background mode is enabled | | PiP not working on Android | Missing `supportsPictureInPicture` in manifest | Add PiP activity attribute in `AndroidManifest.xml`. See [PiP](/calls/react-native/picture-in-picture) | + +--- + +## Ringing + +| Symptom | Cause | Fix | +| --- | --- | --- | +| Nothing rings in any app state | `@cometchat/push-notifications-voip-react-native` not installed, or `CometChatVoipNotifications.init()` never called | Install it and initialise it; `getDiagnostics()` reports `voip: false` when it is missing | +| Rings in the foreground only | The app awaited something before `init()`, so the cold-launch ring window was spent | Make `CometChatVoipNotifications.init()` the first `await` in your entry point | +| Caller never shows "Ringing…" | The caller joined right after `initiateCall` instead of listening on the prepared session, or the call was delivered over the websocket rather than by push | Listen for `onParticipantConnected` on `getActiveSession()` before joining | +| Chat options silently lost after adding calling | A config was passed to `CometChatVoipNotifications.init()` in an app that also calls the core `init` | Pass the config to the core only; call the VoIP `init()` with no arguments | +| Incoming calls stop after switching users | The ring identity is stale | Check `hasLoggedInUid` in diagnostics; log out through the Calls SDK or call `clearIdentity()` | +| Answering a second call never connects | The new session was prepared before the live one was released | The Push SDK handles this; if you join yourself, wait for `leave()` to settle first | +| iOS: the app dies mid-ring | `NSMicrophoneUsageDescription` is missing | Declare it; check the device crash reports before assuming a ring-timeout bug | +| Two ringtones or two screens | Legacy app-side ringing still in place | Remove the `CallListener` ring path — see [Migrating from Legacy Ringing](/calls/react-native/migrating-from-legacy-ringing) | diff --git a/calls/react-native/voip-calling.mdx b/calls/react-native/voip-calling.mdx index 24cae7ae4..c607558f9 100644 --- a/calls/react-native/voip-calling.mdx +++ b/calls/react-native/voip-calling.mdx @@ -1,348 +1,475 @@ --- -title: "VoIP Calling" -sidebarTitle: "VoIP Calling" +title: "Push Ringing" +sidebarTitle: "Push Ringing (VoIP)" sdk_version: "5.x" -description: "Set up CometChat Calls SDK v5 VoIP calling on React Native for call notifications, incoming call handling, and app wake flows." +description: "Ring incoming CometChat calls on React Native with the Push Notifications SDK 2.0: FCM and PushKit setup, configuration, answering, permissions, busy and missed calls, your own ring screen, and behaviour when the app is killed." --- - - **Quick Reference for AI Agents & Developers** +The Push Notifications SDK's VoIP package turns a CometChat call push into a ringing phone: on Android a call-style notification with Answer and Decline, a full-screen screen over the lock screen and the ringtone; on iOS a CallKit call. It works with the app in the foreground, in the background or not running at all. + +This page covers the push side of ringing. Starting, answering and ending calls with the Calls SDK is on the [Ringing](/calls/react-native/ringing) page. + +```mermaid +flowchart LR + FCM["FCM data push (Android)
PushKit (iOS)"] --> H["Push SDK native layer"] + H --> D{Push SDK decides} + D -->|ring| R["Notification + full-screen screen / CallKit"] + D -->|in another call| W[Rings over the live call] + D -->|another call ringing| B[Declined as busy] + R -->|Answer| P[Mic / camera prompt] + P --> A["onCallAccepted → your call screen"] + R -->|Decline| Q["endCall(rejected), queued"] + R -->|45 s| T["endCall(unanswered) + missed call"] +``` - ```tsx theme={null} - import { Platform } from 'react-native'; - import { CometChatNotifications } from '@cometchat/chat-sdk-react-native'; +## Prerequisites - const { PushPlatforms } = CometChatNotifications; +- [Calls SDK 5.1 set up](/calls/react-native/setup) and the user logged in. +- **Android**: a Firebase project with your app, `google-services.json` in `android/app/`, and `@react-native-firebase/app` + `@react-native-firebase/messaging` installed. +- **iOS**: a VoIP Services certificate uploaded to the CometChat dashboard, and the **Push Notifications** capability plus the **Voice over IP** background mode enabled in Xcode. +- Push notifications enabled in the CometChat dashboard with an **FCM** provider (Android) and an **APNs** provider (iOS), and those providers' ids. +- `minSdkVersion 26` on Android, iOS 13+. - // Register AFTER login. iOS VoIP is its OWN platform, distinct from ordinary iOS push. - // providerId is the Provider ID from Dashboard → Notifications. - await CometChatNotifications.registerPushToken( - token, - Platform.OS === 'ios' ? PushPlatforms.APNS_REACT_NATIVE_VOIP - : PushPlatforms.FCM_REACT_NATIVE_ANDROID, - PROVIDER_ID, - ); +## Step 1: Add the dependencies + +```bash +npm install @cometchat/push-notifications-react-native \ + @cometchat/push-notifications-voip-react-native \ + @cometchat/calls-sdk-react-native +cd ios && pod install +``` - await CometChatNotifications.unregisterPushToken(); // on logout - ``` - - * **Not code-only.** iOS needs a VoIP `.p12` certificate + a native PushKit/CallKit module; Android needs Firebase + a self-managed `ConnectionService` - * On iOS you **must** call `reportNewIncomingCall` for every VoIP push — the OS terminates an app that does not - * Skipping the logout unregister leaves the next user on that device receiving the previous user's calls - * **Prerequisite** → [Ringing](/calls/react-native/ringing) covers the foreground half -
- -Implement VoIP push notifications to receive incoming calls even when your app is in the background or terminated. This requires platform-specific configuration for iOS CallKit and Android ConnectionService. - -## iOS VoIP Configuration - -### Enable VoIP Push Notifications - -1. In Xcode, select your target -2. Go to **Signing & Capabilities** -3. Add **Push Notifications** capability -4. Add **Background Modes** capability -5. Enable **Voice over IP** - -### Create VoIP Certificate - -1. Go to [Apple Developer Portal](https://developer.apple.com) -2. Navigate to **Certificates, Identifiers & Profiles** -3. Create a new **VoIP Services Certificate** -4. Download and install the certificate -5. Export the `.p12` file for your server - -### Configure CometChat Dashboard - -1. Go to your CometChat Dashboard -2. Navigate to **Notifications > Push Notifications** -3. Upload your VoIP certificate (`.p12` file) -4. Configure the certificate password - -### Implement CallKit - -Create a native module to handle CallKit: - -```swift -// ios/CallKitManager.swift -import CallKit -import PushKit - -@objc(CallKitManager) -class CallKitManager: NSObject, CXProviderDelegate, PKPushRegistryDelegate { - - static let shared = CallKitManager() - - private let provider: CXProvider - private let callController = CXCallController() - private var voipRegistry: PKPushRegistry? - - override init() { - let config = CXProviderConfiguration() - config.supportsVideo = true - config.maximumCallsPerCallGroup = 1 - config.supportedHandleTypes = [.generic] - - provider = CXProvider(configuration: config) - super.init() - provider.setDelegate(self, queue: nil) - } - - @objc func registerForVoIPPushes() { - voipRegistry = PKPushRegistry(queue: .main) - voipRegistry?.delegate = self - voipRegistry?.desiredPushTypes = [.voIP] - } - - // PKPushRegistryDelegate - func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) { - let token = pushCredentials.token.map { String(format: "%02x", $0) }.joined() - // Send token to CometChat - NotificationCenter.default.post( - name: NSNotification.Name("VoIPTokenReceived"), - object: nil, - userInfo: ["token": token] - ) - } - - func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) { - guard type == .voIP else { return } - - let callerId = payload.dictionaryPayload["callerId"] as? String ?? "Unknown" - let callerName = payload.dictionaryPayload["callerName"] as? String ?? "Unknown" - let sessionId = payload.dictionaryPayload["sessionId"] as? String ?? "" - let hasVideo = payload.dictionaryPayload["hasVideo"] as? Bool ?? false - - reportIncomingCall( - uuid: UUID(), - handle: callerId, - callerName: callerName, - hasVideo: hasVideo - ) { error in - completion() - } - } - - func reportIncomingCall(uuid: UUID, handle: String, callerName: String, hasVideo: Bool, completion: @escaping (Error?) -> Void) { - let update = CXCallUpdate() - update.remoteHandle = CXHandle(type: .generic, value: handle) - update.localizedCallerName = callerName - update.hasVideo = hasVideo - - provider.reportNewIncomingCall(with: uuid, update: update) { error in - completion(error) - } - } - - // CXProviderDelegate - func providerDidReset(_ provider: CXProvider) {} - - func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { - // Notify React Native to accept the call - NotificationCenter.default.post( - name: NSNotification.Name("CallKitAnswerCall"), - object: nil, - userInfo: ["callUUID": action.callUUID.uuidString] - ) - action.fulfill() - } - - func provider(_ provider: CXProvider, perform action: CXEndCallAction) { - // Notify React Native to end the call - NotificationCenter.default.post( - name: NSNotification.Name("CallKitEndCall"), - object: nil, - userInfo: ["callUUID": action.callUUID.uuidString] - ) - action.fulfill() - } +```json package.json +{ + "dependencies": { + "@cometchat/calls-sdk-react-native": "^5.1.0", + "@cometchat/push-notifications-react-native": "^2.0.0", + "@cometchat/push-notifications-voip-react-native": "^2.0.0" + } } ``` -## Android VoIP Configuration +Installing the VoIP package is the whole opt-in: the core package detects it and installs the ringer. The Chat SDK is optional for both packages; a calls-only app works. + + +The Calls SDK is a dependency of the VoIP package, not a peer: the package calls into it to pre-warm and leave sessions, and a version mismatch there is a silent failure rather than a build error. + + +## Step 2: Initialise once -### Add Firebase Cloud Messaging +Run this once per app start, as early as you can — the configuration is saved natively, so a push that wakes a cold process still finds it. -1. Add Firebase to your Android project -2. Add the FCM dependency to `android/app/build.gradle`: +There are two shapes, and which one you use matters. -```groovy -dependencies { - implementation 'com.google.firebase:firebase-messaging:23.0.0' -} -``` +**A calls-only app** passes its configuration to the VoIP package and calls nothing else. Calling still needs a push token, and the core package is what registers it, so this initialises the core for you: -### Configure CometChat Dashboard - -1. Go to your CometChat Dashboard -2. Navigate to **Notifications > Push Notifications** -3. Upload your Firebase Server Key - -### Implement ConnectionService - -Create a ConnectionService for incoming calls: - -```java -// android/app/src/main/java/com/yourapp/CallConnectionService.java -package com.yourapp; - -import android.telecom.Connection; -import android.telecom.ConnectionRequest; -import android.telecom.ConnectionService; -import android.telecom.PhoneAccountHandle; -import android.telecom.TelecomManager; - -public class CallConnectionService extends ConnectionService { - - @Override - public Connection onCreateIncomingConnection( - PhoneAccountHandle connectionManagerPhoneAccount, - ConnectionRequest request) { - - CallConnection connection = new CallConnection(); - connection.setConnectionProperties(Connection.PROPERTY_SELF_MANAGED); - connection.setCallerDisplayName( - request.getExtras().getString("callerName"), - TelecomManager.PRESENTATION_ALLOWED - ); - connection.setRinging(); - - return connection; - } - - @Override - public Connection onCreateOutgoingConnection( - PhoneAccountHandle connectionManagerPhoneAccount, - ConnectionRequest request) { - - CallConnection connection = new CallConnection(); - connection.setConnectionProperties(Connection.PROPERTY_SELF_MANAGED); - connection.setDialing(); - - return connection; - } -} +```ts +import { CometChatVoipNotifications } from '@cometchat/push-notifications-voip-react-native'; + +await CometChatVoipNotifications.init({ + appId: APP_ID, + region: REGION, + fcmProviderId: FCM_PROVIDER_ID, + apnsProviderId: APNS_PROVIDER_ID, + callKitName: 'Acme', +}); ``` -### Register ConnectionService +**An app that also does chat** initialises the core with its own settings first, then calls the VoIP `init()` with **no arguments**: + +```ts +import { CometChatPushNotifications } from '@cometchat/push-notifications-react-native'; +import { CometChatVoipNotifications } from '@cometchat/push-notifications-voip-react-native'; -Add to `AndroidManifest.xml`: +await CometChatPushNotifications.init({ + appId: APP_ID, + region: REGION, + fcmProviderId: FCM_PROVIDER_ID, + apnsProviderId: APNS_PROVIDER_ID, + notificationSmallIcon: 'ic_notification', + androidChannelId: 'chat_messages', + callKitName: 'Acme', +}); -```xml - - - - - +await CometChatVoipNotifications.init(); // no config ``` -### Add Permissions + +**Passing a config to `CometChatVoipNotifications.init()` when the core is already initialised re-runs that initialisation and drops the chat options you set.** A chat app that calls `init(config)` on the VoIP package silently loses `notificationSmallIcon`, `androidChannelId` and every other chat-side setting. + + + +**`CometChatVoipNotifications.init()` must be the first thing you `await` on startup.** Anything awaited before it — a stored-credentials read, a chat login, a remote config fetch — eats the cold-launch ring window, and a call that woke the process is lost before the ring is wired up. + + +| Option | Default | What it does | +|--------|---------|--------------| +| `appId`, `region` | none | Your CometChat app; required unless the Chat SDK already initialised | +| `fcmProviderId` / `apnsProviderId` | none | The dashboard providers the token is registered under | +| `callMode` | `'AUTO'` | `'AUTO'` rings. `'MANUAL'` only parses and forwards; see [MANUAL mode](#manual-mode) | +| `notificationSmallIcon` | none | Status-bar icon for every notification (Android) | +| `androidChannelId` / `androidChannelName` | SDK defaults | The channel chat notifications are posted on | +| `defaultRingTimeoutMs` | `45000` | Ring window when the push carries no `ringTimeout` | +| `showMissedCallNotification` | `true` | Post a missed-call notification | +| `ringInForeground` | `true` | Ring even when your app is in the foreground | +| `showInForeground` | `false` | Show chat notifications while the app is in the foreground | +| `callKitName` | app name | The name CallKit shows in the system call log (iOS) | +| `callKitIconName`, `callKitRingtone`, `callKitSupportsVideo`, `callKitIncludeInRecents` | SDK defaults | CallKit appearance and behaviour (iOS) | +| `clientHost` | none | Same value as the Chat SDK's host override; see [Staging deployments](#staging-or-dedicated-deployments) | +| `enableBadgeCount`, `sendDeliveryReceipts` | `true` | Chat notification behaviour | +| `tokenRegistrar`, `authToken` | auto | Token registration; leave the defaults | + +## Step 3: Register the background task + +Android can decline or time out a call while your app is fully killed. Register the background task at module scope in `index.js`, before `AppRegistry.registerComponent`: + +```js index.js +import { registerBackgroundCallTask } from '@cometchat/push-notifications-voip-react-native'; + +registerBackgroundCallTask(); +``` + +Without it, a decline tapped on a killed app is never delivered and the caller rings on to `unanswered`. + +## Step 4: Firebase delegation (only if you own the transport) + +**You normally skip this step.** The package ships its own Firebase messaging service and registers it in its manifest, so call pushes reach it without any code of yours. Declaring your own service in addition is what breaks delivery, not what fixes it. -```xml - - +Delegate only if your app already owns a `setBackgroundMessageHandler`: + +```js index.js +import messaging from '@react-native-firebase/messaging'; +import { CometChatPushNotifications } from '@cometchat/push-notifications-react-native'; + +messaging().setBackgroundMessageHandler(async remoteMessage => { + // A background handler is a fresh JS runtime with none of your app's state: + // initialise CometChat here before handling. + const handled = await CometChatPushNotifications.handlePushNotification( + remoteMessage.data ?? {}, + remoteMessage.sentTime, + ); + if (!handled) { /* not a CometChat push: your own handling */ } +}); ``` -## Register Push Token + +**Pass `sentTime` and hand over every push.** Firebase queues pushes while a device dozes; without the sent time a push delivered late looks fresh and rings for a call that is already over. And do not skip call pushes when the app is in the foreground: the push is the only signal that a call is coming in, in every app state. + -Register the VoIP/FCM token with CometChat using `CometChatNotifications.registerPushToken`. iOS VoIP is -its own platform — `APNS_REACT_NATIVE_VOIP`, distinct from the `APNS_REACT_NATIVE_DEVICE` used for ordinary -message notifications — so an app that both rings and notifies registers **two** tokens. + +**Send call pushes at high FCM priority.** Android lets a background app start a foreground service only with the temporary allowlist that a high-priority data message grants. At normal priority the call still rings, but the process can be killed between the ring and the answer. + + + +**More than one messaging service is not a conflict.** Every app that includes Firebase declares `FirebaseMessagingService` at priority −500; what matters is the service Android actually resolves to. The SDK reports that in `getDiagnostics()` as `competingMessagingService`, which is `null` when all is well. + + +## Step 5: Answer and join + +On **Answer** the SDK stops the ring, asks for the microphone (and the camera for a video call), brings your app to the front and emits `onCallAccepted`. There is nothing to "accept". ```tsx -import { CometChatNotifications } from '@cometchat/chat-sdk-react-native'; - -const { PushPlatforms } = CometChatNotifications; - -async function registerPushToken(token: string, platform: 'ios' | 'android') { - try { - await CometChatNotifications.registerPushToken( - token, - platform === 'ios' - ? PushPlatforms.APNS_REACT_NATIVE_VOIP // the PushKit token - : PushPlatforms.FCM_REACT_NATIVE_ANDROID, - PROVIDER_ID, // Dashboard → Notifications → Provider ID - ); - console.log('Push token registered'); - } catch (error) { - console.error('Error registering push token:', error); - } +function App() { + const [activeCall, setActiveCall] = useState(null); + + useEffect(() => { + const off = CometChatVoipNotifications.onCallAccepted(call => setActiveCall(call)); + const offEnded = CometChatVoipNotifications.onCallEnded(() => setActiveCall(null)); + return () => { off(); offEnded(); }; + }, []); + + if (activeCall) return ; + return ; } ``` - - Register the token **after** the user logs in — a token registered with no logged-in user is attached to - nobody. Re-register when the token refreshes. +`PNCallInfo` fields: `sessionId`, `callerUid`, `callerName`, `callerAvatar`, `callType`, `receiverId`, `receiverType`, `conversationId`, `initiatedAtMs`, `expiresAtMs`, `isPeerCall` and `source` (`push` or `websocket`). The ring window is anchored to the caller's `initiatedAtMs`, not to a timer on this device, so both sides stop ringing together however late the push arrived; prefer `expiresAtMs` to a timer of your own. + + +**Every field arrives from Android as a string**, including `isPeerCall` (`"true"` / `"false"`) and `expiresAtMs`. Coerce before use. + - `providerId` is the **Provider ID** you configured in **Dashboard → Notifications → Push Notifications** - (for example `APNS_PROVIDER_ID`). Pass it so the token is bound to the right provider. + +A call answered before your JavaScript existed — the killed-app case this package is for — is buffered natively and replayed to the first `onCallAccepted` subscriber. Subscribing late is safe; not subscribing at all means the user answers and nothing happens. -### Unregister on Logout +## Step 6: Permissions and ring readiness -Release the token when the user logs out, or the next user on that device receives their calls: +Declare in the manifest and `Info.plist` what the SDKs only request: -```tsx -import { CometChatNotifications } from '@cometchat/chat-sdk-react-native'; +```xml android/app/src/main/AndroidManifest.xml + + + +``` -async function unregisterPushToken() { - try { - await CometChatNotifications.unregisterPushToken(); - } catch (error) { - console.error('Error unregistering push token:', error); - } +```xml ios/YourApp/Info.plist +NSMicrophoneUsageDescription +Used for voice and video calls. +NSCameraUsageDescription +Used for video calls. +UIBackgroundModes +voipaudio +``` + +Everything else (full-screen intent, foreground service of type `phoneCall`, wake lock, vibrate, the ring activity and the ring service on Android; CallKit and PushKit on iOS) comes from the packages' own manifests. + + +**A missing `NSMicrophoneUsageDescription` terminates the app mid-ring on iOS.** It looks exactly like the ring expiring early. Check the device's crash reports before treating a short ring as an SDK bug. + + +**Microphone and camera.** The SDK asks at the moment of answering: the answer is recorded first so the ring stops, the system prompt appears, and your screen opens whatever the user chose. A denied microphone joins muted and never fails the call. Ask earlier as well (at sign-in, for example) with `CometChatPNHelper.requestCallPermissions()` so the first call is not where the user meets the prompt. + +**Ring readiness.** Several device settings silence a call with no error anywhere. Check once after sign-in and tell the user: + +```ts +const readiness = await CometChatPushNotifications.ensureRingReady(); +if (readiness !== 'ready') { + showBanner(`Incoming calls may not ring: ${readiness}`); + await CometChatPushNotifications.openRingSettings(); } ``` -## Handle Incoming VoIP Push +| Result | Meaning | +|--------|---------| +| `ready` | Nothing in the way | +| `notificationsOff` | The app cannot post notifications | +| `channelMuted` | The user muted or downgraded the incoming-call channel | +| `fullScreenIntentRevoked` | Android 14+ revoked full-screen intents: no ring screen on a locked device, only the heads-up notification | +| `dndBlocking` | Do Not Disturb is filtering | +| `overlayDenied` | The ring screen cannot be drawn over other apps | +| `batteryOptimised` | The process may be frozen before the push arrives | +| `autostartDisabled` | An OEM autostart restriction blocks the push wake | +| `voipBackgroundModeMissing` | iOS: the `voip` background mode is not declared | +| `voipTokenMissing` | iOS: PushKit never issued a token, so no call push can arrive | +| `microphoneDenied` | The user refused the microphone at an answer; calls still ring but join muted | + +**Diagnostics for a bug report.** `getDiagnostics()` returns a redacted snapshot (whether a provider id exists, never what it is; never a token or payload): + +| Key | Read it as | +|-----|------------| +| `voip` | `false`: the VoIP package was never initialised, nothing will ring | +| `hasLoggedInUid` | `false`: the ring gates have no identity, and a user's own outgoing call can ring their own phone | +| `competingMessagingService` | non-`null`: another service is resolving ahead of the SDK's | +| `canUseFullScreenIntent` | `false` on Android 14+: no ring screen on a locked device | +| `callChannelImportance` | below `IMPORTANCE_HIGH`: no heads-up and no sound | +| `interruptionFilter` | anything but `1`: Do Not Disturb is filtering | +| `ringReadiness`, `ignoringBatteryOptimizations`, `canDrawOverlays` | Why a push may never reach the device | +| `callMode`, `ringInForeground`, `defaultRingTimeoutMs` | `MANUAL` explains a phone that never rings by design | +| `aggressiveOem`, `manufacturer`, `model`, `sdkInt`, `platform` | OEM background restrictions | + +`verifyPushSetup()` registers the token and reports `ok`, `tokenNotRegistered`, `notLoggedIn`, `testPushUnavailable` (registration worked, the loopback test push is not available on the backend yet: a warning, not a failure) or `testPushTimeout` (the test push never arrived: delivery is broken). The token value is never logged, only its length and a short prefix. + +## How a ring ends + +Every way out is handled by the SDK and needs no code in your app. + +| What happened | On the callee's device | What the caller learns | +|---------------|------------------------|------------------------| +| The user answers | Ring stops, `onCallAccepted` | `onParticipantJoined` | +| The user declines | Ring stops; `endCall(sessionId, 'rejected')` is queued and retried across process death | `onCallEnded` with `rejected` | +| The user declines while in another call | Same, with `busy` | `busy` | +| The caller cancels | Ring stops on the `cancelled` push, or sooner when the prepared session closes; missed-call notification | Nothing; the caller ended it | +| Nobody answers a 1:1 call | At the ring window the ring stops, a missed call is posted and `endCall(sessionId, 'unanswered')` is sent | `unanswered` | +| Answered on another device | Ring stops on the `ongoing` push, no missed call | `onParticipantJoined` | +| Nobody answers a group call | Ring stops, missed call posted, **nothing sent**; the call keeps running | Nothing | +| The user logs out while ringing | Ring stops, nothing sent, no missed call | Rings to its own window, then `unanswered` | + +Once the session is prepared, the Calls SDK reporting it ended also stops the ring immediately, without waiting for a terminal push. + + +**An `ongoing` push is addressed to the CALLER, not the callee.** Its `sender` is whoever performed the action — the callee who answered — so neither a sender nor a receiver comparison can tell you whether it concerns you. The SDK gates it on local ring state instead. + + +## A call while in a call + +Call waiting, the way the phone app does it. "In a call" means the Calls SDK is joined to a session, or a call answered here is still joining; a session that is only prepared while it rings does not count. + +| Situation | Behaviour | +|-----------|-----------| +| Call while in a call | Rings over the live call with the normal ring; the text adds "Answering ends your current call". The live call is untouched | +| Answer (End & accept) | Leaves the live call, waits for the Calls SDK to release it, prepares the new session, emits `onCallAccepted` for it | +| Decline | `busy` to the caller; the live call continues | +| No action | `unanswered` to the caller, missed call; the live call continues | +| Call while another one is already ringing here | Declined as `busy` without ringing, missed call. The first keeps ringing | +| Call while the user's own outgoing call is ringing | Declined as `busy` without ringing, missed call | + + +End & accept waits for the new session's prepare to settle, up to 5 seconds, before handing the call to your screen. Joining before the previous session has been released is what silently never completes. + + +## Missed calls + +Posted as "Missed call" / "Missed group call" with the caller's name and call type, one per session, grouped under the SDK's own summary so Android never folds them into your other notifications. + +Posted when: nobody answers in the ring window; a `cancelled`, `unanswered` or `timed_out` push ends a ring here; such a push arrives for a call that never rang here (the `initiated` push was lost or overtaken); an `initiated` push arrives already expired; a call is declined as busy without ringing. + +Not posted when: answered here or on another device; declined (`rejected` or `busy`) here; ended; the user's own outgoing call; logout while ringing. + +Turn missed calls off with `showMissedCallNotification: false`. This is a notification only; call history comes from [Call Logs](/calls/react-native/call-logs). + +## When the app is killed + +This is the case the push exists for. + +1. The high-priority data push (Android) or the PushKit push (iOS) starts the app process. +2. The native layer finds no in-memory configuration and restores it from disk. +3. The ring runs on a foreground service of type `phoneCall` on Android, so the system keeps the process alive through the answer; on iOS CallKit holds the call. An alarm backs up the ring timeout. +4. On Answer, the SDK asks for permissions, launches your app and buffers the answered call until your JavaScript subscribes. +5. A decline or timeout is sent through the background task, so it goes out even if the process dies right after the tap. + +Call `CometChatVoipNotifications.init()` as the first `await` in your app's entry point, from stored credentials, so the answer finds a ready SDK. + + +**On iOS, APNs and VoIP pushes really are delivered to the Simulator** and will cold-launch a killed app. Only CallKit's own call presentation does not appear there, so test the ring on a device and the delivery path anywhere. + + +## Staging or dedicated deployments + +If your Chat and Calls SDKs point at a staging or dedicated host, give the Push SDK the same one. Otherwise the token is registered on the default production host, where your app id does not exist, and no push ever arrives. + +```ts +await CometChatVoipNotifications.init({ + appId: APP_ID, + region: REGION, + clientHost: `${APP_ID}.apiclient-us.cometchat-staging.com/v3.0`, +}); +``` + + +**The host override shapes differ between SDKs.** The Chat SDK wants a bare host, the Calls SDK a full URL, and giving one the other's shape hangs login forever rather than failing. The Calls SDK also has three base URLs — admin, client and calls — and setting only one splits call start and call end across environments. + + +## Tokens + +You do not register the push token. The SDK fetches it from Firebase or PushKit and registers it on login, under the Chat auth token when there is one and the Calls auth token otherwise; it unregisters on logout. + +Unregister **before** the session goes away: + +```ts +await CometChatPushNotifications.unregister(); // needs the credential; must come first +CometChatVoipNotifications.clearIdentity(); +await CometChatUIKit.logout(); +``` + +`unregister()` deletes the token from the backend and needs the logged-in credential, so after `logout()` there is nothing left to authenticate the delete, and the device goes on receiving the departed user's call pushes. + + +The SDK keeps its ring identity in step with the Calls SDK's login listener: logging in writes the uid, logging out clears it, and `init()` re-reads it to heal a login it missed. An app that logs the **Chat** SDK in and out without `CometChatCalls.login` / `logout` must call `clearIdentity()` itself. + + + +In a calls-only app, `logout()` needs the Chat SDK — so use `clearIdentity()` and `unregister()` instead, and call `init()` again before the next login or the device stays silent. + + +## Your own incoming-call screen + +Subscribe to `onCallRinging` and draw your own UI. The SDK still owns the ringtone, the pre-warm, the timeout, the keep-alive and the decline queue; only the UI is yours. ```tsx -import { useEffect } from 'react'; -import { NativeEventEmitter, NativeModules, Platform } from 'react-native'; -import { CometChat } from '@cometchat/chat-sdk-react-native'; -import { CometChatCalls } from '@cometchat/calls-sdk-react-native'; +function IncomingCallOverlay() { + const [call, setCall] = useState(null); -function useVoIPPush() { useEffect(() => { - if (Platform.OS === 'ios') { - const eventEmitter = new NativeEventEmitter(NativeModules.CallKitManager); - - const answerSubscription = eventEmitter.addListener( - 'CallKitAnswerCall', - async (data) => { - // Accept the call via Chat SDK - const sessionId = data.sessionId; - await CometChat.acceptCall(sessionId); - - // Start the call session - const { token } = await CometChatCalls.generateToken(sessionId); - // Navigate to call screen with token - } - ); - - const endSubscription = eventEmitter.addListener( - 'CallKitEndCall', - async (data) => { - CometChatCalls.leaveSession(); - } - ); - - return () => { - answerSubscription.remove(); - endSubscription.remove(); - }; - } + const off = CometChatVoipNotifications.onCallRinging(setCall); + // Close when the ring ends elsewhere: caller cancelled, answered on another device, timed out. + const offEnd = CometChatVoipNotifications.onRingEnded(e => { + setCall(c => (c?.sessionId === e.sessionId ? null : c)); + }); + return () => { off(); offEnd(); }; }, []); + + if (!call) return null; + + return ( + + {call.callerName} +