diff --git a/notifications/react-native-push-notifications-android.mdx b/notifications/react-native-push-notifications-android.mdx index 7b52c5fc4..a05bada63 100644 --- a/notifications/react-native-push-notifications-android.mdx +++ b/notifications/react-native-push-notifications-android.mdx @@ -1,6 +1,6 @@ --- -title: "React Native Push Notification (Android)" -description: "Bring the SampleAppWithPushNotifications experience—FCM + VoIP calls—into any React Native project using CometChat UI Kit." +title: "React Native Push Notifications (Android)" +description: "CometChat push notifications and VoIP calls in React Native apps on Android using Firebase Cloud Messaging (FCM) and the @cometchat/push-notifications-react-native package." --- @@ -8,10 +8,11 @@ description: "Bring the SampleAppWithPushNotifications experience—FCM + VoIP c | Field | Value | | --- | --- | | Platform | Android (FCM) | -| Key Classes | `CometChatNotifications`, `VoipNotificationHandler`, `PendingCallManager` | -| Key Methods | `registerPushToken()`, `unregisterPushToken()`, `messaging().getToken()` | -| Push Platform | `CometChatNotifications.PushPlatforms.FCM_REACT_NATIVE_ANDROID` | -| Prerequisites | CometChat SDK initialized, user logged in, FCM configured, `google-services.json` in `android/app` | +| Package | `@cometchat/push-notifications-react-native` | +| Key APIs | `CometChatPushNotifications.init()`, `onNotificationTap()`, `onCallAccepted()`, `onCallEnded()`, `unregister()`, `registerBackgroundCallTask()`, `CometChatPNHelper.requestNotificationPermission()` | +| Push Platform | `FCM_REACT_NATIVE_ANDROID`, registered by `init()` with `fcmProviderId` | +| Native setup | `google-services.json` + Google Services plugin, `minSdkVersion 24`, an `ic_notification` drawable. No manifest or Kotlin changes | +| Prerequisites | React Native 0.78 or later and `@cometchat/chat-sdk-react-native` 4.0.10 or later; CometChat initialized and the user logged in before `init()`, an FCM provider ID, a physical device for call tests | @@ -20,29 +21,24 @@ description: "Bring the SampleAppWithPushNotifications experience—FCM + VoIP c icon="github" href="https://github.com/cometchat/cometchat-uikit-react-native/tree/v5/examples/SampleAppWithPushNotifications" > - Reference implementation of React Native UI Kit, FCM and Push Notification Setup. + Reference implementation of React Native UI Kit, FCM and Push Notification Setup. ## What this guide covers -- CometChat Dashboard setup (enable push, add FCM providers). -- Platform credentials (Firebase). -- Copying the sample notification stack and aligning IDs/provider IDs. -- Native glue for Android (manifest permissions). -- VoIP call alerts with FCM data-only pushes + CallKeep native dialer. -- Token registration, navigation from pushes, testing, and troubleshooting. - -## What you need first - -- CometChat app credentials (App ID, Region, Auth Key) and Push Notifications enabled with an **FCM provider (React Native Android)**. -- Firebase project with an Android app (`google-services.json` in `android/app`) and Cloud Messaging enabled. -- React Native 0.81+, Node 18+, physical Android devices for reliable push/call testing. +- CometChat dashboard setup (enable push, add FCM provider) with screenshots. +- Firebase + React Native wiring (credentials, the push package, the Google Services plugin). +- Wiring the package's notification and call handlers into your app. +- Native Android setup (Gradle, notification icon) — no manifest entries or Kotlin code to write. +- Token registration, notification/call handling, navigation, testing, and troubleshooting. +- App icon badge count using `unreadMessageCount` from the CometChat push payload. ## How FCM + CometChat work together -- **FCM (Android) is the transport:** Firebase issues the Android FCM token and delivers payloads to devices. -- **CometChat provider holds your credentials:** The FCM provider you add (for React Native Android) stores your Firebase service account JSON. -- **Registration flow:** Request permission → Android returns the FCM token → after `CometChat.login`, register with `CometChatNotifications.registerPushToken(token, platform, providerId)` using `FCM_REACT_NATIVE_ANDROID` → CometChat sends pushes to FCM on your behalf → the app handles taps/foreground events via Notifee. +- **FCM's role:** Issues the Android registration token and delivers the push payload to the device. +- **CometChat's role:** The FCM provider you add in the CometChat dashboard stores your Firebase service account. When `CometChatPushNotifications.init()` runs after login, the package registers the token for the logged-in user, and CometChat sends pushes to FCM on your behalf. +- **The package's role:** Its own `FirebaseMessagingService` receives each push and shows the chat notification or the full-screen incoming call. Every CometChat action — registering the token, accepting or rejecting a call — runs in JavaScript through the Chat SDK your app already uses. +- **Flow:** Permission (Android 13+ `POST_NOTIFICATIONS`) → Firebase returns the FCM token → after login, `init()` registers it with `AppCredentials.fcmProviderId` → CometChat sends to FCM → FCM delivers to the device → the package shows the notification or call → your `onNotificationTap`, `onCallAccepted` and `onCallEnded` handlers navigate. ## 1. Enable push and add providers (CometChat Dashboard) @@ -52,909 +48,673 @@ description: "Bring the SampleAppWithPushNotifications experience—FCM + VoIP c Enable Push Notifications -2. Add an **FCM** provider for React Native Android; upload the Firebase service account JSON and copy the Provider ID. +2. Click **Add Credentials**, choose **FCM**, upload the Firebase service account JSON (Firebase → Project settings → Service accounts → Generate new private key), and copy the Provider ID. Upload FCM service account JSON -## 2. Prepare platform credentials +Keep the provider ID—you'll use it in `AppCredentials.fcmProviderId`. + +## 2. Prepare Firebase and credentials ### 2.1 Firebase Console -1. Register your Android package name (same as `applicationId` in `android/app/build.gradle`) and download `google-services.json` into `android/app`. +1. Register your Android package name (the same as `applicationId` in `android/app/build.gradle`) and download `google-services.json` into `android/app`. 2. Enable Cloud Messaging. - Firebase - Push Notifications + Firebase - Push Notifications -## 3. Local configuration - -- Update `src/utils/AppConstants.tsx` with `appId`, `authKey`, `region`, and `fcmProviderId`. -- Keep `app.json` name consistent with your bundle ID / applicationId. - -```ts lines -const APP_ID = ""; -const AUTH_KEY = ""; -const REGION = ""; -const DEMO_UID = "cometchat-uid-1"; -``` - -### 3.1 Dependencies snapshot (from Sample App) - -Install these dependencies in your React Native app: - -```npm lines -npm install \ - @react-native-firebase/app@23.4.0 \ - @react-native-firebase/messaging@23.4.0 \ - @notifee/react-native@9.1.8 \ - @cometchat/chat-sdk-react-native@4.0.18 \ - @cometchat/calls-sdk-react-native@4.4.0 \ - @cometchat/chat-uikit-react-native@5.2.6 \ - @react-native-async-storage/async-storage@2.2.0 \ - react-native-callkeep@github:cometchat/react-native-callkeep \ - react-native-voip-push-notification@3.3.3 -``` - -Match these or newer compatible versions in your app. - -## 4. Android App Setup - -### 4.1 Configure Firebase with Android credentials - -To allow Firebase on Android to use the credentials, the `google-services` plugin must be enabled on the project. This requires modification to two files in the Android directory. +### 2.2 Local configuration file -First, add the google-services plugin as a dependency inside of your `/android/build.gradle` file: +Create `src/AppCredentials.ts` with your app credentials and provider IDs. The same file serves the [iOS guide](/notifications/react-native-push-notifications-ios): -```android lines -buildscript { - dependencies { - // ... other dependencies - classpath("com.google.gms:google-services:4.4.4") - } -} -``` - -Lastly, execute the plugin by adding the following to your `/android/app/build.gradle` file: +```ts src/AppCredentials.ts lines +export const AppCredentials = { + appId: 'YOUR_APP_ID', + region: 'YOUR_REGION', + authKey: 'YOUR_AUTH_KEY', -```android lines -apply plugin: 'com.android.application' -apply plugin: 'com.google.gms.google-services' -``` + // Android — the FCM provider ID from the CometChat dashboard + fcmProviderId: 'FCM-PROVIDER-ID', -### 4.2 Configure required permissions in `AndroidManifest.xml` as shown. - -```xml lines - - - - - - - - - - - - - - - - + // iOS — one APNs provider covers both the device token and the VoIP token + apnsProviderId: 'APNS-PROVIDER-ID', +}; ``` -and ask for runtime permissions where needed (e.g. `POST_NOTIFICATIONS` on Android 13+). +## 3. Bring the push package into React Native -```tsx lines -import { PermissionsAndroid, Platform } from "react-native"; - - const requestAndroidPermissions = async () => { - if (Platform.OS !== 'android') return; - - try { - // Ask for push‑notification permission - const authStatus = await messaging().requestPermission(); - const enabled = - authStatus === messaging.AuthorizationStatus.AUTHORIZED || - authStatus === messaging.AuthorizationStatus.PROVISIONAL; - - if (!enabled) { - console.warn('Notification permission denied (FCM).'); - } - } catch (error) { - console.warn('FCM permission request error:', error); - } +### 3.1 Install the package - try { - await PermissionsAndroid.requestMultiple([ - PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE, - PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE, - PermissionsAndroid.PERMISSIONS.CAMERA, - PermissionsAndroid.PERMISSIONS.RECORD_AUDIO, - PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS, - ]); - } catch (err) { - console.warn('Android permissions error:', err); - } -} +```bash +npm install @cometchat/push-notifications-react-native ``` -### 4.3 Register FCM token with CometChat + +**Remove other push and call libraries first** — `@notifee/react-native`, `react-native-callkeep`, `react-native-voip-push-notification` — along with their code and native setup, or every notification or call arrives twice. If you keep `@react-native-firebase/messaging` for other features, follow step 4.5. + -Inside your main app file where you initialize CometChat, add the below code snippet after the user has logged in successfully. -Initilize and register the FCM token for Android as shown: +### 3.2 Wire the entry points -```ts lines -requestAndroidPermissions(); - -const FCM_TOKEN = await messaging().getToken(); -console.log("FCM Token:", FCM_TOKEN); - -// For React Native Android -CometChatNotifications.registerPushToken( - FCM_TOKEN, - CometChatNotifications.PushPlatforms.FCM_REACT_NATIVE_ANDROID, - "YOUR_FCM_PROVIDER_ID" // from CometChat Dashboard - ) - .then(() => { - console.log("Token registration successful"); - }) - .catch((err) => { - console.log("Token registration failed:", err); - }); -``` + +The JavaScript below is the same for Android and iOS — one set of files serves both guides. Lines for one platform do nothing on the other: `registerBackgroundCallTask()` and `notificationSmallIcon` only apply on Android, and waiting for each permission answer before the next request matters only on Android. + -### 4.4 Unregister FCM token on logout +**`index.js`** — register the package's background task at module scope. It lets a **fully killed** app reject a call declined from its notification: the package re-initializes the Chat SDK with the settings `init()` saved, and rejects the call. -Typically, push token unregistration should occur prior to user logout, using the `CometChat.logout()` method. -For token unregistration, use the `CometChatNotifications.unregisterPushToken()` method provided by the SDKs. +```js index.js lines +import { AppRegistry } from 'react-native'; +import { registerBackgroundCallTask } from '@cometchat/push-notifications-react-native'; +import App from './App'; +import { name as appName } from './app.json'; -## 5. VoIP call notifications +// Android: lets a FULLY KILLED app reject a call declined from its notification. The package +// does the work — this only registers its background task. (No-op on iOS.) +registerBackgroundCallTask(); -These steps are Android-only—copy/paste and fill your IDs. +AppRegistry.registerComponent(appName, () => App); +``` -### 5.1 Add CallKeep services to `android/app/src/main/AndroidManifest.xml` -Inside the `` tag add: +To add your own logic, pass a handler — it runs **after** the package has rejected the call: -```xml lines - - - - - +```js lines +registerBackgroundCallTask(async (action, info) => { + // Your logic, e.g. record the declined call. The Chat SDK is initialized and logged in here. + console.log('Declined call from', info.callerUid); +}); - +// Or reject it yourself instead: +// registerBackgroundCallTask(myHandler, { rejectDeclinedCalls: false }); ``` -### 5.2 Background handler for call pushes (`index.js`) -Data-only FCM calls show the native dialer even when the app is killed. - -```js lines -import messaging from "@react-native-firebase/messaging"; -import { Platform } from "react-native"; -import { CometChat } from "@cometchat/chat-sdk-react-native"; -import { voipHandler } from "./VoipNotificationHandler"; -import { displayLocalNotification } from "./LocalNotificationHandler"; - -if (Platform.OS === "android") { - messaging().setBackgroundMessageHandler(async remoteMessage => { - const data = remoteMessage.data || {}; - if (data.type === "call") { - await voipHandler.initialize(); - switch (data.callAction) { - case "initiated": - voipHandler.msg = data; - await voipHandler.displayCallAndroid(); - break; - case "ended": - case "unanswered": - case "busy": - case "rejected": - case "cancelled": - CometChat.clearActiveCall(); - if (voipHandler?.callerId) { - voipHandler.removeCallDialerWithUUID(voipHandler.callerId); - } - await voipHandler.endCall({ callUUID: voipHandler.callerId }); - break; - case "ongoing": - voipHandler.displayNotification({ - title: data?.receiverName || "", - body: "ongoing call", - }); - break; - default: - break; - } - return; - } - await displayLocalNotification(remoteMessage); +**`src/navigation/navigationRef.ts`** — a notification tap or answered call that **launched** the app arrives before your navigator exists, so every navigation waits for it: + +```ts src/navigation/navigationRef.ts lines +import { createNavigationContainerRef } from '@react-navigation/native'; + +/** Pass this to your . */ +export const navigationRef = createNavigationContainerRef(); + +/** + * Resolves once the NavigationContainer is mounted. A notification tap or answered call + * that LAUNCHED the app arrives before the navigator exists, and navigating then is + * silently dropped. The ref queues listeners added before it mounts. + */ +export function whenNavigationReady(): Promise { + if (navigationRef.isReady()) return Promise.resolve(); + return new Promise(resolve => { + const unsubscribe = navigationRef.addListener('ready', () => { + unsubscribe(); + resolve(); + }); }); } -``` -### 5.3 Drop in `VoipNotificationHandler.ts` -Handles CallKeep setup, shows the incoming call UI, accepts/rejects via CometChat, and defers acceptance if login/navigation isn’t ready. +/** Navigate by route name once the navigator is ready. */ +export async function navigate(name: string, params?: object): Promise { + await whenNavigationReady(); + (navigationRef.navigate as (name: string, params?: object) => void)(name, params); +} +``` -```ts lines -import { Platform } from "react-native"; -import notifee, { AndroidImportance } from "@notifee/react-native"; -import RNCallKeep, { IOptions } from "react-native-callkeep"; -import { CometChat } from "@cometchat/chat-sdk-react-native"; -import { setPendingAnsweredCall } from "./PendingCallManager"; - -const options: IOptions = { - android: { - alertTitle: "VoIP permissions", - alertDescription: "Allow phone account access to show incoming calls", - cancelButton: "Cancel", - okButton: "OK", - imageName: "ic_notification", - additionalPermissions: [], - foregroundService: { - channelId: "com.cometchat.sampleapp.reactnative.android", - channelName: "Sampleapp Channel", - notificationTitle: "Sampleapp is running in the background", - }, - }, - ios: { appName: "Sampleapp" }, -}; +**`src/push/pushNotifications.ts`** — everything push does for the logged-in user: the tap, call-accepted and call-ended handlers, the permission requests, and `init()`: + +```ts src/push/pushNotifications.ts lines +import { useEffect, useState } from 'react'; +import { CometChat } from '@cometchat/chat-sdk-react-native'; +import { CometChatCalls } from '@cometchat/calls-sdk-react-native'; +import { CometChatUIEventHandler, MessageEvents } from '@cometchat/chat-uikit-react-native'; +import { + CometChatPNHelper, + CometChatPushNotifications, + type PNCallEndEvent, + type PNCallInfo, + type PNNotificationTapInfo, +} from '@cometchat/push-notifications-react-native'; + +import { AppCredentials } from '../AppCredentials'; +import { navigate, navigationRef } from '../navigation/navigationRef'; + +/** Your navigator's route names — these are the CometChat UI Kit sample app's. */ +const SCREENS = { + messages: 'Messages', + thread: 'ThreadView', + ongoingCall: 'OngoingCallScreen', + home: 'BottomTabNavigator', +} as const; + +const LOGIN_LISTENER_ID = 'push-notifications-login'; + +/** + * Starts push for the logged-in user. Call it from React with `usePushOnLogin()` (below) + * rather than directly: it returns a cleanup that must run on logout, or every handler + * fires twice after the next login. + */ +export function setupPushOnLogin(): () => void { + // Subscribe BEFORE init(): the tap or answered call that LAUNCHED the app is delivered + // as soon as init() runs. + const unsubscribes = [ + CometChatPushNotifications.onNotificationTap(openFromNotification), + CometChatPushNotifications.onCallAccepted(openCallScreen), + CometChatPushNotifications.onCallEnded(endCall), + ]; + + const start = async () => { + // Await each permission request before the next — Android allows only one pending + // request per activity. A rejection means the OS could not be asked (not that the user + // declined), and must not stop init(): the push token still has to register. + await CometChatPNHelper.requestNotificationPermission().catch(() => false); + await CometChatPNHelper.requestCallPermissions(); // mic + camera, needed before a call connects + + await CometChatPushNotifications.init({ + fcmProviderId: AppCredentials.fcmProviderId, // Android + apnsProviderId: AppCredentials.apnsProviderId, // iOS (APNs device + VoIP) + notificationSmallIcon: 'ic_notification', // Android status-bar icon + showInForeground: true, // one notification while the app is open, too + ringInForeground: false, // your app rings while it's open — see src/calls/IncomingCall.tsx + }); + }; + start().catch(error => console.log('Push setup failed:', error)); -function uuid() { - return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { - const r = Math.floor(Math.random() * 16); - const v = c === "x" ? r : (r & 0x3) | 0x8; - return v.toString(16); - }); + return () => unsubscribes.forEach(unsubscribe => unsubscribe()); } -class VoipNotificationHandler { - channelId = ""; - isRinging = false; - isAnswered = false; - pendingAcceptance = false; - callerId = ""; - msg: any = {}; - initialized = false; - private setupPromise: Promise | null = null; - private listenersAttached = false; - - async initialize() { - if (this.initialized && this.setupPromise) { - await this.setupPromise; - return; - } - if (!this.setupPromise) { - this.setupPromise = (async () => { - if (Platform.OS === "android") { - await this.createNotificationChannel(); - } - await this.getPermissions(); - this.setupEventListeners(); - this.initialized = true; - })().catch((err) => { - this.setupPromise = null; - throw err; - }); - } - await this.setupPromise; - } - - async getPermissions() { - await RNCallKeep.setup(options); - RNCallKeep.setAvailable(true); - RNCallKeep.setReachable(); - try { - await RNCallKeep.checkPhoneAccountEnabled(); - } catch {} - } +/** + * Runs push while a user is logged in — after a fresh login AND after a session restored + * on launch — and cleans up on logout. Use it once, in a component rendered after + * CometChat has been initialized. + */ +export function usePushOnLogin(): void { + const [loggedIn, setLoggedIn] = useState(false); + + useEffect(() => { + // A restored session never fires loginSuccess, so check once on mount. + CometChat.getLoggedinUser() + .then(user => setLoggedIn(!!user)) + .catch(() => setLoggedIn(false)); + + CometChat.addLoginListener( + LOGIN_LISTENER_ID, + new CometChat.LoginListener({ + loginSuccess: () => setLoggedIn(true), + logoutSuccess: () => setLoggedIn(false), + }), + ); + return () => CometChat.removeLoginListener(LOGIN_LISTENER_ID); + }, []); - async createNotificationChannel() { - this.channelId = await notifee.createChannel({ - id: "message", - name: "Messages", - lights: true, - vibration: true, - importance: AndroidImportance.HIGH, - }); - } + useEffect(() => { + if (!loggedIn) return; + return setupPushOnLogin(); + }, [loggedIn]); +} - async displayNotification({ - title, - body, - data, - }: { - title: string; - body: string; - data?: any; - }) { - if (Platform.OS === "android" && !this.channelId) - await this.createNotificationChannel(); - await notifee.displayNotification({ - title, - body, - data, - android: this.channelId - ? { channelId: this.channelId, smallIcon: "ic_launcher" } - : undefined, - }); - } +/** Open the thread for a thread reply, otherwise the conversation. */ +async function openFromNotification(info: PNNotificationTapInfo): Promise { + const isGroup = info.receiverType === 'group'; + try { + const user = !isGroup && info.sender ? await CometChat.getUser(info.sender) : undefined; + const group = isGroup && info.receiver ? await CometChat.getGroup(info.receiver) : undefined; + if (!user && !group) return; - async displayCallAndroid() { - if (this.isAnswered || this.pendingAcceptance) return; - await this.initialize(); - this.isRinging = true; - this.callerId = uuid(); - const callerName = this.msg?.senderName || "Incoming Call"; - await RNCallKeep.displayIncomingCall( - this.callerId, - callerName, - callerName, - "generic", - ); - } + markConversationRead(isGroup ? info.receiver! : info.sender!, isGroup); - onAnswerCall = async ({ callUUID }: { callUUID: string }) => { - if (this.isAnswered) return; - this.isRinging = false; - this.isAnswered = true; - const sessionID = this.msg?.sessionId; - if (!sessionID) return; - - setTimeout(async () => { - const loggedInUser = await CometChat.getLoggedinUser().catch(() => null); - if (!loggedInUser) { - this.pendingAcceptance = true; - await setPendingAnsweredCall({ - sessionId: sessionID, - raw: this.msg, - storedAt: Date.now(), - }); - try { - RNCallKeep.backToForeground(); - } catch (err) { - // Activity may not exist yet if app was killed - the pending call will be handled when app opens - console.log( - "[VoIP] backToForeground failed, pending call saved:", - err, - ); - } - return; - } + if (info.parentMessageId) { try { - await CometChat.acceptCall(sessionID); - } catch (error: any) { - if (error?.code !== "ERR_CALL_USER_ALREADY_JOINED") throw error; - } - RNCallKeep.endAllCalls(); - this.pendingAcceptance = false; - }, 600); - }; - - endCall = async ({ callUUID }: { callUUID: string }) => { - if (this.msg?.type === "call") { - const sessionID = this.msg.sessionId; - if (this.isAnswered && sessionID) { - this.isAnswered = false; - CometChat.endCall(sessionID); - } else if (sessionID) { - const loggedInUser = await CometChat.getLoggedinUser().catch( - () => null, - ); - if (loggedInUser) { - setTimeout(() => { - CometChat.rejectCall(sessionID, CometChat.CALL_STATUS.REJECTED); - }, 300); - } + const parent = await CometChat.getMessageDetails(info.parentMessageId); + // The thread screen needs the user or group, not just the parent message. + await navigate(SCREENS.thread, { message: parent, user, group, highlightMessageId: info.messageId }); + return; + } catch (error) { + console.log('Could not open the thread, opening the conversation:', error); } } - const id = callUUID || this.callerId; - if (id) RNCallKeep.endCall(id); - RNCallKeep.endAllCalls(); - this.isRinging = false; - this.isAnswered = false; - this.pendingAcceptance = false; - this.callerId = ""; - this.msg = {}; - }; - - removeCallDialerWithUUID = (callerId: string) => { - const id = callerId || this.callerId; - if (id) RNCallKeep.reportEndCallWithUUID(id, 6); - }; - - setupEventListeners() { - if (this.listenersAttached) return; - RNCallKeep.addEventListener("answerCall", this.onAnswerCall); - RNCallKeep.addEventListener("endCall", this.endCall); - RNCallKeep.addEventListener("didDisplayIncomingCall", ({ callUUID }) => { - if (callUUID) this.callerId = callUUID; - this.isRinging = true; - }); - this.listenersAttached = true; + await navigate(SCREENS.messages, { user, group }); + } catch (error) { + console.log('Could not open the conversation from a notification:', error); } } -export const voipHandler = new VoipNotificationHandler(); -``` - -### 5.4 Add `PendingCallManager.ts` -Stores an answered call during cold-start so you can accept it once login/navigation is ready. - -```ts lines -import AsyncStorage from "@react-native-async-storage/async-storage"; - -export interface PendingAnsweredCallPayload { - sessionId: string; - raw: any; - storedAt: number; +/** Mark the conversation read and clear its unread badge in the UI Kit's conversation list. */ +function markConversationRead(conversationWith: string, isGroup: boolean): void { + const type = isGroup ? CometChat.RECEIVER_TYPE.GROUP : CometChat.RECEIVER_TYPE.USER; + CometChat.markConversationAsRead(conversationWith, type) + .then(() => CometChat.getConversation(conversationWith, type)) + .then(conversation => { + const lastMessage = conversation.getLastMessage(); + if (lastMessage) { + CometChatUIEventHandler.emitMessageEvent(MessageEvents.ccMessageRead, { message: lastMessage }); + } + }) + .catch(error => console.log('Could not mark the conversation read:', error)); } -let inMemoryPending: PendingAnsweredCallPayload | null = null; -const STORAGE_KEY = "pendingAnsweredCall"; - -export async function setPendingAnsweredCall(payload: PendingAnsweredCallPayload) { - inMemoryPending = payload; - try { await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); } catch {} +/** The package has already accepted the call — just show the call screen. */ +function openCallScreen(info: PNCallInfo): void { + navigate(SCREENS.ongoingCall, { sessionId: info.sessionId, callType: info.callType }); } -export async function consumePendingAnsweredCall(): Promise { - if (inMemoryPending) { - const tmp = inMemoryPending; - inMemoryPending = null; - try { await AsyncStorage.removeItem(STORAGE_KEY); } catch {} - return tmp; - } +/** + * A ringing call was cancelled or declined, or the user ended the call from the iOS call + * screen — which the Calls SDK does not see, so tear the call down here. + */ +function endCall(info: PNCallEndEvent): void { + if (info.sessionId) CometChat.endCall(info.sessionId).catch(() => {}); try { - const raw = await AsyncStorage.getItem(STORAGE_KEY); - if (raw) { - await AsyncStorage.removeItem(STORAGE_KEY); - const parsed: PendingAnsweredCallPayload = JSON.parse(raw); - inMemoryPending = null; - return parsed; - } + CometChatCalls.endSession(); + } catch {} + try { + CometChat.clearActiveCall(); } catch {} - return null; + if (navigationRef.isReady() && navigationRef.getCurrentRoute()?.name === SCREENS.ongoingCall) { + navigate(SCREENS.home); + } } +``` -export function isPendingStale(p: PendingAnsweredCallPayload, maxAgeMs = 2 * 60 * 1000) { - return Date.now() - p.storedAt > maxAgeMs; +**`src/calls/IncomingCall.tsx`** — `init()` above sets `ringInForeground: false`, so **while the app is open the package doesn't ring: your app must show its own incoming-call screen**, or calls won't ring at all while it's open. Calls reach an open app over the Chat SDK's connection; this component listens for them and shows the UI Kit's `CometChatIncomingCall`, which accepts the call and shows the call screen itself: + +```tsx src/calls/IncomingCall.tsx lines +import React, { useEffect, useState } from 'react'; +import { CometChat } from '@cometchat/chat-sdk-react-native'; +import { CometChatIncomingCall, CometChatUIEventHandler } from '@cometchat/chat-uikit-react-native'; + +const LISTENER_ID = 'incoming-call'; + +/** + * Rings for a call while the app is open — init() sets ringInForeground: false, so the + * package leaves this to the app. CometChatIncomingCall accepts the call and shows the call + * screen itself; this component shows it when a call arrives and removes it when the call is + * declined, cancelled by the caller, or ends. + */ +export function IncomingCall() { + const [call, setCall] = useState(null); + + useEffect(() => { + CometChat.addCallListener( + LISTENER_ID, + new CometChat.CallListener({ + onIncomingCallReceived: (incoming: CometChat.Call) => setCall(incoming), + onIncomingCallCancelled: () => setCall(null), // the caller hung up while it was ringing + }), + ); + // An accepted call ended. + CometChatUIEventHandler.addCallListener(LISTENER_ID, { ccCallEnded: () => setCall(null) }); + + return () => { + CometChat.removeCallListener(LISTENER_ID); + CometChatUIEventHandler.removeCallListener(LISTENER_ID); + }; + }, []); + + if (!call) return null; + return setCall(null)} />; } ``` -### 5.5 Wire `App.tsx` to init VoIP + consume pending accepts -Add this after CometChat init/login: + +Your app already shows an incoming-call screen while it's open? Keep it and skip this file. Don't want one? Set `ringInForeground: true` — the default — and skip this file: the package then rings with the system call UI while the app is open, too. + -```ts lines -import { Platform } from "react-native"; -import messaging from "@react-native-firebase/messaging"; -import { CometChat, CometChatNotifications } from "@cometchat/chat-sdk-react-native"; -import { voipHandler } from "./VoipNotificationHandler"; -import { consumePendingAnsweredCall, isPendingStale } from "./PendingCallManager"; - -if (Platform.OS === "android") { - const fcmToken = await messaging().getToken(); - await CometChatNotifications.registerPushToken( - fcmToken, - CometChatNotifications.PushPlatforms.FCM_REACT_NATIVE_ANDROID, - "YOUR_FCM_PROVIDER_ID" - ); + +Not using the UI Kit? Delete the `@cometchat/chat-uikit-react-native` import and the `emitMessageEvent` block in `markConversationRead`, point `SCREENS` and the route params at your own screens, and in `logout.ts` call `CometChat.logout()` instead of `CometChatUIKit.logout()`. For calls while the app is open, set `ringInForeground: true`, or build your own incoming-call screen on `CometChat.addCallListener` in place of `IncomingCall.tsx`. + + +**`App.tsx`** — call `usePushOnLogin()` once, in a component that renders **after** CometChat is initialized, pass `navigationRef` to your `NavigationContainer`, and render `` **before** your navigator: + +```tsx App.tsx lines +import React, { useEffect, useState } from 'react'; +import { NavigationContainer } from '@react-navigation/native'; +import { CometChat } from '@cometchat/chat-sdk-react-native'; +import { CometChatUIKit, UIKitSettings } from '@cometchat/chat-uikit-react-native'; + +import { AppCredentials } from './AppCredentials'; +import { navigationRef } from './navigation/navigationRef'; +import { usePushOnLogin } from './push/pushNotifications'; +import { IncomingCall } from './calls/IncomingCall'; + +export default function App() { + const [initialized, setInitialized] = useState(false); + + useEffect(() => { + // Your existing CometChat initialization. + CometChatUIKit.init({ + appId: AppCredentials.appId, + region: AppCredentials.region, + authKey: AppCredentials.authKey, + subscriptionType: CometChat.AppSettings.SUBSCRIPTION_TYPE_ALL_USERS as UIKitSettings['subscriptionType'], + } as UIKitSettings) + .then(() => setInitialized(true)) + .catch(error => console.log('CometChat init failed:', error)); + }, []); + + // Push must start only after CometChat is initialized. + if (!initialized) return null; + return ; } -useEffect(() => { - if (Platform.OS === "android" && loggedIn) { - const t = setTimeout(() => voipHandler.initialize(), 3000); - return () => clearTimeout(t); - } -}, [loggedIn]); +function Root() { + usePushOnLogin(); // push follows login and logout from here on -// Handle pending calls in a useEffect -useEffect(() => { - const handlePendingCall = async () => { - const pending = await consumePendingAnsweredCall(); - if (pending && !isPendingStale(pending)) { - try { - await CometChat.acceptCall(pending.sessionId); - } catch (err) { - console.log(err); - } - } - }; - handlePendingCall(); -}, []); + return ( + + {/* before your navigator: it shows at the top, and an accepted call fills the screen */} + {/* your existing navigator */} + + ); +} ``` -### 5.6 Call push payload (FCM data) -Send a data-only FCM message like: +`usePushOnLogin()` starts push after a fresh login **and** when a session is restored on launch, and removes the handlers when the user logs out — so a later login never registers them twice. `` goes before your navigator because the UI Kit's incoming-call screen isn't a modal: rendered first, it shows at the top of the screen, and an accepted call fills the screen. -```json -{ - "to": "", - "priority": "high", - "data": { - "type": "call", - "callAction": "initiated", - "sessionId": "", - "senderName": "Alice", - "receiverName": "Bob" - } -} -``` +### 3.3 Align dependencies and configuration -### 5.7 Local notification helper (`LocalNotificationHandler.ts`) -> Ensure `@notifee/react-native` is installed (listed in Dependencies above). -Add this helper next to your `index.js` to show local alerts for non-call pushes: +- **Peer dependencies:** `@cometchat/chat-sdk-react-native` (or the UI Kit) for chat, `@cometchat/calls-sdk-react-native` for calls, and React Navigation for the handlers above. +- **`init()` options:** + - `fcmProviderId` (Android) and `apnsProviderId` (iOS) — from step 1. + - `notificationSmallIcon` — the Android status-bar icon. + - `showInForeground` (default `false`) — show chat notifications while the app is open. + - `ringInForeground` (default `true`) — ring with the system call UI while the app is open. With `false`, a call that arrives while the app is open is left to your app, so your app must show its own incoming-call screen — `IncomingCall.tsx` above. With `false` and no such screen, calls don't ring while the app is open. + - `voip` (default `true`) — `false` turns calls off for a chat-only app: no call rings, and iOS doesn't register the VoIP token. + - `androidChannelId`, `androidChannelName` — the Android notification channel. -```ts lines -import { Platform } from "react-native"; -import notifee, { AndroidImportance } from "@notifee/react-native"; - -const CHANNEL_ID = "default"; - -async function ensureChannel(): Promise { - if (Platform.OS !== "android") return undefined; - return notifee.createChannel({ - id: CHANNEL_ID, - name: "Default", - lights: true, - vibration: true, - importance: AndroidImportance.HIGH, - }); -} +## 4. Configure the native Android layer -export async function displayLocalNotification(remoteMessage: any) { - try { - const { notification = {}, data = {} } = remoteMessage || {}; - const title = notification?.title || data?.title || "Notification"; - const body = notification?.body || data?.body || ""; +### 4.1 Gradle + Firebase - if (Platform.OS === "ios") { - await notifee.requestPermission(); - } +1. Add `google-services.json` to `android/app`. +2. Apply the Google Services plugin. The package already depends on `firebase-messaging`, so don't add it yourself: - const channelId = await ensureChannel(); - - await notifee.displayNotification({ - title, - body, - data, - android: channelId - ? { - channelId, - pressAction: { id: "default" }, - importance: AndroidImportance.HIGH, - smallIcon: "ic_launcher", - } - : undefined, - }); - } catch (error) { - console.error("[LocalNotificationHandler] Failed to display notification", error); +```groovy android/build.gradle lines +buildscript { + dependencies { + classpath("com.google.gms:google-services:4.4.2") } } ``` -- For a proper notification icon, create a dedicated `ic_notification.xml` (vector) or PNG in `android/app/src/main/res/drawable/`; Android expects a white glyph with transparency for best results. -## 6. Handling notification taps and navigation +```groovy android/app/build.gradle lines +apply plugin: "com.google.gms.google-services" +``` -To handle notification taps and navigate to the appropriate chat screen, you need to set up handlers for both foreground and background notifications. +Keep `minSdkVersion 24` or higher. -{/* :TODO: Add code snippets and explanation for setting up Notifee handlers and navigation logic. */} +### 4.2 Manifest permissions and components +You don't edit `AndroidManifest.xml`. The package's manifest is merged into your app with everything push and calls need: -## 7. Badge Count Implementation +- **Components:** its `FirebaseMessagingService`, the incoming-call foreground service, the full-screen `CallRingingActivity` (shows over the lock screen), the call action receiver, and the background task service for killed-app declines. +- **Permissions:** `POST_NOTIFICATIONS`, `USE_FULL_SCREEN_INTENT`, `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_PHONE_CALL`, `MANAGE_OWN_CALLS`, `WAKE_LOCK`, `VIBRATE`, `RECORD_AUDIO`, `CAMERA` and `BLUETOOTH_CONNECT`. Camera and microphone hardware are declared optional, so Google Play doesn't hide your app from devices without them. -CometChat's Enhanced Push Notification payload includes an `unreadMessageCount` field that represents the total number of unread messages across all conversations for the logged-in user. You can use this value to update the app icon badge, providing users with a visual indicator of unread messages. + +**Google Play (Android 14+):** apps with calls complete two declarations in Play Console under **App content** — **Full-screen intent permission** (calling as core functionality) and **Foreground service permissions** (the **Phone call** type). Without the first, Play revokes `USE_FULL_SCREEN_INTENT` and calls ring only as a heads-up notification. + -### 7.1 Enable Unread Badge Count on the CometChat Dashboard +**Chat-only apps** pass `voip: false` to `init()` so call pushes never ring, remove the call permissions and components (with `xmlns:tools="http://schemas.android.com/tools"` on the `manifest` element), and skip `requestCallPermissions()` and `registerBackgroundCallTask()`: - - - Go to **CometChat Dashboard → Notifications Engine → Settings → Preferences → Push Notification Preferences**. - - - Scroll down and enable the **Unread Badge Count** toggle. - - +```xml android/app/src/main/AndroidManifest.xml lines + + + + + + -Once enabled, CometChat automatically includes the `unreadMessageCount` field in every push payload sent to your app. + + + + +``` -### 7.2 Expected Payload Format +`tools:node="remove"` drops an entry whichever library declared it — keep any permission another part of your app still uses. -CometChat sends push notifications with the following structure: +### 4.3 Notification icon -```jsonc -{ - "data": { - "unreadMessageCount": "5", - "title": "New Message", - "body": "John: Hello!", - "conversationId": "user_abc123", - "parentId": "176001", // Optional - parent message ID; sent only for threaded notifications - "receiverType": "user", - "type": "chat" - } -} -``` +Add a **white-on-transparent** drawable named `ic_notification` — for example `android/app/src/main/res/drawable/ic_notification.png`. Android Studio generates one: right-click `res` → **New → Image Asset**, icon type **Notification Icons**, name `ic_notification`. Without it, the package falls back to your launcher icon, which the status bar draws as a plain white shape. - -The `unreadMessageCount` field is a **string** representing the total unread messages across all conversations for the logged-in user. - +There is no Kotlin bridge to write: the ringing screen, the Answer and Decline actions, and the killed-app decline are built into the package. -### 7.3 Handle Badge Count in Background Messages +### 4.4 OEM permissions for lock-screen calls -Update your FCM background message handler in `index.js` to extract and set the badge count: +Stock Android shows the full-screen call over the lock screen out of the box. **OEM skins (MIUI/Redmi/POCO, Oppo, Vivo) gate background-launched full-screen activities** behind their own toggles — without them, a locked or killed call shows only a heads-up notification, and the ringing screen appears after unlock. Guide users to grant, on those devices: -```javascript -import messaging from "@react-native-firebase/messaging"; -import notifee from "@notifee/react-native"; +- **Autostart** — Settings → Apps → *your app* → Autostart (or the Security app). +- **Display pop-up windows while running in background** — Settings → Apps → *your app* → Other permissions. +- **Show on lock screen** — the same "Other permissions" screen. +- Disable **battery optimization** for the app. -messaging().setBackgroundMessageHandler(async (remoteMessage) => { - const data = remoteMessage.data || {}; +These settings can't be granted programmatically; open the app's settings page so the user can toggle them: - // Extract and set badge count from push payload - const unreadCount = data?.unreadMessageCount; - if (unreadCount !== undefined && unreadCount !== null) { - const count = parseInt(unreadCount, 10); - if (!isNaN(count) && count >= 0) { - try { - await notifee.setBadgeCount(count); - console.log("Badge count updated (Android):", count); - } catch (error) { - console.error("Error setting badge:", error); - } - } - } - - // Display local notification - await displayLocalNotification(remoteMessage); -}); +```ts lines +import { Linking, Platform } from 'react-native'; +if (Platform.OS === 'android') Linking.openSettings(); ``` -### 7.4 Handle Badge Count in Foreground Messages - -In your `App.tsx`, set up a listener for foreground FCM messages: - -```typescript -import messaging from "@react-native-firebase/messaging"; -import notifee from "@notifee/react-native"; - -useEffect(() => { - if (Platform.OS === "android") { - const unsubscribe = messaging().onMessage(async (remoteMessage) => { - // Extract and set badge count from push payload - const unreadCount = remoteMessage.data?.unreadMessageCount; - if (unreadCount !== undefined && unreadCount !== null) { - const count = parseInt(unreadCount as string, 10); - if (!isNaN(count) && count >= 0) { - try { - await notifee.setBadgeCount(count); - console.log("Badge count updated (Android):", count); - } catch (error) { - console.error("Error setting badge:", error); - } - } - } - - // Display local notification - await displayLocalNotification(remoteMessage); - }); +### 4.5 Apps that also use `@react-native-firebase/messaging` - return () => unsubscribe(); - } -}, []); -``` +Android delivers FCM messages and token refreshes to only **one** `FirebaseMessagingService`, and React Native Firebase ships its own — so with both installed, one of them silently receives nothing. Replace both with a service of your own that forwards to each: -### 7.5 Display Local Notification with Badge Count +1. Remove both library services and register yours: -Update your notification display function to include the badge count: +```xml android/app/src/main/AndroidManifest.xml lines + + + + + + + + + +``` -```typescript -import notifee, { AndroidImportance } from "@notifee/react-native"; +2. Add Firebase Messaging to your app so the service can extend it (use the BOM version your other Firebase libraries use): -export async function displayLocalNotification(remoteMessage: any) { - const { title, body, senderAvatar } = remoteMessage.data || {}; +```groovy android/app/build.gradle lines +dependencies { + implementation platform("com.google.firebase:firebase-bom:33.16.0") + implementation "com.google.firebase:firebase-messaging" +} +``` - // Create notification channel - const channelId = await notifee.createChannel({ - id: "chat-messages", - name: "Chat Messages", - vibration: true, - importance: AndroidImportance.HIGH, - }); +3. Add the service next to `MainApplication.kt`: - // Parse badge count from payload - const unreadCount = remoteMessage.data?.unreadMessageCount; - const badgeCount = unreadCount ? parseInt(unreadCount, 10) : undefined; +```kotlin AppMessagingService.kt lines +package com.yourapp // your app's package - // Optionally enhance title with unread count - const displayTitle = - badgeCount && badgeCount > 1 - ? `${title || "New Message"} (${badgeCount} unread)` - : title || "New Message"; +import com.cometchat.pushnotification.reactnative.CometChatFcmService +import com.google.firebase.messaging.RemoteMessage +import io.invertase.firebase.messaging.ReactNativeFirebaseMessagingService - // Update badge count - if (badgeCount && badgeCount > 0) { - await notifee.setBadgeCount(badgeCount); +class AppMessagingService : ReactNativeFirebaseMessagingService() { + override fun onMessageReceived(message: RemoteMessage) { + // CometChat's pushes are shown by this package; anything else goes on. + if (!CometChatFcmService.handleMessage(this, message)) super.onMessageReceived(message) } - // Display notification with fixed ID to prevent badge accumulation - // on devices that sum badge counts from multiple notifications - await notifee.displayNotification({ - id: "chat-notification", - title: displayTitle, - body: body || "You received a new message.", - android: { - channelId, - autoCancel: true, - smallIcon: "ic_notification", - largeIcon: - senderAvatar || - "https://cdn-icons-png.flaticon.com/512/149/149071.png", - importance: AndroidImportance.HIGH, - badgeCount: badgeCount, - pressAction: { - id: "default", - }, - }, - data: { - receiverType: remoteMessage.data?.receiverType, - sender: remoteMessage.data?.sender, - conversationId: remoteMessage.data?.conversationId, - }, - }); + override fun onNewToken(token: String) { + CometChatFcmService.handleNewToken(this, token) + super.onNewToken(token) + } } ``` -### 7.6 Clear Badge When App Becomes Active +4. React Native Firebase also hands every push to its JavaScript handlers. Skip CometChat's there, in `onMessage` and `setBackgroundMessageHandler`: -Clear all notifications and reset the badge when the app returns to the foreground: +```ts lines +messaging().onMessage(async (remoteMessage) => { + if (CometChatPNHelper.isCometChatNotification(remoteMessage.data)) return; // shown by this package + // your handling +}); +``` -```typescript -import { AppState, AppStateStatus, Platform } from "react-native"; -import notifee from "@notifee/react-native"; +## 5. Token registration and runtime events -useEffect(() => { - const handleAppStateChange = async (nextState: AppStateStatus) => { - if (nextState === "active" && Platform.OS === "android") { - // Clear all notifications (also resets badge count) - await notifee.cancelAllNotifications(); - console.log("Notifications cleared (Android)"); - } - }; +### 5.1 FCM tokens - const subscription = AppState.addEventListener("change", handleAppStateChange); - return () => subscription.remove(); -}, []); -``` +The package fetches the FCM token during `init()` and registers it with your FCM provider for the logged-in user; when Firebase refreshes the token, it registers the new one. If `init()` runs a moment before login finishes, registration retries 5 times, 3 seconds apart. -### 7.7 Clear Badge on Logout +`setupPushOnLogin()` requests the permissions, in order, before `init()`. To check or request them elsewhere: -When a user logs out, clear the badge so it doesn't show a stale count on the login screen or for the next user: +```ts lines +const granted = await CometChatPNHelper.requestNotificationPermission().catch(() => false); +await CometChatPNHelper.requestCallPermissions(); // mic + camera (Android); iOS asks on first use +const enabled = await CometChatPNHelper.hasNotificationPermission(); // checks without prompting +``` + + +**Always `await` one permission request before starting the next.** Android allows only one pending request per activity: a second request cancels the dialog still on screen, and the OS reports it as denied without the user seeing it. On a fresh install that leaves the app with no notification permission — pushes arrive and are dropped. + -```typescript -import notifee from "@notifee/react-native"; -import { CometChat, CometChatNotifications } from "@cometchat/chat-sdk-react-native"; +`requestNotificationPermission()` resolves `true` or `false` on the user's answer. It **rejects** when the permission could not be requested at all — a different situation from the user declining, and not a reason to skip `init()`: -const handleLogout = async () => { - // Unregister push token first - await CometChatNotifications.unregisterPushToken(); +| Rejection | Meaning | +| --- | --- | +| `ERR_NO_ACTIVITY` | No foreground activity, so no dialog can be shown. Retry when the app is in the foreground. | +| `ERR_PERMISSION_IN_FLIGHT` | A request is already open. Await that one instead. | +| `ERR_ACTIVITY_NOT_PERMISSION_AWARE` | Your host activity does not extend `ReactActivity`. Fix the activity. | - // Clear badge before logout - await notifee.setBadgeCount(0); - await notifee.cancelAllNotifications(); +You rarely need it, but you can register a token yourself: - // Logout from CometChat - await CometChat.logout(); - console.log("User logged out, badge cleared"); -}; +```ts lines +await CometChatPushNotifications.registerToken('fcm', token); // or 'apns' / 'voip' ``` -### 7.8 Clear Badge on Fresh Install / No Logged-In User +### 5.2 Local notifications and navigation -Clear the badge during app initialization when no user is logged in. This handles cases where badge count may persist after app reinstall: +- **App in the background or killed:** the package's service shows the notification with your `ic_notification` icon. +- **App open:** the payload goes to `onMessageReceived`; a system notification also shows when `showInForeground` is `true`. +- **Tap:** `onNotificationTap` fires, and `openFromNotification` marks the conversation read, then opens the thread for a thread reply, otherwise the conversation. A tap that **launched** the app is held until your handler subscribes, and navigation waits for the navigator. -```typescript -import notifee from "@notifee/react-native"; -import { CometChat } from "@cometchat/chat-sdk-react-native"; +### 5.3 Call events -// During app initialization, after CometChat.init() -const initializeApp = async () => { - // Initialize CometChat first - await CometChatUIKit.init(uiKitSettings); +| Event | What happens | +| --- | --- | +| **Call push arrives** | App in the background or killed: the package starts a phone-call foreground service with the full-screen ringing screen, over the lock screen too. App open: it rings the same way unless `ringInForeground` is `false` — then your app's `IncomingCall` screen rings instead. The ring stops itself after 45 seconds if nothing ends it. | +| **Accept** | The app opens and the package accepts the call through the Chat SDK; then `onCallAccepted` fires and `openCallScreen` opens your call screen. | +| **Decline** | The package rejects the call through the Chat SDK. In a **fully killed** app the background task registered in `index.js` does it, so the caller sees the call rejected right away. | +| **Caller hangs up** | A cancel push stops the ring and `onCallEnded` fires; `endCall` tears the call down and leaves the call screen. | + +### 5.4 Unregister on logout - // Check if user is logged in - const loggedInUser = await CometChat.getLoggedinUser(); +Add `src/push/logout.ts` and call it from your logout button instead of logging out directly: - if (!loggedInUser) { - // No user logged in - clear any stale badge - await notifee.setBadgeCount(0); - await notifee.cancelAllNotifications(); - console.log("No logged-in user, badge cleared"); +```ts src/push/logout.ts lines +import { CometChatUIKit } from '@cometchat/chat-uikit-react-native'; +import { CometChatPushNotifications } from '@cometchat/push-notifications-react-native'; + +/** Log out and stop this device receiving the user's notifications. Resolves false on failure. */ +export async function logout(): Promise { + // Unregister BEFORE logout: it needs the session's auth token, so after logout it fails + // and the device keeps receiving notifications for the user who just logged out. + try { + await CometChatPushNotifications.unregister(); + } catch (error) { + console.log('Failed to unregister the push token:', error); + return false; } + try { + await CometChatUIKit.logout(); + return true; + } catch (error) { + console.log('Logout failed:', error); + return false; + } +} +``` + +```tsx lines +const onLogoutPress = async () => { + if (loggingOut) return; // ignore a second tap while logging out + setLoggingOut(true); + const loggedOut = await logout(); + setLoggingOut(false); + if (loggedOut) navigation.navigate('Login'); // your login screen }; ``` -### 7.9 Clear Badge in Login Listener (Safety Net) + +`unregister()` must run **before** logout. It needs the session's auth token — after logout it fails, and the device keeps receiving notifications for the user who just logged out. + -Register a login listener to clear the badge on logout as a backup mechanism: +## 6. Badge count -```typescript -import notifee from "@notifee/react-native"; -import { CometChat } from "@cometchat/chat-sdk-react-native"; +CometChat's Enhanced Push Notification payload includes an `unreadMessageCount` field (a string) representing the total unread messages across all conversations for the logged-in user. You can use it to set a launcher badge. -useEffect(() => { - const listenerID = "BADGE_LOGOUT_LISTENER"; +### 6.1 Enable unread badge count on the CometChat Dashboard - CometChat.addLoginListener( - listenerID, - new CometChat.LoginListener({ - logoutOnSuccess: async () => { - // Safety net: clear badge when logout succeeds - await notifee.setBadgeCount(0); - await notifee.cancelAllNotifications(); - console.log("Logout listener: badge cleared"); - }, - }) - ); +1. Go to **CometChat Dashboard → Notifications → Settings → Preferences → Push Notification Preferences**. +2. Scroll to the bottom and enable the **Unread Badge Count** toggle. - return () => { - CometChat.removeLoginListener(listenerID); - }; -}, []); +This ensures CometChat includes the `unreadMessageCount` field in every push payload sent to your app. + +### 6.2 Expected payload format + +CometChat sends FCM data messages with this structure (relevant fields): + +```jsonc +{ + "data": { + "unreadMessageCount": "5", + "title": "New Message", + "alert": "John: Hello!", + "conversationId": "user_abc123", + "parentId": "176001", // Optional - parent message ID; sent only for threaded notifications + "conversationType": "user" + } +} ``` -### 7.10 Key Implementation Notes +`unreadMessageCount` is a string; the package hands every payload value to JavaScript as a string, so convert it with `Number()` before use. -| Consideration | Details | -| --- | --- | -| **Backend-driven badge count** | The `unreadMessageCount` value comes directly from CometChat's backend via the push payload, ensuring consistency across all devices. | -| **Fixed notification ID** | Using a fixed notification ID (`'chat-notification'`) prevents certain devices from accumulating badge counts across multiple notifications. The badge always reflects the exact `unreadMessageCount` from the backend. | -| **Clear on app active** | Always clear the badge when the app becomes active. New notifications will update the badge with the fresh `unreadMessageCount` from the backend. | -| **Clear on logout** | Always clear the badge when a user logs out to prevent stale counts for the next user. | -| **Clear on fresh install** | Clear the badge during app initialization when no user is logged in to handle reinstall scenarios. | -| **Login listener safety net** | Use CometChat's login listener as a backup to ensure badge is cleared on logout. | -| **Title enhancement** | Optionally display the unread count in the notification title (e.g., "John (5 unread)") for devices that don't support app icon badges. | - -## 8. Testing Checklist - -1. Install on a physical Android device, grant `POST_NOTIFICATIONS` permission, log in, and verify FCM token registration succeeds. -2. Send a message from another user: - - **Foreground:** Notifee banner appears unless that chat is already open. - - **Background/terminated:** Tap opens the correct conversation; Notifee background handler runs. -3. **VoIP call:** Send a `callAction=initiated` push; expect the native dialer to appear. Answer and verify the call connects; send `callAction=ended` to dismiss it. -4. Rotate tokens (reinstall or revoke) and confirm `onTokenRefresh` re-registers the new token. - -## 9. Troubleshooting - -| Symptom | Quick Checks | -| --- | --- | -| No pushes | Confirm `google-services.json` location, package IDs match Firebase, Push extension enabled with correct provider IDs, permissions granted. | -| Token registration fails | Ensure registration runs **after login**, provider IDs are set, and `registerDeviceForRemoteMessages()` is called. | -{/* | Notification taps do nothing | Keep Notifee foreground/background handlers and ensure the navigation ref is ready before routing. | */} -{/* | Call UI not showing | Verify CallKeep setup, telecom permissions, and that `VoipNotificationHandler.initialize()` runs post-login. | */} -{/* | Inline reply needed | Extend Notifee action buttons; CometChat expects you to send the message manually after reading `remoteMessage.data`. | */} +### 6.3 Update the app badge from the push payload ---- +Android has no OS-level app icon badge API, and the push package doesn't manage launcher badges. While the app is open, `onMessageReceived` receives each payload — hand `unreadMessageCount` to a launcher-badge library. In the background the package shows the notification, and Android's notification dot marks the app icon. -## Next Steps +```ts lines +CometChatPushNotifications.onMessageReceived(data => { + const count = Number(data.unreadMessageCount ?? 0); + // hand `count` to your badge library +}); +``` - - -Set up APNs push notifications for iOS - - -Strip HTML tags and customize notification content - - -Learn how to send different types of messages - - -Handle incoming messages in real time - - +## 7. Testing checklist + +Use physical devices and a **release** build: a debug build loads its JavaScript from Metro, which delays the first JavaScript that runs in a killed app. + +1. **Fresh install:** install, log in, and confirm the notification prompt **waits** for your answer before the microphone/camera prompt appears. Then send a message from another user — it must arrive. +2. **Chat notifications:** + - App open: exactly **one** notification (`showInForeground: true`). + - App in the background: a notification appears; tapping it opens the conversation. + - App killed: tapping the notification starts the app **in** the conversation. + - A thread reply opens the **thread**; a group message opens the group. +3. **Calls, app killed, phone locked:** + - The **full-screen ringing screen** shows with Accept and Decline. + - **Accept** connects the call with audio both ways. + - **Decline** shows the call as rejected on the caller's side. + - The caller **cancelling** stops the ring. +4. **Calls, app in the background:** the ringing screen shows, and accept and decline both work. +5. **Calls, app open** (`ringInForeground: false`): your in-app incoming-call screen rings, not the system call UI. **Accept** opens the call full-screen with audio both ways; **Decline** shows the call as rejected on the caller's side; the caller **hanging up** removes the screen. +6. **Logout:** log out, send a message from another user — nothing arrives. Log in as another user — only that user's notifications arrive. +7. **OEM devices** (MIUI, Oppo, Vivo): grant the step 4.4 permissions and re-check locked and killed calls. + +## 8. Troubleshooting tips + +| Symptom | Quick checks | +| --- | --- | +| No notifications received | `google-services.json` is in `android/app`, its package name matches the app, the Google Services plugin is applied, and `POST_NOTIFICATIONS` is granted (Android 13+). | +| `init()` rejects with `ERR_PN_CONFIGURE` | The native setup failed, and the error message says why. If it mentions `FirebaseApp`, Firebase isn't initialized: `google-services.json` must be in `android/app` and the Google Services plugin applied (step 4.1). | +| Notifications work on an existing install but not on a fresh one | A permission request wasn't awaited, so the notification dialog was cancelled unseen (step 5.1). `adb shell dumpsys package \| grep POST_NOTIFICATIONS`: `granted=false` with no `USER_SET` flag means the dialog was never answered. | +| Two notifications for one message | Another push library is still installed (step 3.1). With `@react-native-firebase/messaging`, follow step 4.5. | +| Incoming call is a plain notification, not the full-screen ringing screen | The merged manifest still has `MANAGE_OWN_CALLS` and `FOREGROUND_SERVICE_PHONE_CALL`. On MIUI, Oppo and Vivo, grant the step 4.4 permissions. From Google Play, complete the full-screen intent declaration (step 4.2). | +| Declining a call in a killed app doesn't reject it | `registerBackgroundCallTask()` is called at module scope in `index.js`, and the app has been opened and logged in once since installing (so `init()` saved the Chat SDK settings). Test on a release build. | +| Token registration errors | The provider IDs match the dashboard exactly, and `usePushOnLogin()` is rendered after CometChat is initialized. | +| No notification while the app is open | Expected with `showInForeground: false` (the default) — set it to `true`. For calls, `ringInForeground` decides whether the system call UI or your in-app screen rings. | +| A call doesn't ring while the app is open | `ringInForeground` is `false`, so your app must ring: render `` before your navigator (see *Wire the entry points*), or set `ringInForeground: true`. | +| Tapping a notification opens the app but not the conversation | `navigationRef` is passed to your `NavigationContainer`, navigation goes through `navigate()` from `navigationRef.ts`, and the route names in `SCREENS` match your navigator. | +| Thread reply opens an empty thread screen | The thread screen is given the user or group as well as the parent message, as `openFromNotification` does. | +| Handlers fire twice after logging out and in | Use `usePushOnLogin()` rather than calling `setupPushOnLogin()` directly — its cleanup must run on logout. | +| Notifications still arrive after logout | `unregister()` runs **before** logout and its failure isn't ignored. | diff --git a/notifications/react-native-push-notifications-ios.mdx b/notifications/react-native-push-notifications-ios.mdx index 6e718dadc..e25b351af 100644 --- a/notifications/react-native-push-notifications-ios.mdx +++ b/notifications/react-native-push-notifications-ios.mdx @@ -1,17 +1,18 @@ --- title: "React Native Push Notifications (iOS)" -description: "Bring the SampleAppWithPushNotifications experience—APNs + VoIP—into any React Native project using CometChat UI Kit." +description: "CometChat push notifications and VoIP calls in React Native apps on iOS using Apple Push Notification service (APNs), PushKit and CallKit, with the @cometchat/push-notifications-react-native package." --- | Field | Value | | --- | --- | -| Platform | iOS (APNs + PushKit/CallKit) | -| Key Classes | `CometChatNotifications`, `VoipNotificationHandler`, `PendingCallManager` | -| Key Methods | `registerPushToken()`, `unregisterPushToken()`, `PushNotificationIOS.requestPermissions()` | -| Push Platforms | `APNS_REACT_NATIVE_DEVICE`, `APNS_REACT_NATIVE_VOIP` | -| Prerequisites | CometChat SDK initialized, user logged in, APNs `.p8` key uploaded, physical iOS device | +| Platform | iOS (APNs + PushKit + CallKit) | +| Package | `@cometchat/push-notifications-react-native` | +| Key APIs | `CometChatPushNotifications.init()`, `onNotificationTap()`, `onCallAccepted()`, `onCallEnded()`, `unregister()`, `CometChatPushNotificationsAppDelegate.registerForVoIPPushes()`, `CometChatPushNotificationsAppDelegate.didRegisterAPNsToken(_:)` | +| Push Platforms | `APNS_REACT_NATIVE_DEVICE` and `APNS_REACT_NATIVE_VOIP`, registered by `init()` with one `apnsProviderId` | +| Native setup | Push Notifications + Background Modes (Voice over IP, Remote notifications, Audio), microphone and camera usage strings, and in `AppDelegate`: `registerForVoIPPushes()` before React Native starts plus the APNs token method | +| Prerequisites | React Native 0.78 or later and `@cometchat/chat-sdk-react-native` 4.0.10 or later; CometChat initialized and the user logged in before `init()`, an APNs provider ID, a physical device | @@ -20,28 +21,24 @@ description: "Bring the SampleAppWithPushNotifications experience—APNs + VoIP icon="github" href="https://github.com/cometchat/cometchat-uikit-react-native/tree/v5/examples/SampleAppWithPushNotifications" > - Reference implementation of React Native UI Kit and APNs Push Notification setup. + Reference implementation of React Native UI Kit, APNs and Push Notification Setup. ## What this guide covers -- CometChat Dashboard setup (enable push, add APNs provider). -- Platform credentials (Apple entitlements). -- Copying the sample notification stack and aligning IDs/provider IDs. -- Native glue for iOS (capabilities + PushKit/CallKit for VoIP). -- Token registration, navigation from pushes, testing, and troubleshooting. - -## What you need first - -- CometChat app credentials (App ID, Region, Auth Key) and Push Notifications enabled with an **APNs provider (React Native iOS)**; add an **APNs VoIP provider** if you plan to receive call invites via PushKit. -- Apple push setup: APNs `.p8` key/cert in CometChat, iOS project with Push Notifications + Background Modes (Remote notifications) permissions. -- React Native 0.81+, Node 18+, physical iOS device for reliable push/call testing. +- CometChat dashboard setup (enable push, add an APNs provider) with screenshots. +- Apple setup (APNs key, capabilities, `Info.plist`). +- Wiring the package's notification and call handlers into your app. +- Native iOS setup — a few `AppDelegate` lines; no PushKit or CallKit code to write. +- Token registration (APNs + VoIP), notification/call handling, navigation, testing, and troubleshooting. +- App icon badge count using `unreadMessageCount` from the CometChat push payload. ## How APNs + CometChat work together -- **APNs (iOS) is the transport:** Apple issues the APNs token and delivers payloads to devices. -- **CometChat provider holds your credentials:** The APNs provider you add stores your `.p8` key/cert. -- **Registration flow:** Request permission → APNs returns token → after `CometChat.login`, register with `CometChatNotifications.registerPushToken(token, platform, providerId)` using `APNS_REACT_NATIVE_DEVICE` → CometChat sends pushes to APNs on your behalf → the app handles taps/foreground events via `PushNotificationIOS`. +- **APNs's role:** Issues the device token for chat notifications and, through PushKit, the VoIP token for calls, and delivers both kinds of push. No Firebase is needed on iOS. +- **CometChat's role:** The APNs provider you add in the CometChat dashboard holds your `.p8` key. When `init()` runs after login, the package registers both tokens with that one provider, and CometChat sends chat pushes and VoIP call pushes through APNs. +- **The package's role:** It creates and owns the PushKit registry and reports every VoIP push to CallKit — before React Native starts in a killed app. Every CometChat action runs in JavaScript through the Chat SDK your app already uses. +- **Flow:** Permission prompt → APNs issues the device token and PushKit the VoIP token → after login, `init()` registers both with `AppCredentials.apnsProviderId` → CometChat sends to APNs → iOS shows the notification, or the package reports the call to CallKit → your `onNotificationTap`, `onCallAccepted` and `onCallEnded` handlers navigate. ## 1. Enable push and add providers (CometChat Dashboard) @@ -51,549 +48,564 @@ description: "Bring the SampleAppWithPushNotifications experience—APNs + VoIP Enable Push Notifications -2. Add an **APNs** provider for iOS and copy the Provider ID. +2. Click **Add Credentials**, choose **APNs**, upload your `.p8` key with its Key ID and Team ID, and copy the Provider ID. One APNs provider covers both chat notifications and VoIP call pushes. - Upload APNs credentials + Upload APNs credentials -## 2. Prepare platform credentials - -### Apple Developer portal - -For iOS we use Apple Push Notification service (APNs) for both standard and VoIP pushes. Follow these steps to create the credentials you’ll upload to CometChat. - - - - 1. Open **Keychain Access** → Certificate Assistant → *Request a Certificate From a Certificate Authority*.
- - Apple Developer portal screenshot - - 2. In **Certificate Information**, enter your Apple Developer email and a common name; choose **Saved to disk**, then **Continue**. - 3. Save the CSR file locally—this contains your public/private key pair. -
- - - 1. Sign in to the [Apple Developer Member Center](https://developer.apple.com/membercenter) → **Certificates, Identifiers & Profiles**.
- - Apple Developer portal screenshot - - 2. Click **+** to add a certificate.
- - Apple Developer portal screenshot - - 3. Under **Services**, pick **Apple Push Notification service SSL (Sandbox & Production)**.
- - Apple Developer portal screenshot - - 4. Select your App ID, upload the CSR, continue, and download the generated `.cer` file.
- - Apple Developer portal screenshot - - - & - - - Apple Developer portal screenshot - - - & - - - Apple Developer portal screenshot - -
- - - 1. In **Certificates, IDs & Profiles**, open **Keys** → click **+**. - 2. Enter a key name, check **Apple Push Notification service (APNs)**, then **Continue** → **Register**. - 3. Download the `.p8` file and note the **Key ID**, **Team ID**, and your **Bundle ID**—you’ll enter these in CometChat. - 4. *(Optional)* If you still use `.p12`, export it from the downloaded key without an export password; keep it handy for upload. - - - **`.p12` certificates are deprecated.** Apple recommends using `.p8` Auth Keys for push notifications. `.p8` keys are simpler to manage (one key works for all your apps), never expire, and are the only format actively supported going forward. Migrate to `.p8` if you haven't already. - - -
- -Enable **Push Notifications** plus **Background Modes → Remote notifications** on the bundle ID. +Keep the provider ID—you'll use it in `AppCredentials.apnsProviderId`. - - Enable Push Notifications and Background Modes for APNs - +## 2. Prepare Apple credentials + +### 2.1 Apple Developer portal -## 3. Local configuration +1. Generate an APNs Auth Key (`.p8`) and note the **Key ID** and **Team ID**. +2. Enable Push Notifications on your app's bundle ID. -- Update `src/utils/AppConstants.tsx` with `appId`, `authKey`, `region`, and `apnProviderId`. -- Keep `app.json` name consistent with your bundle ID / applicationId. + +**`.p12` certificates are deprecated.** Apple recommends `.p8` Auth Keys for push notifications: they never expire and work across all your apps. + -```ts lines -const APP_ID = ""; -const AUTH_KEY = ""; -const REGION = ""; -const DEMO_UID = "cometchat-uid-1"; +## 3. Local configuration file + +Create `src/AppCredentials.ts` with your app credentials and provider IDs. The same file serves the [Android guide](/notifications/react-native-push-notifications-android): + +```ts src/AppCredentials.ts lines +export const AppCredentials = { + appId: 'YOUR_APP_ID', + region: 'YOUR_REGION', + authKey: 'YOUR_AUTH_KEY', + + // Android — the FCM provider ID from the CometChat dashboard + fcmProviderId: 'FCM-PROVIDER-ID', + + // iOS — one APNs provider covers both the device token and the VoIP token + apnsProviderId: 'APNS-PROVIDER-ID', +}; ``` -### 3.1 Dependencies snapshot (from Sample App) - -Install these dependencies in your React Native app: - -```npm lines -npm install \ - @cometchat/chat-sdk-react-native@4.0.18 \ - @cometchat/calls-sdk-react-native@4.4.0 \ - @cometchat/chat-uikit-react-native@5.2.6 \ - @notifee/react-native@9.1.8 \ - @react-native-async-storage/async-storage@2.2.0 \ - @react-native-community/push-notification-ios@1.12.0 \ - react-native-push-notification@8.1.1 \ - react-native-callkeep@4.3.16 \ - react-native-voip-push-notification@3.3.3 +## 4. Bring the push package into React Native + +### 4.1 Install the package + +```bash +npm install @cometchat/push-notifications-react-native +cd ios && pod install && cd .. ``` -Match these or newer compatible versions in your app. +Keep the Podfile's `platform :ios, min_ios_version_supported` from the React Native template. Don't lower it: current React Native requires iOS 15.1, and a lower platform fails the build — for example with `'hermes/hermes.h' file not found`. + + +**Remove other push and call libraries first** — `@react-native-firebase/messaging`, `@notifee/react-native`, `react-native-callkeep`, `react-native-voip-push-notification` — along with their code and native setup. Each registers its own push handler or PushKit registry, and every notification or call then arrives twice. + -## 4. iOS App setup +### 4.2 Wire the entry points -### 4.1 Project Setup + +The JavaScript below is the same for Android and iOS — one set of files serves both guides. Lines for one platform do nothing on the other: `registerBackgroundCallTask()` and `notificationSmallIcon` only apply on Android, and waiting for each permission answer before the next request matters only on Android. + -Enable **Push Notifications** and **Background Modes** (Remote notifications) in Xcode. +**`index.js`** — the same file as on Android. `registerBackgroundCallTask()` is a no-op on iOS, where CallKit handles a decline in a killed app: - - Enable Push Notifications - +```js index.js lines +import { AppRegistry } from 'react-native'; +import { registerBackgroundCallTask } from '@cometchat/push-notifications-react-native'; +import App from './App'; +import { name as appName } from './app.json'; -### 4.2 Install dependencies + pods +// Android: lets a FULLY KILLED app reject a call declined from its notification. The package +// does the work — this only registers its background task. (No-op on iOS.) +registerBackgroundCallTask(); -After running the npm install above, install pods from the `ios` directory: -```bash lines -cd ios -pod install +AppRegistry.registerComponent(appName, () => App); ``` -### 4.3 AppDelegate.swift modifications: +**`src/navigation/navigationRef.ts`** — a notification tap or answered call that **launched** the app arrives before your navigator exists, so every navigation waits for it: + +```ts src/navigation/navigationRef.ts lines +import { createNavigationContainerRef } from '@react-navigation/native'; + +/** Pass this to your . */ +export const navigationRef = createNavigationContainerRef(); + +/** + * Resolves once the NavigationContainer is mounted. A notification tap or answered call + * that LAUNCHED the app arrives before the navigator exists, and navigating then is + * silently dropped. The ref queues listeners added before it mounts. + */ +export function whenNavigationReady(): Promise { + if (navigationRef.isReady()) return Promise.resolve(); + return new Promise(resolve => { + const unsubscribe = navigationRef.addListener('ready', () => { + unsubscribe(); + resolve(); + }); + }); +} -Add imports at the top: -```swift lines -import UserNotifications -import RNCPushNotificationIOS +/** Navigate by route name once the navigator is ready. */ +export async function navigate(name: string, params?: object): Promise { + await whenNavigationReady(); + (navigationRef.navigate as (name: string, params?: object) => void)(name, params); +} ``` -Add `UNUserNotificationCenterDelegate` to the `AppDelegate` class declaration: -```swift -class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate -``` +**`src/push/pushNotifications.ts`** — everything push does for the logged-in user: the tap, call-accepted and call-ended handlers, the permission requests, and `init()`: + +```ts src/push/pushNotifications.ts lines +import { useEffect, useState } from 'react'; +import { CometChat } from '@cometchat/chat-sdk-react-native'; +import { CometChatCalls } from '@cometchat/calls-sdk-react-native'; +import { CometChatUIEventHandler, MessageEvents } from '@cometchat/chat-uikit-react-native'; +import { + CometChatPNHelper, + CometChatPushNotifications, + type PNCallEndEvent, + type PNCallInfo, + type PNNotificationTapInfo, +} from '@cometchat/push-notifications-react-native'; + +import { AppCredentials } from '../AppCredentials'; +import { navigate, navigationRef } from '../navigation/navigationRef'; + +/** Your navigator's route names — these are the CometChat UI Kit sample app's. */ +const SCREENS = { + messages: 'Messages', + thread: 'ThreadView', + ongoingCall: 'OngoingCallScreen', + home: 'BottomTabNavigator', +} as const; + +const LOGIN_LISTENER_ID = 'push-notifications-login'; + +/** + * Starts push for the logged-in user. Call it from React with `usePushOnLogin()` (below) + * rather than directly: it returns a cleanup that must run on logout, or every handler + * fires twice after the next login. + */ +export function setupPushOnLogin(): () => void { + // Subscribe BEFORE init(): the tap or answered call that LAUNCHED the app is delivered + // as soon as init() runs. + const unsubscribes = [ + CometChatPushNotifications.onNotificationTap(openFromNotification), + CometChatPushNotifications.onCallAccepted(openCallScreen), + CometChatPushNotifications.onCallEnded(endCall), + ]; + + const start = async () => { + // Await each permission request before the next — Android allows only one pending + // request per activity. A rejection means the OS could not be asked (not that the user + // declined), and must not stop init(): the push token still has to register. + await CometChatPNHelper.requestNotificationPermission().catch(() => false); + await CometChatPNHelper.requestCallPermissions(); // mic + camera, needed before a call connects + + await CometChatPushNotifications.init({ + fcmProviderId: AppCredentials.fcmProviderId, // Android + apnsProviderId: AppCredentials.apnsProviderId, // iOS (APNs device + VoIP) + notificationSmallIcon: 'ic_notification', // Android status-bar icon + showInForeground: true, // one notification while the app is open, too + ringInForeground: false, // your app rings while it's open — see src/calls/IncomingCall.tsx + }); + }; + start().catch(error => console.log('Push setup failed:', error)); -Add the following inside the `didFinishLaunchingWithOptions` method: -```swift lines -UNUserNotificationCenter.current().delegate = self - -UNUserNotificationCenter.current().requestAuthorization( - options: [.alert, .badge, .sound] -) { - granted, - error in - if granted { - DispatchQueue.main.async { - application.registerForRemoteNotifications() - } - } else { - print("Push Notification permission not granted: \(String(describing: error))") - } + return () => unsubscribes.forEach(unsubscribe => unsubscribe()); } -``` -Add the following methods to handle push notification events: -```swift lines -func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { - print("APNs device token received: \(deviceToken)") - RNCPushNotificationIOS.didRegisterForRemoteNotifications(withDeviceToken: deviceToken) +/** + * Runs push while a user is logged in — after a fresh login AND after a session restored + * on launch — and cleans up on logout. Use it once, in a component rendered after + * CometChat has been initialized. + */ +export function usePushOnLogin(): void { + const [loggedIn, setLoggedIn] = useState(false); + + useEffect(() => { + // A restored session never fires loginSuccess, so check once on mount. + CometChat.getLoggedinUser() + .then(user => setLoggedIn(!!user)) + .catch(() => setLoggedIn(false)); + + CometChat.addLoginListener( + LOGIN_LISTENER_ID, + new CometChat.LoginListener({ + loginSuccess: () => setLoggedIn(true), + logoutSuccess: () => setLoggedIn(false), + }), + ); + return () => CometChat.removeLoginListener(LOGIN_LISTENER_ID); + }, []); + + useEffect(() => { + if (!loggedIn) return; + return setupPushOnLogin(); + }, [loggedIn]); } -func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { - print("APNs registration failed: \(error)") - RNCPushNotificationIOS.didFailToRegisterForRemoteNotificationsWithError(error) +/** Open the thread for a thread reply, otherwise the conversation. */ +async function openFromNotification(info: PNNotificationTapInfo): Promise { + const isGroup = info.receiverType === 'group'; + try { + const user = !isGroup && info.sender ? await CometChat.getUser(info.sender) : undefined; + const group = isGroup && info.receiver ? await CometChat.getGroup(info.receiver) : undefined; + if (!user && !group) return; + + markConversationRead(isGroup ? info.receiver! : info.sender!, isGroup); + + if (info.parentMessageId) { + try { + const parent = await CometChat.getMessageDetails(info.parentMessageId); + // The thread screen needs the user or group, not just the parent message. + await navigate(SCREENS.thread, { message: parent, user, group, highlightMessageId: info.messageId }); + return; + } catch (error) { + console.log('Could not open the thread, opening the conversation:', error); + } + } + await navigate(SCREENS.messages, { user, group }); + } catch (error) { + console.log('Could not open the conversation from a notification:', error); + } } -func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { - RNCPushNotificationIOS.didReceiveRemoteNotification(userInfo, fetchCompletionHandler: completionHandler) +/** Mark the conversation read and clear its unread badge in the UI Kit's conversation list. */ +function markConversationRead(conversationWith: string, isGroup: boolean): void { + const type = isGroup ? CometChat.RECEIVER_TYPE.GROUP : CometChat.RECEIVER_TYPE.USER; + CometChat.markConversationAsRead(conversationWith, type) + .then(() => CometChat.getConversation(conversationWith, type)) + .then(conversation => { + const lastMessage = conversation.getLastMessage(); + if (lastMessage) { + CometChatUIEventHandler.emitMessageEvent(MessageEvents.ccMessageRead, { message: lastMessage }); + } + }) + .catch(error => console.log('Could not mark the conversation read:', error)); } -func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { - completionHandler([.banner, .sound, .badge]) +/** The package has already accepted the call — just show the call screen. */ +function openCallScreen(info: PNCallInfo): void { + navigate(SCREENS.ongoingCall, { sessionId: info.sessionId, callType: info.callType }); } - -func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { - RNCPushNotificationIOS.didReceive(response) - completionHandler() + +/** + * A ringing call was cancelled or declined, or the user ended the call from the iOS call + * screen — which the Calls SDK does not see, so tear the call down here. + */ +function endCall(info: PNCallEndEvent): void { + if (info.sessionId) CometChat.endCall(info.sessionId).catch(() => {}); + try { + CometChatCalls.endSession(); + } catch {} + try { + CometChat.clearActiveCall(); + } catch {} + if (navigationRef.isReady() && navigationRef.getCurrentRoute()?.name === SCREENS.ongoingCall) { + navigate(SCREENS.home); + } } ``` -Add the following to `Podfile` to avoid framework linkage issues: -```ruby -use_frameworks! :linkage => :static -``` +**`src/calls/IncomingCall.tsx`** — `init()` above sets `ringInForeground: false`, so **while the app is open the package doesn't ring: your app must show its own incoming-call screen**, or calls won't ring at all while it's open. Calls reach an open app over the Chat SDK's connection; this component listens for them and shows the UI Kit's `CometChatIncomingCall`, which accepts the call and shows the call screen itself: + +```tsx src/calls/IncomingCall.tsx lines +import React, { useEffect, useState } from 'react'; +import { CometChat } from '@cometchat/chat-sdk-react-native'; +import { CometChatIncomingCall, CometChatUIEventHandler } from '@cometchat/chat-uikit-react-native'; + +const LISTENER_ID = 'incoming-call'; + +/** + * Rings for a call while the app is open — init() sets ringInForeground: false, so the + * package leaves this to the app. CometChatIncomingCall accepts the call and shows the call + * screen itself; this component shows it when a call arrives and removes it when the call is + * declined, cancelled by the caller, or ends. + */ +export function IncomingCall() { + const [call, setCall] = useState(null); + + useEffect(() => { + CometChat.addCallListener( + LISTENER_ID, + new CometChat.CallListener({ + onIncomingCallReceived: (incoming: CometChat.Call) => setCall(incoming), + onIncomingCallCancelled: () => setCall(null), // the caller hung up while it was ringing + }), + ); + // An accepted call ended. + CometChatUIEventHandler.addCallListener(LISTENER_ID, { ccCallEnded: () => setCall(null) }); -You might have to remove below code if already present in your Podfile: -```ruby lines -linkage = ENV['USE_FRAMEWORKS'] -if linkage != nil - Pod::UI.puts "Configuring Pod with #{linkage}ally linked Frameworks".green - use_frameworks! :linkage => linkage.to_sym -end -``` + return () => { + CometChat.removeCallListener(LISTENER_ID); + CometChatUIEventHandler.removeCallListener(LISTENER_ID); + }; + }, []); -Then lets install pods and open the workspace: -```bash lines -cd ios -pod install -open YourProjectName.xcworkspace + if (!call) return null; + return setCall(null)} />; +} ``` -### 4.4 App.tsx modifications: + +Your app already shows an incoming-call screen while it's open? Keep it and skip this file. Don't want one? Set `ringInForeground: true` — the default — and skip this file: the package then rings with the system call UI while the app is open, too. + -Import CometChatNotifications and PushNotificationIOS: - -```tsx -import { CometChat, CometChatNotifications } from "@cometchat/chat-sdk-react-native"; -import PushNotificationIOS from "@react-native-community/push-notification-ios"; -``` + +Not using the UI Kit? Delete the `@cometchat/chat-uikit-react-native` import and the `emitMessageEvent` block in `markConversationRead`, point `SCREENS` and the route params at your own screens, and in `logout.ts` call `CometChat.logout()` instead of `CometChatUIKit.logout()`. For calls while the app is open, set `ringInForeground: true`, or build your own incoming-call screen on `CometChat.addCallListener` in place of `IncomingCall.tsx`. + -Get device token and store it in a ref: -Also, define your APNs provider ID from the CometChat Dashboard. -And request permissions on mount: +**`App.tsx`** — call `usePushOnLogin()` once, in a component that renders **after** CometChat is initialized, pass `navigationRef` to your `NavigationContainer`, and render `` **before** your navigator: + +```tsx App.tsx lines +import React, { useEffect, useState } from 'react'; +import { NavigationContainer } from '@react-navigation/native'; +import { CometChat } from '@cometchat/chat-sdk-react-native'; +import { CometChatUIKit, UIKitSettings } from '@cometchat/chat-uikit-react-native'; + +import { AppCredentials } from './AppCredentials'; +import { navigationRef } from './navigation/navigationRef'; +import { usePushOnLogin } from './push/pushNotifications'; +import { IncomingCall } from './calls/IncomingCall'; + +export default function App() { + const [initialized, setInitialized] = useState(false); + + useEffect(() => { + // Your existing CometChat initialization. + CometChatUIKit.init({ + appId: AppCredentials.appId, + region: AppCredentials.region, + authKey: AppCredentials.authKey, + subscriptionType: CometChat.AppSettings.SUBSCRIPTION_TYPE_ALL_USERS as UIKitSettings['subscriptionType'], + } as UIKitSettings) + .then(() => setInitialized(true)) + .catch(error => console.log('CometChat init failed:', error)); + }, []); + + // Push must start only after CometChat is initialized. + if (!initialized) return null; + return ; +} -```tsx lines -const APNS_PROVIDER_ID = 'YOUR_APNS_PROVIDER_ID'; // from CometChat Dashboard -const apnsTokenRef = useRef < string | null > (null); +function Root() { + usePushOnLogin(); // push follows login and logout from here on -useEffect(() => { - if (Platform.OS !== 'ios') return; + return ( + + {/* before your navigator: it shows at the top, and an accepted call fills the screen */} + {/* your existing navigator */} + + ); +} +``` - const onRegister = (deviceToken: string) => { - console.log(' APNs device token captured:', deviceToken); - apnsTokenRef.current = deviceToken; - }; +`usePushOnLogin()` starts push after a fresh login **and** when a session is restored on launch, and removes the handlers when the user logs out — so a later login never registers them twice. `` goes before your navigator because the UI Kit's incoming-call screen isn't a modal: rendered first, it shows at the top of the screen, and an accepted call fills the screen. - PushNotificationIOS.addEventListener('register', onRegister); +### 4.3 Align dependencies and configuration - PushNotificationIOS.addEventListener('registrationError', error => { - console.error(' APNs registration error:', error); - }); +- **Peer dependencies:** `@cometchat/chat-sdk-react-native` (or the UI Kit) for chat, `@cometchat/calls-sdk-react-native` for calls, and React Navigation for the handlers above. +- **`init()` options:** + - `fcmProviderId` (Android) and `apnsProviderId` (iOS) — from step 1. + - `notificationSmallIcon` — the Android status-bar icon. + - `showInForeground` (default `false`) — show chat notifications while the app is open. + - `ringInForeground` (default `true`) — ring with the system call UI while the app is open. With `false`, a call that arrives while the app is open is left to your app, so your app must show its own incoming-call screen — `IncomingCall.tsx` above. With `false` and no such screen, calls don't ring while the app is open. + - `voip` (default `true`) — `false` turns calls off for a chat-only app: no call rings, and iOS doesn't register the VoIP token. + - `androidChannelId`, `androidChannelName` — the Android notification channel. - // Trigger permission + native registration - PushNotificationIOS.requestPermissions().then(p => - console.log('Push permissions:', p), - ); +## 5. Configure the native iOS layer - return () => { - PushNotificationIOS.removeEventListener('register'); - PushNotificationIOS.removeEventListener('registrationError'); - }; -}, []); -``` +### 5.1 Capabilities and Info.plist -After user login, register the APNs token: -```tsx lines -// Register token ONLY if we already have it -if (apnsTokenRef.current) { - await CometChatNotifications.registerPushToken( - apnsTokenRef.current, - CometChatNotifications.PushPlatforms.APNS_REACT_NATIVE_DEVICE, - APNS_PROVIDER_ID - ); - console.log(' APNs token registered with CometChat'); -} -``` +1. Open `ios/.xcworkspace` in Xcode. +2. Under *Signing & Capabilities*, enable **Push Notifications** and **Background Modes** with **Voice over IP**, **Remote notifications**, and **Audio, AirPlay, and Picture in Picture**. +3. Add the microphone and camera usage strings to `Info.plist` — a call can't use either without them: -Prior to logout, unregister the APNs token: -```tsx -await CometChatNotifications.unregisterPushToken(); +```xml ios//Info.plist lines +NSMicrophoneUsageDescription +Needed for voice and video calls +NSCameraUsageDescription +Needed for video calls ``` -## 5. VoIP call notifications (iOS) + + Enable Push Notifications and Background Modes for APNs + -These steps are iOS-only—copy/paste and fill your IDs. +### 5.2 `AppDelegate.swift` -### 5.1 Enable capabilities in Xcode -- Target ➜ Signing & Capabilities: add **Push Notifications**. -- Add **Background Modes** → enable **Voice over IP** and **Remote notifications**. -- Run on a real device (PushKit/CallKit don’t work on the simulator). +Replace `ios//AppDelegate.swift` with this — React Native's current template plus the push lines. Set `withModuleName` to your app's name: -### 5.2 AppDelegate.swift (PushKit + CallKit bridge) -Update your `AppDelegate` to register for VoIP pushes ASAP and forward events to JS/CallKeep: +```swift ios//AppDelegate.swift lines +import UIKit +import React +import React_RCTAppDelegate +import ReactAppDependencyProvider +import react_native_cometchat_push_notifications -```swift lines -import PushKit -import RNVoipPushNotification -import RNCallKeep -// ... @main -class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate, PKPushRegistryDelegate { - // ... - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { - // existing UNUserNotificationCenter code ... - RNVoipPushNotificationManager.voipRegistration() // triggers PushKit token +class AppDelegate: UIResponder, UIApplicationDelegate { + var window: UIWindow? + var reactNativeDelegate: ReactNativeDelegate? + var reactNativeFactory: RCTReactNativeFactory? + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + let delegate = ReactNativeDelegate() + let factory = RCTReactNativeFactory(delegate: delegate) + delegate.dependencyProvider = RCTAppDependencyProvider() + reactNativeDelegate = delegate + reactNativeFactory = factory + + // VoIP calls: the package creates and owns the PushKit registry. Call it BEFORE + // starting React Native — when a call wakes a killed app, iOS terminates the app + // unless the call reaches CallKit within ~5 seconds. + CometChatPushNotificationsAppDelegate.registerForVoIPPushes() + + window = UIWindow(frame: UIScreen.main.bounds) + factory.startReactNative( + withModuleName: "YourAppName", // your app's registered name + in: window, + launchOptions: launchOptions + ) return true } - // APNs device token handlers stay unchanged - - // PushKit token -> JS - func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) { - RNVoipPushNotificationManager.didUpdate(pushCredentials, forType: type.rawValue) + // APNs device token — chat notifications + func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + CometChatPushNotificationsAppDelegate.didRegisterAPNsToken(deviceToken) } - // Incoming VoIP push -> CallKit + JS - func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) { - let dict = payload.dictionaryPayload - let uuid = (dict["uuid"] as? String) ?? UUID().uuidString - RNVoipPushNotificationManager.addCompletionHandler(uuid, completionHandler: completion) - RNVoipPushNotificationManager.didReceiveIncomingPush(with: payload, forType: type.rawValue) - RNCallKeep.reportNewIncomingCall(uuid, handle: (dict["handle"] as? String) ?? "Unknown", handleType: "generic", hasVideo: false, localizedCallerName: (dict["callerName"] as? String) ?? "Incoming Call", supportsHolding: true, supportsDTMF: true, supportsGrouping: true, supportsUngrouping: true, fromPushKit: true, payload: nil) + // Optional — background data pushes reach onMessageReceived + func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) { + CometChatPushNotificationsAppDelegate.didReceiveRemoteNotification(userInfo) + completionHandler(.noData) } } -``` - -### 5.3 Drop in `VoipNotificationHandler.ts` -Handles CallKeep UI, defers acceptance until login, and listens for PushKit events. - -```ts lines -import { Platform } from "react-native"; -import notifee, { AndroidImportance } from "@notifee/react-native"; -import RNCallKeep, { IOptions } from "react-native-callkeep"; -import { CometChat } from "@cometchat/chat-sdk-react-native"; -import VoipPushNotification from "react-native-voip-push-notification"; -import { setPendingAnsweredCall } from "./PendingCallManager"; -const options: IOptions = { - ios: { appName: "YourAppName" }, - android: { alertTitle: "VOIP required", alertDescription: "Allow phone account access", cancelButton: "Cancel", okButton: "OK", imageName: "ic_notification" }, -}; - -type IncomingPayload = { sessionId?: string; senderName?: string; callerName?: string; name?: string; type?: string; [k: string]: any; }; - -class VoipNotificationHandler { - channelId = ""; - isRinging = false; - isAnswered = false; - pendingAcceptance = false; - callerId = ""; - msg: IncomingPayload | null = null; - initialized = false; - private setupPromise: Promise | null = null; - private listenersAttached = false; - private lastSessionId: string | null = null; - private lastRingAt = 0; - - async initialize() { - if (this.initialized && this.setupPromise) { await this.setupPromise; return; } - if (!this.setupPromise) { - this.setupPromise = (async () => { - if (Platform.OS === "android") { await this.createNotificationChannel(); } - await this.setupCallKeep(); - this.setupEventListeners(); - this.initialized = true; - })().catch(err => { this.setupPromise = null; throw err; }); - } - await this.setupPromise; +class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate { + override func sourceURL(for bridge: RCTBridge) -> URL? { + self.bundleURL() } - private async setupCallKeep() { - await RNCallKeep.setup(options); - RNCallKeep.setAvailable(true); - if (Platform.OS === "android") { RNCallKeep.setReachable(); } + override func bundleURL() -> URL? { +#if DEBUG + return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index") +#else + return Bundle.main.url(forResource: "main", withExtension: "jsbundle") +#endif } +} +``` - private async createNotificationChannel() { - this.channelId = await notifee.createChannel({ id: "message", name: "Messages", lights: true, vibration: true, importance: AndroidImportance.HIGH }); - } +You don't write PushKit or CallKit code, and you don't set a `UNUserNotificationCenter` delegate — the package installs its own at launch to handle foreground notifications and taps. - async displayIncomingCall(payload: IncomingPayload) { - this.msg = payload || {}; - const sessionId = this.msg?.sessionId; - const now = Date.now(); - if (sessionId && this.lastSessionId === sessionId && now - this.lastRingAt < 5000) return; - if (this.isAnswered || this.pendingAcceptance) return; - await this.initialize(); - - const callerName = this.msg?.senderName || this.msg?.callerName || this.msg?.name || "Incoming Call"; - this.callerId = this.callerId || Math.random().toString(); - this.isRinging = true; - - await RNCallKeep.displayIncomingCall(this.callerId, callerName, callerName, "generic", true); - this.lastSessionId = sessionId || null; - this.lastRingAt = now; - } + +**Don't create a `PKPushRegistry` of your own.** The package owns it, and a second registry — yours or a library's — makes iOS deliver every VoIP push twice. If another library must own PushKit, skip `registerForVoIPPushes()` and forward that registry's `didUpdate` and `didReceiveIncomingPushWith` callbacks to `CometChatPushNotificationsAppDelegate.didUpdateVoIPToken(_:)` and `.didReceiveIncomingVoIPPush(_:)`, calling `completion()` after it. + - onAnswerCall = async ({ callUUID }: { callUUID: string }) => { - if (this.isAnswered) return; - this.isRinging = false; this.isAnswered = true; - const sessionID = this.msg?.sessionId; if (!sessionID) return; - RNCallKeep.backToForeground(); - setTimeout(async () => { - const loggedInUser = await CometChat.getLoggedinUser().catch(() => null); - if (!loggedInUser) { this.pendingAcceptance = true; await setPendingAnsweredCall({ sessionId: sessionID, raw: this.msg, storedAt: Date.now() }); return; } - try { await CometChat.acceptCall(sessionID); } catch (error: any) { if (error?.code !== "ERR_CALL_USER_ALREADY_JOINED") throw error; } - RNCallKeep.endAllCalls(); this.pendingAcceptance = false; - }, 350); - }; + +**Chat-only apps** skip `registerForVoIPPushes()` and the *Voice over IP* and *Audio* background modes, and pass `voip: false` to `init()`. + - endCall = async ({ callUUID }: { callUUID: string }) => { - const sessionID = this.msg?.sessionId; - if (sessionID) { - const loggedInUser = await CometChat.getLoggedinUser().catch(() => null); - if (this.isAnswered) { await CometChat.endCall(sessionID).catch(() => {}); } - else if (loggedInUser) { await CometChat.rejectCall(sessionID, CometChat.CALL_STATUS.REJECTED).catch(() => {}); } - } - const id = callUUID || this.callerId; - if (id) RNCallKeep.endCall(id); - RNCallKeep.endAllCalls(); - this.isRinging = false; this.isAnswered = false; this.pendingAcceptance = false; this.callerId = ""; this.msg = null; this.lastSessionId = null; this.lastRingAt = 0; - }; + +**Older Swift template** (an `RCTAppDelegate` subclass): call `registerForVoIPPushes()` before `return super.application(...)`, which starts React Native, and add the same token method. **Objective-C `AppDelegate.mm`:** the package's iOS entry points are Swift-only, so move the AppDelegate to Swift first — the [React Native Upgrade Helper](https://react-native-community.github.io/upgrade-helper/) shows the change. + - setupEventListeners() { - if (this.listenersAttached) return; - if (Platform.OS === "ios") { - VoipPushNotification.addEventListener("notification", (notification: any) => this.displayIncomingCall(notification)); - VoipPushNotification.addEventListener("didLoadWithEvents", (events: any[]) => { - (events || []).forEach(event => { - if (event?.name === VoipPushNotification.RNVoipPushRemoteNotificationReceivedEvent) { - this.displayIncomingCall(event.data); - } - }); - }); - } - RNCallKeep.addEventListener("answerCall", this.onAnswerCall); - RNCallKeep.addEventListener("endCall", this.endCall); - RNCallKeep.addEventListener("didDisplayIncomingCall", ({ callUUID }) => { if (callUUID) this.callerId = callUUID; this.isRinging = true; }); - this.listenersAttached = true; - } -} +## 6. Token registration and runtime events -export const voipHandler = new VoipNotificationHandler(); -``` +### 6.1 Standard APNs tokens -### 5.4 Add `PendingCallManager.ts` -Stores an answered call during cold start so you can accept it after login/navigation is ready. +`didRegisterAPNsToken(_:)` hands the device token to the package, and `init()` registers it with your APNs provider for the logged-in user — re-registering it whenever iOS issues a new one. `setupPushOnLogin()` asks for notification permission before `init()`. On iOS `requestCallPermissions()` does nothing: iOS asks for the microphone and camera the first time a call uses them. -```ts lines -import AsyncStorage from "@react-native-async-storage/async-storage"; +### 6.2 VoIP tokens -export interface PendingAnsweredCallPayload { sessionId: string; raw: any; storedAt: number; } -let inMemoryPending: PendingAnsweredCallPayload | null = null; -const STORAGE_KEY = "pendingAnsweredCall"; +`registerForVoIPPushes()` creates the PushKit registry at launch, and PushKit hands over the VoIP token right away — before React Native runs. The package holds it, and `init()` registers it with the same APNs provider. If `init()` runs a moment before login finishes, registration retries 5 times, 3 seconds apart. -export async function setPendingAnsweredCall(payload: PendingAnsweredCallPayload) { - inMemoryPending = payload; try { await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); } catch {} -} +### 6.3 Local notifications and navigation -export async function consumePendingAnsweredCall(): Promise { - if (inMemoryPending) { const tmp = inMemoryPending; inMemoryPending = null; try { await AsyncStorage.removeItem(STORAGE_KEY); } catch {} return tmp; } - try { const raw = await AsyncStorage.getItem(STORAGE_KEY); if (raw) { await AsyncStorage.removeItem(STORAGE_KEY); return JSON.parse(raw); } } catch {} - return null; -} +- **App in the background or killed:** iOS shows the APNs notification. +- **App open:** the package's notification delegate shows the banner when `showInForeground` is `true`; otherwise the payload goes to `onMessageReceived`. +- **Tap:** `onNotificationTap` fires, and `openFromNotification` marks the conversation read, then opens the thread for a thread reply, otherwise the conversation. A tap that **launched** the app is held until your handler subscribes, and navigation waits for the navigator. + +### 6.4 Call events -export function isPendingStale(p: PendingAnsweredCallPayload, maxAgeMs = 2 * 60 * 1000) { - return Date.now() - p.storedAt > maxAgeMs; +| Event | What happens | +| --- | --- | +| **VoIP push arrives** | The package reports the call to CallKit, which rings, in every app state — except while the app is open with `ringInForeground: false`, when your app's `IncomingCall` screen rings instead. Then, on iOS 26.4 and later, the package leaves CallKit out, since iOS no longer requires the report while the app is open; on earlier iOS it still reports the call, as iOS requires, and ends it at once. | +| **Accept** | The package puts the audio session in call mode, accepts the call through the Chat SDK, and hands CallKit's audio to WebRTC; then `onCallAccepted` fires and `openCallScreen` opens your call screen. | +| **Decline** | The package rejects the call through the Chat SDK. In a killed app, iOS has already launched the app for the VoIP push, and the package keeps it running long enough to reject. | +| **Caller hangs up** | The cancel VoIP push ends the CallKit call and `onCallEnded` fires. | +| **Ended from the iOS call screen** | `onCallEnded` fires; `endCall` ends the call on the server, ends the media session, and leaves the call screen. | + + +**Killed-app VoIP:** when a VoIP push wakes a killed app, the package reports the call to CallKit before React Native is ready. When the user answers, the app starts, `init()` delivers the answered call, and `onCallAccepted` opens your call screen — the call is already accepted. This is why `registerForVoIPPushes()` runs before React Native starts (step 5.2). + + +### 6.5 Unregister on logout + +Add `src/push/logout.ts` and call it from your logout button instead of logging out directly: + +```ts src/push/logout.ts lines +import { CometChatUIKit } from '@cometchat/chat-uikit-react-native'; +import { CometChatPushNotifications } from '@cometchat/push-notifications-react-native'; + +/** Log out and stop this device receiving the user's notifications. Resolves false on failure. */ +export async function logout(): Promise { + // Unregister BEFORE logout: it needs the session's auth token, so after logout it fails + // and the device keeps receiving notifications for the user who just logged out. + try { + await CometChatPushNotifications.unregister(); + } catch (error) { + console.log('Failed to unregister the push token:', error); + return false; + } + try { + await CometChatUIKit.logout(); + return true; + } catch (error) { + console.log('Logout failed:', error); + return false; + } } ``` -### 5.5 Wire `App.tsx` for APNs + VoIP token registration and handler init - ```tsx lines -import PushNotificationIOS from "@react-native-community/push-notification-ios"; -import VoipPushNotification from "react-native-voip-push-notification"; -import { voipHandler } from "./VoipNotificationHandler"; -import { consumePendingAnsweredCall, isPendingStale } from "./PendingCallManager"; - -const APNS_PROVIDER_ID = "YOUR_APNS_PROVIDER_ID"; - -// Capture APNs device token -useEffect(() => { - if (Platform.OS !== "ios") return; - const onRegister = (deviceToken: string) => { apnsTokenRef.current = deviceToken; }; - PushNotificationIOS.addEventListener("register", onRegister); - PushNotificationIOS.requestPermissions(); - return () => PushNotificationIOS.removeEventListener("register"); -}, []); - -// Capture VoIP token -useEffect(() => { - if (Platform.OS !== "ios") return; - const onVoipRegister = (token: string) => { - CometChatNotifications.registerPushToken( - token, - CometChatNotifications.PushPlatforms.APNS_REACT_NATIVE_VOIP, - APNS_PROVIDER_ID - ).catch(err => console.log("[VoIP] register failed", err)); - }; - VoipPushNotification.addEventListener("register", onVoipRegister); - // token request is triggered in AppDelegate via RNVoipPushNotificationManager.voipRegistration() - return () => VoipPushNotification.removeEventListener("register"); -}, []); - -// After login: register APNs token + init VoIP handler + consume pending accepts -useEffect(() => { - const run = async () => { - if (!loggedIn || Platform.OS !== "ios") return; - const pending = await consumePendingAnsweredCall(); - if (pending && !isPendingStale(pending)) { await CometChat.acceptCall(pending.sessionId).catch(console.log); } - const token = apnsTokenRef.current; - if (token) { - await CometChatNotifications.registerPushToken( - token, - CometChatNotifications.PushPlatforms.APNS_REACT_NATIVE_DEVICE, - APNS_PROVIDER_ID - ); - } - await voipHandler.initialize(); - }; - run(); -}, [loggedIn]); -``` - -### 5.6 VoIP push payload (APNs / PushKit) -Send a VoIP push with `push_type=voip` via APNs using a payload shaped like: - -```json -{ - "aps": { "alert": { "title": "Alice", "body": "Incoming call" }, "content-available": 1 }, - "sessionId": "", - "callerName": "Alice", - "handle": "alice", - "type": "call", - "uuid": "" -} +const onLogoutPress = async () => { + if (loggingOut) return; // ignore a second tap while logging out + setLoggingOut(true); + const loggedOut = await logout(); + setLoggingOut(false); + if (loggedOut) navigation.navigate('Login'); // your login screen +}; ``` -## 6. Handling notification taps and navigation - -To handle notification taps and navigate to the appropriate chat screen, you need to set up handlers for both foreground and background notifications. + +`unregister()` must run **before** logout. It needs the session's auth token — after logout it fails, and the device keeps receiving notifications for the user who just logged out. + -{/* :TODO: Add code snippets and explanation for setting up Notifee handlers and navigation logic. */} +## 7. Badge count using `unreadMessageCount` +CometChat's Enhanced Push Notification payload includes an `unreadMessageCount` field representing the total unread messages across all conversations for the logged-in user. On iOS the badge is handled by the server: CometChat sets `aps.badge` in the push payload, and iOS updates the app icon badge when the notification is delivered — no dependency or client code required. -## 7. Badge Count Implementation +### 7.1 Enable unread badge count on the CometChat Dashboard -CometChat's Enhanced Push Notification payload includes an `unreadMessageCount` field that represents the total number of unread messages across all conversations for the logged-in user. You can use this value to update the app icon badge, providing users with a visual indicator of unread messages. +1. Go to **CometChat Dashboard → Notifications → Settings → Preferences → Push Notification Preferences**. +2. Scroll to the bottom and enable the **Unread Badge Count** toggle. -### 7.1 Enable Unread Badge Count on the CometChat Dashboard +This ensures CometChat includes the `unreadMessageCount` field in every push payload and sets `aps.badge` for APNs. - - - Go to **CometChat Dashboard → Notifications Engine → Settings → Preferences → Push Notification Preferences**. - - - Scroll down and enable the **Unread Badge Count** toggle. - - +### 7.2 Expected payload format -Once enabled, CometChat automatically includes the `unreadMessageCount` field in every push payload sent to your app. - -### 7.2 Expected Payload Format - -CometChat sends APNs payloads with the following structure: +CometChat sends APNs payloads with this structure (relevant fields): ```jsonc { @@ -611,237 +623,39 @@ CometChat sends APNs payloads with the following structure: } ``` - -The `aps.badge` field is set server-side by CometChat. iOS automatically updates the app icon badge when the push notification is delivered. - - -### 7.3 Handle Badge Count from Notifications - -Update your iOS notification handler to set the badge count programmatically: +The `aps.badge` field is set by CometChat server-side, so iOS updates the badge when the push is delivered. In JavaScript (`onMessageReceived`), the package hands `unreadMessageCount` over as a string, as on Android. -```typescript -import PushNotificationIOS from "@react-native-community/push-notification-ios"; +## 8. Testing checklist -export async function onRemoteNotificationIOS(notification: any) { - // Extract badge count from push payload - const data = notification.getData(); - const unreadCount = data?.unreadMessageCount; +Use a physical iPhone — the Simulator can't receive APNs or VoIP pushes — and a **release** build for killed-app calls. - if (unreadCount !== undefined && unreadCount !== null) { - const count = parseInt(unreadCount, 10); - if (!isNaN(count) && count >= 0) { - PushNotificationIOS.setApplicationIconBadgeNumber(count); - console.log("Badge count updated (iOS):", count); - } - } - - // Handle notification tap - const isClicked = data?.userInteraction === 1; - if (isClicked && data?.type === "chat") { - // Navigate to conversation... - } - - // Required: Notify iOS that processing is complete - notification.finish(PushNotificationIOS.FetchResult.NoData); -} -``` - -### 7.4 Register Notification Listener - -In your `App.tsx`, set up the notification listener: - -```typescript -import PushNotificationIOS from "@react-native-community/push-notification-ios"; - -useEffect(() => { - if (Platform.OS === "ios") { - const onNotification = async (notification: any) => { - try { - await onRemoteNotificationIOS(notification); - } catch (error) { - console.log("Error in onRemoteNotificationIOS:", error); - } - }; - - PushNotificationIOS.addEventListener("notification", onNotification); - - return () => { - PushNotificationIOS.removeEventListener("notification"); - }; - } -}, []); -``` - -### 7.5 Clear Badge When App Becomes Active - -Clear the badge count when the app launches or returns to the foreground: - -```typescript -import { AppState, AppStateStatus, Platform } from "react-native"; -import PushNotificationIOS from "@react-native-community/push-notification-ios"; - -useEffect(() => { - const handleAppStateChange = async (nextState: AppStateStatus) => { - if (nextState === "active" && Platform.OS === "ios") { - PushNotificationIOS.setApplicationIconBadgeNumber(0); - console.log("Badge cleared (iOS)"); - } - }; - - const subscription = AppState.addEventListener("change", handleAppStateChange); - return () => subscription.remove(); -}, []); -``` +1. **First launch:** log in and allow notifications. Then send a message from another user — it must arrive. +2. **Chat notifications:** + - App open: exactly **one** banner (`showInForeground: true`). + - App in the background: a notification appears; tapping it opens the conversation. + - App killed: tapping the notification starts the app **in** the conversation. + - A thread reply opens the **thread**; a group message opens the group. +3. **Calls, app killed (locked and unlocked):** + - CallKit shows the call with Accept and Decline. + - **Accept** connects the call with audio both ways. + - **Decline** shows the call as rejected on the caller's side. + - The caller **cancelling** stops the ring. +4. **Calls, app in the background:** CallKit rings, and ending the call from the iOS call screen closes your call screen. +5. **Calls, app open** (`ringInForeground: false`): your in-app incoming-call screen rings, not the system call UI. **Accept** opens the call full-screen with audio both ways; **Decline** shows the call as rejected on the caller's side; the caller **hanging up** removes the screen. +6. **Logout:** log out, send a message from another user — nothing arrives. Log in as another user — only that user's notifications arrive. -### 7.6 Clear Badge on Logout +## 9. Troubleshooting tips -When a user logs out, clear the badge so it doesn't show a stale count on the login screen or for the next user: - -```typescript -import PushNotificationIOS from "@react-native-community/push-notification-ios"; -import { CometChat, CometChatNotifications } from "@cometchat/chat-sdk-react-native"; - -const handleLogout = async () => { - // Unregister push token first - await CometChatNotifications.unregisterPushToken(); - - // Clear badge before logout - PushNotificationIOS.setApplicationIconBadgeNumber(0); - - // Logout from CometChat - await CometChat.logout(); - console.log("User logged out, badge cleared"); -}; -``` - -### 7.7 Clear Badge on Fresh Install / No Logged-In User - -On iOS, the badge count may persist after app uninstall and reinstall in certain scenarios. Clear the badge during app initialization when no user is logged in: - -```typescript -import PushNotificationIOS from "@react-native-community/push-notification-ios"; -import { CometChat } from "@cometchat/chat-sdk-react-native"; - -// During app initialization, after CometChat.init() -const initializeApp = async () => { - // Initialize CometChat first - await CometChatUIKit.init(uiKitSettings); - - // Check if user is logged in - const loggedInUser = await CometChat.getLoggedinUser(); - - if (!loggedInUser) { - // No user logged in - clear any stale badge - PushNotificationIOS.setApplicationIconBadgeNumber(0); - console.log("No logged-in user, badge cleared"); - } -}; -``` - -### 7.8 Clear Badge in Login Listener (Safety Net) - -Register a login listener to clear the badge on logout as a backup mechanism: - -```typescript -import PushNotificationIOS from "@react-native-community/push-notification-ios"; -import { CometChat } from "@cometchat/chat-sdk-react-native"; - -useEffect(() => { - const listenerID = "BADGE_LOGOUT_LISTENER"; - - CometChat.addLoginListener( - listenerID, - new CometChat.LoginListener({ - logoutOnSuccess: () => { - // Safety net: clear badge when logout succeeds - PushNotificationIOS.setApplicationIconBadgeNumber(0); - console.log("Logout listener: badge cleared"); - }, - }) - ); - - return () => { - CometChat.removeLoginListener(listenerID); - }; -}, []); -``` - -### 7.9 Key Implementation Notes - -| Consideration | Details | +| Symptom | Quick checks | | --- | --- | -| **Backend-driven badge count** | The `unreadMessageCount` value comes directly from CometChat's backend via the push payload, ensuring consistency across all devices. | -| **iOS server-side badge** | For iOS using APNs, the `aps.badge` field is set server-side by CometChat, so the badge updates automatically even without client-side code. However, you still need to clear it when the app opens. | -| **Clear on app active** | Always clear the badge when the app becomes active. New notifications will update the badge with the fresh `unreadMessageCount` from the backend. | -| **Clear on logout** | Always clear the badge when a user logs out to prevent stale counts for the next user. | -| **Clear on fresh install** | On iOS, the badge count may persist after app reinstall in certain scenarios. Clear the badge during app initialization when no user is logged in. | -| **Login listener safety net** | Use CometChat's login listener as a backup to ensure badge is cleared on logout. | -| **Title enhancement** | Optionally display the unread count in the notification title (e.g., "John (5 unread)") for additional visibility. | - -### 7.10 Cross-Platform App State Handler - -If you're building a cross-platform app, use this combined handler for both iOS and Android: - -```typescript -import { AppState, AppStateStatus, Platform } from "react-native"; -import PushNotificationIOS from "@react-native-community/push-notification-ios"; -import notifee from "@notifee/react-native"; - -useEffect(() => { - const handleAppStateChange = async (nextState: AppStateStatus) => { - if (nextState === "active") { - // Clear badge for iOS - if (Platform.OS === "ios") { - PushNotificationIOS.setApplicationIconBadgeNumber(0); - console.log("Badge cleared (iOS)"); - } - // Clear all notifications for Android (also resets badge) - else if (Platform.OS === "android") { - await notifee.cancelAllNotifications(); - console.log("Notifications cleared (Android)"); - } - } - }; - - const subscription = AppState.addEventListener("change", handleAppStateChange); - return () => subscription.remove(); -}, []); -``` - -## 8. Testing Checklist - -1. Install on a physical iOS device, log in, and verify APNs token registration succeeds. -2. Send a message from another user: - - **Foreground:** Banner appears unless that chat is already open. - - **Background/terminated:** Tap opens the correct conversation; handler runs. -3. **VoIP:** Send a PushKit VoIP push (payload above); expect CallKit incoming UI; answer and confirm CometChat call connects; end clears the dialer. -4. Rotate tokens (reinstall or revoke) and confirm `onTokenRefresh` re-registers the new token. - -## 9. Troubleshooting - -| Symptom | Quick Checks | -| --- | --- | -| No pushes | Confirm APNs key uploaded, bundle ID matches, Push extension enabled with correct provider IDs, permissions granted. | -| Token registration fails | Ensure registration runs **after login**, provider IDs are set, and `registerForRemoteNotifications()` is called. | -{/* | Notification taps do nothing | Keep foreground/background handlers and ensure navigation ref is ready before routing. | */} -{/* | Call UI not showing | Verify PushKit VoIP capability, CallKeep entitlements/permissions, and that `voipHandler.initialize()` runs after login. | */} -{/* | Inline reply needed | Extend Notifee action buttons; CometChat expects you to send the message manually after reading `remoteMessage.data`. | */} - ---- - -## Next Steps - - - -Set up FCM push notifications for Android - - -Strip HTML tags and customize notification content - - -Learn how to send different types of messages - - -Handle incoming messages in real time - - +| No VoIP pushes | Push Notifications + Background Modes (Voice over IP) are enabled, `aps-environment` matches the build (`production` for release), and the bundle ID matches the CometChat APNs provider. | +| Killed app doesn't ring for a VoIP push | `registerForVoIPPushes()` is called in `didFinishLaunchingWithOptions` **before** React Native starts, and nothing else in the app creates a `PKPushRegistry` (step 5.2). | +| iOS build fails with `'hermes/hermes.h' file not found` | The Podfile platform was lowered below React Native's minimum. Restore `platform :ios, min_ios_version_supported` and run `pod install`. | +| Accepted call connects but has no audio | The **Audio** background mode is enabled, and the Calls SDK (with `react-native-webrtc`) is installed. | +| Token registration errors | The provider IDs match the dashboard exactly, and `usePushOnLogin()` is rendered after CometChat is initialized. | +| No notification while the app is open | Expected with `showInForeground: false` (the default) — set it to `true`. For calls, `ringInForeground` decides whether the system call UI or your in-app screen rings. | +| A call doesn't ring while the app is open | `ringInForeground` is `false`, so your app must ring: render `` before your navigator (see *Wire the entry points*), or set `ringInForeground: true`. | +| Tapping a notification opens the app but not the conversation | `navigationRef` is passed to your `NavigationContainer`, navigation goes through `navigate()` from `navigationRef.ts`, and the route names in `SCREENS` match your navigator. | +| Thread reply opens an empty thread screen | The thread screen is given the user or group as well as the parent message, as `openFromNotification` does. | +| Handlers fire twice after logging out and in | Use `usePushOnLogin()` rather than calling `setupPushOnLogin()` directly — its cleanup must run on logout. | +| Notifications still arrive after logout | `unregister()` runs **before** logout and its failure isn't ignored. |