@poolse/react-native-calls 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -35,6 +35,14 @@ var onRingEnded = (callId, reason) => native?.onRingEnded(callId, reason);
35
35
  var onCallFinished = (callId, reason) => native?.onCallFinished(callId, reason ?? null);
36
36
  var canUseFullScreenIntent = () => native?.canUseFullScreenIntent() ?? true;
37
37
  var openFullScreenIntentSettings = () => native?.openFullScreenIntentSettings();
38
+ function getVoipToken() {
39
+ return native?.getVoipToken?.() ?? null;
40
+ }
41
+ function onVoipToken(cb) {
42
+ if (!native || !native.getVoipToken) return () => void 0;
43
+ const sub = native.addListener("onVoipToken", cb);
44
+ return () => sub.remove();
45
+ }
38
46
  var nativeVideo;
39
47
  function nativeVideoView() {
40
48
  if (nativeVideo !== void 0) return nativeVideo;
@@ -63,12 +71,14 @@ exports.canUseFullScreenIntent = canUseFullScreenIntent;
63
71
  exports.configure = configure;
64
72
  exports.decline = decline;
65
73
  exports.getCallState = getCallState;
74
+ exports.getVoipToken = getVoipToken;
66
75
  exports.hangUp = hangUp;
67
76
  exports.join = join;
68
77
  exports.nativeCallsAvailable = nativeCallsAvailable;
69
78
  exports.onCallFinished = onCallFinished;
70
79
  exports.onCallState = onCallState;
71
80
  exports.onRingEnded = onRingEnded;
81
+ exports.onVoipToken = onVoipToken;
72
82
  exports.openFullScreenIntentSettings = openFullScreenIntentSettings;
73
83
  exports.place = place;
74
84
  exports.setCamera = setCamera;
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"names":["requireOptionalNativeModule","requireNativeViewManager","createElement","View"],"mappings":";;;;;;;AAqFA,IAAM,MAAA,GAASA,4CAA+C,aAAa,CAAA;AAGpE,IAAM,uBAAuB,MAAA,IAAU;AAG9C,eAAsB,SAAA,CAAU,YAAoB,OAAA,EAA2C;AAC7F,EAAA,MAAM,MAAA,EAAQ,SAAA,CAAU,UAAA,EAAY,OAAA,IAAW,IAAI,CAAA;AACrD;AAGO,SAAS,YAAA,GAAgC;AAC9C,EAAA,OAAO,MAAA,EAAQ,YAAA,EAAa,CAAE,QAAA,IAAY,IAAA;AAC5C;AAKO,SAAS,YAAY,EAAA,EAAsD;AAChF,EAAA,IAAI,CAAC,MAAA,EAAQ,OAAO,MAAM,MAAA;AAC1B,EAAA,MAAM,GAAA,GAAM,OAAO,WAAA,CAAY,aAAA,EAAe,CAAC,OAAA,KAAY,EAAA,CAAG,OAAA,CAAQ,QAAQ,CAAC,CAAA;AAC/E,EAAA,OAAO,MAAM,IAAI,MAAA,EAAO;AAC1B;AAEO,SAAS,KAAA,CAAM,gBAAwB,OAAA,EAAsC;AAClF,EAAA,OAAO,MAAA,EAAQ,MAAM,cAAA,EAAgB,OAAA,CAAQ,OAAO,OAAA,CAAQ,YAAY,CAAA,IAAK,OAAA,CAAQ,OAAA,EAAQ;AAC/F;AAEO,SAAS,IAAA,CACd,MAAA,EACA,cAAA,EACA,YAAA,EACe;AACf,EAAA,OAAO,QAAQ,IAAA,CAAK,MAAA,EAAQ,gBAAgB,YAAY,CAAA,IAAK,QAAQ,OAAA,EAAQ;AAC/E;AAEO,IAAM,MAAA,GAAS,MAAY,MAAA,EAAQ,MAAA;AACnC,IAAM,OAAA,GAAU,MAAY,MAAA,EAAQ,OAAA;AACpC,IAAM,MAAA,GAAS,MAAY,MAAA,EAAQ,MAAA;AACnC,IAAM,QAAA,GAAW,CAAC,KAAA,KAAyB,MAAA,EAAQ,SAAS,KAAK;AACjE,IAAM,SAAA,GAAY,CAAC,EAAA,KAAsB,MAAA,EAAQ,UAAU,EAAE;AAC7D,IAAM,YAAA,GAAe,MAAY,MAAA,EAAQ,YAAA;AAEzC,IAAM,UAAA,GAAa,CAAC,EAAA,KAAsB,MAAA,EAAQ,WAAW,EAAE;AAM/D,IAAM,cAAc,CAAC,MAAA,EAAgB,WAC1C,MAAA,EAAQ,WAAA,CAAY,QAAQ,MAAM;AAQ7B,IAAM,cAAA,GAAiB,CAAC,MAAA,EAAgB,MAAA,KAC7C,QAAQ,cAAA,CAAe,MAAA,EAAQ,UAAU,IAAI;AAMxC,IAAM,sBAAA,GAAyB,MAAe,MAAA,EAAQ,sBAAA,EAAuB,IAAK;AAGlF,IAAM,4BAAA,GAA+B,MAAY,MAAA,EAAQ,4BAAA;AAgBhE,IAAI,WAAA;AAGJ,SAAS,eAAA,GAA0D;AACjE,EAAA,IAAI,WAAA,KAAgB,QAAW,OAAO,WAAA;AACtC,EAAA,WAAA,GAAc,IAAA;AACd,EAAA,IAAI,MAAA,EAAQ;AACV,IAAA,IAAI;AACF,MAAA,WAAA,GAAcC,wCAAA,CAA2C,eAAe,iBAAiB,CAAA;AAAA,IAC3F,CAAA,CAAA,MAAQ;AACN,MAAA,WAAA,GAAc,IAAA;AAAA,IAChB;AAAA,EACF;AACA,EAAA,OAAO,WAAA;AACT;AAQO,SAAS,cAAc,KAAA,EAAgD;AAC5E,EAAA,MAAM,SAAS,eAAA,EAAgB;AAC/B,EAAA,IAAI,CAAC,MAAA,EAAQ;AACX,IAAA,MAAM,EAAE,aAAA,EAAe,EAAA,EAAI,KAAA,EAAO,EAAA,EAAI,MAAA,EAAQ,EAAA,EAAI,GAAA,EAAK,EAAA,EAAI,GAAG,IAAA,EAAK,GAAI,KAAA;AACvE,IAAA,OAAOC,mBAAA,CAAcC,kBAAM,IAAI,CAAA;AAAA,EACjC;AACA,EAAA,OAAOD,mBAAA,CAAc,QAAQ,KAAK,CAAA;AACpC","file":"index.cjs","sourcesContent":["// @poolse/react-native-calls — the React Native face of the native\n// call core (CONTRACT.md §8). A thin, typed surface over the native\n// module: commands in, one snapshot event out. All call logic lives in\n// the native cores; nothing here decides anything about a call.\n//\n// On a platform or build without the native module (Expo Go, web,\n// unlinked), every command is a safe no-op and the state stays idle —\n// so a host can render against this unconditionally.\nimport { createElement, type ComponentType, type ReactElement } from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport { requireNativeViewManager, requireOptionalNativeModule } from 'expo-modules-core';\n\n/**\n * A call's complete state. `null` means idle — no call. After a call\n * ends the snapshot stays `phase: 'ended'` (with `reason`) for ~2.5 s,\n * then becomes `null`.\n */\nexport interface Snapshot {\n cv: 1;\n callId: string;\n conversationId: string;\n conversationName: string | null;\n direction: 'incoming' | 'outgoing' | 'joined';\n media: 'audio' | 'video';\n phase: 'ringing_in' | 'ringing_out' | 'connecting' | 'active' | 'reconnecting' | 'ended';\n reason?:\n | 'left'\n | 'finished'\n | 'declined_all'\n | 'busy_all'\n | 'no_answer'\n | 'cancelled'\n | 'connection_lost'\n | 'failed';\n /** REMOTE participants only; `userId` is the LiveKit identity. */\n participants: Array<{\n userId: string;\n externalId: string;\n speaking: boolean;\n muted: boolean;\n /** True only with a published, subscribed, unmuted camera track (show video, not avatar). */\n cameraOn: boolean;\n }>;\n audioRoute: 'earpiece' | 'speaker' | 'bluetooth' | 'headset';\n mutedSelf: boolean;\n /** True only while the local camera track is actually published and enabled. */\n cameraOn: boolean;\n /** Facing of the published local camera; absent when the camera is off. */\n cameraFacing?: 'front' | 'back';\n}\n\nexport interface ConfigureOptions {\n /** Drawable resource NAME for the call notification's small icon. */\n smallIcon?: string;\n /** Raw resource NAME of the ringback tone (Android). */\n ringback?: string;\n}\n\nexport interface PlaceOptions {\n media: 'audio' | 'video';\n /** The user's session token — authorizes placing/joining (CONTRACT.md §3.2). */\n sessionToken: string;\n}\n\ntype NativeSnapshot = { snapshot: Snapshot | null };\n\ninterface PoolseCallsNative {\n configure(apiBaseUrl: string, options: ConfigureOptions | null): Promise<void>;\n getCallState(): NativeSnapshot;\n place(conversationId: string, media: string, sessionToken: string): Promise<void>;\n join(callId: string, conversationId: string | null, sessionToken: string): Promise<void>;\n answer(): void;\n decline(): void;\n hangUp(): void;\n setMuted(muted: boolean): void;\n setCamera(on: boolean): void;\n switchCamera(): void;\n setSpeaker(on: boolean): void;\n onRingEnded(callId: string, reason: string): void;\n onCallFinished(callId: string, reason: string | null): void;\n canUseFullScreenIntent(): boolean;\n openFullScreenIntentSettings(): void;\n addListener(event: 'onCallState', cb: (payload: NativeSnapshot) => void): { remove(): void };\n}\n\nconst native = requireOptionalNativeModule<PoolseCallsNative>('PoolseCalls');\n\n/** True when the native call core is present in this build. */\nexport const nativeCallsAvailable = native != null;\n\n/** Configure once, before any call can arrive. No secrets — just the API origin. */\nexport async function configure(apiBaseUrl: string, options?: ConfigureOptions): Promise<void> {\n await native?.configure(apiBaseUrl, options ?? null);\n}\n\n/** The current snapshot — safe to read at any moment, including cold answer and before configure. */\nexport function getCallState(): Snapshot | null {\n return native?.getCallState().snapshot ?? null;\n}\n\nexport type Unsubscribe = () => void;\n\n/** Subscribe to call state. Fires a full snapshot on every change. */\nexport function onCallState(cb: (snapshot: Snapshot | null) => void): Unsubscribe {\n if (!native) return () => undefined;\n const sub = native.addListener('onCallState', (payload) => cb(payload.snapshot));\n return () => sub.remove();\n}\n\nexport function place(conversationId: string, options: PlaceOptions): Promise<void> {\n return native?.place(conversationId, options.media, options.sessionToken) ?? Promise.resolve();\n}\n\nexport function join(\n callId: string,\n conversationId: string | null,\n sessionToken: string,\n): Promise<void> {\n return native?.join(callId, conversationId, sessionToken) ?? Promise.resolve();\n}\n\nexport const answer = (): void => native?.answer();\nexport const decline = (): void => native?.decline();\nexport const hangUp = (): void => native?.hangUp();\nexport const setMuted = (muted: boolean): void => native?.setMuted(muted);\nexport const setCamera = (on: boolean): void => native?.setCamera(on);\nexport const switchCamera = (): void => native?.switchCamera();\n/** Route call audio to the loudspeaker (`true`) or back to earpiece / headset (`false`). */\nexport const setSpeaker = (on: boolean): void => native?.setSpeaker(on);\n\n/**\n * Socket forwarding for an open app: a `call:ring_ended` seen on the\n * socket. Idempotent with the push path — the core dedupes.\n */\nexport const onRingEnded = (callId: string, reason: string): void =>\n native?.onRingEnded(callId, reason);\n\n/**\n * Socket forwarding: `call:finished`. Pass the payload's `reason`\n * (`finished` | `declined_all` | `no_answer` | `cancelled`) so an\n * unanswered caller sees \"Declined\"/\"No answer\", not \"Call ended\".\n * Idempotent with the `finished` push.\n */\nexport const onCallFinished = (callId: string, reason?: string): void =>\n native?.onCallFinished(callId, reason ?? null);\n\n/**\n * Android 14+: whether the app may show the full-screen ring over the\n * lock screen. `true` on older Android and where the module is absent.\n */\nexport const canUseFullScreenIntent = (): boolean => native?.canUseFullScreenIntent() ?? true;\n\n/** Open the system screen where the user grants full-screen intents (falls back to notification settings). */\nexport const openFullScreenIntentSettings = (): void => native?.openFullScreenIntentSettings();\n\n// ── video ─────────────────────────────────────────────────────────────\n\nexport interface CallVideoViewProps extends ViewProps {\n /** Remote participant's userId (LiveKit identity). Omit with local=true for self view. */\n participantId?: string;\n local?: boolean;\n /** default: true for local front camera, false otherwise */\n mirror?: boolean;\n /** default 'cover' */\n fit?: 'cover' | 'contain';\n}\n\ntype NativeVideoProps = CallVideoViewProps;\n\nlet nativeVideo: ComponentType<NativeVideoProps> | null | undefined;\n\n/** Resolved lazily (first render) and only when the native module exists. */\nfunction nativeVideoView(): ComponentType<NativeVideoProps> | null {\n if (nativeVideo !== undefined) return nativeVideo;\n nativeVideo = null;\n if (native) {\n try {\n nativeVideo = requireNativeViewManager<NativeVideoProps>('PoolseCalls', 'PoolseCallVideo');\n } catch {\n nativeVideo = null; // an older native build without the view\n }\n }\n return nativeVideo;\n}\n\n/**\n * A participant's camera (or, with `local`, your own), rendered natively\n * by the call core. Shows nothing (stays transparent) while that camera\n * is off — draw the avatar from the snapshot's `cameraOn` underneath.\n * Without the native module it renders an empty View.\n */\nexport function CallVideoView(props: CallVideoViewProps): ReactElement | null {\n const Native = nativeVideoView();\n if (!Native) {\n const { participantId: _p, local: _l, mirror: _m, fit: _f, ...rest } = props;\n return createElement(View, rest);\n }\n return createElement(Native, props);\n}\n"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":["requireOptionalNativeModule","requireNativeViewManager","createElement","View"],"mappings":"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","file":"index.cjs","sourcesContent":["// @poolse/react-native-calls — the React Native face of the native\n// call core (CONTRACT.md §8). A thin, typed surface over the native\n// module: commands in, one snapshot event out. All call logic lives in\n// the native cores; nothing here decides anything about a call.\n//\n// On a platform or build without the native module (Expo Go, web,\n// unlinked), every command is a safe no-op and the state stays idle —\n// so a host can render against this unconditionally.\nimport { createElement, type ComponentType, type ReactElement } from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport { requireNativeViewManager, requireOptionalNativeModule } from 'expo-modules-core';\n\n/**\n * A call's complete state. `null` means idle — no call. After a call\n * ends the snapshot stays `phase: 'ended'` (with `reason`) for ~2.5 s,\n * then becomes `null`.\n */\nexport interface Snapshot {\n cv: 1;\n callId: string;\n conversationId: string;\n conversationName: string | null;\n direction: 'incoming' | 'outgoing' | 'joined';\n media: 'audio' | 'video';\n phase: 'ringing_in' | 'ringing_out' | 'connecting' | 'active' | 'reconnecting' | 'ended';\n reason?:\n | 'left'\n | 'finished'\n | 'declined_all'\n | 'busy_all'\n | 'no_answer'\n | 'cancelled'\n | 'connection_lost'\n | 'failed';\n /** REMOTE participants only; `userId` is the LiveKit identity. */\n participants: Array<{\n userId: string;\n externalId: string;\n speaking: boolean;\n muted: boolean;\n /** True only with a published, subscribed, unmuted camera track (show video, not avatar). */\n cameraOn: boolean;\n }>;\n audioRoute: 'earpiece' | 'speaker' | 'bluetooth' | 'headset';\n mutedSelf: boolean;\n /** True only while the local camera track is actually published and enabled. */\n cameraOn: boolean;\n /** Facing of the published local camera; absent when the camera is off. */\n cameraFacing?: 'front' | 'back';\n}\n\nexport interface ConfigureOptions {\n /** Drawable resource NAME for the call notification's small icon. */\n smallIcon?: string;\n /** Ringback tone: raw resource NAME (Android); main-bundle sound name, mp3/caf/wav (iOS). */\n ringback?: string;\n /**\n * iOS: the APNs environment of this build's `aps-environment`\n * entitlement. Echoed back with the VoIP token so the server pushes to\n * the right gateway. Ignored on Android.\n */\n apnsEnvironment?: 'production' | 'sandbox';\n}\n\n/** The iOS PushKit (VoIP) token and the APNs environment it belongs to. */\nexport interface VoipToken {\n /** Hex-encoded PushKit token. */\n token: string;\n environment: 'production' | 'sandbox' | string;\n}\n\nexport interface PlaceOptions {\n media: 'audio' | 'video';\n /** The user's session token — authorizes placing/joining (CONTRACT.md §3.2). */\n sessionToken: string;\n}\n\ntype NativeSnapshot = { snapshot: Snapshot | null };\n\ninterface PoolseCallsNative {\n configure(apiBaseUrl: string, options: ConfigureOptions | null): Promise<void>;\n getCallState(): NativeSnapshot;\n place(conversationId: string, media: string, sessionToken: string): Promise<void>;\n join(callId: string, conversationId: string | null, sessionToken: string): Promise<void>;\n answer(): void;\n decline(): void;\n hangUp(): void;\n setMuted(muted: boolean): void;\n setCamera(on: boolean): void;\n switchCamera(): void;\n setSpeaker(on: boolean): void;\n onRingEnded(callId: string, reason: string): void;\n onCallFinished(callId: string, reason: string | null): void;\n canUseFullScreenIntent(): boolean;\n openFullScreenIntentSettings(): void;\n getVoipToken?(): VoipToken | null;\n addListener(event: 'onCallState', cb: (payload: NativeSnapshot) => void): { remove(): void };\n addListener(event: 'onVoipToken', cb: (payload: VoipToken) => void): { remove(): void };\n}\n\nconst native = requireOptionalNativeModule<PoolseCallsNative>('PoolseCalls');\n\n/** True when the native call core is present in this build. */\nexport const nativeCallsAvailable = native != null;\n\n/** Configure once, before any call can arrive. No secrets — just the API origin. */\nexport async function configure(apiBaseUrl: string, options?: ConfigureOptions): Promise<void> {\n await native?.configure(apiBaseUrl, options ?? null);\n}\n\n/** The current snapshot — safe to read at any moment, including cold answer and before configure. */\nexport function getCallState(): Snapshot | null {\n return native?.getCallState().snapshot ?? null;\n}\n\nexport type Unsubscribe = () => void;\n\n/** Subscribe to call state. Fires a full snapshot on every change. */\nexport function onCallState(cb: (snapshot: Snapshot | null) => void): Unsubscribe {\n if (!native) return () => undefined;\n const sub = native.addListener('onCallState', (payload) => cb(payload.snapshot));\n return () => sub.remove();\n}\n\nexport function place(conversationId: string, options: PlaceOptions): Promise<void> {\n return native?.place(conversationId, options.media, options.sessionToken) ?? Promise.resolve();\n}\n\nexport function join(\n callId: string,\n conversationId: string | null,\n sessionToken: string,\n): Promise<void> {\n return native?.join(callId, conversationId, sessionToken) ?? Promise.resolve();\n}\n\nexport const answer = (): void => native?.answer();\nexport const decline = (): void => native?.decline();\nexport const hangUp = (): void => native?.hangUp();\nexport const setMuted = (muted: boolean): void => native?.setMuted(muted);\nexport const setCamera = (on: boolean): void => native?.setCamera(on);\nexport const switchCamera = (): void => native?.switchCamera();\n/** Route call audio to the loudspeaker (`true`) or back to earpiece / headset (`false`). */\nexport const setSpeaker = (on: boolean): void => native?.setSpeaker(on);\n\n/**\n * Socket forwarding for an open app: a `call:ring_ended` seen on the\n * socket. Idempotent with the push path — the core dedupes.\n */\nexport const onRingEnded = (callId: string, reason: string): void =>\n native?.onRingEnded(callId, reason);\n\n/**\n * Socket forwarding: `call:finished`. Pass the payload's `reason`\n * (`finished` | `declined_all` | `no_answer` | `cancelled`) so an\n * unanswered caller sees \"Declined\"/\"No answer\", not \"Call ended\".\n * Idempotent with the `finished` push.\n */\nexport const onCallFinished = (callId: string, reason?: string): void =>\n native?.onCallFinished(callId, reason ?? null);\n\n/**\n * Android 14+: whether the app may show the full-screen ring over the\n * lock screen. `true` on older Android and where the module is absent.\n */\nexport const canUseFullScreenIntent = (): boolean => native?.canUseFullScreenIntent() ?? true;\n\n/** Open the system screen where the user grants full-screen intents (falls back to notification settings). */\nexport const openFullScreenIntentSettings = (): void => native?.openFullScreenIntentSettings();\n\n// ── VoIP push (iOS) ───────────────────────────────────────────────────\n\n/**\n * iOS: the current PushKit VoIP token, registered natively at launch.\n * `null` until iOS issues one, and always on Android (FCM carries rings).\n */\nexport function getVoipToken(): VoipToken | null {\n return native?.getVoipToken?.() ?? null;\n}\n\n/** iOS: fires whenever PushKit issues (or rotates) the VoIP token. No-op on Android. */\nexport function onVoipToken(cb: (token: VoipToken) => void): Unsubscribe {\n if (!native || !native.getVoipToken) return () => undefined;\n const sub = native.addListener('onVoipToken', cb);\n return () => sub.remove();\n}\n\n// ── video ─────────────────────────────────────────────────────────────\n\nexport interface CallVideoViewProps extends ViewProps {\n /** Remote participant's userId (LiveKit identity). Omit with local=true for self view. */\n participantId?: string;\n local?: boolean;\n /** default: true for local front camera, false otherwise */\n mirror?: boolean;\n /** default 'cover' */\n fit?: 'cover' | 'contain';\n}\n\ntype NativeVideoProps = CallVideoViewProps;\n\nlet nativeVideo: ComponentType<NativeVideoProps> | null | undefined;\n\n/** Resolved lazily (first render) and only when the native module exists. */\nfunction nativeVideoView(): ComponentType<NativeVideoProps> | null {\n if (nativeVideo !== undefined) return nativeVideo;\n nativeVideo = null;\n if (native) {\n try {\n nativeVideo = requireNativeViewManager<NativeVideoProps>('PoolseCalls', 'PoolseCallVideo');\n } catch {\n nativeVideo = null; // an older native build without the view\n }\n }\n return nativeVideo;\n}\n\n/**\n * A participant's camera (or, with `local`, your own), rendered natively\n * by the call core. Shows nothing (stays transparent) while that camera\n * is off — draw the avatar from the snapshot's `cameraOn` underneath.\n * Without the native module it renders an empty View.\n */\nexport function CallVideoView(props: CallVideoViewProps): ReactElement | null {\n const Native = nativeVideoView();\n if (!Native) {\n const { participantId: _p, local: _l, mirror: _m, fit: _f, ...rest } = props;\n return createElement(View, rest);\n }\n return createElement(Native, props);\n}\n"]}
package/dist/index.d.cts CHANGED
@@ -34,8 +34,20 @@ interface Snapshot {
34
34
  interface ConfigureOptions {
35
35
  /** Drawable resource NAME for the call notification's small icon. */
36
36
  smallIcon?: string;
37
- /** Raw resource NAME of the ringback tone (Android). */
37
+ /** Ringback tone: raw resource NAME (Android); main-bundle sound name, mp3/caf/wav (iOS). */
38
38
  ringback?: string;
39
+ /**
40
+ * iOS: the APNs environment of this build's `aps-environment`
41
+ * entitlement. Echoed back with the VoIP token so the server pushes to
42
+ * the right gateway. Ignored on Android.
43
+ */
44
+ apnsEnvironment?: 'production' | 'sandbox';
45
+ }
46
+ /** The iOS PushKit (VoIP) token and the APNs environment it belongs to. */
47
+ interface VoipToken {
48
+ /** Hex-encoded PushKit token. */
49
+ token: string;
50
+ environment: 'production' | 'sandbox' | string;
39
51
  }
40
52
  interface PlaceOptions {
41
53
  media: 'audio' | 'video';
@@ -80,6 +92,13 @@ declare const onCallFinished: (callId: string, reason?: string) => void;
80
92
  declare const canUseFullScreenIntent: () => boolean;
81
93
  /** Open the system screen where the user grants full-screen intents (falls back to notification settings). */
82
94
  declare const openFullScreenIntentSettings: () => void;
95
+ /**
96
+ * iOS: the current PushKit VoIP token, registered natively at launch.
97
+ * `null` until iOS issues one, and always on Android (FCM carries rings).
98
+ */
99
+ declare function getVoipToken(): VoipToken | null;
100
+ /** iOS: fires whenever PushKit issues (or rotates) the VoIP token. No-op on Android. */
101
+ declare function onVoipToken(cb: (token: VoipToken) => void): Unsubscribe;
83
102
  interface CallVideoViewProps extends ViewProps {
84
103
  /** Remote participant's userId (LiveKit identity). Omit with local=true for self view. */
85
104
  participantId?: string;
@@ -97,4 +116,4 @@ interface CallVideoViewProps extends ViewProps {
97
116
  */
98
117
  declare function CallVideoView(props: CallVideoViewProps): ReactElement | null;
99
118
 
100
- export { CallVideoView, type CallVideoViewProps, type ConfigureOptions, type PlaceOptions, type Snapshot, type Unsubscribe, answer, canUseFullScreenIntent, configure, decline, getCallState, hangUp, join, nativeCallsAvailable, onCallFinished, onCallState, onRingEnded, openFullScreenIntentSettings, place, setCamera, setMuted, setSpeaker, switchCamera };
119
+ export { CallVideoView, type CallVideoViewProps, type ConfigureOptions, type PlaceOptions, type Snapshot, type Unsubscribe, type VoipToken, answer, canUseFullScreenIntent, configure, decline, getCallState, getVoipToken, hangUp, join, nativeCallsAvailable, onCallFinished, onCallState, onRingEnded, onVoipToken, openFullScreenIntentSettings, place, setCamera, setMuted, setSpeaker, switchCamera };
package/dist/index.d.ts CHANGED
@@ -34,8 +34,20 @@ interface Snapshot {
34
34
  interface ConfigureOptions {
35
35
  /** Drawable resource NAME for the call notification's small icon. */
36
36
  smallIcon?: string;
37
- /** Raw resource NAME of the ringback tone (Android). */
37
+ /** Ringback tone: raw resource NAME (Android); main-bundle sound name, mp3/caf/wav (iOS). */
38
38
  ringback?: string;
39
+ /**
40
+ * iOS: the APNs environment of this build's `aps-environment`
41
+ * entitlement. Echoed back with the VoIP token so the server pushes to
42
+ * the right gateway. Ignored on Android.
43
+ */
44
+ apnsEnvironment?: 'production' | 'sandbox';
45
+ }
46
+ /** The iOS PushKit (VoIP) token and the APNs environment it belongs to. */
47
+ interface VoipToken {
48
+ /** Hex-encoded PushKit token. */
49
+ token: string;
50
+ environment: 'production' | 'sandbox' | string;
39
51
  }
40
52
  interface PlaceOptions {
41
53
  media: 'audio' | 'video';
@@ -80,6 +92,13 @@ declare const onCallFinished: (callId: string, reason?: string) => void;
80
92
  declare const canUseFullScreenIntent: () => boolean;
81
93
  /** Open the system screen where the user grants full-screen intents (falls back to notification settings). */
82
94
  declare const openFullScreenIntentSettings: () => void;
95
+ /**
96
+ * iOS: the current PushKit VoIP token, registered natively at launch.
97
+ * `null` until iOS issues one, and always on Android (FCM carries rings).
98
+ */
99
+ declare function getVoipToken(): VoipToken | null;
100
+ /** iOS: fires whenever PushKit issues (or rotates) the VoIP token. No-op on Android. */
101
+ declare function onVoipToken(cb: (token: VoipToken) => void): Unsubscribe;
83
102
  interface CallVideoViewProps extends ViewProps {
84
103
  /** Remote participant's userId (LiveKit identity). Omit with local=true for self view. */
85
104
  participantId?: string;
@@ -97,4 +116,4 @@ interface CallVideoViewProps extends ViewProps {
97
116
  */
98
117
  declare function CallVideoView(props: CallVideoViewProps): ReactElement | null;
99
118
 
100
- export { CallVideoView, type CallVideoViewProps, type ConfigureOptions, type PlaceOptions, type Snapshot, type Unsubscribe, answer, canUseFullScreenIntent, configure, decline, getCallState, hangUp, join, nativeCallsAvailable, onCallFinished, onCallState, onRingEnded, openFullScreenIntentSettings, place, setCamera, setMuted, setSpeaker, switchCamera };
119
+ export { CallVideoView, type CallVideoViewProps, type ConfigureOptions, type PlaceOptions, type Snapshot, type Unsubscribe, type VoipToken, answer, canUseFullScreenIntent, configure, decline, getCallState, getVoipToken, hangUp, join, nativeCallsAvailable, onCallFinished, onCallState, onRingEnded, onVoipToken, openFullScreenIntentSettings, place, setCamera, setMuted, setSpeaker, switchCamera };
package/dist/index.js CHANGED
@@ -33,6 +33,14 @@ var onRingEnded = (callId, reason) => native?.onRingEnded(callId, reason);
33
33
  var onCallFinished = (callId, reason) => native?.onCallFinished(callId, reason ?? null);
34
34
  var canUseFullScreenIntent = () => native?.canUseFullScreenIntent() ?? true;
35
35
  var openFullScreenIntentSettings = () => native?.openFullScreenIntentSettings();
36
+ function getVoipToken() {
37
+ return native?.getVoipToken?.() ?? null;
38
+ }
39
+ function onVoipToken(cb) {
40
+ if (!native || !native.getVoipToken) return () => void 0;
41
+ const sub = native.addListener("onVoipToken", cb);
42
+ return () => sub.remove();
43
+ }
36
44
  var nativeVideo;
37
45
  function nativeVideoView() {
38
46
  if (nativeVideo !== void 0) return nativeVideo;
@@ -55,6 +63,6 @@ function CallVideoView(props) {
55
63
  return createElement(Native, props);
56
64
  }
57
65
 
58
- export { CallVideoView, answer, canUseFullScreenIntent, configure, decline, getCallState, hangUp, join, nativeCallsAvailable, onCallFinished, onCallState, onRingEnded, openFullScreenIntentSettings, place, setCamera, setMuted, setSpeaker, switchCamera };
66
+ export { CallVideoView, answer, canUseFullScreenIntent, configure, decline, getCallState, getVoipToken, hangUp, join, nativeCallsAvailable, onCallFinished, onCallState, onRingEnded, onVoipToken, openFullScreenIntentSettings, place, setCamera, setMuted, setSpeaker, switchCamera };
59
67
  //# sourceMappingURL=index.js.map
60
68
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":";;;;;AAqFA,IAAM,MAAA,GAAS,4BAA+C,aAAa,CAAA;AAGpE,IAAM,uBAAuB,MAAA,IAAU;AAG9C,eAAsB,SAAA,CAAU,YAAoB,OAAA,EAA2C;AAC7F,EAAA,MAAM,MAAA,EAAQ,SAAA,CAAU,UAAA,EAAY,OAAA,IAAW,IAAI,CAAA;AACrD;AAGO,SAAS,YAAA,GAAgC;AAC9C,EAAA,OAAO,MAAA,EAAQ,YAAA,EAAa,CAAE,QAAA,IAAY,IAAA;AAC5C;AAKO,SAAS,YAAY,EAAA,EAAsD;AAChF,EAAA,IAAI,CAAC,MAAA,EAAQ,OAAO,MAAM,MAAA;AAC1B,EAAA,MAAM,GAAA,GAAM,OAAO,WAAA,CAAY,aAAA,EAAe,CAAC,OAAA,KAAY,EAAA,CAAG,OAAA,CAAQ,QAAQ,CAAC,CAAA;AAC/E,EAAA,OAAO,MAAM,IAAI,MAAA,EAAO;AAC1B;AAEO,SAAS,KAAA,CAAM,gBAAwB,OAAA,EAAsC;AAClF,EAAA,OAAO,MAAA,EAAQ,MAAM,cAAA,EAAgB,OAAA,CAAQ,OAAO,OAAA,CAAQ,YAAY,CAAA,IAAK,OAAA,CAAQ,OAAA,EAAQ;AAC/F;AAEO,SAAS,IAAA,CACd,MAAA,EACA,cAAA,EACA,YAAA,EACe;AACf,EAAA,OAAO,QAAQ,IAAA,CAAK,MAAA,EAAQ,gBAAgB,YAAY,CAAA,IAAK,QAAQ,OAAA,EAAQ;AAC/E;AAEO,IAAM,MAAA,GAAS,MAAY,MAAA,EAAQ,MAAA;AACnC,IAAM,OAAA,GAAU,MAAY,MAAA,EAAQ,OAAA;AACpC,IAAM,MAAA,GAAS,MAAY,MAAA,EAAQ,MAAA;AACnC,IAAM,QAAA,GAAW,CAAC,KAAA,KAAyB,MAAA,EAAQ,SAAS,KAAK;AACjE,IAAM,SAAA,GAAY,CAAC,EAAA,KAAsB,MAAA,EAAQ,UAAU,EAAE;AAC7D,IAAM,YAAA,GAAe,MAAY,MAAA,EAAQ,YAAA;AAEzC,IAAM,UAAA,GAAa,CAAC,EAAA,KAAsB,MAAA,EAAQ,WAAW,EAAE;AAM/D,IAAM,cAAc,CAAC,MAAA,EAAgB,WAC1C,MAAA,EAAQ,WAAA,CAAY,QAAQ,MAAM;AAQ7B,IAAM,cAAA,GAAiB,CAAC,MAAA,EAAgB,MAAA,KAC7C,QAAQ,cAAA,CAAe,MAAA,EAAQ,UAAU,IAAI;AAMxC,IAAM,sBAAA,GAAyB,MAAe,MAAA,EAAQ,sBAAA,EAAuB,IAAK;AAGlF,IAAM,4BAAA,GAA+B,MAAY,MAAA,EAAQ,4BAAA;AAgBhE,IAAI,WAAA;AAGJ,SAAS,eAAA,GAA0D;AACjE,EAAA,IAAI,WAAA,KAAgB,QAAW,OAAO,WAAA;AACtC,EAAA,WAAA,GAAc,IAAA;AACd,EAAA,IAAI,MAAA,EAAQ;AACV,IAAA,IAAI;AACF,MAAA,WAAA,GAAc,wBAAA,CAA2C,eAAe,iBAAiB,CAAA;AAAA,IAC3F,CAAA,CAAA,MAAQ;AACN,MAAA,WAAA,GAAc,IAAA;AAAA,IAChB;AAAA,EACF;AACA,EAAA,OAAO,WAAA;AACT;AAQO,SAAS,cAAc,KAAA,EAAgD;AAC5E,EAAA,MAAM,SAAS,eAAA,EAAgB;AAC/B,EAAA,IAAI,CAAC,MAAA,EAAQ;AACX,IAAA,MAAM,EAAE,aAAA,EAAe,EAAA,EAAI,KAAA,EAAO,EAAA,EAAI,MAAA,EAAQ,EAAA,EAAI,GAAA,EAAK,EAAA,EAAI,GAAG,IAAA,EAAK,GAAI,KAAA;AACvE,IAAA,OAAO,aAAA,CAAc,MAAM,IAAI,CAAA;AAAA,EACjC;AACA,EAAA,OAAO,aAAA,CAAc,QAAQ,KAAK,CAAA;AACpC","file":"index.js","sourcesContent":["// @poolse/react-native-calls — the React Native face of the native\n// call core (CONTRACT.md §8). A thin, typed surface over the native\n// module: commands in, one snapshot event out. All call logic lives in\n// the native cores; nothing here decides anything about a call.\n//\n// On a platform or build without the native module (Expo Go, web,\n// unlinked), every command is a safe no-op and the state stays idle —\n// so a host can render against this unconditionally.\nimport { createElement, type ComponentType, type ReactElement } from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport { requireNativeViewManager, requireOptionalNativeModule } from 'expo-modules-core';\n\n/**\n * A call's complete state. `null` means idle — no call. After a call\n * ends the snapshot stays `phase: 'ended'` (with `reason`) for ~2.5 s,\n * then becomes `null`.\n */\nexport interface Snapshot {\n cv: 1;\n callId: string;\n conversationId: string;\n conversationName: string | null;\n direction: 'incoming' | 'outgoing' | 'joined';\n media: 'audio' | 'video';\n phase: 'ringing_in' | 'ringing_out' | 'connecting' | 'active' | 'reconnecting' | 'ended';\n reason?:\n | 'left'\n | 'finished'\n | 'declined_all'\n | 'busy_all'\n | 'no_answer'\n | 'cancelled'\n | 'connection_lost'\n | 'failed';\n /** REMOTE participants only; `userId` is the LiveKit identity. */\n participants: Array<{\n userId: string;\n externalId: string;\n speaking: boolean;\n muted: boolean;\n /** True only with a published, subscribed, unmuted camera track (show video, not avatar). */\n cameraOn: boolean;\n }>;\n audioRoute: 'earpiece' | 'speaker' | 'bluetooth' | 'headset';\n mutedSelf: boolean;\n /** True only while the local camera track is actually published and enabled. */\n cameraOn: boolean;\n /** Facing of the published local camera; absent when the camera is off. */\n cameraFacing?: 'front' | 'back';\n}\n\nexport interface ConfigureOptions {\n /** Drawable resource NAME for the call notification's small icon. */\n smallIcon?: string;\n /** Raw resource NAME of the ringback tone (Android). */\n ringback?: string;\n}\n\nexport interface PlaceOptions {\n media: 'audio' | 'video';\n /** The user's session token — authorizes placing/joining (CONTRACT.md §3.2). */\n sessionToken: string;\n}\n\ntype NativeSnapshot = { snapshot: Snapshot | null };\n\ninterface PoolseCallsNative {\n configure(apiBaseUrl: string, options: ConfigureOptions | null): Promise<void>;\n getCallState(): NativeSnapshot;\n place(conversationId: string, media: string, sessionToken: string): Promise<void>;\n join(callId: string, conversationId: string | null, sessionToken: string): Promise<void>;\n answer(): void;\n decline(): void;\n hangUp(): void;\n setMuted(muted: boolean): void;\n setCamera(on: boolean): void;\n switchCamera(): void;\n setSpeaker(on: boolean): void;\n onRingEnded(callId: string, reason: string): void;\n onCallFinished(callId: string, reason: string | null): void;\n canUseFullScreenIntent(): boolean;\n openFullScreenIntentSettings(): void;\n addListener(event: 'onCallState', cb: (payload: NativeSnapshot) => void): { remove(): void };\n}\n\nconst native = requireOptionalNativeModule<PoolseCallsNative>('PoolseCalls');\n\n/** True when the native call core is present in this build. */\nexport const nativeCallsAvailable = native != null;\n\n/** Configure once, before any call can arrive. No secrets — just the API origin. */\nexport async function configure(apiBaseUrl: string, options?: ConfigureOptions): Promise<void> {\n await native?.configure(apiBaseUrl, options ?? null);\n}\n\n/** The current snapshot — safe to read at any moment, including cold answer and before configure. */\nexport function getCallState(): Snapshot | null {\n return native?.getCallState().snapshot ?? null;\n}\n\nexport type Unsubscribe = () => void;\n\n/** Subscribe to call state. Fires a full snapshot on every change. */\nexport function onCallState(cb: (snapshot: Snapshot | null) => void): Unsubscribe {\n if (!native) return () => undefined;\n const sub = native.addListener('onCallState', (payload) => cb(payload.snapshot));\n return () => sub.remove();\n}\n\nexport function place(conversationId: string, options: PlaceOptions): Promise<void> {\n return native?.place(conversationId, options.media, options.sessionToken) ?? Promise.resolve();\n}\n\nexport function join(\n callId: string,\n conversationId: string | null,\n sessionToken: string,\n): Promise<void> {\n return native?.join(callId, conversationId, sessionToken) ?? Promise.resolve();\n}\n\nexport const answer = (): void => native?.answer();\nexport const decline = (): void => native?.decline();\nexport const hangUp = (): void => native?.hangUp();\nexport const setMuted = (muted: boolean): void => native?.setMuted(muted);\nexport const setCamera = (on: boolean): void => native?.setCamera(on);\nexport const switchCamera = (): void => native?.switchCamera();\n/** Route call audio to the loudspeaker (`true`) or back to earpiece / headset (`false`). */\nexport const setSpeaker = (on: boolean): void => native?.setSpeaker(on);\n\n/**\n * Socket forwarding for an open app: a `call:ring_ended` seen on the\n * socket. Idempotent with the push path — the core dedupes.\n */\nexport const onRingEnded = (callId: string, reason: string): void =>\n native?.onRingEnded(callId, reason);\n\n/**\n * Socket forwarding: `call:finished`. Pass the payload's `reason`\n * (`finished` | `declined_all` | `no_answer` | `cancelled`) so an\n * unanswered caller sees \"Declined\"/\"No answer\", not \"Call ended\".\n * Idempotent with the `finished` push.\n */\nexport const onCallFinished = (callId: string, reason?: string): void =>\n native?.onCallFinished(callId, reason ?? null);\n\n/**\n * Android 14+: whether the app may show the full-screen ring over the\n * lock screen. `true` on older Android and where the module is absent.\n */\nexport const canUseFullScreenIntent = (): boolean => native?.canUseFullScreenIntent() ?? true;\n\n/** Open the system screen where the user grants full-screen intents (falls back to notification settings). */\nexport const openFullScreenIntentSettings = (): void => native?.openFullScreenIntentSettings();\n\n// ── video ─────────────────────────────────────────────────────────────\n\nexport interface CallVideoViewProps extends ViewProps {\n /** Remote participant's userId (LiveKit identity). Omit with local=true for self view. */\n participantId?: string;\n local?: boolean;\n /** default: true for local front camera, false otherwise */\n mirror?: boolean;\n /** default 'cover' */\n fit?: 'cover' | 'contain';\n}\n\ntype NativeVideoProps = CallVideoViewProps;\n\nlet nativeVideo: ComponentType<NativeVideoProps> | null | undefined;\n\n/** Resolved lazily (first render) and only when the native module exists. */\nfunction nativeVideoView(): ComponentType<NativeVideoProps> | null {\n if (nativeVideo !== undefined) return nativeVideo;\n nativeVideo = null;\n if (native) {\n try {\n nativeVideo = requireNativeViewManager<NativeVideoProps>('PoolseCalls', 'PoolseCallVideo');\n } catch {\n nativeVideo = null; // an older native build without the view\n }\n }\n return nativeVideo;\n}\n\n/**\n * A participant's camera (or, with `local`, your own), rendered natively\n * by the call core. Shows nothing (stays transparent) while that camera\n * is off — draw the avatar from the snapshot's `cameraOn` underneath.\n * Without the native module it renders an empty View.\n */\nexport function CallVideoView(props: CallVideoViewProps): ReactElement | null {\n const Native = nativeVideoView();\n if (!Native) {\n const { participantId: _p, local: _l, mirror: _m, fit: _f, ...rest } = props;\n return createElement(View, rest);\n }\n return createElement(Native, props);\n}\n"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["// @poolse/react-native-calls — the React Native face of the native\n// call core (CONTRACT.md §8). A thin, typed surface over the native\n// module: commands in, one snapshot event out. All call logic lives in\n// the native cores; nothing here decides anything about a call.\n//\n// On a platform or build without the native module (Expo Go, web,\n// unlinked), every command is a safe no-op and the state stays idle —\n// so a host can render against this unconditionally.\nimport { createElement, type ComponentType, type ReactElement } from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport { requireNativeViewManager, requireOptionalNativeModule } from 'expo-modules-core';\n\n/**\n * A call's complete state. `null` means idle — no call. After a call\n * ends the snapshot stays `phase: 'ended'` (with `reason`) for ~2.5 s,\n * then becomes `null`.\n */\nexport interface Snapshot {\n cv: 1;\n callId: string;\n conversationId: string;\n conversationName: string | null;\n direction: 'incoming' | 'outgoing' | 'joined';\n media: 'audio' | 'video';\n phase: 'ringing_in' | 'ringing_out' | 'connecting' | 'active' | 'reconnecting' | 'ended';\n reason?:\n | 'left'\n | 'finished'\n | 'declined_all'\n | 'busy_all'\n | 'no_answer'\n | 'cancelled'\n | 'connection_lost'\n | 'failed';\n /** REMOTE participants only; `userId` is the LiveKit identity. */\n participants: Array<{\n userId: string;\n externalId: string;\n speaking: boolean;\n muted: boolean;\n /** True only with a published, subscribed, unmuted camera track (show video, not avatar). */\n cameraOn: boolean;\n }>;\n audioRoute: 'earpiece' | 'speaker' | 'bluetooth' | 'headset';\n mutedSelf: boolean;\n /** True only while the local camera track is actually published and enabled. */\n cameraOn: boolean;\n /** Facing of the published local camera; absent when the camera is off. */\n cameraFacing?: 'front' | 'back';\n}\n\nexport interface ConfigureOptions {\n /** Drawable resource NAME for the call notification's small icon. */\n smallIcon?: string;\n /** Ringback tone: raw resource NAME (Android); main-bundle sound name, mp3/caf/wav (iOS). */\n ringback?: string;\n /**\n * iOS: the APNs environment of this build's `aps-environment`\n * entitlement. Echoed back with the VoIP token so the server pushes to\n * the right gateway. Ignored on Android.\n */\n apnsEnvironment?: 'production' | 'sandbox';\n}\n\n/** The iOS PushKit (VoIP) token and the APNs environment it belongs to. */\nexport interface VoipToken {\n /** Hex-encoded PushKit token. */\n token: string;\n environment: 'production' | 'sandbox' | string;\n}\n\nexport interface PlaceOptions {\n media: 'audio' | 'video';\n /** The user's session token — authorizes placing/joining (CONTRACT.md §3.2). */\n sessionToken: string;\n}\n\ntype NativeSnapshot = { snapshot: Snapshot | null };\n\ninterface PoolseCallsNative {\n configure(apiBaseUrl: string, options: ConfigureOptions | null): Promise<void>;\n getCallState(): NativeSnapshot;\n place(conversationId: string, media: string, sessionToken: string): Promise<void>;\n join(callId: string, conversationId: string | null, sessionToken: string): Promise<void>;\n answer(): void;\n decline(): void;\n hangUp(): void;\n setMuted(muted: boolean): void;\n setCamera(on: boolean): void;\n switchCamera(): void;\n setSpeaker(on: boolean): void;\n onRingEnded(callId: string, reason: string): void;\n onCallFinished(callId: string, reason: string | null): void;\n canUseFullScreenIntent(): boolean;\n openFullScreenIntentSettings(): void;\n getVoipToken?(): VoipToken | null;\n addListener(event: 'onCallState', cb: (payload: NativeSnapshot) => void): { remove(): void };\n addListener(event: 'onVoipToken', cb: (payload: VoipToken) => void): { remove(): void };\n}\n\nconst native = requireOptionalNativeModule<PoolseCallsNative>('PoolseCalls');\n\n/** True when the native call core is present in this build. */\nexport const nativeCallsAvailable = native != null;\n\n/** Configure once, before any call can arrive. No secrets — just the API origin. */\nexport async function configure(apiBaseUrl: string, options?: ConfigureOptions): Promise<void> {\n await native?.configure(apiBaseUrl, options ?? null);\n}\n\n/** The current snapshot — safe to read at any moment, including cold answer and before configure. */\nexport function getCallState(): Snapshot | null {\n return native?.getCallState().snapshot ?? null;\n}\n\nexport type Unsubscribe = () => void;\n\n/** Subscribe to call state. Fires a full snapshot on every change. */\nexport function onCallState(cb: (snapshot: Snapshot | null) => void): Unsubscribe {\n if (!native) return () => undefined;\n const sub = native.addListener('onCallState', (payload) => cb(payload.snapshot));\n return () => sub.remove();\n}\n\nexport function place(conversationId: string, options: PlaceOptions): Promise<void> {\n return native?.place(conversationId, options.media, options.sessionToken) ?? Promise.resolve();\n}\n\nexport function join(\n callId: string,\n conversationId: string | null,\n sessionToken: string,\n): Promise<void> {\n return native?.join(callId, conversationId, sessionToken) ?? Promise.resolve();\n}\n\nexport const answer = (): void => native?.answer();\nexport const decline = (): void => native?.decline();\nexport const hangUp = (): void => native?.hangUp();\nexport const setMuted = (muted: boolean): void => native?.setMuted(muted);\nexport const setCamera = (on: boolean): void => native?.setCamera(on);\nexport const switchCamera = (): void => native?.switchCamera();\n/** Route call audio to the loudspeaker (`true`) or back to earpiece / headset (`false`). */\nexport const setSpeaker = (on: boolean): void => native?.setSpeaker(on);\n\n/**\n * Socket forwarding for an open app: a `call:ring_ended` seen on the\n * socket. Idempotent with the push path — the core dedupes.\n */\nexport const onRingEnded = (callId: string, reason: string): void =>\n native?.onRingEnded(callId, reason);\n\n/**\n * Socket forwarding: `call:finished`. Pass the payload's `reason`\n * (`finished` | `declined_all` | `no_answer` | `cancelled`) so an\n * unanswered caller sees \"Declined\"/\"No answer\", not \"Call ended\".\n * Idempotent with the `finished` push.\n */\nexport const onCallFinished = (callId: string, reason?: string): void =>\n native?.onCallFinished(callId, reason ?? null);\n\n/**\n * Android 14+: whether the app may show the full-screen ring over the\n * lock screen. `true` on older Android and where the module is absent.\n */\nexport const canUseFullScreenIntent = (): boolean => native?.canUseFullScreenIntent() ?? true;\n\n/** Open the system screen where the user grants full-screen intents (falls back to notification settings). */\nexport const openFullScreenIntentSettings = (): void => native?.openFullScreenIntentSettings();\n\n// ── VoIP push (iOS) ───────────────────────────────────────────────────\n\n/**\n * iOS: the current PushKit VoIP token, registered natively at launch.\n * `null` until iOS issues one, and always on Android (FCM carries rings).\n */\nexport function getVoipToken(): VoipToken | null {\n return native?.getVoipToken?.() ?? null;\n}\n\n/** iOS: fires whenever PushKit issues (or rotates) the VoIP token. No-op on Android. */\nexport function onVoipToken(cb: (token: VoipToken) => void): Unsubscribe {\n if (!native || !native.getVoipToken) return () => undefined;\n const sub = native.addListener('onVoipToken', cb);\n return () => sub.remove();\n}\n\n// ── video ─────────────────────────────────────────────────────────────\n\nexport interface CallVideoViewProps extends ViewProps {\n /** Remote participant's userId (LiveKit identity). Omit with local=true for self view. */\n participantId?: string;\n local?: boolean;\n /** default: true for local front camera, false otherwise */\n mirror?: boolean;\n /** default 'cover' */\n fit?: 'cover' | 'contain';\n}\n\ntype NativeVideoProps = CallVideoViewProps;\n\nlet nativeVideo: ComponentType<NativeVideoProps> | null | undefined;\n\n/** Resolved lazily (first render) and only when the native module exists. */\nfunction nativeVideoView(): ComponentType<NativeVideoProps> | null {\n if (nativeVideo !== undefined) return nativeVideo;\n nativeVideo = null;\n if (native) {\n try {\n nativeVideo = requireNativeViewManager<NativeVideoProps>('PoolseCalls', 'PoolseCallVideo');\n } catch {\n nativeVideo = null; // an older native build without the view\n }\n }\n return nativeVideo;\n}\n\n/**\n * A participant's camera (or, with `local`, your own), rendered natively\n * by the call core. Shows nothing (stays transparent) while that camera\n * is off — draw the avatar from the snapshot's `cameraOn` underneath.\n * Without the native module it renders an empty View.\n */\nexport function CallVideoView(props: CallVideoViewProps): ReactElement | null {\n const Native = nativeVideoView();\n if (!Native) {\n const { participantId: _p, local: _l, mirror: _m, fit: _f, ...rest } = props;\n return createElement(View, rest);\n }\n return createElement(Native, props);\n}\n"]}
@@ -1,6 +1,11 @@
1
1
  {
2
- "platforms": ["android"],
2
+ "platforms": ["android", "ios"],
3
3
  "android": {
4
4
  "modules": ["expo.modules.poolsecalls.PoolseCallsModule"]
5
+ },
6
+ "ios": {
7
+ "podspecPath": "ios/PoolseCalls.podspec",
8
+ "modules": ["PoolseCallsModule"],
9
+ "appDelegateSubscribers": ["PoolseCallsAppDelegateSubscriber"]
5
10
  }
6
11
  }
@@ -0,0 +1,393 @@
1
+ import Foundation
2
+
3
+ /// The engine around the machine: owns the state, serializes events,
4
+ /// executes effects through the ports, runs the timers, and emits a
5
+ /// snapshot after every change. Direct port of CallCoreRuntime.kt.
6
+ ///
7
+ /// The machine decides; this class merely obeys. If behavior is wrong,
8
+ /// the fix is in CallMachine + vectors, not here.
9
+ ///
10
+ /// Threading: ONE serial queue (the main thread), no lock. Every input —
11
+ /// PushKit, CallKit actions, URLSession callbacks, SFU events, timers, JS
12
+ /// commands — is `post`ed through `executor` (default: hop to
13
+ /// DispatchQueue.main). A result produced while effects execute is QUEUED
14
+ /// behind the current dispatch, never run inside it, and each dispatch
15
+ /// emits its snapshot only after its last effect. Tests pass a direct
16
+ /// executor; the internal trampoline gives them the same ordering.
17
+ public final class CallCoreRuntime {
18
+
19
+ public static let ENDED_LINGER_MS: Int64 = 2_500
20
+ public static let RECONNECT_BUDGET_MS: Int64 = 25_000
21
+ /// CONNECTING watchdog: two HTTP attempts plus an SFU connect fit inside.
22
+ public static let CONNECT_WATCHDOG_MS: Int64 = 20_000
23
+ /// Caller-side backstop if the server's finish/no-answer never arrives.
24
+ public static let RING_OUT_BACKSTOP_MS: Int64 = 75_000
25
+ /// Device clocks drift; the deadline is server epoch.
26
+ public static let CLOCK_SLACK_MS: Int64 = 10_000
27
+ public static let RESOLVED_LRU = 32
28
+
29
+ private static let T_DEADLINE = "ring_deadline"
30
+ private static let T_LINGER = "linger"
31
+ private static let T_RECONNECT = "reconnect_budget"
32
+ private static let T_CONNECT = "connect_watchdog"
33
+ private static let T_RING_OUT = "ring_out_backstop"
34
+
35
+ private let http: HttpPort
36
+ private let media: MediaPort
37
+ private let surface: RingSurfacePort
38
+ private let session: CallSessionPort
39
+ private let timers: TimerPort
40
+ private let sink: SnapshotSink
41
+ private let executor: (@escaping () -> Void) -> Void
42
+
43
+ private var state: MachineState = .IDLE
44
+ /// The ring being shown (its grant answers/declines it).
45
+ private var ring: RingContext?
46
+ /// Grants of rings auto-declined busy, kept until the decline fires.
47
+ private var busyRings: [String: RingContext] = [:]
48
+ /// The session bundle of the current call, once the server accepted.
49
+ private var bundle: CallSessionBundle?
50
+ /// The session token place/join were called with, for one retry.
51
+ private var sessionToken: String?
52
+ /// Call generation: bumped when a call starts CONNECTING and when one ends.
53
+ private var gen = 0
54
+ /// callIds already resolved on this device; bounded LRU (insertion/refresh order).
55
+ private var resolved: [String] = []
56
+
57
+ private var queue: [() -> Void] = []
58
+ private var draining = false
59
+
60
+ /// `executor` runs a task on the single runtime thread; must be serial.
61
+ /// Default: inline when already on the main thread, else DispatchQueue.main.async.
62
+ public init(http: HttpPort, media: MediaPort, surface: RingSurfacePort, session: CallSessionPort,
63
+ timers: TimerPort, sink: SnapshotSink,
64
+ executor: ((@escaping () -> Void) -> Void)? = nil) {
65
+ self.http = http
66
+ self.media = media
67
+ self.surface = surface
68
+ self.session = session
69
+ self.timers = timers
70
+ self.sink = sink
71
+ self.executor = executor ?? { block in
72
+ if Thread.isMainThread { block() } else { DispatchQueue.main.async(execute: block) }
73
+ }
74
+ }
75
+
76
+ // MARK: serial queue
77
+
78
+ /// Run `block` on the runtime thread, after anything already queued.
79
+ public func post(_ block: @escaping () -> Void) {
80
+ executor { [weak self] in self?.enqueue(block) }
81
+ }
82
+
83
+ private func enqueue(_ block: @escaping () -> Void) {
84
+ queue.append(block)
85
+ if draining { return } // a dispatch is executing: run after it, not inside it
86
+ draining = true
87
+ defer { draining = false }
88
+ while !queue.isEmpty {
89
+ let next = queue.removeFirst()
90
+ next()
91
+ }
92
+ }
93
+
94
+ // MARK: inputs from adapters
95
+
96
+ public func currentState() -> MachineState { state }
97
+
98
+ /// Best name for the current call: the server's bundle, else the ring's. Runtime thread only.
99
+ public var conversationName: String? { bundle?.conversationName ?? ring?.conversationName }
100
+
101
+ /// True if `callId` was already resolved here — its ring must never surface again.
102
+ public func isResolved(_ callId: String) -> Bool { resolved.contains(callId) }
103
+
104
+ private func markResolved(_ callId: String?) {
105
+ guard let callId = callId, !callId.hasPrefix("pending:") else { return }
106
+ resolved.removeAll { $0 == callId }
107
+ resolved.append(callId)
108
+ if resolved.count > CallCoreRuntime.RESOLVED_LRU { resolved.removeFirst(resolved.count - CallCoreRuntime.RESOLVED_LRU) }
109
+ }
110
+
111
+ /// A ring arrived — push or socket, the machine treats them alike.
112
+ public func onRing(_ ctx: RingContext) {
113
+ post { [self] in
114
+ if isResolved(ctx.callId) { return }
115
+ // A late duplicate of the ring for the call we are already IN must
116
+ // not read as "another call while busy".
117
+ let s = state
118
+ if s.callId == ctx.callId && s.phase != .idle && s.phase != .ringingIn { return }
119
+ let stale = ctx.ringDeadlineEpochMs + CallCoreRuntime.CLOCK_SLACK_MS <= timers.nowMs()
120
+ if !stale { busyRings[ctx.callId] = ctx }
121
+ dispatch(.ring(callId: ctx.callId, conversationId: ctx.conversationId, media: ctx.media, stale: stale),
122
+ ringCtx: ctx)
123
+ busyRings.removeValue(forKey: ctx.callId)
124
+ }
125
+ }
126
+
127
+ public func onRingEnded(callId: String, reason: String) {
128
+ post { [self] in
129
+ dispatch(.ringEnded(callId: callId, reason: reason))
130
+ markResolved(callId)
131
+ }
132
+ }
133
+
134
+ public func onCallFinished(callId: String, reason: String? = nil) {
135
+ post { [self] in
136
+ dispatch(.callFinished(callId: callId, reason: reason))
137
+ markResolved(callId)
138
+ }
139
+ }
140
+
141
+ public func answer() { post { [self] in dispatch(.userAnswer) } }
142
+ public func decline() { post { [self] in dispatch(.userDecline) } }
143
+ public func hangUp() { post { [self] in dispatch(.userHangup) } }
144
+
145
+ public func place(conversationId: String, media: String, sessionToken: String) {
146
+ post { [self] in
147
+ if state.phase != .idle && state.phase != .ended { return }
148
+ self.sessionToken = sessionToken
149
+ // call_id is server-minted on place; a placeholder keys the state until the bundle arrives.
150
+ dispatch(.userPlace(callId: "pending:\(conversationId)", conversationId: conversationId, media: media))
151
+ }
152
+ }
153
+
154
+ public func join(callId: String, conversationId: String?, sessionToken: String) {
155
+ post { [self] in
156
+ if state.phase != .idle && state.phase != .ended { return }
157
+ self.sessionToken = sessionToken
158
+ dispatch(.userJoin(callId: callId, conversationId: conversationId))
159
+ }
160
+ }
161
+
162
+ /// Caller-side: the bridge resolved every ring from `ring_states`.
163
+ public func allRingsResolved(_ outcome: String) { post { [self] in dispatch(.allRingsResolved(outcome: outcome)) } }
164
+
165
+ public func onMediaConnected() { post { [self] in dispatch(.mediaConnected) } }
166
+ public func onMediaFailed() { post { [self] in dispatch(.mediaFailed) } }
167
+ public func onMediaLost() { post { [self] in dispatch(.mediaLost) } }
168
+ public func onMediaReconnecting() { post { [self] in dispatch(.mediaReconnecting) } }
169
+ public func onMediaRecovered() { post { [self] in dispatch(.mediaRecovered) } }
170
+ public func onRemoteJoined() { post { [self] in dispatch(.remoteJoined) } }
171
+
172
+ public func setMuted(_ muted: Bool) { post { [self] in media.setMuted(muted) } }
173
+ public func setCamera(_ on: Bool) { post { [self] in media.setCamera(on) } }
174
+ public func switchCamera() { post { [self] in media.switchCamera() } }
175
+ public func setSpeaker(_ on: Bool) { post { [self] in media.setSpeaker(on) } }
176
+
177
+ /// Re-emit the current state (the bridge's roster/route changed).
178
+ public func reemit() { post { [self] in if state.phase != .idle { sink.emit(state: state) } } }
179
+
180
+ // MARK: dispatch
181
+
182
+ private func isTerminal(_ p: Phase) -> Bool { p == .idle || p == .ended }
183
+
184
+ private func dispatch(_ event: Event, ringCtx: RingContext? = nil) {
185
+ let before = state
186
+ let t = CallMachine.transition(before, event)
187
+ let next = t.state
188
+ state = next
189
+ // A new call attempt, or the end of one: old callbacks go stale.
190
+ if next.phase == .connecting && before.phase != .connecting { gen += 1 }
191
+ if isTerminal(next.phase) && !isTerminal(before.phase) {
192
+ gen += 1
193
+ markResolved(before.callId)
194
+ }
195
+ for effect in t.effects { execute(effect, ringCtx: ringCtx) }
196
+ // Retire the ring grant / bundle only after EVERY effect had its chance.
197
+ if isTerminal(next.phase) {
198
+ ring = nil
199
+ bundle = nil
200
+ sessionToken = nil
201
+ }
202
+ runTimers(before, next)
203
+ if next != before || !t.effects.isEmpty { sink.emit(state: next) }
204
+ }
205
+
206
+ private func execute(_ effect: Effect, ringCtx: RingContext?) {
207
+ let g = gen
208
+ switch effect {
209
+ case .showRing:
210
+ ring = ringCtx
211
+ if let r = ringCtx { surface.show(ring: r) }
212
+ case .dismissRing:
213
+ // The grant is NOT retired here: HTTP_DECLINE may follow in this list.
214
+ if let r = ring ?? ringCtx { surface.dismiss(callId: r.callId) }
215
+ case .ringtoneStart: surface.ringtoneStart()
216
+ case .ringtoneStop: surface.ringtoneStop()
217
+ case .ringbackStart: surface.ringbackStart()
218
+ case .ringbackStop: surface.ringbackStop()
219
+
220
+ case .httpAnswer:
221
+ if let r = ring {
222
+ http.answer(callId: r.callId, grant: r.grant) { [weak self] res in
223
+ self?.post { self?.onHttpResult(g, res) }
224
+ }
225
+ } else {
226
+ post { [weak self] in self?.onHttpResult(g, .authFailed(code: "no_grant")) }
227
+ }
228
+ case .httpJoin:
229
+ if let callId = state.callId, let token = sessionToken {
230
+ http.join(callId: callId, sessionToken: token) { [weak self] res in
231
+ self?.post { self?.onHttpResult(g, res) }
232
+ }
233
+ } else {
234
+ post { [weak self] in self?.onHttpResult(g, .authFailed(code: "no_session")) }
235
+ }
236
+ case .httpPlace:
237
+ if let conversationId = state.conversationId, let token = sessionToken {
238
+ http.place(conversationId: conversationId, media: state.media, sessionToken: token) { [weak self] res in
239
+ self?.post { self?.onHttpResult(g, res) }
240
+ }
241
+ } else {
242
+ post { [weak self] in self?.onHttpResult(g, .authFailed(code: "no_session")) }
243
+ }
244
+ case .httpDecline:
245
+ if let r = ring { http.decline(callId: r.callId, auth: r.grant, busy: false) }
246
+ case let .httpDeclineBusy(callId):
247
+ if let r = busyRings[callId] { http.decline(callId: r.callId, auth: r.grant, busy: true) }
248
+ markResolved(callId)
249
+ case .httpHangup:
250
+ if let b = bundle {
251
+ http.hangup(callId: b.callId, controlToken: b.controlToken)
252
+ } else if let r = ring {
253
+ // Hanging up a connecting answer with no bundle yet: resolve the ring instead.
254
+ http.decline(callId: r.callId, auth: r.grant, busy: false)
255
+ }
256
+ case .httpReconnect:
257
+ if let b = bundle {
258
+ http.reconnect(current: b) { [weak self] res in
259
+ self?.post { self?.onReconnectResult(g, res) }
260
+ }
261
+ } else {
262
+ post { [weak self] in
263
+ guard let self = self else { return }
264
+ if g == self.gen { self.dispatch(.httpGone) }
265
+ }
266
+ }
267
+
268
+ case .mediaJoin:
269
+ if let b = bundle { media.join(bundle: b) }
270
+ case .mediaLeave:
271
+ media.leave()
272
+
273
+ case .callSessionStart:
274
+ session.start(callId: state.callId ?? "", media: state.media)
275
+ case .callSessionStop:
276
+ session.stop()
277
+ bundle = nil
278
+ ring = nil
279
+ sessionToken = nil
280
+ }
281
+ }
282
+
283
+ private func onHttpResult(_ requestGen: Int, _ result: HttpResult) {
284
+ if requestGen != gen {
285
+ // The call this request belonged to is over. The server may still have
286
+ // let us in: leave with the token it just gave us, and keep nothing.
287
+ if case let .ok(b) = result { http.hangup(callId: b.callId, controlToken: b.controlToken) }
288
+ return
289
+ }
290
+ switch result {
291
+ case let .ok(b):
292
+ if let existing = bundle {
293
+ // The timeout retry's twin answered too. Same call: ignore; different: leave it.
294
+ if existing.callId != b.callId { http.hangup(callId: b.callId, controlToken: b.controlToken) }
295
+ return
296
+ }
297
+ if state.phase != .connecting {
298
+ http.hangup(callId: b.callId, controlToken: b.controlToken)
299
+ return
300
+ }
301
+ bundle = b
302
+ // The server is the authority on the call's identity, conversation and media.
303
+ state.callId = b.callId
304
+ state.conversationId = b.conversationId
305
+ state.media = b.media
306
+ dispatch(.httpOk(created: b.created))
307
+ case .gone: dispatch(.httpGone)
308
+ case .timeout: dispatch(.httpTimeout)
309
+ case let .authFailed(code): dispatch(.httpFailed(code: code))
310
+ case let .failed(code): dispatch(.httpFailed(code: code))
311
+ }
312
+ }
313
+
314
+ private func onReconnectResult(_ requestGen: Int, _ result: HttpResult) {
315
+ if requestGen != gen || state.phase != .reconnecting { return }
316
+ switch result {
317
+ case let .ok(b):
318
+ // Fresh token (and possibly URL): rejoin. MEDIA_CONNECTED returns us to ACTIVE.
319
+ bundle = b
320
+ media.join(bundle: b)
321
+ case .gone: dispatch(.httpGone)
322
+ case let .authFailed(code): dispatch(.httpFailed(code: code))
323
+ case .timeout, .failed: break // transient: the reconnect budget timer is the backstop
324
+ }
325
+ }
326
+
327
+ // MARK: timers
328
+
329
+ private func schedule(_ key: String, _ delayMs: Int64, _ event: Event) {
330
+ let g = gen
331
+ timers.schedule(key: key, delayMs: delayMs) { [weak self] in
332
+ self?.post {
333
+ guard let self = self else { return }
334
+ if g == self.gen { self.dispatch(event) }
335
+ }
336
+ }
337
+ }
338
+
339
+ private func runTimers(_ before: MachineState, _ after: MachineState) {
340
+ if before.phase == after.phase { return }
341
+ switch before.phase {
342
+ case .ringingIn: timers.cancel(key: CallCoreRuntime.T_DEADLINE)
343
+ case .ended: timers.cancel(key: CallCoreRuntime.T_LINGER)
344
+ case .reconnecting: timers.cancel(key: CallCoreRuntime.T_RECONNECT)
345
+ case .connecting: timers.cancel(key: CallCoreRuntime.T_CONNECT)
346
+ case .ringingOut: timers.cancel(key: CallCoreRuntime.T_RING_OUT)
347
+ default: break
348
+ }
349
+ switch after.phase {
350
+ case .ringingIn:
351
+ guard let deadline = ring?.ringDeadlineEpochMs else { return }
352
+ // Floor at the clock slack: a non-stale ring still gets a moment on screen.
353
+ let delay = max(deadline - timers.nowMs(), CallCoreRuntime.CLOCK_SLACK_MS)
354
+ schedule(CallCoreRuntime.T_DEADLINE, delay, .ringDeadline)
355
+ case .ended:
356
+ schedule(CallCoreRuntime.T_LINGER, CallCoreRuntime.ENDED_LINGER_MS, .lingerElapsed)
357
+ case .reconnecting:
358
+ schedule(CallCoreRuntime.T_RECONNECT, CallCoreRuntime.RECONNECT_BUDGET_MS, .reconnectBudgetElapsed)
359
+ case .connecting:
360
+ schedule(CallCoreRuntime.T_CONNECT, CallCoreRuntime.CONNECT_WATCHDOG_MS, .connectTimeout)
361
+ case .ringingOut:
362
+ schedule(CallCoreRuntime.T_RING_OUT, CallCoreRuntime.RING_OUT_BACKSTOP_MS, .allRingsResolved(outcome: "no_answer"))
363
+ default: break
364
+ }
365
+ }
366
+ }
367
+
368
+ /// The real TimerPort: DispatchQueue.main work items keyed by token.
369
+ /// `nowMs` is wall-clock epoch (compared against the server's ring_deadline);
370
+ /// the delay itself counts down monotonically.
371
+ public final class MainQueueTimerPort: TimerPort {
372
+ private var pending: [String: DispatchWorkItem] = [:]
373
+ private let queue: DispatchQueue
374
+
375
+ public init(queue: DispatchQueue = .main) { self.queue = queue }
376
+
377
+ public func nowMs() -> Int64 { Int64((Date().timeIntervalSince1970 * 1000).rounded()) }
378
+
379
+ public func schedule(key: String, delayMs: Int64, fire: @escaping () -> Void) {
380
+ cancel(key: key)
381
+ var item: DispatchWorkItem!
382
+ item = DispatchWorkItem { [weak self] in
383
+ if let self = self, self.pending[key] === item { self.pending.removeValue(forKey: key) }
384
+ fire()
385
+ }
386
+ pending[key] = item
387
+ queue.asyncAfter(deadline: .now() + .milliseconds(Int(max(0, delayMs))), execute: item)
388
+ }
389
+
390
+ public func cancel(key: String) {
391
+ pending.removeValue(forKey: key)?.cancel()
392
+ }
393
+ }