@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 +10 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +21 -2
- package/dist/index.d.ts +21 -2
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/expo-module.config.json +6 -1
- package/ios/Core/CallCoreRuntime.swift +393 -0
- package/ios/Core/CallHttpClient.swift +210 -0
- package/ios/Core/CallMachine.swift +304 -0
- package/ios/Core/PORTS_READY.md +93 -0
- package/ios/Core/Ports.swift +193 -0
- package/ios/Core/SnapshotEncoding.swift +75 -0
- package/ios/Module/PoolseCallVideoView.swift +62 -0
- package/ios/Module/PoolseCallsAppDelegateSubscriber.swift +16 -0
- package/ios/Module/PoolseCallsModule.swift +118 -0
- package/ios/Platform/CallCore.swift +512 -0
- package/ios/Platform/CallKitController.swift +268 -0
- package/ios/Platform/LiveKitMediaPort.swift +445 -0
- package/ios/Platform/LiveKitVideoSurface.swift +67 -0
- package/ios/Platform/Ringback.swift +61 -0
- package/ios/Platform/VoipPush.swift +172 -0
- package/ios/PoolseCalls.podspec +64 -0
- package/package.json +7 -1
- package/src/index.ts +33 -1
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;
|
package/dist/index.cjs.map
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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"]}
|
package/expo-module.config.json
CHANGED
|
@@ -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
|
+
}
|