expo-callkit-telecom 0.1.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.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +197 -0
  3. package/android/build.gradle +32 -0
  4. package/android/src/main/AndroidManifest.xml +33 -0
  5. package/android/src/main/java/expo/modules/callkittelecom/ExpoCallKitTelecomModule.kt +384 -0
  6. package/android/src/main/java/expo/modules/callkittelecom/IncomingCallActivity.kt +275 -0
  7. package/android/src/main/java/expo/modules/callkittelecom/events/CallEventEmitter.kt +151 -0
  8. package/android/src/main/java/expo/modules/callkittelecom/events/CallEvents.kt +59 -0
  9. package/android/src/main/java/expo/modules/callkittelecom/managers/CallAudioManager.kt +361 -0
  10. package/android/src/main/java/expo/modules/callkittelecom/managers/CallManager.kt +891 -0
  11. package/android/src/main/java/expo/modules/callkittelecom/managers/CallNotificationManager.kt +445 -0
  12. package/android/src/main/java/expo/modules/callkittelecom/managers/CaptureSessionManager.kt +27 -0
  13. package/android/src/main/java/expo/modules/callkittelecom/managers/DialtonePlayer.kt +171 -0
  14. package/android/src/main/java/expo/modules/callkittelecom/managers/FulfillRequestManager.kt +150 -0
  15. package/android/src/main/java/expo/modules/callkittelecom/managers/VoIPPushManager.kt +54 -0
  16. package/android/src/main/java/expo/modules/callkittelecom/models/CallModels.kt +269 -0
  17. package/android/src/main/java/expo/modules/callkittelecom/services/CallNotificationReceiver.kt +54 -0
  18. package/android/src/main/java/expo/modules/callkittelecom/services/ExpoCallKitTelecomMessagingService.kt +161 -0
  19. package/android/src/main/java/expo/modules/callkittelecom/store/CallStore.kt +181 -0
  20. package/android/src/main/java/expo/modules/callkittelecom/utils/CallKitTelecomLog.kt +52 -0
  21. package/android/src/main/java/expo/modules/callkittelecom/utils/PermissionUtils.kt +28 -0
  22. package/android/src/main/res/drawable/expo_callkit_telecom_bg_answer.xml +9 -0
  23. package/android/src/main/res/drawable/expo_callkit_telecom_bg_avatar.xml +5 -0
  24. package/android/src/main/res/drawable/expo_callkit_telecom_bg_decline.xml +9 -0
  25. package/android/src/main/res/drawable/expo_callkit_telecom_ic_answer.xml +9 -0
  26. package/android/src/main/res/drawable/expo_callkit_telecom_ic_decline.xml +9 -0
  27. package/android/src/main/res/drawable/expo_callkit_telecom_ic_videocam.xml +9 -0
  28. package/android/src/main/res/layout/activity_incoming_call.xml +169 -0
  29. package/app.json +8 -0
  30. package/app.plugin.js +1 -0
  31. package/build/Calls.d.ts +577 -0
  32. package/build/Calls.d.ts.map +1 -0
  33. package/build/Calls.js +715 -0
  34. package/build/Calls.js.map +1 -0
  35. package/build/Calls.types.d.ts +203 -0
  36. package/build/Calls.types.d.ts.map +1 -0
  37. package/build/Calls.types.js +2 -0
  38. package/build/Calls.types.js.map +1 -0
  39. package/build/ExpoCallKitTelecomModule.d.ts +3 -0
  40. package/build/ExpoCallKitTelecomModule.d.ts.map +1 -0
  41. package/build/ExpoCallKitTelecomModule.js +4 -0
  42. package/build/ExpoCallKitTelecomModule.js.map +1 -0
  43. package/build/hooks/index.d.ts +2 -0
  44. package/build/hooks/index.d.ts.map +1 -0
  45. package/build/hooks/index.js +2 -0
  46. package/build/hooks/index.js.map +1 -0
  47. package/build/hooks/useVoIPPushToken.d.ts +14 -0
  48. package/build/hooks/useVoIPPushToken.d.ts.map +1 -0
  49. package/build/hooks/useVoIPPushToken.js +26 -0
  50. package/build/hooks/useVoIPPushToken.js.map +1 -0
  51. package/build/index.d.ts +4 -0
  52. package/build/index.d.ts.map +1 -0
  53. package/build/index.js +4 -0
  54. package/build/index.js.map +1 -0
  55. package/expo-module.config.json +10 -0
  56. package/ios/AppDelegateSubscriber.swift +93 -0
  57. package/ios/ExpoCallKitTelecom.podspec +31 -0
  58. package/ios/ExpoCallKitTelecomLogger.swift +55 -0
  59. package/ios/ExpoCallKitTelecomModule.swift +503 -0
  60. package/ios/Managers/AudioManager.swift +363 -0
  61. package/ios/Managers/CallEventEmitter.swift +199 -0
  62. package/ios/Managers/CallManager+CXProviderDelegate.swift +195 -0
  63. package/ios/Managers/CallManager.swift +714 -0
  64. package/ios/Managers/CaptureSessionManager.swift +54 -0
  65. package/ios/Managers/DialtonePlayer.swift +126 -0
  66. package/ios/Managers/FulfillRequestManager.swift +154 -0
  67. package/ios/Managers/VoIPPushManager+PKPushRegistryDelegate.swift +123 -0
  68. package/ios/Managers/VoIPPushManager.swift +58 -0
  69. package/ios/Models/CallEvents.swift +263 -0
  70. package/ios/Models/CallOptions.swift +15 -0
  71. package/ios/Models/CallParticipant.swift +37 -0
  72. package/ios/Models/CallSession.swift +80 -0
  73. package/ios/Models/IncomingCallEvent.swift +196 -0
  74. package/ios/Stores/CallStore.swift +149 -0
  75. package/package.json +56 -0
  76. package/plugin/build/constants.d.ts +3 -0
  77. package/plugin/build/constants.js +7 -0
  78. package/plugin/build/withExpoCallKitTelecom.d.ts +67 -0
  79. package/plugin/build/withExpoCallKitTelecom.js +16 -0
  80. package/plugin/build/withExpoCallKitTelecomAndroid.d.ts +3 -0
  81. package/plugin/build/withExpoCallKitTelecomAndroid.js +177 -0
  82. package/plugin/build/withExpoCallKitTelecomIos.d.ts +3 -0
  83. package/plugin/build/withExpoCallKitTelecomIos.js +195 -0
  84. package/plugin/src/constants.ts +4 -0
  85. package/plugin/src/withExpoCallKitTelecom.ts +83 -0
  86. package/plugin/src/withExpoCallKitTelecomAndroid.ts +293 -0
  87. package/plugin/src/withExpoCallKitTelecomIos.ts +276 -0
  88. package/src/Calls.ts +848 -0
  89. package/src/Calls.types.ts +275 -0
  90. package/src/ExpoCallKitTelecomModule.ts +4 -0
  91. package/src/hooks/index.ts +1 -0
  92. package/src/hooks/useVoIPPushToken.ts +34 -0
  93. package/src/index.ts +3 -0
