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,149 @@
1
+ import Collections
2
+ import Foundation
3
+
4
+ /// Thread-safe store for active call sessions.
5
+ actor CallStore {
6
+ private var sessions: OrderedDictionary<UUID, CallSession> = [:]
7
+
8
+ private struct SessionObserver: Sendable {
9
+ let continuation: AsyncStream<CallSession>.Continuation
10
+ let filter: @Sendable (CallSession) -> Bool
11
+ }
12
+ private var sessionObservers: [UUID: [UUID: SessionObserver]] = [:]
13
+
14
+ /// The first session, if any.
15
+ var firstSession: CallSession? {
16
+ sessions.values.first
17
+ }
18
+
19
+ /// All sessions in insertion order.
20
+ var allSessions: [CallSession] {
21
+ Array(sessions.values)
22
+ }
23
+
24
+ /// Get a session by its system call ID.
25
+ func session(for id: UUID) -> CallSession? {
26
+ sessions[id]
27
+ }
28
+
29
+ /// Add a new session. No-op if session with same ID already exists.
30
+ func add(_ session: CallSession) {
31
+ guard sessions[session.id] == nil else { return }
32
+ sessions[session.id] = session
33
+ Task { @MainActor in
34
+ CallEventEmitter.shared.send(CallSessionAddedEvent(session: session))
35
+ }
36
+ }
37
+
38
+ /// Remove a session by its system call ID. No-op if session doesn't exist.
39
+ func remove(for id: UUID) {
40
+ guard sessions.removeValue(forKey: id) != nil else { return }
41
+ Task { @MainActor in
42
+ CallEventEmitter.shared.send(CallSessionRemovedEvent(id: id))
43
+ }
44
+ }
45
+
46
+ /// Remove all sessions.
47
+ func removeAll() {
48
+ let ids = Array(sessions.keys)
49
+ sessions.removeAll()
50
+ Task { @MainActor in
51
+ for id in ids {
52
+ CallEventEmitter.shared.send(CallSessionRemovedEvent(id: id))
53
+ }
54
+ }
55
+ }
56
+
57
+ // MARK: - Updates
58
+
59
+ /// Update a session using a closure.
60
+ /// Only sends update events if the session actually changed.
61
+ func update(for id: UUID, _ transform: (inout CallSession) -> Void) {
62
+ guard var session = sessions[id] else { return }
63
+ let previousSession = session
64
+ transform(&session)
65
+
66
+ // Skip if nothing changed
67
+ guard session != previousSession else { return }
68
+
69
+ sessions[id] = session
70
+
71
+ // Notify observers whose filter matches
72
+ sessionObservers[id]?.values.forEach { observer in
73
+ if observer.filter(session) {
74
+ observer.continuation.yield(session)
75
+ }
76
+ }
77
+
78
+ Task { @MainActor in
79
+ CallEventEmitter.shared.send(CallSessionUpdatedEvent(session: session))
80
+ }
81
+ }
82
+
83
+ /// Update the status of a session.
84
+ func updateStatus(for id: UUID, status: CallSession.Status) {
85
+ update(for: id) { session in
86
+ session.status = status
87
+ }
88
+ }
89
+
90
+ /// Update the connectedAt timestamp of a session.
91
+ func updateConnectedAt(for id: UUID, connectedAt: Date?) {
92
+ update(for: id) { session in
93
+ session.connectedAt = connectedAt
94
+ }
95
+ }
96
+
97
+ /// Update the muted state of a session.
98
+ func updateMuted(for id: UUID, isMuted: Bool) {
99
+ update(for: id) { session in
100
+ session.isMuted = isMuted
101
+ }
102
+ }
103
+
104
+ /// Update the held state of a session.
105
+ func updateHeld(for id: UUID, isOnHold: Bool) {
106
+ update(for: id) { session in
107
+ session.isOnHold = isOnHold
108
+ }
109
+ }
110
+
111
+ // MARK: - Session Observation
112
+
113
+ /// Returns an AsyncStream that emits session updates matching the filter.
114
+ /// Immediately emits the current session if it exists and matches the filter.
115
+ func sessionUpdates(
116
+ for callId: UUID,
117
+ where filter: @escaping @Sendable (CallSession) -> Bool = { _ in true }
118
+ ) -> AsyncStream<CallSession> {
119
+ AsyncStream { continuation in
120
+ let observerId = UUID()
121
+
122
+ // Emit current session if it matches
123
+ if let session = sessions[callId], filter(session) {
124
+ continuation.yield(session)
125
+ }
126
+
127
+ // Register observer
128
+ if sessionObservers[callId] == nil {
129
+ sessionObservers[callId] = [:]
130
+ }
131
+ sessionObservers[callId]?[observerId] = SessionObserver(
132
+ continuation: continuation,
133
+ filter: filter
134
+ )
135
+
136
+ continuation.onTermination = { @Sendable [weak self] _ in
137
+ guard let self else { return }
138
+ Task { await self.removeSessionObserver(callId: callId, observerId: observerId) }
139
+ }
140
+ }
141
+ }
142
+
143
+ private func removeSessionObserver(callId: UUID, observerId: UUID) {
144
+ sessionObservers[callId]?[observerId] = nil
145
+ if sessionObservers[callId]?.isEmpty == true {
146
+ sessionObservers[callId] = nil
147
+ }
148
+ }
149
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "expo-callkit-telecom",
3
+ "version": "0.1.0",
4
+ "description": "Expo module wrapping CallKit (iOS) and Jetpack Core-Telecom (Android) with API parity",
5
+ "main": "build/index.js",
6
+ "types": "build/index.d.ts",
7
+ "scripts": {
8
+ "build": "expo-module build && expo-module build plugin",
9
+ "clean": "expo-module clean",
10
+ "lint": "expo-module lint",
11
+ "test": "expo-module test",
12
+ "typecheck": "tsc --noEmit && tsc --noEmit -p plugin",
13
+ "prepare": "expo-module prepare",
14
+ "prepublishOnly": "expo-module prepublishOnly",
15
+ "expo-module": "expo-module",
16
+ "open:ios": "xed example/client/ios",
17
+ "open:android": "open -a \"Android Studio\" example/client/android"
18
+ },
19
+ "keywords": [
20
+ "react-native",
21
+ "expo",
22
+ "callkit",
23
+ "telecom",
24
+ "voip",
25
+ "calling",
26
+ "webrtc"
27
+ ],
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/mfairley/expo-callkit-telecom.git"
31
+ },
32
+ "bugs": {
33
+ "url": "https://github.com/mfairley/expo-callkit-telecom/issues"
34
+ },
35
+ "author": {
36
+ "name": "Michael Fairley",
37
+ "url": "https://github.com/mfairley"
38
+ },
39
+ "license": "MIT",
40
+ "homepage": "https://github.com/mfairley/expo-callkit-telecom#readme",
41
+ "dependencies": {},
42
+ "devDependencies": {
43
+ "@types/react": "~19.2.14",
44
+ "expo-module-scripts": "^55.0.2",
45
+ "expo": "^55.0.24",
46
+ "react": "19.2.0",
47
+ "react-native": "0.83.6"
48
+ },
49
+ "peerDependencies": {
50
+ "expo": "*",
51
+ "react": "*",
52
+ "react-native": "*",
53
+ "expo-notifications": "*",
54
+ "@livekit/react-native-webrtc": ">=144.0.0"
55
+ }
56
+ }
@@ -0,0 +1,3 @@
1
+ export declare const DEFAULT_INCOMING_CALL_TIMEOUT = 45;
2
+ export declare const DEFAULT_OUTGOING_CALL_TIMEOUT = 60;
3
+ export declare const DEFAULT_FULFILL_ANSWER_CALL_TIMEOUT = 30;
@@ -0,0 +1,7 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_FULFILL_ANSWER_CALL_TIMEOUT = exports.DEFAULT_OUTGOING_CALL_TIMEOUT = exports.DEFAULT_INCOMING_CALL_TIMEOUT = void 0;
4
+ // Default timeout values in seconds
5
+ exports.DEFAULT_INCOMING_CALL_TIMEOUT = 45;
6
+ exports.DEFAULT_OUTGOING_CALL_TIMEOUT = 60;
7
+ exports.DEFAULT_FULFILL_ANSWER_CALL_TIMEOUT = 30;
@@ -0,0 +1,67 @@
1
+ import { type ConfigPlugin } from "expo/config-plugins";
2
+ export type ExpoCallKitTelecomPluginProps = {
3
+ /**
4
+ * Custom message for microphone permission prompt. Set to false to skip.
5
+ * @platform ios
6
+ */
7
+ microphonePermission?: string | false;
8
+ /**
9
+ * Custom message for camera permission prompt. Set to false to skip.
10
+ * @platform ios
11
+ */
12
+ cameraPermission?: string | false;
13
+ /**
14
+ * Timeout in seconds for incoming calls before they are marked as unanswered.
15
+ * @default 45
16
+ * @platform ios
17
+ * @platform android
18
+ */
19
+ incomingCallTimeout?: number;
20
+ /**
21
+ * Timeout in seconds for outgoing calls to connect before they are marked as unanswered.
22
+ * @default 60
23
+ * @platform ios
24
+ * @platform android
25
+ */
26
+ outgoingCallTimeout?: number;
27
+ /**
28
+ * Timeout in seconds for waiting for the call to connect after answering.
29
+ * @default 30
30
+ * @platform ios
31
+ * @platform android
32
+ */
33
+ fulfillAnswerCallTimeout?: number;
34
+ /**
35
+ * Array of sound file paths (relative to project root) to include in the app.
36
+ * These files will be copied into the iOS bundle and Android raw resources.
37
+ * Supported formats: .caf, .aiff, .wav (max 30 seconds for CallKit ringtones).
38
+ * @platform ios
39
+ * @platform android
40
+ */
41
+ sounds?: string[];
42
+ /**
43
+ * The default ringtone for incoming calls on iOS (CallKit).
44
+ * Can be the filename (with extension) of one of the provided sounds,
45
+ * or 'default' to use the system ringtone.
46
+ * @default 'default'
47
+ * @platform ios
48
+ */
49
+ defaultRingtoneIos?: string;
50
+ /**
51
+ * The default ringtone for incoming calls on Android (notification channel).
52
+ * Can be the filename (with extension) of one of the provided sounds,
53
+ * or 'default' to use the system ringtone.
54
+ * @default 'default'
55
+ * @platform android
56
+ */
57
+ defaultRingtoneAndroid?: string;
58
+ /**
59
+ * The default dialtone to play during outgoing calls while connecting.
60
+ * Must be the filename (with extension) of one of the provided sounds.
61
+ * @platform ios
62
+ * @platform android
63
+ */
64
+ defaultDialtone?: string;
65
+ };
66
+ declare const _default: ConfigPlugin<void | ExpoCallKitTelecomPluginProps>;
67
+ export default _default;
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const config_plugins_1 = require("expo/config-plugins");
7
+ const package_json_1 = __importDefault(require("../../package.json"));
8
+ const withExpoCallKitTelecomAndroid_1 = require("./withExpoCallKitTelecomAndroid");
9
+ const withExpoCallKitTelecomIos_1 = require("./withExpoCallKitTelecomIos");
10
+ const withExpoCallKitTelecom = (config, props) => {
11
+ const opts = props ?? {};
12
+ config = (0, withExpoCallKitTelecomAndroid_1.withExpoCallKitTelecomAndroid)(config, opts);
13
+ config = (0, withExpoCallKitTelecomIos_1.withExpoCallKitTelecomIos)(config, opts);
14
+ return config;
15
+ };
16
+ exports.default = (0, config_plugins_1.createRunOncePlugin)(withExpoCallKitTelecom, package_json_1.default.name, package_json_1.default.version);
@@ -0,0 +1,3 @@
1
+ import { type ConfigPlugin } from "expo/config-plugins";
2
+ import type { ExpoCallKitTelecomPluginProps } from "./withExpoCallKitTelecom";
3
+ export declare const withExpoCallKitTelecomAndroid: ConfigPlugin<ExpoCallKitTelecomPluginProps>;
@@ -0,0 +1,177 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.withExpoCallKitTelecomAndroid = void 0;
4
+ const config_plugins_1 = require("expo/config-plugins");
5
+ const fs_1 = require("fs");
6
+ const path_1 = require("path");
7
+ const constants_1 = require("./constants");
8
+ const ERROR_MSG_PREFIX = "An error occurred while configuring Android calls. ";
9
+ // biome-ignore lint/suspicious/noExplicitAny: Expo config plugin manifest types are untyped
10
+ function setMetaDataValue(app, key, value) {
11
+ const existing = app["meta-data"]?.find(
12
+ // biome-ignore lint/suspicious/noExplicitAny: manifest meta-data items are untyped
13
+ (item) => item.$["android:name"] === key);
14
+ if (existing) {
15
+ existing.$["android:value"] = value;
16
+ return;
17
+ }
18
+ if (!app["meta-data"]) {
19
+ app["meta-data"] = [];
20
+ }
21
+ app["meta-data"].push({
22
+ $: {
23
+ "android:name": key,
24
+ "android:value": value,
25
+ },
26
+ });
27
+ }
28
+ /**
29
+ * Sanitizes a filename for use as an Android raw resource name.
30
+ *
31
+ * Android raw resource names must be lowercase, alphanumeric + underscores,
32
+ * and cannot start with a digit.
33
+ */
34
+ function toAndroidRawResourceName(filename) {
35
+ const withoutExtension = filename.replace(/\.[^.]+$/, "");
36
+ const sanitized = withoutExtension.toLowerCase().replace(/[^a-z0-9_]/g, "_");
37
+ return /^\d/.test(sanitized) ? `_${sanitized}` : sanitized;
38
+ }
39
+ /**
40
+ * Configures call timeout values in AndroidManifest metadata.
41
+ */
42
+ const withTimeouts = (config, { incomingCallTimeout, outgoingCallTimeout, fulfillAnswerCallTimeout }) => {
43
+ return (0, config_plugins_1.withAndroidManifest)(config, (config) => {
44
+ const app = config_plugins_1.AndroidConfig.Manifest.getMainApplicationOrThrow(config.modResults);
45
+ setMetaDataValue(app, "ExpoCallKitTelecomIncomingCallTimeout", String(incomingCallTimeout ?? constants_1.DEFAULT_INCOMING_CALL_TIMEOUT));
46
+ setMetaDataValue(app, "ExpoCallKitTelecomOutgoingCallTimeout", String(outgoingCallTimeout ?? constants_1.DEFAULT_OUTGOING_CALL_TIMEOUT));
47
+ setMetaDataValue(app, "ExpoCallKitTelecomFulfillAnswerCallTimeout", String(fulfillAnswerCallTimeout ?? constants_1.DEFAULT_FULFILL_ANSWER_CALL_TIMEOUT));
48
+ return config;
49
+ });
50
+ };
51
+ /**
52
+ * Copies sound files into the Android raw resources directory.
53
+ */
54
+ const withSounds = (config, { sounds }) => {
55
+ if (!sounds || sounds.length === 0) {
56
+ return config;
57
+ }
58
+ return (0, config_plugins_1.withDangerousMod)(config, [
59
+ "android",
60
+ (config) => {
61
+ const projectRoot = config.modRequest.projectRoot;
62
+ const rawDir = (0, path_1.resolve)(projectRoot, "android", "app", "src", "main", "res", "raw");
63
+ (0, fs_1.mkdirSync)(rawDir, { recursive: true });
64
+ for (const soundPath of sounds) {
65
+ const filename = (0, path_1.basename)(soundPath);
66
+ const sourcePath = (0, path_1.resolve)(projectRoot, soundPath);
67
+ if (!(0, fs_1.existsSync)(sourcePath)) {
68
+ throw new Error(`${ERROR_MSG_PREFIX}Sound file not found: ${sourcePath}`);
69
+ }
70
+ const resourceName = toAndroidRawResourceName(filename);
71
+ const extension = filename.includes(".")
72
+ ? filename.substring(filename.lastIndexOf("."))
73
+ : "";
74
+ const destinationPath = (0, path_1.resolve)(rawDir, `${resourceName}${extension}`);
75
+ (0, fs_1.copyFileSync)(sourcePath, destinationPath);
76
+ }
77
+ return config;
78
+ },
79
+ ]);
80
+ };
81
+ /**
82
+ * Configures the default dialtone for outgoing calls in AndroidManifest metadata.
83
+ */
84
+ const withDefaultDialtone = (config, { sounds, defaultDialtone }) => {
85
+ if (!defaultDialtone) {
86
+ return config;
87
+ }
88
+ const soundFilenames = sounds?.map((s) => (0, path_1.basename)(s)) ?? [];
89
+ if (soundFilenames.length === 0) {
90
+ throw new Error(`${ERROR_MSG_PREFIX}"defaultDialtone" was specified but no ` +
91
+ `sounds were provided.`);
92
+ }
93
+ if (!soundFilenames.includes(defaultDialtone)) {
94
+ throw new Error(`${ERROR_MSG_PREFIX}"defaultDialtone" must be one of the provided ` +
95
+ `sounds (${soundFilenames.join(", ")}).`);
96
+ }
97
+ return (0, config_plugins_1.withAndroidManifest)(config, (config) => {
98
+ const app = config_plugins_1.AndroidConfig.Manifest.getMainApplicationOrThrow(config.modResults);
99
+ setMetaDataValue(app, "ExpoCallKitTelecomDefaultDialtone", toAndroidRawResourceName(defaultDialtone));
100
+ return config;
101
+ });
102
+ };
103
+ /**
104
+ * Configures the default ringtone for incoming calls in AndroidManifest metadata.
105
+ *
106
+ * Mirrors the iOS `withDefaultRingtone` plugin. The Kotlin side reads
107
+ * `ExpoCallKitTelecomDefaultRingtone` from manifest metadata and sets it as the
108
+ * notification channel sound.
109
+ */
110
+ const withDefaultRingtone = (config, { sounds, defaultRingtone }) => {
111
+ if (!defaultRingtone || defaultRingtone === "default") {
112
+ return config;
113
+ }
114
+ const soundFilenames = sounds?.map((s) => (0, path_1.basename)(s)) ?? [];
115
+ if (soundFilenames.length === 0) {
116
+ throw new Error(`${ERROR_MSG_PREFIX}"defaultRingtone" was specified but no ` +
117
+ `sounds were provided.`);
118
+ }
119
+ if (!soundFilenames.includes(defaultRingtone)) {
120
+ throw new Error(`${ERROR_MSG_PREFIX}"defaultRingtone" must be one of the provided ` +
121
+ `sounds (${soundFilenames.join(", ")}) or "default" for ` +
122
+ `system ringtone.`);
123
+ }
124
+ return (0, config_plugins_1.withAndroidManifest)(config, (config) => {
125
+ const app = config_plugins_1.AndroidConfig.Manifest.getMainApplicationOrThrow(config.modResults);
126
+ setMetaDataValue(app, "ExpoCallKitTelecomDefaultRingtone", toAndroidRawResourceName(defaultRingtone));
127
+ return config;
128
+ });
129
+ };
130
+ /**
131
+ * Removes expo-notifications' ExpoFirebaseMessagingService from the manifest.
132
+ *
133
+ * Our ExpoCallKitTelecomMessagingService extends it and takes over as the sole
134
+ * MESSAGING_EVENT handler, delegating non-call messages via super.
135
+ * Having both services registered would cause undefined delivery behaviour.
136
+ *
137
+ * The service is declared in expo-notifications' library AndroidManifest.xml,
138
+ * so we must use `tools:node="remove"` to tell the manifest merger to strip it.
139
+ */
140
+ const withFirebaseMessagingService = (config) => {
141
+ return (0, config_plugins_1.withAndroidManifest)(config, (config) => {
142
+ const manifest = config.modResults.manifest;
143
+ // Ensure the tools namespace is declared on the root <manifest> element.
144
+ if (!manifest.$["xmlns:tools"]) {
145
+ manifest.$["xmlns:tools"] = "http://schemas.android.com/tools";
146
+ }
147
+ const app = config_plugins_1.AndroidConfig.Manifest.getMainApplicationOrThrow(config.modResults);
148
+ if (!app.service) {
149
+ app.service = [];
150
+ }
151
+ const notificationsService = "expo.modules.notifications.service.ExpoFirebaseMessagingService";
152
+ // Remove any existing entry first (idempotent across repeated prebuilds).
153
+ app.service = app.service.filter((service) => service.$?.["android:name"] !== notificationsService);
154
+ // Add a tools:node="remove" marker so the manifest merger strips the
155
+ // library-declared service during the Gradle build.
156
+ const removeEntry = {
157
+ $: {
158
+ "android:name": notificationsService,
159
+ "tools:node": "remove",
160
+ },
161
+ };
162
+ app.service.push(removeEntry);
163
+ return config;
164
+ });
165
+ };
166
+ const withExpoCallKitTelecomAndroid = (config, props) => {
167
+ config = withTimeouts(config, props);
168
+ config = withSounds(config, props);
169
+ config = withDefaultRingtone(config, {
170
+ sounds: props.sounds,
171
+ defaultRingtone: props.defaultRingtoneAndroid,
172
+ });
173
+ config = withDefaultDialtone(config, props);
174
+ config = withFirebaseMessagingService(config, props);
175
+ return config;
176
+ };
177
+ exports.withExpoCallKitTelecomAndroid = withExpoCallKitTelecomAndroid;
@@ -0,0 +1,3 @@
1
+ import { type ConfigPlugin } from "expo/config-plugins";
2
+ import type { ExpoCallKitTelecomPluginProps } from "./withExpoCallKitTelecom";
3
+ export declare const withExpoCallKitTelecomIos: ConfigPlugin<ExpoCallKitTelecomPluginProps>;
@@ -0,0 +1,195 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.withExpoCallKitTelecomIos = void 0;
4
+ const config_plugins_1 = require("expo/config-plugins");
5
+ const fs_1 = require("fs");
6
+ const path_1 = require("path");
7
+ const constants_1 = require("./constants");
8
+ const ERROR_MSG_PREFIX = "An error occurred while configuring iOS calls. ";
9
+ // Default permission messages
10
+ const CAMERA_USAGE = "Allow $(PRODUCT_NAME) to access your camera";
11
+ const MICROPHONE_USAGE = "Allow $(PRODUCT_NAME) to access your microphone";
12
+ // Required background modes for CallKit and PushKit:
13
+ // - voip: Receive VoIP push notifications to wake the app for incoming calls
14
+ // - audio: Continue audio playback/recording during calls when app is backgrounded
15
+ const BACKGROUND_MODES = ["voip", "audio"];
16
+ // SiriKit intents for voice-activated calls
17
+ // INStartCallIntent: Unified intent for iOS 13+ (recommended)
18
+ // INStartAudioCallIntent/INStartVideoCallIntent: Deprecated in iOS 13, but still
19
+ // sent by the system in some cases (e.g., redialing from call history)
20
+ const SIRI_INTENTS = [
21
+ "INStartCallIntent",
22
+ "INStartAudioCallIntent",
23
+ "INStartVideoCallIntent",
24
+ ];
25
+ /**
26
+ * Configures camera and microphone permissions for VoIP and video calls.
27
+ */
28
+ const withPermissions = (config, { cameraPermission, microphonePermission }) => {
29
+ return config_plugins_1.IOSConfig.Permissions.createPermissionsPlugin({
30
+ NSCameraUsageDescription: CAMERA_USAGE,
31
+ NSMicrophoneUsageDescription: MICROPHONE_USAGE,
32
+ })(config, {
33
+ NSCameraUsageDescription: cameraPermission,
34
+ NSMicrophoneUsageDescription: microphonePermission,
35
+ });
36
+ };
37
+ /**
38
+ * Configures push notification entitlement for PushKit VoIP notifications.
39
+ */
40
+ const withPushNotificationEntitlement = (config) => {
41
+ return (0, config_plugins_1.withEntitlementsPlist)(config, (config) => {
42
+ const key = "aps-environment";
43
+ // Only set if not already configured; production builds use provisioning profile value
44
+ if (!config.modResults[key]) {
45
+ config.modResults[key] = "development";
46
+ }
47
+ return config;
48
+ });
49
+ };
50
+ /**
51
+ * Configures UIBackgroundModes for VoIP call handling.
52
+ */
53
+ const withBackgroundModes = (config) => {
54
+ return (0, config_plugins_1.withInfoPlist)(config, (config) => {
55
+ const existingModes = config.modResults.UIBackgroundModes;
56
+ const modes = Array.isArray(existingModes)
57
+ ? existingModes
58
+ : [];
59
+ const newModes = new Set([...modes, ...BACKGROUND_MODES]);
60
+ config.modResults.UIBackgroundModes = [...newModes];
61
+ return config;
62
+ });
63
+ };
64
+ /**
65
+ * Configures SiriKit intents for voice-activated audio/video calls.
66
+ */
67
+ const withSiriIntents = (config) => {
68
+ return (0, config_plugins_1.withInfoPlist)(config, (config) => {
69
+ const existingIntents = config.modResults.NSUserActivityTypes;
70
+ const intents = Array.isArray(existingIntents)
71
+ ? existingIntents
72
+ : [];
73
+ const newIntents = new Set([...intents, ...SIRI_INTENTS]);
74
+ config.modResults.NSUserActivityTypes = [...newIntents];
75
+ return config;
76
+ });
77
+ };
78
+ /**
79
+ * Configures call timeout values in Info.plist.
80
+ */
81
+ const withTimeouts = (config, { incomingCallTimeout, outgoingCallTimeout, fulfillAnswerCallTimeout }) => {
82
+ return (0, config_plugins_1.withInfoPlist)(config, (config) => {
83
+ config.modResults.ExpoCallKitTelecomIncomingCallTimeout =
84
+ incomingCallTimeout ?? constants_1.DEFAULT_INCOMING_CALL_TIMEOUT;
85
+ config.modResults.ExpoCallKitTelecomOutgoingCallTimeout =
86
+ outgoingCallTimeout ?? constants_1.DEFAULT_OUTGOING_CALL_TIMEOUT;
87
+ config.modResults.ExpoCallKitTelecomFulfillAnswerCallTimeout =
88
+ fulfillAnswerCallTimeout ?? constants_1.DEFAULT_FULFILL_ANSWER_CALL_TIMEOUT;
89
+ return config;
90
+ });
91
+ };
92
+ /**
93
+ * Copies sound files into the iOS project bundle.
94
+ */
95
+ function setSoundFiles(config, sounds) {
96
+ const projectRoot = config.modRequest.projectRoot;
97
+ const projectName = config.modRequest.projectName;
98
+ if (!projectName) {
99
+ throw new Error(`${ERROR_MSG_PREFIX}Unable to find iOS project name.`);
100
+ }
101
+ const sourceRoot = (0, path_1.resolve)(projectRoot, "ios", projectName);
102
+ for (const soundPath of sounds) {
103
+ const filename = (0, path_1.basename)(soundPath);
104
+ const sourcePath = (0, path_1.resolve)(projectRoot, soundPath);
105
+ const destinationPath = (0, path_1.resolve)(sourceRoot, filename);
106
+ if (!(0, fs_1.existsSync)(sourcePath)) {
107
+ throw new Error(`${ERROR_MSG_PREFIX}Sound file not found: ${sourcePath}`);
108
+ }
109
+ // Copy the file to the iOS project directory
110
+ (0, fs_1.copyFileSync)(sourcePath, destinationPath);
111
+ // Add the file to the Xcode project if not already present
112
+ if (!config.modResults.hasFile(`${projectName}/${filename}`)) {
113
+ config.modResults = config_plugins_1.IOSConfig.XcodeUtils.addResourceFileToGroup({
114
+ filepath: `${projectName}/${filename}`,
115
+ groupName: projectName,
116
+ isBuildFile: true,
117
+ project: config.modResults,
118
+ });
119
+ }
120
+ }
121
+ return config;
122
+ }
123
+ /**
124
+ * Copies sound files into the iOS project bundle.
125
+ */
126
+ const withSounds = (config, { sounds }) => {
127
+ if (sounds && sounds.length > 0) {
128
+ config = (0, config_plugins_1.withXcodeProject)(config, (config) => {
129
+ return setSoundFiles(config, sounds);
130
+ });
131
+ }
132
+ return config;
133
+ };
134
+ /**
135
+ * Configures the default ringtone for incoming calls in Info.plist.
136
+ */
137
+ const withDefaultRingtone = (config, { sounds, defaultRingtone }) => {
138
+ const soundFilenames = sounds?.map((s) => (0, path_1.basename)(s)) ?? [];
139
+ // Validate defaultRingtone if specified and not 'default'
140
+ if (defaultRingtone && defaultRingtone !== "default") {
141
+ if (soundFilenames.length === 0) {
142
+ throw new Error(`${ERROR_MSG_PREFIX}"defaultRingtone" was specified but no ` +
143
+ `sounds were provided.`);
144
+ }
145
+ if (!soundFilenames.includes(defaultRingtone)) {
146
+ throw new Error(`${ERROR_MSG_PREFIX}"defaultRingtone" must be one of the provided ` +
147
+ `sounds (${soundFilenames.join(", ")}) or "default" for ` +
148
+ `system ringtone.`);
149
+ }
150
+ }
151
+ return (0, config_plugins_1.withInfoPlist)(config, (config) => {
152
+ config.modResults.ExpoCallKitTelecomDefaultRingtone = defaultRingtone || "default";
153
+ return config;
154
+ });
155
+ };
156
+ /**
157
+ * Configures the default dialtone for outgoing calls in Info.plist.
158
+ */
159
+ const withDefaultDialtone = (config, { sounds, defaultDialtone }) => {
160
+ if (!defaultDialtone) {
161
+ return config;
162
+ }
163
+ const soundFilenames = sounds?.map((s) => (0, path_1.basename)(s)) ?? [];
164
+ if (soundFilenames.length === 0) {
165
+ throw new Error(`${ERROR_MSG_PREFIX}"defaultDialtone" was specified but no ` +
166
+ `sounds were provided.`);
167
+ }
168
+ if (!soundFilenames.includes(defaultDialtone)) {
169
+ throw new Error(`${ERROR_MSG_PREFIX}"defaultDialtone" must be one of the provided ` +
170
+ `sounds (${soundFilenames.join(", ")}).`);
171
+ }
172
+ return (0, config_plugins_1.withInfoPlist)(config, (config) => {
173
+ config.modResults.ExpoCallKitTelecomDefaultDialtone = defaultDialtone;
174
+ return config;
175
+ });
176
+ };
177
+ const withExpoCallKitTelecomIos = (config, { cameraPermission, microphonePermission, incomingCallTimeout, outgoingCallTimeout, fulfillAnswerCallTimeout, sounds, defaultRingtoneIos, defaultDialtone, }) => {
178
+ config = withPermissions(config, { cameraPermission, microphonePermission });
179
+ config = withPushNotificationEntitlement(config);
180
+ config = withBackgroundModes(config);
181
+ config = withSiriIntents(config);
182
+ config = withTimeouts(config, {
183
+ incomingCallTimeout,
184
+ outgoingCallTimeout,
185
+ fulfillAnswerCallTimeout,
186
+ });
187
+ config = withSounds(config, { sounds });
188
+ config = withDefaultRingtone(config, {
189
+ sounds,
190
+ defaultRingtone: defaultRingtoneIos,
191
+ });
192
+ config = withDefaultDialtone(config, { sounds, defaultDialtone });
193
+ return config;
194
+ };
195
+ exports.withExpoCallKitTelecomIos = withExpoCallKitTelecomIos;