react-native-nitro-call-detector
v1.0.6
Published
React Native Nitro Module for detecting phone calls on iOS and Android — cellular (PSTN) by default, with opt-in VoIP detection (CallKit / ConnectionService) for WhatsApp, Zoom, Teams, Google Meet and friends. Zero manifest declarations by default. Built
Maintainers
Readme
react-native-nitro-call-detector
React Native Nitro Module for detecting phone calls on iOS and Android — cellular (PSTN) by default, with opt-in VoIP detection (CallKit / ConnectionService) for WhatsApp, Zoom, Teams, Google Meet and friends. Zero manifest declarations by default. Built for meeting-notes apps, compliance recorders, and anything else that needs to know when the user is on a call.
[!NOTE]
- This library was originally created for my work app, where we needed to prompt "want our app to join and take notes?" no matter which one they picked up.
- We looked at rolling our own
CXCallObserverbridge and anInCallServiceand quickly ran out of afternoon. Every existing library on npm either only did PSTN, only did iOS, or wrapped a deprecated API that shipped before ConnectionService self-managed calls existed. Nothing gave us PSTN + optional VoIP in one API, on both platforms, without polluting your Play Store submission.- A calling app is a scary thing to ship by accident. The moment your
AndroidManifest.xmldeclares anInCallService, Google Play treats you as a calling app — the same category as full-blown dialers — even if all you want is "tell me when a call starts." Every third-party library that ships a manifest with telephony declarations forces that category onto every host app that installs it. This library ships an empty Android manifest. You opt in by adding a few lines to your own app manifest — and you only add the VoIP piece if you actually want it.- A missed call event means a missed meeting. If the rep gets on the call before we notice, our app joins 30 seconds late, misses the opening context, and the summary is worse. Call detection is the trigger for the whole recording / joining / transcription pipeline downstream, so it has to be fast, synchronous, and never miss a state change.
What you get out of the box:
- PSTN detection on both platforms with zero manifest surgery on iOS and one permission on Android (
READ_PHONE_STATE, non-restricted, no Play Console approval needed)statetransitions:incoming → dialing → connected → disconnected, exactly one event per transition- Synchronous
isInCall()andgetActiveCalls()— safe to read from renderaddCallListener(cb)returns an unsubscribe closure — noremoveListenerstring keys, no leaks- Uses
TelephonyCallbackon Android (API 31+) with aPhoneStateListenerfallback on older devices — modern APIs, not deprecated ones- No
InCallService, no calling-app intent filter, no restricted permissionsOpt-in extras (see
docs/android-setup.md):
- VoIP detection on Android — WhatsApp, Zoom, Teams, Google Meet, Telegram, Signal — by declaring an
InCallServicein your app manifest. Ships the service implementation in the AAR; it stays dormant until you declare it.- VoIP detection on iOS — always on. CallKit exposes cellular and VoIP through the same observer with no way to separate them.
What this library does NOT do (by design):
- No meeting URL extraction. iOS sandboxing and Android app isolation forbid reading data out of Zoom/Teams/Meet. For scheduled meetings, pair with a calendar integration; for ad-hoc meetings, prompt the user to paste.
- No runtime permission requester. Use
PermissionsAndroidfrom React Native, orreact-native-permissions, or your own module. Then callCallDetector.refreshPermissions(). One less activity in your manifest, one less thing Google reviewers see.- No call recording. For long-form microphone capture that survives interruptions, use
react-native-nitro-audio-anvil. This library is the trigger; Anvil is the recorder.- No detection of non-CallKit / non-ConnectionService VoIP. A handful of older or regional apps route audio without registering with the OS call framework. They are invisible to any observer-based approach. Platform limitation, not a bug.
If your app needs to know the second a call starts, on both platforms, without lighting up Google Play's calling-app review — this is the detector.
🎥 Demo
📦 Installation
yarn add react-native-nitro-call-detector react-native-nitro-modules
cd ios && pod installThen follow Android setup to add the manifest declaration for the detection level you want (PSTN or PSTN + VoIP). Nothing to add on iOS.
[!IMPORTANT]
- iOS: Uses
CXCallObserver(CallKit). Zero manifest / permission setup. Detects every CallKit-integrated app — cellular, FaceTime, WhatsApp, Zoom, Teams, Google Meet, Telegram, Signal — in one stream.- Android: Uses
TelephonyCallback(API 31+) orPhoneStateListener(older). PSTN only by default. VoIP detection is opt-in via manifest — see android-setup.md.- Tested on React Native 0.85+ with the New Architecture (required by Nitro Modules).
🧠 Overview
| Feature | Implementation |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| iOS backend | CXCallObserver on the main queue |
| Android backend (PSTN) | TelephonyCallback (API 31+) / PhoneStateListener (older) |
| Android backend (VoIP) | InCallService, opt-in via host app manifest |
| PSTN detection | ✅ Both platforms, no manifest changes on iOS |
| VoIP detection | ✅ iOS (bundled with PSTN via CallKit) · ✅ Android with manifest opt-in |
| Source app identification | Android VoIP mode only — package name of the hosting app |
| State transitions | incoming → dialing → connected → disconnected, one event each |
| Sync queries | isInCall(), getActiveCalls() — safe from render |
| Event stream | addCallListener(cb) → unsubscribe, JS-thread delivery |
| Runtime permissions | Handled by the host app (PermissionsAndroid etc). Library provides refreshPermissions() to reactivate the backend after a grant. |
| Library manifest | Empty. Nothing is added to your app unless you declare it yourself. |
| Threading | One owner thread per platform, no locks, no JS-thread blocking |
📚 Documentation
| Doc | When to read |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| Android setup | Read first. Manifest snippets for PSTN-only and PSTN+VoIP, plus Play Store readiness notes. |
| iOS quirks | Before shipping on iOS — CallKit's opaque observer, why type is unknown, background behaviour |
| OEM quirks | Before shipping on Android — the "Calling accounts" toggle Xiaomi/Vivo/Oppo/Realme hide |
| Troubleshooting | When something breaks — symptom-first debugging |
⚙️ Basic Usage
import { PermissionsAndroid, Platform } from 'react-native';
import { CallDetector, type CallInfo } from 'react-native-nitro-call-detector';
// One-shot check
if (CallDetector.isInCall()) {
const [call] = CallDetector.getActiveCalls();
console.log(call);
}
// Live stream
const unsubscribe = CallDetector.addCallListener((call) => {
switch (call.state) {
case 'incoming':
// Prompt: "Our App detected an incoming call — take notes?"
break;
case 'connected':
// Start local backup recording, dispatch the bot
break;
case 'disconnected':
// Wrap up, upload, transcribe
break;
}
});
// later
unsubscribe();Requesting the runtime permission on Android
The library does NOT request permissions itself. Use your app's existing flow:
async function enableCallDetection() {
if (Platform.OS !== 'android') return;
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE
);
if (granted === PermissionsAndroid.RESULTS.GRANTED) {
// Kick the native backend so it starts observing right now.
CallDetector.refreshPermissions();
}
}Routing on source app (Android VoIP mode only)
CallDetector.addCallListener((call) => {
if (call.state !== 'connected') return;
switch (call.sourceApp) {
case 'us.zoom.videomeetings':
return startZoomFlow();
case 'com.microsoft.teams':
return startTeamsFlow();
case 'net.whatsapp.WhatsApp':
return startWhatsAppFlow();
default:
return startGenericFlow(call);
}
});sourceApp is undefined on iOS and undefined on Android in PSTN-only mode. If you need this signal, enable VoIP mode via android-setup.md.
React hook
import { useEffect, useState } from 'react';
import { CallDetector, type CallInfo } from 'react-native-nitro-call-detector';
export function useActiveCall(): CallInfo | null {
const [call, setCall] = useState<CallInfo | null>(null);
useEffect(() => {
setCall(CallDetector.getActiveCalls()[0] ?? null);
return CallDetector.addCallListener((next) => {
setCall(next.state === 'disconnected' ? null : next);
});
}, []);
return call;
}🔐 Permissions
iOS — Info.plist
Nothing to add. CXCallObserver requires no permissions and no Info.plist keys. Import and use.
Android
Handled by the host app. See android-setup.md for the exact snippet. Short version:
- PSTN only: declare
READ_PHONE_STATEin your manifest, request it at runtime viaPermissionsAndroid, callCallDetector.refreshPermissions()after the grant. - PSTN + VoIP: also declare the
CallDetectorInCallServicecomponent. See the doc.
READ_CALL_LOG is not declared. It's a Play Store restricted permission that only unlocks the remote phone number for PSTN. If you need it, declare and request it yourself in the host app.
📡 API
CallDetector.isInCall(): boolean
CallDetector.getActiveCalls(): CallInfo[]
CallDetector.hasRequiredPermissions(): boolean
CallDetector.refreshPermissions(): boolean // Android; no-op on iOS
CallDetector.addCallListener(cb: (call: CallInfo) => void): () => voidCallInfo
interface CallInfo {
readonly id: string;
readonly type: 'pstn' | 'voip' | 'unknown';
readonly state: 'incoming' | 'dialing' | 'connected' | 'disconnected';
readonly remoteHandle?: string; // phone number or SIP URI, best-effort
readonly sourceApp?: string; // Android VoIP mode only, e.g. "us.zoom.videomeetings"
readonly startedAt: number; // ms since epoch
readonly connectedAt?: number; // ms since epoch, set when state → connected
}Field availability
| Field | iOS | Android PSTN mode | Android VoIP mode (opt-in) |
| -------------- | -------------------------- | ------------------------------------------------------------------------------- | ---------------------------------------------- |
| id | ✅ CXCall.uuid | ✅ generated | ✅ telecom Call hashCode |
| type | ⚠️ almost always unknown | ✅ always pstn | ✅ pstn or voip, inferred from sourceApp |
| state | ✅ | ✅ | ✅ |
| remoteHandle | ❌ CallKit hides it | ⚠️ with READ_PHONE_STATE on Android <12; needs READ_CALL_LOG on Android 12+ | ✅ for VoIP; ⚠️ same restriction as PSTN mode |
| sourceApp | ❌ CallKit hides it | ❌ not observed in PSTN mode | ✅ package name of the hosting app |
🎯 What's actually detected
| App | iOS | Android PSTN mode | Android VoIP mode (opt-in) | | --------------- | --- | ----------------- | ----------------------------------- | | Cellular / PSTN | ✅ | ✅ | ✅ | | FaceTime | ✅ | — | — | | WhatsApp | ✅ | ❌ | ✅ | | Zoom | ✅ | ❌ | ✅ (self-managed ConnectionService) | | Microsoft Teams | ✅ | ❌ | ✅ | | Google Meet | ✅ | ❌ | ✅ | | Telegram | ✅ | ❌ | ✅ | | Signal | ✅ | ❌ | ✅ |
🧩 Supported Platforms
| Platform | Status |
| -------------------- | -------------------------------------------------------------------------------------------------- |
| iOS | ✅ Fully Supported |
| Android | ✅ Fully Supported (PSTN default, VoIP opt-in) |
| iOS Simulator | ⚠️ Partial — CXCallObserver does not fire for cellular. Test on a real device. |
| Android Emulator | ⚠️ Partial — cellular via emulator console works; VoIP apps are hit-and-miss. Test on real device. |
🔧 Building the library
yarn install
yarn nitrogen # generates nitrogen/generated from src/specs/*.nitro.ts
yarn typechecknitrogen/generated/ must be committed and shipped in the npm package.
🤝 Contributing
Contributions are welcome!
🪪 License
MIT © Gautham Vijayan
Made with ❤️ and Nitro Modules