@@ -0,0 +1,4 @@
1
+ // Default timeout values in seconds
2
+ export const DEFAULT_INCOMING_CALL_TIMEOUT = 45;
3
+ export const DEFAULT_OUTGOING_CALL_TIMEOUT = 60;
4
+ export const DEFAULT_FULFILL_ANSWER_CALL_TIMEOUT = 30;
@@ -0,0 +1,83 @@
1
+ import { type ConfigPlugin, createRunOncePlugin } from "expo/config-plugins";
2
+
3
+ import pkg from "../../package.json";
4
+ import { withExpoCallKitTelecomAndroid } from "./withExpoCallKitTelecomAndroid";
5
+ import { withExpoCallKitTelecomIos } from "./withExpoCallKitTelecomIos";
6
+
7
+ export type ExpoCallKitTelecomPluginProps = {
8
+ /**
9
+ * Custom message for microphone permission prompt. Set to false to skip.
10
+ * @platform ios
11
+ */
12
+ microphonePermission?: string | false;
13
+ /**
14
+ * Custom message for camera permission prompt. Set to false to skip.
15
+ * @platform ios
16
+ */
17
+ cameraPermission?: string | false;
18
+ /**
19
+ * Timeout in seconds for incoming calls before they are marked as unanswered.
20
+ * @default 45
21
+ * @platform ios
22
+ * @platform android
23
+ */
24
+ incomingCallTimeout?: number;
25
+ /**
26
+ * Timeout in seconds for outgoing calls to connect before they are marked as unanswered.
27
+ * @default 60
28
+ * @platform ios
29
+ * @platform android
30
+ */
31
+ outgoingCallTimeout?: number;
32
+ /**
33
+ * Timeout in seconds for waiting for the call to connect after answering.
34
+ * @default 30
35
+ * @platform ios
36
+ * @platform android
37
+ */
38
+ fulfillAnswerCallTimeout?: number;
39
+ /**
40
+ * Array of sound file paths (relative to project root) to include in the app.
41
+ * These files will be copied into the iOS bundle and Android raw resources.
42
+ * Supported formats: .caf, .aiff, .wav (max 30 seconds for CallKit ringtones).
43
+ * @platform ios
44
+ * @platform android
45
+ */
46
+ sounds?: string[];
47
+ /**
48
+ * The default ringtone for incoming calls on iOS (CallKit).
49
+ * Can be the filename (with extension) of one of the provided sounds,
50
+ * or 'default' to use the system ringtone.
51
+ * @default 'default'
52
+ * @platform ios
53
+ */
54
+ defaultRingtoneIos?: string;
55
+ /**
56
+ * The default ringtone for incoming calls on Android (notification channel).
57
+ * Can be the filename (with extension) of one of the provided sounds,
58
+ * or 'default' to use the system ringtone.
59
+ * @default 'default'
60
+ * @platform android
61
+ */
62
+ defaultRingtoneAndroid?: string;
63
+ /**
64
+ * The default dialtone to play during outgoing calls while connecting.
65
+ * Must be the filename (with extension) of one of the provided sounds.
66
+ * @platform ios
67
+ * @platform android
68
+ */
69
+ defaultDialtone?: string;
70
+ };
71
+
72
+ const withExpoCallKitTelecom: ConfigPlugin<ExpoCallKitTelecomPluginProps | void> = (
73
+ config,
74
+ props,
75
+ ) => {
76
+ const opts: ExpoCallKitTelecomPluginProps = props ?? {};
77
+ config = withExpoCallKitTelecomAndroid(config, opts);
78
+ config = withExpoCallKitTelecomIos(config, opts);
79
+
80
+ return config;
81
+ };
82
+
83
+ export default createRunOncePlugin(withExpoCallKitTelecom, pkg.name, pkg.version);
@@ -0,0 +1,293 @@
1
+ import {
2
+ AndroidConfig,
3
+ type ConfigPlugin,
4
+ withAndroidManifest,
5
+ withDangerousMod,
6
+ } from "expo/config-plugins";
7
+ import { copyFileSync, existsSync, mkdirSync } from "fs";
8
+ import { basename, resolve } from "path";
9
+
10
+ import {
11
+ DEFAULT_FULFILL_ANSWER_CALL_TIMEOUT,
12
+ DEFAULT_INCOMING_CALL_TIMEOUT,
13
+ DEFAULT_OUTGOING_CALL_TIMEOUT,
14
+ } from "./constants";
15
+ import type { ExpoCallKitTelecomPluginProps } from "./withExpoCallKitTelecom";
16
+
17
+ const ERROR_MSG_PREFIX = "An error occurred while configuring Android calls. ";
18
+
19
+ // biome-ignore lint/suspicious/noExplicitAny: Expo config plugin manifest types are untyped
20
+ function setMetaDataValue(app: any, key: string, value: string): void {
21
+ const existing = app["meta-data"]?.find(
22
+ // biome-ignore lint/suspicious/noExplicitAny: manifest meta-data items are untyped
23
+ (item: any) => item.$["android:name"] === key,
24
+ );
25
+ if (existing) {
26
+ existing.$["android:value"] = value;
27
+ return;
28
+ }
29
+
30
+ if (!app["meta-data"]) {
31
+ app["meta-data"] = [];
32
+ }
33
+ app["meta-data"].push({
34
+ $: {
35
+ "android:name": key,
36
+ "android:value": value,
37
+ },
38
+ });
39
+ }
40
+
41
+ /**
42
+ * Sanitizes a filename for use as an Android raw resource name.
43
+ *
44
+ * Android raw resource names must be lowercase, alphanumeric + underscores,
45
+ * and cannot start with a digit.
46
+ */
47
+ function toAndroidRawResourceName(filename: string): string {
48
+ const withoutExtension = filename.replace(/\.[^.]+$/, "");
49
+ const sanitized = withoutExtension.toLowerCase().replace(/[^a-z0-9_]/g, "_");
50
+ return /^\d/.test(sanitized) ? `_${sanitized}` : sanitized;
51
+ }
52
+
53
+ /**
54
+ * Configures call timeout values in AndroidManifest metadata.
55
+ */
56
+ const withTimeouts: ConfigPlugin<{
57
+ incomingCallTimeout?: number;
58
+ outgoingCallTimeout?: number;
59
+ fulfillAnswerCallTimeout?: number;
60
+ }> = (
61
+ config,
62
+ { incomingCallTimeout, outgoingCallTimeout, fulfillAnswerCallTimeout },
63
+ ) => {
64
+ return withAndroidManifest(config, (config) => {
65
+ const app = AndroidConfig.Manifest.getMainApplicationOrThrow(
66
+ config.modResults,
67
+ );
68
+
69
+ setMetaDataValue(
70
+ app,
71
+ "ExpoCallKitTelecomIncomingCallTimeout",
72
+ String(incomingCallTimeout ?? DEFAULT_INCOMING_CALL_TIMEOUT),
73
+ );
74
+
75
+ setMetaDataValue(
76
+ app,
77
+ "ExpoCallKitTelecomOutgoingCallTimeout",
78
+ String(outgoingCallTimeout ?? DEFAULT_OUTGOING_CALL_TIMEOUT),
79
+ );
80
+
81
+ setMetaDataValue(
82
+ app,
83
+ "ExpoCallKitTelecomFulfillAnswerCallTimeout",
84
+ String(fulfillAnswerCallTimeout ?? DEFAULT_FULFILL_ANSWER_CALL_TIMEOUT),
85
+ );
86
+
87
+ return config;
88
+ });
89
+ };
90
+
91
+ /**
92
+ * Copies sound files into the Android raw resources directory.
93
+ */
94
+ const withSounds: ConfigPlugin<{ sounds?: string[] }> = (
95
+ config,
96
+ { sounds },
97
+ ) => {
98
+ if (!sounds || sounds.length === 0) {
99
+ return config;
100
+ }
101
+
102
+ return withDangerousMod(config, [
103
+ "android",
104
+ (config) => {
105
+ const projectRoot = config.modRequest.projectRoot;
106
+ const rawDir = resolve(
107
+ projectRoot,
108
+ "android",
109
+ "app",
110
+ "src",
111
+ "main",
112
+ "res",
113
+ "raw",
114
+ );
115
+
116
+ mkdirSync(rawDir, { recursive: true });
117
+
118
+ for (const soundPath of sounds) {
119
+ const filename = basename(soundPath);
120
+ const sourcePath = resolve(projectRoot, soundPath);
121
+
122
+ if (!existsSync(sourcePath)) {
123
+ throw new Error(
124
+ `${ERROR_MSG_PREFIX}Sound file not found: ${sourcePath}`,
125
+ );
126
+ }
127
+
128
+ const resourceName = toAndroidRawResourceName(filename);
129
+ const extension = filename.includes(".")
130
+ ? filename.substring(filename.lastIndexOf("."))
131
+ : "";
132
+ const destinationPath = resolve(rawDir, `${resourceName}${extension}`);
133
+
134
+ copyFileSync(sourcePath, destinationPath);
135
+ }
136
+
137
+ return config;
138
+ },
139
+ ]);
140
+ };
141
+
142
+ /**
143
+ * Configures the default dialtone for outgoing calls in AndroidManifest metadata.
144
+ */
145
+ const withDefaultDialtone: ConfigPlugin<{
146
+ sounds?: string[];
147
+ defaultDialtone?: string;
148
+ }> = (config, { sounds, defaultDialtone }) => {
149
+ if (!defaultDialtone) {
150
+ return config;
151
+ }
152
+
153
+ const soundFilenames = sounds?.map((s) => basename(s)) ?? [];
154
+
155
+ if (soundFilenames.length === 0) {
156
+ throw new Error(
157
+ `${ERROR_MSG_PREFIX}"defaultDialtone" was specified but no ` +
158
+ `sounds were provided.`,
159
+ );
160
+ }
161
+ if (!soundFilenames.includes(defaultDialtone)) {
162
+ throw new Error(
163
+ `${ERROR_MSG_PREFIX}"defaultDialtone" must be one of the provided ` +
164
+ `sounds (${soundFilenames.join(", ")}).`,
165
+ );
166
+ }
167
+
168
+ return withAndroidManifest(config, (config) => {
169
+ const app = AndroidConfig.Manifest.getMainApplicationOrThrow(
170
+ config.modResults,
171
+ );
172
+
173
+ setMetaDataValue(
174
+ app,
175
+ "ExpoCallKitTelecomDefaultDialtone",
176
+ toAndroidRawResourceName(defaultDialtone),
177
+ );
178
+
179
+ return config;
180
+ });
181
+ };
182
+
183
+ /**
184
+ * Configures the default ringtone for incoming calls in AndroidManifest metadata.
185
+ *
186
+ * Mirrors the iOS `withDefaultRingtone` plugin. The Kotlin side reads
187
+ * `ExpoCallKitTelecomDefaultRingtone` from manifest metadata and sets it as the
188
+ * notification channel sound.
189
+ */
190
+ const withDefaultRingtone: ConfigPlugin<{
191
+ sounds?: string[];
192
+ defaultRingtone?: string;
193
+ }> = (config, { sounds, defaultRingtone }) => {
194
+ if (!defaultRingtone || defaultRingtone === "default") {
195
+ return config;
196
+ }
197
+
198
+ const soundFilenames = sounds?.map((s) => basename(s)) ?? [];
199
+
200
+ if (soundFilenames.length === 0) {
201
+ throw new Error(
202
+ `${ERROR_MSG_PREFIX}"defaultRingtone" was specified but no ` +
203
+ `sounds were provided.`,
204
+ );
205
+ }
206
+ if (!soundFilenames.includes(defaultRingtone)) {
207
+ throw new Error(
208
+ `${ERROR_MSG_PREFIX}"defaultRingtone" must be one of the provided ` +
209
+ `sounds (${soundFilenames.join(", ")}) or "default" for ` +
210
+ `system ringtone.`,
211
+ );
212
+ }
213
+
214
+ return withAndroidManifest(config, (config) => {
215
+ const app = AndroidConfig.Manifest.getMainApplicationOrThrow(
216
+ config.modResults,
217
+ );
218
+
219
+ setMetaDataValue(
220
+ app,
221
+ "ExpoCallKitTelecomDefaultRingtone",
222
+ toAndroidRawResourceName(defaultRingtone),
223
+ );
224
+
225
+ return config;
226
+ });
227
+ };
228
+
229
+ /**
230
+ * Removes expo-notifications' ExpoFirebaseMessagingService from the manifest.
231
+ *
232
+ * Our ExpoCallKitTelecomMessagingService extends it and takes over as the sole
233
+ * MESSAGING_EVENT handler, delegating non-call messages via super.
234
+ * Having both services registered would cause undefined delivery behaviour.
235
+ *
236
+ * The service is declared in expo-notifications' library AndroidManifest.xml,
237
+ * so we must use `tools:node="remove"` to tell the manifest merger to strip it.
238
+ */
239
+ const withFirebaseMessagingService: ConfigPlugin<ExpoCallKitTelecomPluginProps> = (
240
+ config,
241
+ ) => {
242
+ return withAndroidManifest(config, (config) => {
243
+ const manifest = config.modResults.manifest;
244
+
245
+ // Ensure the tools namespace is declared on the root <manifest> element.
246
+ if (!manifest.$["xmlns:tools"]) {
247
+ manifest.$["xmlns:tools"] = "http://schemas.android.com/tools";
248
+ }
249
+
250
+ const app = AndroidConfig.Manifest.getMainApplicationOrThrow(
251
+ config.modResults,
252
+ );
253
+
254
+ if (!app.service) {
255
+ app.service = [];
256
+ }
257
+
258
+ const notificationsService =
259
+ "expo.modules.notifications.service.ExpoFirebaseMessagingService";
260
+
261
+ // Remove any existing entry first (idempotent across repeated prebuilds).
262
+ app.service = app.service.filter(
263
+ (service) => service.$?.["android:name"] !== notificationsService,
264
+ );
265
+
266
+ // Add a tools:node="remove" marker so the manifest merger strips the
267
+ // library-declared service during the Gradle build.
268
+ const removeEntry = {
269
+ $: {
270
+ "android:name": notificationsService,
271
+ "tools:node": "remove",
272
+ },
273
+ };
274
+ app.service.push(removeEntry);
275
+
276
+ return config;
277
+ });
278
+ };
279
+
280
+ export const withExpoCallKitTelecomAndroid: ConfigPlugin<ExpoCallKitTelecomPluginProps> = (
281
+ config,
282
+ props,
283
+ ) => {
284
+ config = withTimeouts(config, props);
285
+ config = withSounds(config, props);
286
+ config = withDefaultRingtone(config, {
287
+ sounds: props.sounds,
288
+ defaultRingtone: props.defaultRingtoneAndroid,
289
+ });
290
+ config = withDefaultDialtone(config, props);
291
+ config = withFirebaseMessagingService(config, props);
292
+ return config;
293
+ };
@@ -0,0 +1,276 @@
1
+ import {
2
+ type ConfigPlugin,
3
+ IOSConfig,
4
+ withEntitlementsPlist,
5
+ withInfoPlist,
6
+ withXcodeProject,
7
+ } from "expo/config-plugins";
8
+ import { copyFileSync, existsSync } from "fs";
9
+ import { basename, resolve } from "path";
10
+
11
+ import {
12
+ DEFAULT_FULFILL_ANSWER_CALL_TIMEOUT,
13
+ DEFAULT_INCOMING_CALL_TIMEOUT,
14
+ DEFAULT_OUTGOING_CALL_TIMEOUT,
15
+ } from "./constants";
16
+ import type { ExpoCallKitTelecomPluginProps } from "./withExpoCallKitTelecom";
17
+
18
+ const ERROR_MSG_PREFIX = "An error occurred while configuring iOS calls. ";
19
+
20
+ // Default permission messages
21
+ const CAMERA_USAGE = "Allow $(PRODUCT_NAME) to access your camera";
22
+ const MICROPHONE_USAGE = "Allow $(PRODUCT_NAME) to access your microphone";
23
+
24
+ // Required background modes for CallKit and PushKit:
25
+ // - voip: Receive VoIP push notifications to wake the app for incoming calls
26
+ // - audio: Continue audio playback/recording during calls when app is backgrounded
27
+ const BACKGROUND_MODES = ["voip", "audio"];
28
+
29
+ // SiriKit intents for voice-activated calls
30
+ // INStartCallIntent: Unified intent for iOS 13+ (recommended)
31
+ // INStartAudioCallIntent/INStartVideoCallIntent: Deprecated in iOS 13, but still
32
+ // sent by the system in some cases (e.g., redialing from call history)
33
+ const SIRI_INTENTS = [
34
+ "INStartCallIntent",
35
+ "INStartAudioCallIntent",
36
+ "INStartVideoCallIntent",
37
+ ];
38
+
39
+ /**
40
+ * Configures camera and microphone permissions for VoIP and video calls.
41
+ */
42
+ const withPermissions: ConfigPlugin<{
43
+ cameraPermission?: string | false;
44
+ microphonePermission?: string | false;
45
+ }> = (config, { cameraPermission, microphonePermission }) => {
46
+ return IOSConfig.Permissions.createPermissionsPlugin({
47
+ NSCameraUsageDescription: CAMERA_USAGE,
48
+ NSMicrophoneUsageDescription: MICROPHONE_USAGE,
49
+ })(config, {
50
+ NSCameraUsageDescription: cameraPermission,
51
+ NSMicrophoneUsageDescription: microphonePermission,
52
+ });
53
+ };
54
+
55
+ /**
56
+ * Configures push notification entitlement for PushKit VoIP notifications.
57
+ */
58
+ const withPushNotificationEntitlement: ConfigPlugin = (config) => {
59
+ return withEntitlementsPlist(config, (config) => {
60
+ const key = "aps-environment";
61
+ // Only set if not already configured; production builds use provisioning profile value
62
+ if (!config.modResults[key]) {
63
+ config.modResults[key] = "development";
64
+ }
65
+ return config;
66
+ });
67
+ };
68
+
69
+ /**
70
+ * Configures UIBackgroundModes for VoIP call handling.
71
+ */
72
+ const withBackgroundModes: ConfigPlugin = (config) => {
73
+ return withInfoPlist(config, (config) => {
74
+ const existingModes = config.modResults.UIBackgroundModes;
75
+ const modes = Array.isArray(existingModes)
76
+ ? (existingModes as string[])
77
+ : [];
78
+
79
+ const newModes = new Set([...modes, ...BACKGROUND_MODES]);
80
+ config.modResults.UIBackgroundModes = [...newModes];
81
+
82
+ return config;
83
+ });
84
+ };
85
+
86
+ /**
87
+ * Configures SiriKit intents for voice-activated audio/video calls.
88
+ */
89
+ const withSiriIntents: ConfigPlugin = (config) => {
90
+ return withInfoPlist(config, (config) => {
91
+ const existingIntents = config.modResults.NSUserActivityTypes;
92
+ const intents = Array.isArray(existingIntents)
93
+ ? (existingIntents as string[])
94
+ : [];
95
+
96
+ const newIntents = new Set([...intents, ...SIRI_INTENTS]);
97
+ config.modResults.NSUserActivityTypes = [...newIntents];
98
+
99
+ return config;
100
+ });
101
+ };
102
+
103
+ /**
104
+ * Configures call timeout values in Info.plist.
105
+ */
106
+ const withTimeouts: ConfigPlugin<{
107
+ incomingCallTimeout?: number;
108
+ outgoingCallTimeout?: number;
109
+ fulfillAnswerCallTimeout?: number;
110
+ }> = (
111
+ config,
112
+ { incomingCallTimeout, outgoingCallTimeout, fulfillAnswerCallTimeout },
113
+ ) => {
114
+ return withInfoPlist(config, (config) => {
115
+ config.modResults.ExpoCallKitTelecomIncomingCallTimeout =
116
+ incomingCallTimeout ?? DEFAULT_INCOMING_CALL_TIMEOUT;
117
+ config.modResults.ExpoCallKitTelecomOutgoingCallTimeout =
118
+ outgoingCallTimeout ?? DEFAULT_OUTGOING_CALL_TIMEOUT;
119
+ config.modResults.ExpoCallKitTelecomFulfillAnswerCallTimeout =
120
+ fulfillAnswerCallTimeout ?? DEFAULT_FULFILL_ANSWER_CALL_TIMEOUT;
121
+ return config;
122
+ });
123
+ };
124
+
125
+ /**
126
+ * Copies sound files into the iOS project bundle.
127
+ */
128
+ function setSoundFiles(
129
+ config: Parameters<Parameters<typeof withXcodeProject>[1]>[0],
130
+ sounds: string[],
131
+ ) {
132
+ const projectRoot = config.modRequest.projectRoot;
133
+ const projectName = config.modRequest.projectName;
134
+
135
+ if (!projectName) {
136
+ throw new Error(`${ERROR_MSG_PREFIX}Unable to find iOS project name.`);
137
+ }
138
+
139
+ const sourceRoot = resolve(projectRoot, "ios", projectName);
140
+
141
+ for (const soundPath of sounds) {
142
+ const filename = basename(soundPath);
143
+ const sourcePath = resolve(projectRoot, soundPath);
144
+ const destinationPath = resolve(sourceRoot, filename);
145
+
146
+ if (!existsSync(sourcePath)) {
147
+ throw new Error(`${ERROR_MSG_PREFIX}Sound file not found: ${sourcePath}`);
148
+ }
149
+
150
+ // Copy the file to the iOS project directory
151
+ copyFileSync(sourcePath, destinationPath);
152
+
153
+ // Add the file to the Xcode project if not already present
154
+ if (!config.modResults.hasFile(`${projectName}/${filename}`)) {
155
+ config.modResults = IOSConfig.XcodeUtils.addResourceFileToGroup({
156
+ filepath: `${projectName}/${filename}`,
157
+ groupName: projectName,
158
+ isBuildFile: true,
159
+ project: config.modResults,
160
+ });
161
+ }
162
+ }
163
+
164
+ return config;
165
+ }
166
+
167
+ /**
168
+ * Copies sound files into the iOS project bundle.
169
+ */
170
+ const withSounds: ConfigPlugin<{ sounds?: string[] }> = (
171
+ config,
172
+ { sounds },
173
+ ) => {
174
+ if (sounds && sounds.length > 0) {
175
+ config = withXcodeProject(config, (config) => {
176
+ return setSoundFiles(config, sounds);
177
+ });
178
+ }
179
+ return config;
180
+ };
181
+
182
+ /**
183
+ * Configures the default ringtone for incoming calls in Info.plist.
184
+ */
185
+ const withDefaultRingtone: ConfigPlugin<{
186
+ sounds?: string[];
187
+ defaultRingtone?: string;
188
+ }> = (config, { sounds, defaultRingtone }) => {
189
+ const soundFilenames = sounds?.map((s) => basename(s)) ?? [];
190
+
191
+ // Validate defaultRingtone if specified and not 'default'
192
+ if (defaultRingtone && defaultRingtone !== "default") {
193
+ if (soundFilenames.length === 0) {
194
+ throw new Error(
195
+ `${ERROR_MSG_PREFIX}"defaultRingtone" was specified but no ` +
196
+ `sounds were provided.`,
197
+ );
198
+ }
199
+ if (!soundFilenames.includes(defaultRingtone)) {
200
+ throw new Error(
201
+ `${ERROR_MSG_PREFIX}"defaultRingtone" must be one of the provided ` +
202
+ `sounds (${soundFilenames.join(", ")}) or "default" for ` +
203
+ `system ringtone.`,
204
+ );
205
+ }
206
+ }
207
+
208
+ return withInfoPlist(config, (config) => {
209
+ config.modResults.ExpoCallKitTelecomDefaultRingtone = defaultRingtone || "default";
210
+ return config;
211
+ });
212
+ };
213
+
214
+ /**
215
+ * Configures the default dialtone for outgoing calls in Info.plist.
216
+ */
217
+ const withDefaultDialtone: ConfigPlugin<{
218
+ sounds?: string[];
219
+ defaultDialtone?: string;
220
+ }> = (config, { sounds, defaultDialtone }) => {
221
+ if (!defaultDialtone) {
222
+ return config;
223
+ }
224
+
225
+ const soundFilenames = sounds?.map((s) => basename(s)) ?? [];
226
+
227
+ if (soundFilenames.length === 0) {
228
+ throw new Error(
229
+ `${ERROR_MSG_PREFIX}"defaultDialtone" was specified but no ` +
230
+ `sounds were provided.`,
231
+ );
232
+ }
233
+ if (!soundFilenames.includes(defaultDialtone)) {
234
+ throw new Error(
235
+ `${ERROR_MSG_PREFIX}"defaultDialtone" must be one of the provided ` +
236
+ `sounds (${soundFilenames.join(", ")}).`,
237
+ );
238
+ }
239
+
240
+ return withInfoPlist(config, (config) => {
241
+ config.modResults.ExpoCallKitTelecomDefaultDialtone = defaultDialtone;
242
+ return config;
243
+ });
244
+ };
245
+
246
+ export const withExpoCallKitTelecomIos: ConfigPlugin<ExpoCallKitTelecomPluginProps> = (
247
+ config,
248
+ {
249
+ cameraPermission,
250
+ microphonePermission,
251
+ incomingCallTimeout,
252
+ outgoingCallTimeout,
253
+ fulfillAnswerCallTimeout,
254
+ sounds,
255
+ defaultRingtoneIos,
256
+ defaultDialtone,
257
+ },
258
+ ) => {
259
+ config = withPermissions(config, { cameraPermission, microphonePermission });
260
+ config = withPushNotificationEntitlement(config);
261
+ config = withBackgroundModes(config);
262
+ config = withSiriIntents(config);
263
+ config = withTimeouts(config, {
264
+ incomingCallTimeout,
265
+ outgoingCallTimeout,
266
+ fulfillAnswerCallTimeout,
267
+ });
268
+ config = withSounds(config, { sounds });
269
+ config = withDefaultRingtone(config, {
270
+ sounds,
271
+ defaultRingtone: defaultRingtoneIos,
272
+ });
273
+ config = withDefaultDialtone(config, { sounds, defaultDialtone });
274
+
275
+ return config;
276
+ };