@fishjam-cloud/react-native-client 0.29.0 → 0.30.0-rc.0

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/README.md CHANGED
@@ -10,6 +10,85 @@ npm install @fishjam-cloud/react-native-client
10
10
  yarn add @fishjam-cloud/react-native-client
11
11
  ```
12
12
 
13
+ ## Android VoIP setup
14
+
15
+ Incoming calls on Android are delivered over Firebase Cloud Messaging, so Firebase is
16
+ only pulled into your build when you opt in. iOS uses PushKit/APNs and needs none of this.
17
+
18
+ ### Expo
19
+
20
+ Enable VoIP in the config plugin and point Expo at your `google-services.json`:
21
+
22
+ ```json
23
+ {
24
+ "expo": {
25
+ "android": { "googleServicesFile": "./google-services.json" },
26
+ "plugins": [["@fishjam-cloud/react-native-client", { "android": { "enableVoIP": true } }]]
27
+ }
28
+ }
29
+ ```
30
+
31
+ Prebuild does the rest. [`android.googleServicesFile`](https://docs.expo.dev/versions/latest/config/app/#googleservicesfile)
32
+ makes Expo add the `com.google.gms:google-services` classpath, apply the Gradle plugin, and copy
33
+ the file into `android/app/`. Omitting it while `enableVoIP` is on is a prebuild error.
34
+
35
+ ### Call timeouts
36
+
37
+ The plugin can set native timeouts in seconds. An unanswered incoming call defaults
38
+ to 45 seconds, an unconnected outgoing call defaults to 60 seconds, and the
39
+ answer-fulfillment handshake defaults to 10 seconds:
40
+
41
+ ```json
42
+ {
43
+ "expo": {
44
+ "plugins": [
45
+ [
46
+ "@fishjam-cloud/react-native-client",
47
+ {
48
+ "android": { "enableVoIP": true },
49
+ "voip": {
50
+ "incomingCallTimeout": 45,
51
+ "outgoingCallTimeout": 60,
52
+ "fulfillAnswerCallTimeout": 10
53
+ }
54
+ }
55
+ ]
56
+ ]
57
+ }
58
+ }
59
+ ```
60
+
61
+ All timeout properties are optional and must be positive finite numbers. Omit a
62
+ property to use its native default.
63
+
64
+ ### Bare React Native
65
+
66
+ Config plugins do not run, and the [`google-services` Gradle plugin](https://developers.google.com/android/guides/google-services-plugin)
67
+ must be applied to the **application** module — a library cannot do it for you. Follow the
68
+ [Firebase Android setup guide](https://firebase.google.com/docs/android/setup) to add
69
+ `google-services.json` and apply the plugin.
70
+
71
+ You must also declare by hand what the config plugin would otherwise inject into
72
+ `AndroidManifest.xml` — the `MANAGE_OWN_CALLS`, `POST_NOTIFICATIONS`,
73
+ `USE_FULL_SCREEN_INTENT` and `VIBRATE` permissions, the `IncomingCallActivity`,
74
+ the `EndCallNotificationReceiver`, and the `PushNotificationService` with its
75
+ `com.google.firebase.MESSAGING_EVENT` intent filter. See `plugin/src/withFishjamVoIPAndroid.ts`
76
+ for the exact entries.
77
+
78
+ Set timeout metadata manually when using bare React Native:
79
+
80
+ ```xml
81
+ <application>
82
+ <meta-data android:name="VoIPIncomingCallTimeout" android:value="45" />
83
+ <meta-data android:name="VoIPOutgoingCallTimeout" android:value="60" />
84
+ <meta-data android:name="VoIPFulfillAnswerTimeout" android:value="10" />
85
+ </application>
86
+ ```
87
+
88
+ For iOS, add the same timeout values as numeric `Info.plist` keys:
89
+ `VoIPIncomingCallTimeout`, `VoIPOutgoingCallTimeout`, and
90
+ `VoIPFulfillAnswerTimeout`.
91
+
13
92
  ## Local Development with WebRTC Fork
14
93
 
15
94
  This package depends on `@fishjam-cloud/react-native-webrtc`, a fork of `react-native-webrtc`. The fork lives in [its own GitHub repo](https://github.com/fishjam-cloud/fishjam-react-native-webrtc) and is included in this monorepo as a git submodule at `packages/react-native-webrtc/`, wired up as a yarn workspace. No manual linking is required.
package/dist/index.d.ts CHANGED
@@ -7,7 +7,13 @@ import './webrtc-polyfill';
7
7
  import React from 'react';
8
8
  import { type FishjamProviderProps as ReactClientFishjamProviderProps } from '@fishjam-cloud/react-client';
9
9
  export { RTCView, RTCPIPView, type RTCVideoViewProps, type RTCPIPViewProps } from './overrides/RTCView';
10
- export { ScreenCapturePickerView, startPIP, stopPIP, AudioDeviceType, useAudioOutput, pushAudioSamples, } from '@fishjam-cloud/react-native-webrtc';
10
+ export { ScreenCapturePickerView, startPIP, stopPIP, AudioDeviceType, useAudioOutput, pushAudioSamples, useVoIPEvents, useTelecom, useTelecomEvent, fulfillIncomingCallConnected, failIncomingCallConnected, getPendingAnswerRequestId, reportOutgoingCallConnected, setCallHeld, setCallMuted, isCallHeld, } from '@fishjam-cloud/react-native-webrtc';
11
+ export type { VoIPEventHandlers, VoIPCallIntent, VoIPIncomingPayload } from '@fishjam-cloud/react-native-webrtc';
12
+ export { VoIPProvider } from './voip/VoIPProvider';
13
+ export type { VoIPProviderProps } from './voip/VoIPProvider';
14
+ export { useVoIP } from './voip/VoIPContext';
15
+ export type { CurrentCall, VoIPCallStatus, VoIPContextValue } from './voip/VoIPContext';
16
+ export type { CallEndedReason, TelecomConfig, TelecomEvent, TelecomEventType, UseTelecomResult, } from '@fishjam-cloud/react-native-webrtc';
11
17
  export type { CallKitAction, CallKitConfig, CustomAudioSink, CustomAudioTrack, MediaStream, MediaStreamTrack, AudioDevice, AudioOutputChangedInfo, UseAudioOutputResult, } from '@fishjam-cloud/react-native-webrtc';
12
18
  export { useForegroundService, type ForegroundServiceConfig } from './useForegroundService';
13
19
  export { useCameraPermissions, useMicrophonePermissions, type PermissionStatus } from './hooks/usePermissions';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAMH,OAAO,mBAAmB,CAAC;AAC3B,OAAO,KAAK,MAAM,OAAO,CAAC;AAC1B,OAAO,EAEL,KAAK,oBAAoB,IAAI,+BAA+B,EAC7D,MAAM,6BAA6B,CAAC;AAGrC,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,KAAK,iBAAiB,EAAE,KAAK,eAAe,EAAE,MAAM,qBAAqB,CAAC;AACxG,OAAO,EACL,uBAAuB,EACvB,QAAQ,EACR,OAAO,EACP,eAAe,EACf,cAAc,EACd,gBAAgB,GACjB,MAAM,oCAAoC,CAAC;AAE5C,YAAY,EACV,aAAa,EACb,aAAa,EACb,eAAe,EACf,gBAAgB,EAChB,WAAW,EACX,gBAAgB,EAChB,WAAW,EACX,sBAAsB,EACtB,oBAAoB,GACrB,MAAM,oCAAoC,CAAC;AAE5C,OAAO,EAAE,oBAAoB,EAAE,KAAK,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AAC5F,OAAO,EAAE,oBAAoB,EAAE,wBAAwB,EAAE,KAAK,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC/G,OAAO,EACL,oBAAoB,EACpB,KAAK,2BAA2B,EAChC,KAAK,0BAA0B,GAChC,MAAM,8BAA8B,CAAC;AAEtC,OAAO,EACL,yBAAyB,EACzB,aAAa,EACb,cAAc,EACd,UAAU,EACV,qBAAqB,EACrB,MAAM,EACN,OAAO,GACR,MAAM,6BAA6B,CAAC;AAErC,OAAO,EACL,SAAS,EACT,oBAAoB,EACpB,aAAa,EACb,cAAc,EACd,eAAe,EACf,qBAAqB,EACrB,mBAAmB,EACnB,QAAQ,EACR,UAAU,EACV,eAAe,EACf,iBAAiB,GAClB,MAAM,mBAAmB,CAAC;AAE3B,YAAY,EACV,cAAc,EACd,qBAAqB,EACrB,2BAA2B,EAC3B,yBAAyB,EACzB,eAAe,EACf,mBAAmB,EACnB,oBAAoB,EACpB,qBAAqB,EACrB,0BAA0B,EAC1B,KAAK,EACL,WAAW,EACX,YAAY,EACZ,uBAAuB,EACvB,cAAc,EACd,WAAW,EACX,gBAAgB,EAChB,eAAe,EACf,gBAAgB,EAChB,sBAAsB,GACvB,MAAM,mBAAmB,CAAC;AAE3B,YAAY,EACV,0BAA0B,EAC1B,cAAc,EACd,mBAAmB,EACnB,QAAQ,EACR,eAAe,EACf,eAAe,EACf,KAAK,EACL,WAAW,EACX,UAAU,EACV,uBAAuB,EACvB,MAAM,EACN,UAAU,EACV,yBAAyB,EACzB,wBAAwB,EACxB,YAAY,EACZ,OAAO,EACP,eAAe,EACf,eAAe,EACf,oBAAoB,EACpB,YAAY,EACZ,kBAAkB,EAClB,QAAQ,EACR,eAAe,EACf,kBAAkB,EAClB,uBAAuB,EACvB,eAAe,EACf,mBAAmB,GACpB,MAAM,6BAA6B,CAAC;AAGrC,MAAM,MAAM,oBAAoB,GAAG,IAAI,CAAC,+BAA+B,EAAE,mBAAmB,GAAG,eAAe,CAAC,CAAC;AAChH,wBAAgB,eAAe,CAAC,KAAK,EAAE,oBAAoB,mEAO1D"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -13,7 +13,9 @@ import React from 'react';
13
13
  import { FishjamProvider as ReactClientFishjamProvider, } from '@fishjam-cloud/react-client';
14
14
  import { FishjamClient } from '@fishjam-cloud/ts-client';
15
15
  export { RTCView, RTCPIPView } from './overrides/RTCView';
16
- export { ScreenCapturePickerView, startPIP, stopPIP, AudioDeviceType, useAudioOutput, pushAudioSamples, } from '@fishjam-cloud/react-native-webrtc';
16
+ export { ScreenCapturePickerView, startPIP, stopPIP, AudioDeviceType, useAudioOutput, pushAudioSamples, useVoIPEvents, useTelecom, useTelecomEvent, fulfillIncomingCallConnected, failIncomingCallConnected, getPendingAnswerRequestId, reportOutgoingCallConnected, setCallHeld, setCallMuted, isCallHeld, } from '@fishjam-cloud/react-native-webrtc';
17
+ export { VoIPProvider } from './voip/VoIPProvider';
18
+ export { useVoIP } from './voip/VoIPContext';
17
19
  export { useForegroundService } from './useForegroundService';
18
20
  export { useCameraPermissions, useMicrophonePermissions } from './hooks/usePermissions';
19
21
  export { useCustomAudioSource, } from './hooks/useCustomAudioSource';
@@ -19,8 +19,10 @@ export declare function usePeers<P = Record<string, unknown>, S = Record<string,
19
19
  };
20
20
  export declare function useCallKit(): {
21
21
  startCallKitSession: (config: CallKitConfig) => Promise<void>;
22
- endCallKitSession: () => Promise<void>;
22
+ endCallKitSession: (reason?: import("@fishjam-cloud/react-native-webrtc").CallEndedReason) => Promise<void>;
23
23
  getCallKitSessionStatus: () => Promise<boolean>;
24
+ setCallHeld: (onHold: boolean) => Promise<void>;
25
+ isHeld: () => boolean;
24
26
  };
25
27
  export declare function useCallKitService(config: CallKitConfig): void;
26
28
  export declare function useCallKitEvent<T extends keyof CallKitAction>(action: T, callback: (event: CallKitAction[T]) => void): void;
@@ -1 +1 @@
1
- {"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["../../src/overrides/hooks.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,WAAW,IAAI,aAAa,EAAE,MAAM,oCAAoC,CAAC;AASrH,OAAO,KAAK,EAEV,uBAAuB,EACvB,cAAc,EACd,WAAW,EAGX,eAAe,EACf,2BAA2B,EAC3B,yBAAyB,EACzB,mBAAmB,EACnB,oBAAoB,EACrB,MAAM,SAAS,CAAC;AAEjB,wBAAgB,SAAS,IAAI,eAAe,CAc3C;AAED,wBAAgB,aAAa,IAAI,mBAAmB,CAcnD;AAED,wBAAgB,cAAc,IAAI,oBAAoB,CAgBrD;AAED,wBAAgB,eAAe,CAAC,CAAC,SAAS,MAAM,EAAE,QAAQ,EAAE,CAAC;YAIhC,aAAa,GAAG,SAAS;eACnB,CAAC,SAAS,EAAE,aAAa,GAAG,IAAI,KAAK,OAAO,CAAC,IAAI,CAAC;EAEpF;AAED,wBAAgB,qBAAqB,IAAI,2BAA2B,CAYnE;AAED,wBAAgB,mBAAmB,IAAI,yBAAyB,CAM/D;AAED,wBAAgB,oBAAoB;uBAGO,CACrC,GAAG,IAAI,EAAE,UAAU,wJAAyB,KACzC,OAAO,CAAC,uBAAuB,CAAC;EAExC;AAED,wBAAgB,QAAQ,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAC9B;IAC/C,SAAS,EAAE,cAAc,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,IAAI,CAAC;IACvC,WAAW,EAAE,cAAc,CAAC,CAAC,EAAE,CAAC,EAAE,WAAW,CAAC,EAAE,CAAC;IACjD,KAAK,EAAE,cAAc,CAAC,CAAC,EAAE,CAAC,EAAE,WAAW,CAAC,EAAE,CAAC;CAC5C,CACF;AAED,wBAAgB,UAAU;;;;EAGzB;AAED,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,aAAa,QAEtD;AAED,wBAAgB,eAAe,CAAC,CAAC,SAAS,MAAM,aAAa,EAAE,MAAM,EAAE,CAAC,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,aAAa,CAAC,CAAC,CAAC,KAAK,IAAI,QAEpH"}
1
+ {"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["../../src/overrides/hooks.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,WAAW,IAAI,aAAa,EAAE,MAAM,oCAAoC,CAAC;AASrH,OAAO,KAAK,EAEV,uBAAuB,EACvB,cAAc,EACd,WAAW,EAGX,eAAe,EACf,2BAA2B,EAC3B,yBAAyB,EACzB,mBAAmB,EACnB,oBAAoB,EACrB,MAAM,SAAS,CAAC;AAEjB,wBAAgB,SAAS,IAAI,eAAe,CAc3C;AAED,wBAAgB,aAAa,IAAI,mBAAmB,CAcnD;AAED,wBAAgB,cAAc,IAAI,oBAAoB,CAgBrD;AAED,wBAAgB,eAAe,CAAC,CAAC,SAAS,MAAM,EAAE,QAAQ,EAAE,CAAC;YAIhC,aAAa,GAAG,SAAS;eACnB,CAAC,SAAS,EAAE,aAAa,GAAG,IAAI,KAAK,OAAO,CAAC,IAAI,CAAC;EAEpF;AAED,wBAAgB,qBAAqB,IAAI,2BAA2B,CAYnE;AAED,wBAAgB,mBAAmB,IAAI,yBAAyB,CAM/D;AAED,wBAAgB,oBAAoB;uBAGO,CACrC,GAAG,IAAI,EAAE,UAAU,wJAAyB,KACzC,OAAO,CAAC,uBAAuB,CAAC;EAExC;AAED,wBAAgB,QAAQ,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAC9B;IAC/C,SAAS,EAAE,cAAc,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,IAAI,CAAC;IACvC,WAAW,EAAE,cAAc,CAAC,CAAC,EAAE,CAAC,EAAE,WAAW,CAAC,EAAE,CAAC;IACjD,KAAK,EAAE,cAAc,CAAC,CAAC,EAAE,CAAC,EAAE,WAAW,CAAC,EAAE,CAAC;CAC5C,CACF;AAED,wBAAgB,UAAU;;8BAhImB,CAAC;;;;EAmI7C;AAED,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,aAAa,QAEtD;AAED,wBAAgB,eAAe,CAAC,CAAC,SAAS,MAAM,aAAa,EAAE,MAAM,EAAE,CAAC,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,aAAa,CAAC,CAAC,CAAC,KAAK,IAAI,QAEpH"}
@@ -0,0 +1,99 @@
1
+ import type { CallEndedReason, VoIPCallIntent } from '@fishjam-cloud/react-native-webrtc';
2
+ /**
3
+ * Lifecycle state of the current VoIP call.
4
+ *
5
+ * - `available` — no call in progress
6
+ * - `incoming` — a call is ringing, awaiting the user's answer
7
+ * - `connecting` — the call was started/answered; **your app should be joining its room now**
8
+ * - `active` — your app reported the media connected and the call is in progress
9
+ */
10
+ export type VoIPCallStatus = 'available' | 'incoming' | 'connecting' | 'active';
11
+ /**
12
+ * Details of the call currently being handled.
13
+ */
14
+ export type CurrentCall = {
15
+ /**
16
+ * Fishjam room the call takes place in. For outgoing calls it is the name passed to
17
+ * `startCall`; for incoming ones it comes from the VoIP push payload.
18
+ */
19
+ roomName: string;
20
+ /** Name shown in the CallKit UI (the remote party). */
21
+ displayName: string;
22
+ /**
23
+ * Stable id of the remote party — use a durable user id, not a display name (which
24
+ * may not be unique), since this is what Recents hands back for redialing.
25
+ */
26
+ handle: string;
27
+ /** Whether the call is a video call. */
28
+ isVideo: boolean;
29
+ /** Timestamp (ms) when the call became `active`, or `null` if not yet connected. */
30
+ startedAt: number | null;
31
+ /** `true` when this device initiated the call, `false` when receiving it. */
32
+ isOutgoing: boolean;
33
+ };
34
+ /**
35
+ * Value returned from {@link useVoIP}.
36
+ */
37
+ export type VoIPContextValue = {
38
+ /** Current call lifecycle status. */
39
+ status: VoIPCallStatus;
40
+ /** This device's VoIP push token, or `null` until APNs has issued one. */
41
+ voipToken: string | null;
42
+ /** The call currently being handled, or `null` when `status` is `available`. */
43
+ currentCall: CurrentCall | null;
44
+ /**
45
+ * Why the most recently handled call ended. `null` until a call has ended at
46
+ * least once. Surfaced so a consumer can react to `missed`/`rejected`/etc., e.g.
47
+ * showing a "missed call" notification.
48
+ */
49
+ lastEndedReason: CallEndedReason | null;
50
+ /**
51
+ * Whether the native CallKit/Core-Telecom session is currently held. Reported only —
52
+ * apply it to your own tracks.
53
+ */
54
+ isOnHold: boolean;
55
+ /** Whether the system call UI has the call muted. Reported only, as with {@link VoIPContextValue.isOnHold}. */
56
+ isMuted: boolean;
57
+ /**
58
+ * A redial requested from the iOS **Recents** list, or `null` when there is none.
59
+ * It carries only the handle to call, never a room, so mint a room name yourself.
60
+ * Held until {@link VoIPContextValue.clearCallIntent}, so one arriving before your
61
+ * app has restored its session is not lost.
62
+ */
63
+ pendingCallIntent: VoIPCallIntent | null;
64
+ /** Discards {@link VoIPContextValue.pendingCallIntent} once you have acted on it. */
65
+ clearCallIntent: () => void;
66
+ /**
67
+ * Reports an outgoing call to `to` in `roomName` to CallKit/Core-Telecom and moves
68
+ * to `connecting`. Run your own signaling (ringing the callee) *before* calling this.
69
+ * It does **not** join the room — react to `status` becoming `connecting` for that.
70
+ */
71
+ startCall: (to: string, roomName: string) => Promise<void>;
72
+ /**
73
+ * Report that the room join succeeded and media is flowing. Fulfills CallKit's answer
74
+ * action (or reports the outgoing call as connected) and moves the call to `active`.
75
+ *
76
+ * An answered incoming call must be fulfilled within `VoIPFulfillAnswerTimeout`
77
+ * (10s by default) or the native side ends it and `onEnded` fires.
78
+ */
79
+ reportConnected: () => Promise<void>;
80
+ /** Tell the SDK your room join failed. Ends the call with reason `failed`. */
81
+ reportConnectFailed: () => Promise<void>;
82
+ /**
83
+ * Ends or rejects the current call. Dismisses CallKit/Telecom and resets state back
84
+ * to `available`; leaving the room is up to you. `reason` (defaults to `local`) is
85
+ * surfaced to the system call UI/log and to `lastEndedReason`.
86
+ */
87
+ endCall: (reason?: CallEndedReason) => Promise<void>;
88
+ /** Requests that the native CallKit/Core-Telecom session be held or resumed. */
89
+ setCallHeld: (onHold: boolean) => Promise<void>;
90
+ };
91
+ export declare const VoIPContext: import("react").Context<VoIPContextValue | null>;
92
+ /**
93
+ * Returns the current {@link VoIPContextValue}.
94
+ *
95
+ * Must be used inside a `VoIPProvider`. Without it the VoIP call machine is not
96
+ * mounted and this hook throws.
97
+ */
98
+ export declare function useVoIP(): VoIPContextValue;
99
+ //# sourceMappingURL=VoIPContext.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"VoIPContext.d.ts","sourceRoot":"","sources":["../../src/voip/VoIPContext.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,oCAAoC,CAAC;AAG1F;;;;;;;GAOG;AACH,MAAM,MAAM,cAAc,GAAG,WAAW,GAAG,UAAU,GAAG,YAAY,GAAG,QAAQ,CAAC;AAEhF;;GAEG;AACH,MAAM,MAAM,WAAW,GAAG;IACxB;;;OAGG;IACH,QAAQ,EAAE,MAAM,CAAC;IACjB,uDAAuD;IACvD,WAAW,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,MAAM,EAAE,MAAM,CAAC;IACf,wCAAwC;IACxC,OAAO,EAAE,OAAO,CAAC;IACjB,oFAAoF;IACpF,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,6EAA6E;IAC7E,UAAU,EAAE,OAAO,CAAC;CACrB,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,gBAAgB,GAAG;IAC7B,qCAAqC;IACrC,MAAM,EAAE,cAAc,CAAC;IACvB,0EAA0E;IAC1E,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,gFAAgF;IAChF,WAAW,EAAE,WAAW,GAAG,IAAI,CAAC;IAChC;;;;OAIG;IACH,eAAe,EAAE,eAAe,GAAG,IAAI,CAAC;IACxC;;;OAGG;IACH,QAAQ,EAAE,OAAO,CAAC;IAClB,+GAA+G;IAC/G,OAAO,EAAE,OAAO,CAAC;IACjB;;;;;OAKG;IACH,iBAAiB,EAAE,cAAc,GAAG,IAAI,CAAC;IACzC,qFAAqF;IACrF,eAAe,EAAE,MAAM,IAAI,CAAC;IAC5B;;;;OAIG;IACH,SAAS,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3D;;;;;;OAMG;IACH,eAAe,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IACrC,8EAA8E;IAC9E,mBAAmB,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IACzC;;;;OAIG;IACH,OAAO,EAAE,CAAC,MAAM,CAAC,EAAE,eAAe,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACrD,gFAAgF;IAChF,WAAW,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACjD,CAAC;AAEF,eAAO,MAAM,WAAW,kDAA+C,CAAC;AAExE;;;;;GAKG;AACH,wBAAgB,OAAO,IAAI,gBAAgB,CAQ1C"}
@@ -0,0 +1,15 @@
1
+ import { createContext, useContext } from 'react';
2
+ export const VoIPContext = createContext(null);
3
+ /**
4
+ * Returns the current {@link VoIPContextValue}.
5
+ *
6
+ * Must be used inside a `VoIPProvider`. Without it the VoIP call machine is not
7
+ * mounted and this hook throws.
8
+ */
9
+ export function useVoIP() {
10
+ const ctx = useContext(VoIPContext);
11
+ if (!ctx) {
12
+ throw new Error('useVoIP must be used inside a VoIPProvider — wrap your app in `<VoIPProvider>` to enable VoIP calls.');
13
+ }
14
+ return ctx;
15
+ }
@@ -0,0 +1,26 @@
1
+ import { type VoIPIncomingPayload } from '@fishjam-cloud/react-native-webrtc';
2
+ import { type PropsWithChildren } from 'react';
3
+ /**
4
+ * Props of {@link VoIPProvider} — the configuration of the VoIP call machine.
5
+ */
6
+ export type VoIPProviderProps = PropsWithChildren & {
7
+ /**
8
+ * A waiting or overflow incoming call was declined from native UI. Does not
9
+ * change local call state - use for signaling (e.g. `call-rejected` to the caller).
10
+ */
11
+ onWaitingCallDeclined?: (payload: VoIPIncomingPayload) => void;
12
+ /**
13
+ * Whether outgoing calls are video calls — reflected in the CallKit session.
14
+ * Make sure the underlying room type is set accordingly. Defaults to `false` (audio-only).
15
+ */
16
+ isVideo?: boolean;
17
+ };
18
+ /**
19
+ * Tracks the current VoIP call state, driven by the native CallKit / Core-Telecom
20
+ * events from {@link useVoIPEvents}, and exposes it through {@link useVoIP}.
21
+ *
22
+ * Joining rooms, peer tokens and media are the consumer's — react to `status` and
23
+ * report back with `reportConnected` / `reportConnectFailed`.
24
+ */
25
+ export declare function VoIPProvider({ onWaitingCallDeclined, isVideo, children }: VoIPProviderProps): import("react/jsx-runtime").JSX.Element;
26
+ //# sourceMappingURL=VoIPProvider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"VoIPProvider.d.ts","sourceRoot":"","sources":["../../src/voip/VoIPProvider.tsx"],"names":[],"mappings":"AAAA,OAAO,EASL,KAAK,mBAAmB,EACzB,MAAM,oCAAoC,CAAC;AAC5C,OAAO,EAAE,KAAK,iBAAiB,EAA0C,MAAM,OAAO,CAAC;AAMvF;;GAEG;AACH,MAAM,MAAM,iBAAiB,GAAG,iBAAiB,GAAG;IAClD;;;OAGG;IACH,qBAAqB,CAAC,EAAE,CAAC,OAAO,EAAE,mBAAmB,KAAK,IAAI,CAAC;IAC/D;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB,CAAC;AAEF;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,EAAE,qBAAqB,EAAE,OAAe,EAAE,QAAQ,EAAE,EAAE,iBAAiB,2CAiTnG"}
@@ -0,0 +1,268 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { failIncomingCallConnected, fulfillIncomingCallConnected, reportOutgoingCallConnected, setCallHeld as setVoIPCallHeld, useTelecom, useVoIPEvents, } from '@fishjam-cloud/react-native-webrtc';
3
+ import { useCallback, useMemo, useRef, useState } from 'react';
4
+ import { Platform } from 'react-native';
5
+ import { useCallKit } from '../overrides/hooks';
6
+ import { VoIPContext } from './VoIPContext';
7
+ /**
8
+ * Tracks the current VoIP call state, driven by the native CallKit / Core-Telecom
9
+ * events from {@link useVoIPEvents}, and exposes it through {@link useVoIP}.
10
+ *
11
+ * Joining rooms, peer tokens and media are the consumer's — react to `status` and
12
+ * report back with `reportConnected` / `reportConnectFailed`.
13
+ */
14
+ export function VoIPProvider({ onWaitingCallDeclined, isVideo = false, children }) {
15
+ const [voipToken, setVoIPToken] = useState(null);
16
+ const [status, setStatus] = useState('available');
17
+ const [currentCall, setCurrentCall] = useState(null);
18
+ const [lastEndedReason, setLastEndedReason] = useState(null);
19
+ const [isOnHold, setIsOnHold] = useState(false);
20
+ const [isMuted, setIsMuted] = useState(false);
21
+ const [pendingCallIntent, setPendingCallIntent] = useState(null);
22
+ const currentCallRef = useRef(null);
23
+ const pendingAnswerRequestIdRef = useRef(null);
24
+ const activationInFlightRef = useRef(false);
25
+ const isCallOnHoldRef = useRef(false);
26
+ /** Serializes native call events so End & Accept cannot interleave their transitions. */
27
+ const callTransitionRef = useRef(Promise.resolve());
28
+ /**
29
+ * Set when the accept (`onAnswered`) is processed before the promoted waiting
30
+ * call's `onIncoming` payload, so `onIncoming` can replay the answer.
31
+ */
32
+ const pendingWaitingAnswerRef = useRef(null);
33
+ const enqueueCallTransition = useCallback((op) => {
34
+ const run = callTransitionRef.current.then(op);
35
+ callTransitionRef.current = run.catch(() => { });
36
+ return run;
37
+ }, []);
38
+ const { startCallKitSession, endCallKitSession } = useCallKit();
39
+ const { startCall: startTelecomSession, endCall: endTelecomSession } = useTelecom();
40
+ const startNativeCallSession = useCallback((to) => Platform.OS === 'ios'
41
+ ? startCallKitSession({ displayName: to, handle: to, isVideo })
42
+ : startTelecomSession({ displayName: to, handle: to, isVideo }), [startCallKitSession, startTelecomSession, isVideo]);
43
+ const endNativeCallSession = useCallback((reason) => (Platform.OS === 'ios' ? endCallKitSession(reason) : endTelecomSession(reason)), [endCallKitSession, endTelecomSession]);
44
+ const setCallHeld = useCallback(async (onHold) => {
45
+ if (!currentCallRef.current) {
46
+ return;
47
+ }
48
+ await setVoIPCallHeld(onHold);
49
+ }, []);
50
+ const resetCallState = useCallback((reason = 'local', endedRoomName) => {
51
+ const roomName = endedRoomName ?? currentCallRef.current?.roomName;
52
+ if (!roomName || currentCallRef.current?.roomName !== roomName) {
53
+ return;
54
+ }
55
+ currentCallRef.current = null;
56
+ pendingAnswerRequestIdRef.current = null;
57
+ pendingWaitingAnswerRef.current = null;
58
+ isCallOnHoldRef.current = false;
59
+ setIsOnHold(false);
60
+ setIsMuted(false);
61
+ setCurrentCall(null);
62
+ setStatus('available');
63
+ setLastEndedReason(reason);
64
+ }, []);
65
+ const endCall = useCallback(async (reason = 'local', options) => {
66
+ const endedCall = currentCallRef.current;
67
+ if (!endedCall)
68
+ return;
69
+ resetCallState(reason, endedCall.roomName);
70
+ // Native already ended the CallKit session before `onEnded` fired — calling
71
+ // endNativeCallSession again during call-waiting swap would end the new call.
72
+ if (!options?.fromNative) {
73
+ await endNativeCallSession(reason);
74
+ }
75
+ }, [endNativeCallSession, resetCallState]);
76
+ const startCall = useCallback(async (to, roomName) => {
77
+ const call = {
78
+ roomName,
79
+ displayName: to,
80
+ handle: to,
81
+ isVideo,
82
+ startedAt: null,
83
+ isOutgoing: true,
84
+ };
85
+ currentCallRef.current = call;
86
+ setCurrentCall(call);
87
+ setStatus('connecting');
88
+ setLastEndedReason(null);
89
+ try {
90
+ await startNativeCallSession(to);
91
+ }
92
+ catch (err) {
93
+ console.error('Failed to start call:', err);
94
+ await endCall('failed');
95
+ }
96
+ }, [startNativeCallSession, isVideo, endCall]);
97
+ const reportConnected = useCallback(async () => {
98
+ const call = currentCallRef.current;
99
+ if (!call || call.startedAt != null || activationInFlightRef.current) {
100
+ return;
101
+ }
102
+ activationInFlightRef.current = true;
103
+ try {
104
+ const requestId = pendingAnswerRequestIdRef.current;
105
+ if (requestId) {
106
+ pendingAnswerRequestIdRef.current = null;
107
+ const connected = await fulfillIncomingCallConnected(requestId);
108
+ if (!connected) {
109
+ if (currentCallRef.current === call) {
110
+ await endCall('failed');
111
+ }
112
+ return;
113
+ }
114
+ }
115
+ else if (call.isOutgoing) {
116
+ await reportOutgoingCallConnected();
117
+ }
118
+ // The call may have ended while we were talking to the native side.
119
+ const activeCall = currentCallRef.current;
120
+ if (!activeCall || activeCall.roomName !== call.roomName)
121
+ return;
122
+ const connectedCall = { ...activeCall, startedAt: Date.now() };
123
+ currentCallRef.current = connectedCall;
124
+ setCurrentCall(connectedCall);
125
+ setStatus('active');
126
+ }
127
+ catch (err) {
128
+ console.error('Failed to activate call:', err);
129
+ if (currentCallRef.current === call) {
130
+ await endCall('failed');
131
+ }
132
+ }
133
+ finally {
134
+ activationInFlightRef.current = false;
135
+ }
136
+ }, [endCall]);
137
+ const reportConnectFailed = useCallback(async () => {
138
+ const call = currentCallRef.current;
139
+ if (!call)
140
+ return;
141
+ const requestId = pendingAnswerRequestIdRef.current;
142
+ if (!requestId) {
143
+ await endCall('failed');
144
+ return;
145
+ }
146
+ pendingAnswerRequestIdRef.current = null;
147
+ try {
148
+ await failIncomingCallConnected(requestId);
149
+ }
150
+ finally {
151
+ resetCallState('failed', call.roomName);
152
+ }
153
+ }, [endCall, resetCallState]);
154
+ const clearCallIntent = useCallback(() => setPendingCallIntent(null), []);
155
+ useVoIPEvents({
156
+ onRegistered: useCallback((token) => {
157
+ setVoIPToken(token);
158
+ }, []),
159
+ // Native only delivers `onIncoming` for a *first* call, or for a waiting call
160
+ // the moment the user picks "End & Accept" — never while a waiting call merely
161
+ // rings (that stays entirely inside CallKit/Telecom). So a payload arriving
162
+ // while we're already in a call means an accepted waiting call is taking over:
163
+ // native has already ended the old one, and we just swap `currentCall` over.
164
+ // Leaving the old room and joining the new one falls out of the consumer
165
+ // reacting to `currentCall.roomName` changing.
166
+ onIncoming: useCallback((payload) => {
167
+ enqueueCallTransition(async () => {
168
+ const call = {
169
+ roomName: payload.roomName,
170
+ displayName: payload.displayName,
171
+ handle: payload.handle,
172
+ isVideo: payload.isVideo,
173
+ startedAt: null,
174
+ isOutgoing: false,
175
+ };
176
+ currentCallRef.current = call;
177
+ pendingAnswerRequestIdRef.current = null;
178
+ isCallOnHoldRef.current = false;
179
+ setCurrentCall(call);
180
+ setStatus('incoming');
181
+ setLastEndedReason(null);
182
+ setIsOnHold(false);
183
+ setIsMuted(false);
184
+ // The user already accepted this call before its payload arrived — apply
185
+ // the answer we stashed then.
186
+ const pendingAnswer = pendingWaitingAnswerRef.current;
187
+ if (pendingAnswer) {
188
+ pendingWaitingAnswerRef.current = null;
189
+ pendingAnswerRequestIdRef.current = pendingAnswer;
190
+ setStatus('connecting');
191
+ }
192
+ });
193
+ }, [enqueueCallTransition]),
194
+ onAnswered: useCallback((requestId) => {
195
+ enqueueCallTransition(async () => {
196
+ if (pendingAnswerRequestIdRef.current) {
197
+ return;
198
+ }
199
+ // No ringing call to answer yet: either the accepted waiting call's
200
+ // `onIncoming` hasn't arrived, or `call` is still the old (already
201
+ // `active`) call that native is ending. Stash so `onIncoming` replays it.
202
+ const call = currentCallRef.current;
203
+ if (!call || call.startedAt != null) {
204
+ pendingWaitingAnswerRef.current = requestId;
205
+ return;
206
+ }
207
+ pendingAnswerRequestIdRef.current = requestId;
208
+ setStatus('connecting');
209
+ });
210
+ }, [enqueueCallTransition]),
211
+ onEnded: useCallback((reason) => {
212
+ enqueueCallTransition(async () => {
213
+ await endCall(reason ?? 'remote', { fromNative: true });
214
+ });
215
+ }, [endCall, enqueueCallTransition]),
216
+ onHeldChanged: useCallback((onHold) => {
217
+ if (!currentCallRef.current?.startedAt || isCallOnHoldRef.current === onHold) {
218
+ return;
219
+ }
220
+ isCallOnHoldRef.current = onHold;
221
+ setIsOnHold(onHold);
222
+ }, []),
223
+ onMuteChanged: useCallback((muted) => {
224
+ if (!currentCallRef.current?.startedAt) {
225
+ return;
226
+ }
227
+ setIsMuted(muted);
228
+ }, []),
229
+ onCallIntent: useCallback((intent) => {
230
+ if (currentCallRef.current) {
231
+ console.warn('Ignoring call intent while another call is active');
232
+ return;
233
+ }
234
+ setPendingCallIntent(intent);
235
+ }, []),
236
+ onWaitingCallDeclined,
237
+ });
238
+ const voipValue = useMemo(() => ({
239
+ voipToken,
240
+ status,
241
+ currentCall,
242
+ lastEndedReason,
243
+ isOnHold,
244
+ isMuted,
245
+ pendingCallIntent,
246
+ clearCallIntent,
247
+ startCall,
248
+ reportConnected,
249
+ reportConnectFailed,
250
+ endCall,
251
+ setCallHeld,
252
+ }), [
253
+ voipToken,
254
+ status,
255
+ currentCall,
256
+ lastEndedReason,
257
+ isOnHold,
258
+ isMuted,
259
+ pendingCallIntent,
260
+ clearCallIntent,
261
+ startCall,
262
+ reportConnected,
263
+ reportConnectFailed,
264
+ endCall,
265
+ setCallHeld,
266
+ ]);
267
+ return _jsx(VoIPContext.Provider, { value: voipValue, children: children });
268
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fishjam-cloud/react-native-client",
3
- "version": "0.29.0",
3
+ "version": "0.30.0-rc.0",
4
4
  "description": "React Native client library for Fishjam",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Fishjam Team",
@@ -42,15 +42,15 @@
42
42
  ]
43
43
  },
44
44
  "peerDependencies": {
45
- "@fishjam-cloud/react-native-webrtc": "0.29.0",
45
+ "@fishjam-cloud/react-native-webrtc": "0.30.0-rc.0",
46
46
  "expo": "*",
47
47
  "react": "*",
48
48
  "react-native": "*",
49
49
  "react-native-get-random-values": "1.11.0"
50
50
  },
51
51
  "dependencies": {
52
- "@fishjam-cloud/react-client": "0.29.0",
53
- "@fishjam-cloud/react-native-webrtc": "0.29.0",
52
+ "@fishjam-cloud/react-client": "0.30.0-rc.0",
53
+ "@fishjam-cloud/react-native-webrtc": "0.30.0-rc.0",
54
54
  "fast-text-encoding": "1.0.6",
55
55
  "react-native-get-random-values": "1.11.0",
56
56
  "react-native-url-polyfill": "3.0.0"
@@ -3,6 +3,21 @@ export type FishjamPluginOptions = {
3
3
  enableForegroundService?: boolean;
4
4
  enableScreensharing?: boolean;
5
5
  supportsPictureInPicture?: boolean;
6
+ enableVoIP?: boolean;
7
+ /**
8
+ * Register the SDK's FCM messaging service (default `true`). Set to `false`
9
+ * when the app ships its own dispatcher service that calls
10
+ * `PushNotificationService.handleVoIPMessage` / `handleNewToken`.
11
+ */
12
+ voipMessagingService?: boolean;
13
+ /**
14
+ * Messaging service that non-VoIP FCM messages and token callbacks are
15
+ * relayed to. Pass a supported library name (`'expo-notifications'`,
16
+ * `'@react-native-firebase/messaging'`) or a fully-qualified service class
17
+ * name. Omit to disable relaying — without it, other push libraries won't
18
+ * receive messages while VoIP is enabled.
19
+ */
20
+ voipFallbackMessagingService?: string;
6
21
  };
7
22
  ios?: {
8
23
  enableScreensharing?: boolean;
@@ -14,4 +29,10 @@ export type FishjamPluginOptions = {
14
29
  iphoneDeploymentTarget?: string;
15
30
  enableVoIPBackgroundMode?: boolean;
16
31
  };
32
+ voip?: {
33
+ incomingCallTimeout?: number;
34
+ outgoingCallTimeout?: number;
35
+ fulfillAnswerCallTimeout?: number;
36
+ notificationIcon?: string;
37
+ };
17
38
  } | undefined;
@@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.withFishjamAndroid = void 0;
4
4
  const config_plugins_1 = require("@expo/config-plugins");
5
5
  const Manifest_1 = require("@expo/config-plugins/build/android/Manifest");
6
+ const withFishjamVoIPAndroid_1 = require("./withFishjamVoIPAndroid");
7
+ const needsForegroundService = (props) => Boolean(props?.android?.enableForegroundService || props?.android?.enableVoIP);
6
8
  const withFishjamPictureInPicture = (config, props) => (0, config_plugins_1.withAndroidManifest)(config, (configuration) => {
7
9
  const activity = config_plugins_1.AndroidConfig.Manifest.getMainActivityOrThrow(configuration.modResults);
8
10
  if (props?.android?.supportsPictureInPicture) {
@@ -14,7 +16,7 @@ const withFishjamPictureInPicture = (config, props) => (0, config_plugins_1.with
14
16
  return configuration;
15
17
  });
16
18
  const withFishjamForegroundService = (config, props) => (0, config_plugins_1.withAndroidManifest)(config, async (configuration) => {
17
- if (!props?.android?.enableForegroundService) {
19
+ if (!needsForegroundService(props)) {
18
20
  return configuration;
19
21
  }
20
22
  const mainApplication = (0, Manifest_1.getMainApplicationOrThrow)(configuration.modResults);
@@ -39,7 +41,7 @@ const withFishjamForegroundService = (config, props) => (0, config_plugins_1.wit
39
41
  return configuration;
40
42
  });
41
43
  const withFishjamForegroundServicePermission = (config, props) => (0, config_plugins_1.withAndroidManifest)(config, (configuration) => {
42
- if (!props?.android?.enableForegroundService) {
44
+ if (!needsForegroundService(props)) {
43
45
  return configuration;
44
46
  }
45
47
  const mainApplication = configuration.modResults;
@@ -71,6 +73,7 @@ const withFishjamForegroundServicePermission = (config, props) => (0, config_plu
71
73
  const withFishjamAndroid = (config, props) => {
72
74
  config = withFishjamForegroundServicePermission(config, props);
73
75
  config = withFishjamForegroundService(config, props);
76
+ config = (0, withFishjamVoIPAndroid_1.withFishjamVoIPAndroid)(config, props);
74
77
  config = withFishjamPictureInPicture(config, props);
75
78
  return config;
76
79
  };
@@ -258,6 +258,59 @@ const withFishjamVoIPBackgroundMode = (config, props) => (0, config_plugins_1.wi
258
258
  }
259
259
  return configuration;
260
260
  });
261
+ const withFishjamVoIPTimeouts = (config, props) => (0, config_plugins_1.withInfoPlist)(config, (configuration) => {
262
+ const timeouts = [
263
+ ['VoIPIncomingCallTimeout', 'incomingCallTimeout'],
264
+ ['VoIPOutgoingCallTimeout', 'outgoingCallTimeout'],
265
+ ['VoIPFulfillAnswerTimeout', 'fulfillAnswerCallTimeout'],
266
+ ];
267
+ timeouts.forEach(([key, option]) => {
268
+ const seconds = props?.voip?.[option];
269
+ if (seconds === undefined) {
270
+ return;
271
+ }
272
+ if (!Number.isFinite(seconds) || seconds <= 0) {
273
+ throw new Error(`Fishjam VoIP ${option} must be a positive finite number of seconds.`);
274
+ }
275
+ configuration.modResults[key] = Math.floor(seconds);
276
+ });
277
+ return configuration;
278
+ });
279
+ const withFishjamExpoVoip = (config, props) => {
280
+ if (!props?.voip) {
281
+ return config;
282
+ }
283
+ return (0, config_plugins_1.withInfoPlist)(config, (configuration) => {
284
+ try {
285
+ require.resolve('@fishjam-cloud/ios-expo-voip/package.json', {
286
+ paths: [configuration.modRequest.projectRoot],
287
+ });
288
+ }
289
+ catch {
290
+ throw new Error('Fishjam VoIP options are enabled but @fishjam-cloud/ios-expo-voip is not installed. ' +
291
+ 'Run: npx expo install @fishjam-cloud/ios-expo-voip');
292
+ }
293
+ configuration.modResults['FishjamVoIPEnabled'] = true;
294
+ return configuration;
295
+ });
296
+ };
297
+ const withFishjamVoIPRecentsAndIntents = (config, props) => {
298
+ if (!props?.voip) {
299
+ return config;
300
+ }
301
+ return (0, config_plugins_1.withInfoPlist)(config, (configuration) => {
302
+ const activityTypes = new Set(Array.isArray(configuration.modResults.NSUserActivityTypes)
303
+ ? configuration.modResults.NSUserActivityTypes
304
+ : []);
305
+ // The audio/video variants are deprecated in favour of INStartCallIntent, but Recents
306
+ // redial still delivers them, so all three must be declared.
307
+ activityTypes.add('INStartCallIntent');
308
+ activityTypes.add('INStartAudioCallIntent');
309
+ activityTypes.add('INStartVideoCallIntent');
310
+ configuration.modResults.NSUserActivityTypes = Array.from(activityTypes);
311
+ return configuration;
312
+ });
313
+ };
261
314
  const withFishjamPictureInPicture = (config, props) => (0, config_plugins_1.withInfoPlist)(config, (configuration) => {
262
315
  if (props?.ios?.supportsPictureInPicture) {
263
316
  const backgroundModes = new Set(configuration.modResults.UIBackgroundModes ?? []);
@@ -278,6 +331,9 @@ const withFishjamIos = (config, props) => {
278
331
  });
279
332
  config = withFishjamPictureInPicture(config, props);
280
333
  config = withFishjamVoIPBackgroundMode(config, props);
334
+ config = withFishjamVoIPTimeouts(config, props);
335
+ config = withFishjamExpoVoip(config, props);
336
+ config = withFishjamVoIPRecentsAndIntents(config, props);
281
337
  return config;
282
338
  };
283
339
  exports.withFishjamIos = withFishjamIos;
@@ -0,0 +1,3 @@
1
+ import type { ConfigPlugin } from '@expo/config-plugins';
2
+ import type { FishjamPluginOptions } from './types';
3
+ export declare const withFishjamVoIPAndroid: ConfigPlugin<FishjamPluginOptions>;
@@ -0,0 +1,207 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.withFishjamVoIPAndroid = void 0;
4
+ const config_plugins_1 = require("@expo/config-plugins");
5
+ const Manifest_1 = require("@expo/config-plugins/build/android/Manifest");
6
+ /**
7
+ * Manifest entries required by the Android Telecom (VoIP calling) integration.
8
+ * Opt in with `android.enableVoIP`.
9
+ *
10
+ * - MANAGE_OWN_CALLS: required to register calls with Telecom via
11
+ * androidx.core.telecom CallsManager.
12
+ * - POST_NOTIFICATIONS: the incoming/ongoing CallStyle notifications.
13
+ * - USE_FULL_SCREEN_INTENT: the incoming-call ring screen over the lock screen.
14
+ * - VIBRATE: the looping ring vibration driven while an incoming call rings.
15
+ */
16
+ const VOIP_PERMISSIONS = [
17
+ 'android.permission.MANAGE_OWN_CALLS',
18
+ 'android.permission.POST_NOTIFICATIONS',
19
+ 'android.permission.USE_FULL_SCREEN_INTENT',
20
+ 'android.permission.VIBRATE',
21
+ ];
22
+ const INCOMING_CALL_ACTIVITY = {
23
+ $: {
24
+ 'android:name': 'com.oney.WebRTCModule.voip.IncomingCallActivity',
25
+ 'android:exported': 'false',
26
+ 'android:showWhenLocked': 'true',
27
+ 'android:turnScreenOn': 'true',
28
+ 'android:launchMode': 'singleInstance',
29
+ 'android:excludeFromRecents': 'true',
30
+ 'android:taskAffinity': '',
31
+ 'android:theme': '@android:style/Theme.Black.NoTitleBar.Fullscreen',
32
+ },
33
+ };
34
+ const END_CALL_RECEIVER = {
35
+ $: {
36
+ 'android:name': 'com.oney.WebRTCModule.voip.EndCallNotificationReceiver',
37
+ 'android:exported': 'false',
38
+ },
39
+ };
40
+ const MESSAGING_SERVICE = {
41
+ '$': {
42
+ 'android:name': 'com.oney.WebRTCModule.voip.PushNotificationService',
43
+ 'android:exported': 'false',
44
+ },
45
+ 'intent-filter': [
46
+ {
47
+ $: { 'android:priority': '1' },
48
+ action: [{ $: { 'android:name': 'com.google.firebase.MESSAGING_EVENT' } }],
49
+ },
50
+ ],
51
+ };
52
+ const INSTALLATION_ID_META = {
53
+ $: {
54
+ 'android:name': 'firebase_messaging_installation_id_enabled',
55
+ 'android:value': 'true',
56
+ },
57
+ };
58
+ const FALLBACK_META_NAME = 'VoIPFallbackMessagingService';
59
+ /**
60
+ * Library names accepted by `android.voipFallbackMessagingService`, mapped to
61
+ * their FCM service class. These services come from **library** manifests, so on
62
+ * top of configuring the relay we re-declare them with tools:node="replace" and
63
+ * no intent-filter: the library's MESSAGING_EVENT filter is dropped (FCM routing
64
+ * stays deterministic) while the manifest entry keeps R8 from stripping the
65
+ * reflectively-loaded class in minified builds.
66
+ */
67
+ const KNOWN_FALLBACK_SERVICES = {
68
+ 'expo-notifications': 'expo.modules.notifications.service.ExpoFirebaseMessagingService',
69
+ '@react-native-firebase/messaging': 'io.invertase.firebase.messaging.ReactNativeFirebaseMessagingService',
70
+ };
71
+ const VOIP_TIMEOUTS = [
72
+ ['VoIPIncomingCallTimeout', 'incomingCallTimeout'],
73
+ ['VoIPOutgoingCallTimeout', 'outgoingCallTimeout'],
74
+ ['VoIPFulfillAnswerTimeout', 'fulfillAnswerCallTimeout'],
75
+ ];
76
+ const NOTIFICATION_ICON_META_NAME = 'VoIPNotificationIcon';
77
+ // The CallStyle notification's small icon defaults to the app's launcher icon.
78
+ const DEFAULT_NOTIFICATION_ICON = '@mipmap/ic_launcher';
79
+ function validateTimeout(name, seconds) {
80
+ if (!Number.isFinite(seconds) || seconds <= 0) {
81
+ throw new Error(`Fishjam VoIP ${name} must be a positive finite number of seconds.`);
82
+ }
83
+ }
84
+ function resolveFallbackMessagingService(props) {
85
+ const option = props?.android?.voipFallbackMessagingService;
86
+ if (!option) {
87
+ return null;
88
+ }
89
+ const known = KNOWN_FALLBACK_SERVICES[option];
90
+ if (known) {
91
+ return { className: known, knownLibrary: true };
92
+ }
93
+ if (option.includes('.')) {
94
+ return { className: option, knownLibrary: false };
95
+ }
96
+ throw new Error(`Fishjam VoIP: unknown voipFallbackMessagingService '${option}'. ` +
97
+ `Use one of ${Object.keys(KNOWN_FALLBACK_SERVICES).join(', ')} ` +
98
+ `or a fully-qualified FirebaseMessagingService class name.`);
99
+ }
100
+ /** Applies every VoIP manifest change. */
101
+ function applyVoIPManifest(androidManifest, props, fallbackService) {
102
+ const manifest = androidManifest.manifest;
103
+ if (!manifest['uses-permission']) {
104
+ manifest['uses-permission'] = [];
105
+ }
106
+ const permissions = manifest['uses-permission'];
107
+ VOIP_PERMISSIONS.forEach((permissionName) => {
108
+ const hasPermission = permissions.some((perm) => perm.$?.['android:name'] === permissionName);
109
+ if (!hasPermission) {
110
+ permissions.push({ $: { 'android:name': permissionName } });
111
+ }
112
+ });
113
+ const mainApplication = (0, Manifest_1.getMainApplicationOrThrow)(androidManifest);
114
+ mainApplication.activity = mainApplication.activity || [];
115
+ const activityName = INCOMING_CALL_ACTIVITY.$['android:name'];
116
+ const existingActivityIndex = mainApplication.activity.findIndex((activity) => activity.$['android:name'] === activityName);
117
+ if (existingActivityIndex !== -1) {
118
+ mainApplication.activity[existingActivityIndex] = INCOMING_CALL_ACTIVITY;
119
+ }
120
+ else {
121
+ mainApplication.activity.push(INCOMING_CALL_ACTIVITY);
122
+ }
123
+ mainApplication.receiver = mainApplication.receiver || [];
124
+ const receiverName = END_CALL_RECEIVER.$['android:name'];
125
+ const existingReceiverIndex = mainApplication.receiver.findIndex((receiver) => receiver.$['android:name'] === receiverName);
126
+ if (existingReceiverIndex !== -1) {
127
+ mainApplication.receiver[existingReceiverIndex] = END_CALL_RECEIVER;
128
+ }
129
+ else {
130
+ mainApplication.receiver.push(END_CALL_RECEIVER);
131
+ }
132
+ mainApplication.service = mainApplication.service || [];
133
+ if (props?.android?.voipMessagingService !== false) {
134
+ // Cast: ManifestIntentFilter's typing omits android:priority, which is valid XML.
135
+ upsertService(mainApplication.service, MESSAGING_SERVICE);
136
+ if (fallbackService?.knownLibrary) {
137
+ manifest.$['xmlns:tools'] = manifest.$['xmlns:tools'] ?? 'http://schemas.android.com/tools';
138
+ upsertService(mainApplication.service, {
139
+ $: {
140
+ 'android:name': fallbackService.className,
141
+ 'tools:node': 'replace',
142
+ },
143
+ });
144
+ }
145
+ }
146
+ const metadataEntries = mainApplication['meta-data'] || [];
147
+ mainApplication['meta-data'] = metadataEntries;
148
+ const upsertMeta = (entry) => {
149
+ const existingIndex = metadataEntries.findIndex((meta) => meta.$['android:name'] === entry.$['android:name']);
150
+ if (existingIndex !== -1) {
151
+ metadataEntries[existingIndex] = entry;
152
+ }
153
+ else {
154
+ metadataEntries.push(entry);
155
+ }
156
+ };
157
+ upsertMeta(INSTALLATION_ID_META);
158
+ if (props?.android?.voipMessagingService !== false && fallbackService) {
159
+ upsertMeta({
160
+ $: {
161
+ 'android:name': FALLBACK_META_NAME,
162
+ 'android:value': fallbackService.className,
163
+ },
164
+ });
165
+ }
166
+ VOIP_TIMEOUTS.forEach(([key, option]) => {
167
+ const seconds = props?.voip?.[option];
168
+ if (seconds === undefined) {
169
+ return;
170
+ }
171
+ validateTimeout(option, seconds);
172
+ upsertMeta({
173
+ $: {
174
+ 'android:name': key,
175
+ 'android:value': String(Math.floor(seconds)),
176
+ },
177
+ });
178
+ });
179
+ // CallStyle notification small icon; defaults to the app's launcher icon.
180
+ upsertMeta({
181
+ $: {
182
+ 'android:name': NOTIFICATION_ICON_META_NAME,
183
+ 'android:resource': props?.voip?.notificationIcon ?? DEFAULT_NOTIFICATION_ICON,
184
+ },
185
+ });
186
+ return androidManifest;
187
+ }
188
+ function upsertService(services, entry) {
189
+ const existingIndex = services.findIndex((service) => service.$?.['android:name'] === entry.$?.['android:name']);
190
+ if (existingIndex !== -1) {
191
+ services[existingIndex] = entry;
192
+ }
193
+ else {
194
+ services.push(entry);
195
+ }
196
+ }
197
+ const withFishjamVoIPAndroid = (config, props) => {
198
+ if (!props?.android?.enableVoIP) {
199
+ return config;
200
+ }
201
+ const fallbackService = resolveFallbackMessagingService(props);
202
+ return (0, config_plugins_1.withAndroidManifest)(config, (configuration) => {
203
+ applyVoIPManifest(configuration.modResults, props, fallbackService);
204
+ return configuration;
205
+ });
206
+ };
207
+ exports.withFishjamVoIPAndroid = withFishjamVoIPAndroid;