@poolse/native

The call engine for phones. A native core (Swift on iOS, Kotlin on Android) rings, answers and carries calls even when the app is closed and the phone is locked: CallKit and PushKit on iOS, a call service and full-screen notifications on Android. The JavaScript side is thin. You send commands, and the core sends back one Snapshot describing the call.

The same package shows poolse's chat notifications on the phone and gives you helpers for them.

This package was called @poolse/react-native-calls before 3.0. See Migrating to 3.0.

Requirements

  • A development build or a release build. The package is a native module, so it isn't in Expo Go. Without the native module (Expo Go, web, an unlinked build) every command does nothing, getCallState() returns null, and startRoom and ringIntoCall reject. Check nativeCallsAvailable to tell.
  • Peer dependencies: expo, expo-notifications, react and react-native. No minimum versions are declared; the package is built against Expo SDK 54.
  • Node 20.19 or newer for expo prebuild (the config plugin's entry file is an ES module loaded with require).
  • iOS 15.1 or newer. Picture-in-picture needs iOS 15.
  • Android: minSdkVersion 24 and compileSdkVersion 36 unless your project sets others. Picture-in-picture needs Android 8.
  • Push credentials for your workspace (an APNs key and an FCM service account) on the dashboard. Without them nothing rings a closed app. See Push setup (iOS & Android).
  • An SFU on the deployment. Calls and rooms carry their media through LiveKit; startRoom rejects when the server has none.

Install

npx expo install @poolse/native expo-notifications

Config plugin

Add the plugin in app.json, after expo-notifications:

{
  "expo": {
    "plugins": [
      "expo-notifications",
      ["@poolse/native", { "ringback": "./assets/sounds/ringback.mp3" }]
    ]
  }
}

Then run npx expo prebuild --clean.

OptionTypeDefaultWhat it does
ringbackstring (path)noneCopies this sound file into both apps (the iOS bundle and Android res/raw). Pass its base name, without the extension, to configure({ ringback }). The file name may only contain lowercase letters, digits and underscores (an Android rule). Prebuild fails if the file is missing. Without a ringback, an Android caller hears nothing while the other phone rings, and an iPhone plays a ringtone.
pictureInPicturebooleantrueAndroid: lets the activity float as a picture-in-picture window, and handles the resize without restarting the activity.
lockscreenbooleantrueAndroid: lets the app show over the lock screen when it is opened for a call, so answering never needs an unlock. Also needed for setShowOnLockscreen.

Always applied on Android:

  • The LiveKit media adapter is turned on. Without it calls connect with no audio.
  • The core receives every FCM message first and passes the ones that aren't calls to expo-notifications, so your other pushes keep working.

On iOS the plugin only adds the ringback. CallKit, PushKit and picture-in-picture come with the module.

iOS app settings

The plugin doesn't change these; add them to app.json:

{
  "expo": {
    "ios": {
      "infoPlist": {
        "UIBackgroundModes": ["audio", "voip", "remote-notification"],
        "NSMicrophoneUsageDescription": "Talk on voice and video calls.",
        "NSCameraUsageDescription": "Show your camera on video calls."
      },
      "entitlements": { "aps-environment": "production" }
    }
  }
}

The bundle ID you enter with your APNs key on the dashboard must match the app's bundle identifier, or VoIP pushes don't arrive.

Android app settings

The library's manifest already adds INTERNET, POST_NOTIFICATIONS, USE_FULL_SCREEN_INTENT, FOREGROUND_SERVICE, FOREGROUND_SERVICE_PHONE_CALL, FOREGROUND_SERVICE_MICROPHONE, RECORD_AUDIO, VIBRATE, WAKE_LOCK and MANAGE_OWN_CALLS. For video calls, add android.permission.CAMERA yourself. Set android.googleServicesFile so FCM works.

Getting started

import * as calls from '@poolse/native';

// 1. Once, at app start, before any call can arrive.
await calls.configure('https://api.poolse.dev', {
  ringback: 'ringback',          // the base name from the plugin option
  apnsEnvironment: 'production', // iOS: match your aps-environment entitlement
});

// 2. Subscribe, then read, so no change is missed in between.
const stop = calls.onCallState((snapshot) => render(snapshot));
render(calls.getCallState());

// 3. Place a call. sessionToken is the user's poolse JWT (what your getToken returns).
await calls.place(conversationId, { media: 'audio', sessionToken });

Your UI reads only the snapshot. A call that arrives while the app is closed is shown by CallKit (iOS) or a full-screen notification (Android). When the user answers, the app opens with the snapshot already set.

The device has to be registered with poolse so calls can reach it; see Push tokens.

Setup functions

configure(apiBaseUrl, options?)

Promise<void>. Call once, early, before anything else in this package. It needs no secrets: only the API origin. startRoom and ringIntoCall reject until it has been called.

OptionTypeMeaning
smallIconstringAndroid: drawable resource name for the call notification's small icon. Defaults to the app icon.
ringbackstringBase name of the ringback sound shipped by the config plugin.
apnsEnvironment'production' | 'sandbox'iOS: the APNs environment of this build's aps-environment. Reported with the VoIP token so poolse uses the right APNs gateway. When unset, debug builds report sandbox and release builds production. Ignored on Android.

ConfigureOptions is the type.

nativeCallsAvailable

boolean. true when the native module is in this build.

The call snapshot

getCallState()

Returns the current Snapshot, or null when there is no call. Safe to call at any time, including before configure and right after the app was opened by an answer.

onCallState(callback)

Calls callback(snapshot) with the full snapshot on every change (null when idle). Returns an unsubscribe function.

Snapshot

After a call ends, the snapshot stays at phase: 'ended' (with a reason) for about 2.5 seconds, then becomes null.

FieldTypeMeaning
cv1Version of this shape.
callIdstring
conversationIdstring
conversationNamestring | null
direction'incoming' | 'outgoing' | 'joined'joined: you joined a call that was already running (a room, or join).
kind'call' | 'room' (optional)room rings nobody. Reads call while a room is still connecting.
media'audio' | 'video'What the call opened as. Anyone can still turn a camera on or off.
phase'ringing_in' | 'ringing_out' | 'connecting' | 'active' | 'reconnecting' | 'ended'
reasonsee below (optional)Set when phase is ended.
participantsarrayOther people only. Each: userId (poolse user id), externalId, speaking, muted, cameraOn (show their video rather than their avatar), connectionQuality (optional).
audioRoute'earpiece' | 'speaker' | 'bluetooth' | 'headset'Where audio plays now (AudioOutputKind).
audioOutputsAudioOutput[]Every output available now: the phone (earpiece, or wired headphones), the speaker, and each Bluetooth or USB device by name. Empty until call audio is up.
audioOutputIdstring | nullThe audioOutputs entry in use.
mutedSelfboolean
cameraOnbooleanYour camera is publishing.
cameraFacing'front' | 'back' (optional)Absent while your camera is off.
waitingWaitingCall | null (optional)A second call ringing during this one. See Call waiting.
connectionQualityConnectionQuality (optional)Your own link. Show a weak-connection hint on poor or lost.
videoPausedboolean (optional)Incoming video is off because your connection can't carry it. Audio continues and video returns by itself once the link is good for a few seconds. Participants read cameraOn: false meanwhile.

reason is one of left, finished, declined_all, busy_all, no_answer, cancelled, connection_lost or failed.

ConnectionQuality is 'good' | 'poor' | 'lost' | 'unknown'. good includes excellent; unknown means not measured yet; a participant with lost has dropped and is reconnecting.

interface AudioOutput {
  id: string;            // opaque; stable while the device stays connected
  kind: 'earpiece' | 'speaker' | 'bluetooth' | 'headset';
  name: string;          // "AirPods Pro"; English defaults for built-ins ("iPhone"/"Phone", "Speaker", "Headphones")
}

interface WaitingCall {
  callId: string;
  conversationId: string | null;
  conversationName: string | null;
  callerName: string | null;
  media: 'audio' | 'video';
}

To translate the built-in output names, relabel them by kind.

Calls

place(conversationId, options)

Promise<void>. Places a call that rings the conversation's other members. options is PlaceOptions:

FieldTypeMeaning
media'audio' | 'video'Required.
sessionTokenstringRequired. The user's poolse JWT.

Progress shows up in the snapshot (ringing_out → connecting → active).

join(callId, conversationId, sessionToken)

Promise<void>. Joins a call or room that is already live, for example from a "Join" banner. conversationId may be null. Use useLiveCall from @poolse/react to know what is live.

answer(), decline(), hangUp()

Answer or decline the ringing call, or hang up. hangUp in a room leaves it; the room goes on for the others.

setMuted(muted), setCamera(on), switchCamera()

Microphone on or off, camera on or off, front or back camera. Turning the camera on is all it takes to go from voice to video.

setAudioOutput(id)

Plays call audio through one of snapshot.audioOutputs. The choice holds for the rest of the call, until that device disconnects or a new headset or Bluetooth device connects (which then takes over). Without a choice, a connected headset wins, then the earpiece for voice calls or the speaker for video calls. On a native build older than this function, only the id 'speaker' does anything.

setSpeaker(on)

One-tap speaker: true for the loudspeaker, false back to the earpiece or headset.

ringIntoCall(people)

Promise<string[]>. "Add people": rings more of the conversation's members into the call you are on. Their phones ring as for a new call, and answering puts them in this call.

FieldTypeMeaning
userIdsstring[]Poolse user ids.
externalIdsstring[]Your user ids.

RingIntoCallOptions is the type. It resolves with the poolse user ids actually rung. People already in the call, still ringing, or not members of the conversation are skipped. It rejects when there is no call in progress, while nobody has answered yet, when the server refuses, before configure, or on a native build without the feature.

Rooms

A room is a conversation's drop-in call: nobody's phone rings, and members join and leave while the chat goes on. On the phone it runs in the native core like a call (CallKit or the call service), so it continues with the phone locked. A room plays on the speaker unless a headset or Bluetooth device is connected.

startRoom(conversationId, options)

Promise<StartRoomResult>. Starts the conversation's room, or joins whatever is already live there (a ringing call included), then hands it to the core. options is PlaceOptions (media, sessionToken).

interface StartRoomResult {
  callId: string;   // what the Join banner and call:* events carry
  created: boolean; // false when it joined something already live
}

It rejects when the server refuses (not a member, no SFU), before configure, or without the native module.

Others join with join(callId, conversationId, sessionToken). Leaving (hangUp) never ends the room for them. It ends once it has been empty for a short time. snapshot.kind is 'room', so you can show it differently from a call.

Call waiting

A call that rings while you are on another call (or in a room) waits instead of being refused. snapshot.waiting describes it, and the phone shows its own surface: CallKit's call-waiting screen on iOS, a heads-up notification with Decline and End & answer (and the waiting beep) on Android.

answerWaiting(callId?)

Ends the current call and answers the waiting one.

declineWaiting(callId?)

Turns the waiting call away; its caller hears busy.

Pass snapshot.waiting.callId. If the waiting call has changed by the time the tap lands, the command is ignored instead of acting on a different call. If the current call ends first, the waiting call simply becomes the ringing call. Left alone, it times out as missed. A third call while one is waiting gets busy.

if (snapshot?.waiting) {
  calls.answerWaiting(snapshot.waiting.callId);
}

Video

<CallVideoView>

A participant's camera, or your own with local, drawn natively. It stays transparent while that camera is off, so draw the avatar underneath (use cameraOn from the snapshot). Without the native module it renders an empty View.

PropTypeDefaultMeaning
participantIdstring—The participant's userId from the snapshot. Leave it out with local.
localboolean—Your own camera.
mirrorbooleantrue for your front camera, else false
fit'cover' | 'contain''cover'
pictureInPictureboolean—iOS: this is the picture that picture-in-picture floats. Mark one view at a time.

It also accepts every View prop (style, …). CallVideoViewProps is the type.

<View style={{ flex: 1 }}>
  <CallAvatar name={name} seed={p.userId} size={120} />
  {p.cameraOn ? (
    <CallVideoView participantId={p.userId} pictureInPicture style={StyleSheet.absoluteFill} />
  ) : null}
</View>

(CallAvatar is from @poolse/react-native.)

Picture-in-picture

FunctionReturnsMeaning
setPictureInPictureEnabled(on)voidWhile on, leaving the app keeps the call's video in a floating window. Turn it on only while a video call shows someone's picture, and off otherwise.
isInPictureInPicture()booleanThe floating window is open now.
pictureInPictureSupported()booleanThis device can float a call at all.
onPictureInPicture(callback)unsubscribecallback(true) when the window opens, callback(false) when it closes.

On iOS 15+ the <CallVideoView pictureInPicture> on screen is what floats. On Android 8+ the whole app floats: draw a compact layout while isInPictureInPicture() is true. Android needs the plugin's pictureInPicture option (on by default).

Android lock screen and full-screen ring

canUseFullScreenIntent()

boolean. Android 14+: whether the app may show the full-screen ring over the lock screen. The user can withdraw this. Returns true on older Android, on iOS, and without the native module.

openFullScreenIntentSettings()

Android: opens the system screen where the user allows full-screen notifications (falls back to the app's notification settings). Does nothing on iOS.

if (!calls.canUseFullScreenIntent()) {
  // explain why, then:
  calls.openFullScreenIntentSettings();
}

setShowOnLockscreen(enabled)

Android: let the app show over the lock screen while a call rings or runs, so answering never needs an unlock. Turn it on while a call is live and off when it ends. Needs the plugin's lockscreen option (on by default). Does nothing on iOS, where CallKit owns the lock screen. Errors are swallowed.

Push tokens

poolse reaches a phone through push. Register the phone's tokens with poolse.devices.register from @poolse/sdk once a user is signed in, and again when a token changes. Unregister on sign-out.

PlatformTokenRegister with
iOSPushKit VoIP token, from getVoipToken() / onVoipToken{ token, platform: 'ios', kind: 'voip', environment }
iOSRegular APNs token, from expo-notifications getDevicePushTokenAsync(){ token, platform: 'ios', kind: 'alert', environment }
AndroidFCM token, from expo-notifications getDevicePushTokenAsync(){ token, platform: 'android' }

On iOS the VoIP token only carries incoming calls. Chat notifications and missed calls use the alert token, so register both.

getVoipToken()

iOS: the current PushKit token as VoipToken ({ token: string /* hex */, environment: 'production' | 'sandbox' | string }), registered natively at launch. null until iOS issues one, and always on Android (FCM carries calls there).

onVoipToken(callback)

iOS: called whenever PushKit issues or rotates the token. Returns an unsubscribe function. Does nothing on Android.

const register = (voip: calls.VoipToken | null) => {
  if (!voip) return;
  void poolse.devices.register({
    token: voip.token,
    platform: 'ios',
    kind: 'voip',
    environment: voip.environment === 'sandbox' ? 'sandbox' : 'production',
  });
};
register(calls.getVoipToken());
const stopVoip = calls.onVoipToken(register);

Missed calls

takeCallAction()

Android: the missed-call notification action the app was launched with, returned once. Read it on mount. null when the app was opened any other way. The core posts the missed-call notification itself from the push, so it works with the app closed.

onCallAction(callback)

Android: a missed-call notification was tapped while the app was running. Returns an unsubscribe function.

interface CallAction {
  type: 'open' | 'call_back';  // the notification, or its Call back button
  conversationId: string;
  callId: string | null;
  media: 'audio' | 'video';    // for call_back: place a call with this media
}

On iOS, missed calls arrive as an ordinary alert push to the alert token. Read its taps with expo-notifications: the data's type is call_missed, the category is poolse_missed_call and the Call back action's id is call_back. Register that category with expo-notifications to show the button.

Chat notifications

poolse sends message notifications itself, using the push credentials you set up for calls and the devices you registered. The phone shows one notification per conversation, with each message as a line, plus Reply and Mark as read actions that work without opening the app. Notifications clear when the chat is read on another device. See Push payloads for what the data contains.

setActiveConversation(conversationId)

Tells the core which conversation is on screen. Its messages aren't notified, and opening it clears the notifications already shown for it. Pass null when the user leaves it and when the app goes to the background.

useEffect(() => {
  calls.setActiveConversation(conversationId);
  const sub = AppState.addEventListener('change', (state) => {
    calls.setActiveConversation(state === 'active' ? conversationId : null);
  });
  return () => {
    sub.remove();
    calls.setActiveConversation(null);
  };
}, [conversationId]);

shouldPresentMessage(data)

For your expo-notifications notification handler. Returns false for a chat notification (data.type === 'message') of the conversation on screen, and true for everything else. Android never asks, because the core already skips those notifications natively.

import * as Notifications from 'expo-notifications';

Notifications.setNotificationHandler({
  handleNotification: async (n) => {
    const show = calls.shouldPresentMessage(n.request.content.data);
    return { shouldShowBanner: show, shouldShowList: show, shouldPlaySound: show, shouldSetBadge: true };
  },
});

conversationOfNotification(data)

Returns the conversation id of a tapped notification's data when it is a chat message (type: 'message') or a missed call (type: 'call_missed'), else null. Use it on iOS with the tap response from expo-notifications. On Android, taps arrive through takeCallAction / onCallAction with type: 'open'.

Forwarding socket events (optional)

The core learns that a ring has ended or a call has finished from push. When the app is open, poolse's socket usually knows first. You can pass those events on; both paths are de-duplicated in the core, so whichever arrives first wins.

FunctionFeed it
onCallFinished(callId, reason?)call:finished, from UserChannel.onCallFinished. Pass the payload's reason (finished, declined_all, busy_all, no_answer, cancelled) so a caller sees "Declined" or "No answer" rather than "Call ended".
onRingEnded(callId, reason)call:ring_ended, from UserChannel.onRingEnded.

Listen on the user channel rather than the conversation channel: the app stays joined to it even after leaving a conversation's screen mid-call. These listeners are in @poolse/sdk 3.1.

const me = await poolse.me.show();
const userChannel = poolse.realtime.user(me.id);

const offRing = userChannel.onRingEnded((e) => calls.onRingEnded(e.call_id, e.reason));
const offFinished = userChannel.onCallFinished((e) => calls.onCallFinished(e.call_id, e.reason));

Exported types

Snapshot, ConnectionQuality, AudioOutputKind, AudioOutput, WaitingCall, ConfigureOptions, VoipToken, PlaceOptions, CallAction, Unsubscribe, StartRoomResult, RingIntoCallOptions, CallVideoViewProps.

@poolse/native · poolse docs