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.
- package/LICENSE +21 -0
- package/README.md +197 -0
- package/android/build.gradle +32 -0
- package/android/src/main/AndroidManifest.xml +33 -0
- package/android/src/main/java/expo/modules/callkittelecom/ExpoCallKitTelecomModule.kt +384 -0
- package/android/src/main/java/expo/modules/callkittelecom/IncomingCallActivity.kt +275 -0
- package/android/src/main/java/expo/modules/callkittelecom/events/CallEventEmitter.kt +151 -0
- package/android/src/main/java/expo/modules/callkittelecom/events/CallEvents.kt +59 -0
- package/android/src/main/java/expo/modules/callkittelecom/managers/CallAudioManager.kt +361 -0
- package/android/src/main/java/expo/modules/callkittelecom/managers/CallManager.kt +891 -0
- package/android/src/main/java/expo/modules/callkittelecom/managers/CallNotificationManager.kt +445 -0
- package/android/src/main/java/expo/modules/callkittelecom/managers/CaptureSessionManager.kt +27 -0
- package/android/src/main/java/expo/modules/callkittelecom/managers/DialtonePlayer.kt +171 -0
- package/android/src/main/java/expo/modules/callkittelecom/managers/FulfillRequestManager.kt +150 -0
- package/android/src/main/java/expo/modules/callkittelecom/managers/VoIPPushManager.kt +54 -0
- package/android/src/main/java/expo/modules/callkittelecom/models/CallModels.kt +269 -0
- package/android/src/main/java/expo/modules/callkittelecom/services/CallNotificationReceiver.kt +54 -0
- package/android/src/main/java/expo/modules/callkittelecom/services/ExpoCallKitTelecomMessagingService.kt +161 -0
- package/android/src/main/java/expo/modules/callkittelecom/store/CallStore.kt +181 -0
- package/android/src/main/java/expo/modules/callkittelecom/utils/CallKitTelecomLog.kt +52 -0
- package/android/src/main/java/expo/modules/callkittelecom/utils/PermissionUtils.kt +28 -0
- package/android/src/main/res/drawable/expo_callkit_telecom_bg_answer.xml +9 -0
- package/android/src/main/res/drawable/expo_callkit_telecom_bg_avatar.xml +5 -0
- package/android/src/main/res/drawable/expo_callkit_telecom_bg_decline.xml +9 -0
- package/android/src/main/res/drawable/expo_callkit_telecom_ic_answer.xml +9 -0
- package/android/src/main/res/drawable/expo_callkit_telecom_ic_decline.xml +9 -0
- package/android/src/main/res/drawable/expo_callkit_telecom_ic_videocam.xml +9 -0
- package/android/src/main/res/layout/activity_incoming_call.xml +169 -0
- package/app.json +8 -0
- package/app.plugin.js +1 -0
- package/build/Calls.d.ts +577 -0
- package/build/Calls.d.ts.map +1 -0
- package/build/Calls.js +715 -0
- package/build/Calls.js.map +1 -0
- package/build/Calls.types.d.ts +203 -0
- package/build/Calls.types.d.ts.map +1 -0
- package/build/Calls.types.js +2 -0
- package/build/Calls.types.js.map +1 -0
- package/build/ExpoCallKitTelecomModule.d.ts +3 -0
- package/build/ExpoCallKitTelecomModule.d.ts.map +1 -0
- package/build/ExpoCallKitTelecomModule.js +4 -0
- package/build/ExpoCallKitTelecomModule.js.map +1 -0
- package/build/hooks/index.d.ts +2 -0
- package/build/hooks/index.d.ts.map +1 -0
- package/build/hooks/index.js +2 -0
- package/build/hooks/index.js.map +1 -0
- package/build/hooks/useVoIPPushToken.d.ts +14 -0
- package/build/hooks/useVoIPPushToken.d.ts.map +1 -0
- package/build/hooks/useVoIPPushToken.js +26 -0
- package/build/hooks/useVoIPPushToken.js.map +1 -0
- package/build/index.d.ts +4 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +4 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +10 -0
- package/ios/AppDelegateSubscriber.swift +93 -0
- package/ios/ExpoCallKitTelecom.podspec +31 -0
- package/ios/ExpoCallKitTelecomLogger.swift +55 -0
- package/ios/ExpoCallKitTelecomModule.swift +503 -0
- package/ios/Managers/AudioManager.swift +363 -0
- package/ios/Managers/CallEventEmitter.swift +199 -0
- package/ios/Managers/CallManager+CXProviderDelegate.swift +195 -0
- package/ios/Managers/CallManager.swift +714 -0
- package/ios/Managers/CaptureSessionManager.swift +54 -0
- package/ios/Managers/DialtonePlayer.swift +126 -0
- package/ios/Managers/FulfillRequestManager.swift +154 -0
- package/ios/Managers/VoIPPushManager+PKPushRegistryDelegate.swift +123 -0
- package/ios/Managers/VoIPPushManager.swift +58 -0
- package/ios/Models/CallEvents.swift +263 -0
- package/ios/Models/CallOptions.swift +15 -0
- package/ios/Models/CallParticipant.swift +37 -0
- package/ios/Models/CallSession.swift +80 -0
- package/ios/Models/IncomingCallEvent.swift +196 -0
- package/ios/Stores/CallStore.swift +149 -0
- package/package.json +56 -0
- package/plugin/build/constants.d.ts +3 -0
- package/plugin/build/constants.js +7 -0
- package/plugin/build/withExpoCallKitTelecom.d.ts +67 -0
- package/plugin/build/withExpoCallKitTelecom.js +16 -0
- package/plugin/build/withExpoCallKitTelecomAndroid.d.ts +3 -0
- package/plugin/build/withExpoCallKitTelecomAndroid.js +177 -0
- package/plugin/build/withExpoCallKitTelecomIos.d.ts +3 -0
- package/plugin/build/withExpoCallKitTelecomIos.js +195 -0
- package/plugin/src/constants.ts +4 -0
- package/plugin/src/withExpoCallKitTelecom.ts +83 -0
- package/plugin/src/withExpoCallKitTelecomAndroid.ts +293 -0
- package/plugin/src/withExpoCallKitTelecomIos.ts +276 -0
- package/src/Calls.ts +848 -0
- package/src/Calls.types.ts +275 -0
- package/src/ExpoCallKitTelecomModule.ts +4 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useVoIPPushToken.ts +34 -0
- 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,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,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,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;
|