cometchat-native-calls

SkillCommunication

CometChat Calls SDK integration for React Native (Expo managed + bare CLI). Covers @cometchat/calls-sdk-react-native install, dual-SDK init, native module linking (iOS pods, Android Gradle), VoIP push via react-native-callkeep + react-native-voip-push-notification + @react-native-firebase/messaging, CallKit on iOS / ConnectionService on Android, foreground service correctness on Android 14+, gesture handler + reanimated peer deps, Expo-specific config plugins, and additive-vs-standalone modes.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the cometchat-native-calls skill

What this skill tells your AI

The instructions your AI receives, as published by cometchat/cometchat-skills in skills/cometchat-native-calls/SKILL.md and read by ahel’s review.

Purpose

Production-grade voice + video calling for React Native (Expo managed + bare CLI). Loaded by cometchat-calls when framework is expo or react-native. Operates in two modes:

  • Standalone — calls is the product. Chat SDK (signaling) + Calls SDK (WebRTC) + your own RN screens. VoIP push is mandatory — same rule as native iOS / Android.
  • Additive — calls layered onto an existing CometChat React Native UI Kit integration. Adds call buttons inline, mounts CometChatIncomingCall at app root.

Read these other skills first:

  • cometchat-calls — dispatcher (modes, hard rules, anti-patterns)
  • cometchat-native-core — Chat SDK init, login, env conventions, gesture handler peer-dep rules
  • Framework path: cometchat-native-expo-patterns (managed) OR cometchat-native-bare-patterns (CLI)

Ground truth:


1. Hard rules — RN specialization

1.0 Calls SDK login — only the STANDALONE/raw-SDK path needs it

