@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()returnsnull, andstartRoomandringIntoCallreject. ChecknativeCallsAvailableto tell. - Peer dependencies:
expo,expo-notifications,reactandreact-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 withrequire). - iOS 15.1 or newer. Picture-in-picture needs iOS 15.
- Android:
minSdkVersion24 andcompileSdkVersion36 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;
startRoomrejects 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.
| Option | Type | Default | What it does |
|---|---|---|---|
ringback | string (path) | none | Copies 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. |
pictureInPicture | boolean | true | Android: lets the activity float as a picture-in-picture window, and handles the resize without restarting the activity. |
lockscreen | boolean | true | Android: 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.
| Option | Type | Meaning |
|---|---|---|
smallIcon | string | Android: drawable resource name for the call notification's small icon. Defaults to the app icon. |
ringback | string | Base 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.
| Field | Type | Meaning |
|---|---|---|
cv | 1 | Version of this shape. |
callId | string | |
conversationId | string | |
conversationName | string | 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' | |
reason | see below (optional) | Set when phase is ended. |
participants | array | Other 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). |
audioOutputs | AudioOutput[] | 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. |
audioOutputId | string | null | The audioOutputs entry in use. |
mutedSelf | boolean | |
cameraOn | boolean | Your camera is publishing. |
cameraFacing | 'front' | 'back' (optional) | Absent while your camera is off. |
waiting | WaitingCall | null (optional) | A second call ringing during this one. See Call waiting. |
connectionQuality | ConnectionQuality (optional) | Your own link. Show a weak-connection hint on poor or lost. |
videoPaused | boolean (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:
| Field | Type | Meaning |
|---|---|---|
media | 'audio' | 'video' | Required. |
sessionToken | string | Required. 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.
| Field | Type | Meaning |
|---|---|---|
userIds | string[] | Poolse user ids. |
externalIds | string[] | 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.
| Prop | Type | Default | Meaning |
|---|---|---|---|
participantId | string | — | The participant's userId from the snapshot. Leave it out with local. |
local | boolean | — | Your own camera. |
mirror | boolean | true for your front camera, else false | |
fit | 'cover' | 'contain' | 'cover' | |
pictureInPicture | boolean | — | 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
| Function | Returns | Meaning |
|---|---|---|
setPictureInPictureEnabled(on) | void | While 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() | boolean | The floating window is open now. |
pictureInPictureSupported() | boolean | This device can float a call at all. |
onPictureInPicture(callback) | unsubscribe | callback(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.
| Platform | Token | Register with |
|---|---|---|
| iOS | PushKit VoIP token, from getVoipToken() / onVoipToken | { token, platform: 'ios', kind: 'voip', environment } |
| iOS | Regular APNs token, from expo-notifications getDevicePushTokenAsync() | { token, platform: 'ios', kind: 'alert', environment } |
| Android | FCM 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.
| Function | Feed 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.