The additive UI-Kit path does NOT call CometChatCalls.login. When calls are layered onto the RN UI Kit (the common case — <CometChatIncomingCall> at root + the auto call buttons in CometChatMessageHeader, calling extension enabled at CometChatUIKit.init), the kit chains the Calls-SDK auth off CometChatUIKit.login for you. The canonical RN SampleApp wires calls end-to-end (incoming/outgoing/ongoing) with ZERO CometChatCalls.login (verified across examples/SampleApp + SampleAppWithPushNotifications; the kit's CometChatUIKit.login only runs CometChat.login). Don't add it on the UI-Kit path — it's redundant.

await CometChatCalls.login(uid, AUTH_KEY) is required ONLY on the STANDALONE / raw-Calls-SDK path (§4a/§4b — no UI Kit; you call CometChatCalls.generateToken / render a custom call surface directly). There the Calls SDK has its own auth state: after CometChat.login(uid, AUTH_KEY) succeeds, also call await CometChatCalls.login(uid, AUTH_KEY) — without it the first raw calls API throws "auth token cannot be null". (No imperative joinSession on RN — see rule below.)

import { CometChat } from "@cometchat/chat-sdk-react-native";
import { CometChatCalls } from "@cometchat/calls-sdk-react-native";

// ✓ RIGHT — chat login first, then calls login
await CometChat.login(uid, AUTH_KEY);
try {
  const callUser = await CometChatCalls.login(uid, AUTH_KEY);  // dev mode
  // OR for production:
  // const callUser = await CometChatCalls.loginWithAuthToken(authToken);
} catch (e) {
  // surface to user — common cause: typo in app id / auth key
}

Surprises that bite on real devices:

  • The Chat SDK persists login across launches via AsyncStorage; the Calls SDK does NOT. Even if CometChat.getLoggedinUser() returns a non-null user on cold start, call CometChatCalls.login again before any calls API works.
  • A single-arg CometChatCalls.login(uid) overload exists for re-login when the SDK has cached auth — default to the (uid, AUTH_KEY) form for dev to avoid foot-guns.
  • Login errors surface as Promise rejections — wrap in try/catch.

1.1 Dual-SDK contract

Same shape as web. @cometchat/chat-sdk-react-native initiates ringing; @cometchat/calls-sdk-react-native runs the WebRTC session. Both packages.

import { CometChat } from "@cometchat/chat-sdk-react-native";
import { CometChatCalls } from "@cometchat/calls-sdk-react-native";

// Chat SDK — initiate
const outgoing = new CometChat.Call(receiverUid, CometChat.CALL_TYPE.VIDEO, CometChat.RECEIVER_TYPE.USER);
const initiated = await CometChat.initiateCall(outgoing);

// Calls SDK — generate the token (v5 takes only sessionId; auth is internal after login).
const { token: callToken } = await CometChatCalls.generateToken(initiated.getSessionId());

// On RN, session/join is rendered via the SDK's declarative Component.
// There is NO imperative joinSession(token, settings, viewRef) on RN — the
// Component IS the call surface. With the kit, <CometChatOngoingCall />
// wraps this internally. For custom UI, render <CometChatCalls.Component
// callToken={callToken} /> directly. See references/custom-ui.md.

1.2 VoIP push — react-native-callkeep + platform-specific push

VoIP push on RN is the highest-effort piece. The standard production stack:

  • react-native-callkeep — bridges CallKit (iOS) + ConnectionService (Android). Single API for "report incoming call to OS"
  • react-native-voip-push-notification — iOS PushKit token registration + payload delivery
  • @react-native-firebase/messaging — Android FCM high-priority data messages
  • Server side — your push server must split iOS sends to PushKit (VoIP cert) and Android sends to FCM with priority: "high" and a data payload (NOT notification, which ConnectionService can't intercept)

The skill scaffolds all four pieces in standalone mode. Additive mode prompts before adding (it's substantial).

1.3 Foreground service — same Android 14+ rules as native

When the call is active on Android, an ongoing-call foreground service must run. react-native-callkeep handles registration but the app's AndroidManifest.xml must declare:

<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
<uses-permission android:name="android.permission.BIND_TELECOM_CONNECTION_SERVICE" />

Same silent-crash failure mode as native Android (cf. cometchat-android-v5-calls rule 1.3).

For Expo managed, these go into app.json expo.android.permissions AND require a config plugin (react-native-callkeep's plugin) to merge into the generated AndroidManifest.xml during prebuild. Bare RN can edit the manifest directly.

1.4 Server-minted auth tokens

Same — cometchat-native-production covers it.

1.5 Hangup cleanup — RTCPeerConnection + audio session

Combined web + iOS rules. RN's WebRTC bridge wraps both:

function endCall() {
  CometChatCalls.leaveSession();            // v5 canonical (endSession is a deprecated shim)
  RNCallKeep.endCall(callUUID);             // tells CallKit/ConnectionService the call ended

  // iOS: matching audio session deactivation happens inside callkeep
  // Android: foreground service stop happens inside callkeep
}

Skipping RNCallKeep.endCall leaves the system call UI stuck (lock-screen card persists, OS thinks there's an active call). Common bug.

1.6 Permissions

Required:

  • iOS Info.plistNSCameraUsageDescription, NSMicrophoneUsageDescription (same as native)
  • Android — runtime requests via PermissionsAndroid.requestMultiple for RECORD_AUDIO, CAMERA, POST_NOTIFICATIONS (Android 13+)
  • Expo managed — declare in app.json expo.ios.infoPlist and expo.android.permissions; the prebuild merges into native manifests

1.7 IncomingCall + OutgoingCall + OngoingCall — full event-listener wiring

<CometChatIncomingCall />, <CometChatOutgoingCall />, and <CometChatOngoingCall /> are NOT auto-mounted by each other. The parent component must register both CometChat.addCallListener (SDK socket) and CometChatUIEventHandler.addCallListener (UI events fired by <CometChatCallButtons> / <CometChatMessageHeader>), then conditionally render whichever overlay matches current state. Validated 2026-05-26 on Pixel 3 + @cometchat/chat-uikit-react-native@5.3.5.

Mount this wiring inside the root navigator OR in the App.tsx wrapper, ABOVE all stacks/tabs — calls only ring on screens where the listener exists.

import { CometChat } from "@cometchat/chat-sdk-react-native";
import { CometChatCalls } from "@cometchat/calls-sdk-react-native";
import {
  CometChatIncomingCall,
  CometChatOutgoingCall,
  CometChatOngoingCall,
  CometChatUIEventHandler,
} from "@cometchat/chat-uikit-react-native";
import { StyleSheet, View } from "react-native";

const CALL_LISTENER_ID = "app-call-listener";

function CallSurfaces() {
  const [outgoingCall, setOutgoingCall] = useState<CometChat.Call | null>(null);
  const [incomingCall, setIncomingCall] = useState<CometChat.Call | null>(null);
  const [ongoingCall, setOngoingCall] = useState<CometChat.Call | null>(null);

  useEffect(() => {
    // SDK socket — incoming + outgoing-rejected
    CometChat.addCallListener(
      CALL_LISTENER_ID,
      new CometChat.CallListener({
        onIncomingCallReceived: (call: CometChat.Call) => setIncomingCall(call),
        onIncomingCallCancelled: () => setIncomingCall(null),
        onOutgoingCallAccepted: () => {},
        onOutgoingCallRejected: () => setOutgoingCall(null),
      })
    );
    // UI events fired by the kit's CallButtons / MessageHeader on tap
    CometChatUIEventHandler.addCallListener(CALL_LISTENER_ID, {
      ccOutgoingCall: ({ call }) => setOutgoingCall(call),
      ccCallEnded: () => {
        setOutgoingCall(null);
        setIncomingCall(null);
        setOngoingCall(null);
      },
      ccShowOngoingCall: ({ call }) => setOngoingCall(call),
    });
    // Mid-call drops (peer left, network loss, server close) do NOT come through the
    // Chat-SDK/UI-Kit channels above — they fire on the Calls SDK session events (v5).
    // For a STANDALONE custom call surface, also register (clone skills/event-listeners):
    //   const offLeft   = CometChatCalls.addEventListener('onSessionLeft',     () => { /* reset state */ });
    //   const offClosed = CometChatCalls.addEventListener('onConnectionClosed', () => { /* reset state */ });
    //   // addEventListener returns an unsubscribe fn — call offLeft()/offClosed() on cleanup.
    // (Additive mode using the kit's <CometChatOngoingCall> handles this internally.)
    return () => {
      CometChat.removeCallListener(CALL_LISTENER_ID);
      CometChatUIEventHandler.removeCallListener(CALL_LISTENER_ID);
    };
  }, []);

  return (
    <>
      {incomingCall && <View style={StyleSheet.absoluteFill}><CometChatIncomingCall call={incomingCall} onDecline={() => setIncomingCall(null)} /></View>}{/* onDecline is REQUIRED on the RN kit's CometChatIncomingCall (onAccept/onError optional) */}
      {outgoingCall && <View style={StyleSheet.absoluteFill}><CometChatOutgoingCall call={outgoingCall} /></View>}
      {/* CometChatOngoingCall has NO `call` prop — it takes sessionID (required) + callSettingsBuilder (required) + onError? (verified vs uikit-react-native-v5 CometChatOngoingCall.tsx). CometChatIncomingCall/OutgoingCall DO take `call`. */}
      {ongoingCall && <View style={StyleSheet.absoluteFill}><CometChatOngoingCall sessionID={ongoingCall.getSessionId()} callSettingsBuilder={new CometChatCalls.CallSettingsBuilder().setIsAudioOnlyCall(ongoingCall.getType() === "audio")} /></View>}
    </>
  );
}

Don't skip the ccOutgoingCall listener. Without it, tapping the video/voice button in <CometChatMessageHeader> triggers the call at the SDK level (WebRTC, camera, audio init) but no overlay UI ever mounts — the user sees nothing change after the tap. This was [[project_v4_3_f75_rn_call_ui_missing]] — F75.

In standalone mode, CallKit/ConnectionService own the foreground UI; <CometChatIncomingCall /> is not used. Instead, a react-native-callkeep event listener at app root reports new calls to the OS.

1.8 Three canonical provider/scaffold patterns (non-negotiable)

Validated across 4 RN cohorts on 2026-05-14. Each bug silently breaks integration in a different way; each fix is one line. Future scaffolds MUST emit all three.

1.8.a — getLoggedInUser() throws on no-session; always .catch(() => null).

// ❌ Throws "User not found" on every fresh launch → init fails → app stuck
const existing = await CometChatUIKit.getLoggedInUser();
if (existing) return;

// ✅
const existing = await CometChatUIKit.getLoggedInUser().catch(() => null);
if (existing) return;

The RN SDK treats "no logged-in user" as a thrown error (not null), unlike the web SDK. Without the catch, every fresh launch aborts before reaching login().

1.8.b — Render e.message, not String(e).

// ❌ Most CometChat SDK errors are plain objects, not Error subclasses
// → setError(String(e)) shows "[object Object]" on screen
catch (e) {
  setError(String(e));
}

// ✅
catch (e) {
  initialized = false; // let hot-reload retry
  const msg = e instanceof Error ? e.message : JSON.stringify(e);
  setError(msg);
}

Hiding the underlying error from the user is the single biggest debugging-time-sink in this stack. Always render e.message (or JSON.stringify(e) as fallback) so the actionable text reaches the screen.

1.8.c — DO NOT pass onAccept to <CometChatIncomingCall>.

// ❌ Short-circuits the kit's internal acceptCall + OngoingCall transition.
// Symptom: callee taps Accept; caller's outgoing screen stays on "Calling…"
// indefinitely; the call connects at server level but UI never transitions.
<CometChatIncomingCall call={call} onAccept={(c) => navigate('OngoingCall', ...)} ... />

// ✅ Let the kit own the accept path; only handle decline + error
<CometChatIncomingCall
  call={call}
  onDecline={() => setCallReceived(false)}
  onError={() => setCallReceived(false)}
/>

The kit calls CometChat.acceptCall internally and pushes its own OngoingCall surface. Providing onAccept replaces that behavior with the caller's function — typically incomplete, never matches what the kit does.

Also recommended — guard creds at init time so undefined @env/process.env.EXPO_PUBLIC_* values surface as actionable errors:

if (!appId || !region) {
  throw new Error(
    `Missing CometChat credentials at init time: appId=${JSON.stringify(appId)}, region=${JSON.stringify(region)}. ` +
    `Check .env defines COMETCHAT_APP_ID/COMETCHAT_REGION/COMETCHAT_AUTH_KEY ` +
    `and restart Metro with cache wipe.`,
  );
}

Without this guard, undefined env values produce opaque TypeError: undefined is not a function from deep inside the SDK — the most expensive failure mode of a typo'd env name.


2. Setup

Bare RN CLI

npm install @cometchat/chat-sdk-react-native@^4 @cometchat/calls-sdk-react-native@^5
npm install react-native-callkeep react-native-voip-push-notification @react-native-firebase/app @react-native-firebase/messaging
npm install react-native-webrtc           # Calls SDK peer dep
npm install react-native-gesture-handler react-native-reanimated  # already installed if using UI Kit

iOS — four hardening steps before pod install (validated 2026-05-14 on Apple Silicon, iOS 26.5 sim):

  1. USE_FRAMEWORKS=static pod install — required for WebRTC + CometChat pods. Default dynamic linkage silently produces a binary that can't load WebRTC at runtime. Set in shell rc or invoke every time:

    cd ios && USE_FRAMEWORKS=static pod install && cd ..
    # Subsequent builds:
    USE_FRAMEWORKS=static npx react-native run-ios
    
  2. Remove EXCLUDED_ARCHS arm64 i386 from ios/Podfile (Apple Silicon). RN templates often add this Intel-era workaround to the post-install hook; on Apple Silicon it blocks react-native-webrtc/JitsiWebRTC arm64 slices from linking against the simulator. Delete the line:

    # ❌ Remove this from post_install on Apple Silicon
    config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = 'arm64 i386'
    
  3. ios/Info.plist — minimum set for calls:

    <key>NSCameraUsageDescription</key>
    <string>Camera access for video calls</string>
    <key>NSMicrophoneUsageDescription</key>
    <string>Microphone access for voice and video calls</string>
    <key>NSBluetoothAlwaysUsageDescription</key>
    <string>Bluetooth access for using headsets during calls</string>
    <key>UIBackgroundModes</key>
    <array>
      <string>audio</string>
      <string>voip</string>
      <string>remote-notification</string>
    </array>
    

    UIBackgroundModes: audio is the load-bearing one for call-audio survival when the app backgrounds; without it, audio cuts the instant the app loses foreground. NSBluetoothAlways prevents a crash when the user connects a Bluetooth headset mid-call.

  4. ios/.xcode.env.local NODE_BINARY — point at a stable Homebrew Node path, not an nvm path that may have been cleaned up:

    # ios/.xcode.env.local
    export NODE_BINARY=/opt/homebrew/opt/node@20/bin/node
    

    nvm paths in ~/.nvm/versions/node/v20.x.y/bin/node go stale when nvm prunes — Xcode build fails with node: command not found. Homebrew /opt/homebrew/opt/node@20/bin/node is symlinked to whatever node@20.x is currently installed; survives brew upgrade.

iOS-on-RN works even where iOS V5 NATIVE is blocked. The native iOS V5 cohort is gated upstream by a Cloudsmith 404 on cometchat-calls-ios. The RN-on-iOS path links a different WebRTC surface (react-native-webrtc + JitsiWebRTC pod transitives), so customers on RN + iOS are NOT blocked by the native cohort's vendor issue.

Android — manifest permissions (rule 1.3 + 1.6), Firebase config (google-services.json in android/app/), service registration:

<service
  android:name="io.wazo.callkeep.RNCallKeepBackgroundMessagingService"
  android:foregroundServiceType="phoneCall|microphone|camera"
  android:exported="false" />

Expo managed

Calls SDK requires native modules — Expo managed CANNOT run it without a custom dev client. The skill detects the project mode:

  • Managed + has expo-dev-client: scaffolds config plugins for callkeep/firebase/voip-push, regenerates native projects, builds dev client
  • Managed without dev client: prompts the user — calls require either ejecting to bare or adding expo-dev-client
  • EAS Build: configures eas.json profiles + build commands

Expo Go (the public dev client) cannot run calls. The skill states this clearly and refuses to scaffold without a dev client.

⚠️ Real build-time landmines on Expo SDK 54 + chat-uikit-react-native 5.3.x (validated 2026-05-14, 4 cohorts)

A previous version of this doc listed three "Expo SDK 54 build traps" (document-picker removal, react-native-worklets install, NDK override). Re-validation on 2026-05-14 across expo-new, expo-existing, rn-new, rn-existing showed none of those three fired on the current combo (chat-uikit-react-native@5.3.5 + calls-sdk-react-native@5.0.0/4.4.1 + Expo SDK 54). Removed. The real landmines on this combo are different:

  1. @cometchat/calls-lib-webrtc is Cloudsmith-only, NOT on npm. npm install @cometchat/calls-lib-webrtc returns 404. The package lives on CometChat's Cloudsmith registry. Use the tarball URL:

    npm install --legacy-peer-deps \
      'https://dl.cloudsmith.io/public/cometchat/cometchat/raw/files/cometchat-calls-lib-webrtc-346a46ff.tgz'
    

    The exact revision hash may roll forward — check the Cloudsmith page for the current version. Without this, runtime fails when the WebRTC layer initializes.

  2. --legacy-peer-deps silently strips peer deps the kit needs at runtime. chat-uikit-react-native does not declare all its transitive runtime peers in peerDependencies (kit + calls SDK together pull in expo-linking, expo-constants, expo-asset, expo-font via expo-router, plus valibot, zustand, @xmldom/xmldom, abab, promise.allsettled, text-encoding, react-native-url-polyfill, react-native-performance). With --legacy-peer-deps, npm skips them. Each one bites on first bundle as Unable to resolve module .... Reinstall them explicitly:

    # Bare RN
    npm install --legacy-peer-deps \
      valibot zustand @xmldom/xmldom abab promise.allsettled text-encoding \
      react-native-url-polyfill react-native-performance
    
    # Expo (resolves to SDK-compatible versions)
    npx expo install \
      expo-linking expo-constants expo-asset expo-font \
      -- --legacy-peer-deps
    
  3. expo.extra (app.json) caches on the Expo dev client manifest. Edits to app.json → expo.extra after expo prebuild do NOT reload to the device — Constants.expoConfig.extra keeps reading the prebuild-time snapshot. For dev iteration on credentials, EITHER hardcode in src/config/*.ts (Metro hot-bundles source changes) OR run expo prebuild --clean && expo run:android after every app.json → extra change.

  4. react-native start does NOT run adb reverse (bare RN only). Only react-native run-android sets adb reverse tcp:8081 tcp:8081. When you restart Metro standalone (e.g. after .env changes), the device loses port-forwarding and shows "unable to load scripts." Fix:

    adb reverse tcp:8081 tcp:8081
    
  5. react-native-dotenv needs Metro --reset-cache after .env changes (bare RN only). The babel plugin processes .env at compile time, not runtime. Workflow:

    pkill -9 -f "react-native start"
    npx react-native start --reset-cache
    adb reverse tcp:8081 tcp:8081
    adb shell am force-stop com.<package> && adb shell monkey -p com.<package> -c android.intent.category.LAUNCHER 1
    

Init

// cometchat/init.ts
import { CometChat } from "@cometchat/chat-sdk-react-native";
import { CometChatCalls } from "@cometchat/calls-sdk-react-native";

let initialized = false;

export async function initCometChat() {
  if (initialized) return;

  const appSettings = new CometChat.AppSettingsBuilder()
    .subscribePresenceForAllUsers()
    .setRegion(process.env.EXPO_PUBLIC_COMETCHAT_REGION!)
    .build();

  await CometChat.init(process.env.EXPO_PUBLIC_COMETCHAT_APP_ID!, appSettings);

  // Calls SDK init takes a FLAT object — there is no CallAppSettingsBuilder in v5 RN.
  await CometChatCalls.init({
    appId: process.env.EXPO_PUBLIC_COMETCHAT_APP_ID!,
    region: process.env.EXPO_PUBLIC_COMETCHAT_REGION!,
    authKey: process.env.EXPO_PUBLIC_COMETCHAT_AUTH_KEY!,
  });

  initialized = true;
}

(Bare RN uses react-native-dotenv and @env imports instead of process.env.EXPO_PUBLIC_* — see cometchat-native-bare-patterns.)


3. Components catalog

Calls SDK primitives

Same names + shapes as the JavaScript SDK (Section 3 of cometchat-react-calls). RN-relevant session helpers include switchCamera(), setAudioMode(mode), muteAudio()/unmuteAudio(), pauseVideo()/resumeVideo() — see the SDK's custom-ui reference. (Web-only helpers like device enumeration and virtual background are not available on RN.)

UI Kit views (additive mode — @cometchat/chat-uikit-react-native)

ComponentPurpose
<CometChatCallButtons user={u} group={g} />Voice + video icon row (typically inside CometChatMessageHeader). Group + user semantics differ — see callout below
<CometChatIncomingCall />Root-mounted; renders ringing UI for incoming call (controlled by parent state — see §1.7)
<CometChatOutgoingCall />Root-mounted; renders "Calling…" UI for outgoing call (controlled by parent state — see §1.7)
<CometChatOngoingCall />Root-mounted; renders active in-call view (controlled by parent state — see §1.7)
<CometChatCallLogs onItemClick={fn} />History
⚠️ Group calls use message-based join, not the ringing channel (validated 2026-05-15)

<CometChatCallButtons group={group} /> does NOT call CometChat.initiateCall like the 1:1 user variant does. Source: node_modules/@cometchat/chat-uikit-react-native/src/calls/CometChatCallButtons/CometChatCallButtons.tsx:138-201.

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
105
Forks
2
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
cometchat-native-calls
Source
github.com/cometchat/cometchat-skills