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,363 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import WebRTC
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
private func permissionStatusString(_ status: AVAudioSession.RecordPermission) -> String {
|
|
6
|
+
switch status {
|
|
7
|
+
case .granted:
|
|
8
|
+
return "granted"
|
|
9
|
+
case .denied:
|
|
10
|
+
return "denied"
|
|
11
|
+
case .undetermined:
|
|
12
|
+
return "undetermined"
|
|
13
|
+
@unknown default:
|
|
14
|
+
return "unknown"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
extension AVAudioSession.CategoryOptions {
|
|
19
|
+
fileprivate var activeOptionNames: [String] {
|
|
20
|
+
let mapping: [(AVAudioSession.CategoryOptions, String)] = [
|
|
21
|
+
(.mixWithOthers, "mixWithOthers"),
|
|
22
|
+
(.duckOthers, "duckOthers"),
|
|
23
|
+
(.allowBluetoothHFP, "allowBluetoothHFP"),
|
|
24
|
+
(.allowBluetoothA2DP, "allowBluetoothA2DP"),
|
|
25
|
+
(.allowAirPlay, "allowAirPlay"),
|
|
26
|
+
(.defaultToSpeaker, "defaultToSpeaker"),
|
|
27
|
+
(.interruptSpokenAudioAndMixWithOthers, "interruptSpokenAudioAndMixWithOthers"),
|
|
28
|
+
(.overrideMutedMicrophoneInterruption, "overrideMutedMicrophoneInterruption"),
|
|
29
|
+
]
|
|
30
|
+
return mapping.compactMap { contains($0.0) ? $0.1 : nil }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
extension AVAudioSession.Port {
|
|
35
|
+
/// Maps AVAudioSession.Port to consistent string identifiers for TypeScript.
|
|
36
|
+
fileprivate var identifier: String {
|
|
37
|
+
switch self {
|
|
38
|
+
// Output ports
|
|
39
|
+
case .builtInSpeaker: return "builtInSpeaker"
|
|
40
|
+
case .builtInReceiver: return "builtInReceiver"
|
|
41
|
+
case .headphones: return "headphones"
|
|
42
|
+
case .bluetoothA2DP: return "bluetoothA2DP"
|
|
43
|
+
case .bluetoothLE: return "bluetoothLE"
|
|
44
|
+
case .bluetoothHFP: return "bluetoothHFP"
|
|
45
|
+
case .airPlay: return "airPlay"
|
|
46
|
+
case .HDMI: return "hdmi"
|
|
47
|
+
case .carAudio: return "carAudio"
|
|
48
|
+
case .usbAudio: return "usbAudio"
|
|
49
|
+
case .lineOut: return "lineOut"
|
|
50
|
+
// Input ports
|
|
51
|
+
case .builtInMic: return "builtInMic"
|
|
52
|
+
case .headsetMic: return "headsetMic"
|
|
53
|
+
case .lineIn: return "lineIn"
|
|
54
|
+
// Fallback for unknown ports
|
|
55
|
+
default: return rawValue
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private struct SavedAudioSessionConfig {
|
|
61
|
+
let category: AVAudioSession.Category
|
|
62
|
+
let mode: AVAudioSession.Mode
|
|
63
|
+
let categoryOptions: AVAudioSession.CategoryOptions
|
|
64
|
+
|
|
65
|
+
/// Converts to RTCAudioSessionConfiguration for use with configureAudioSession.
|
|
66
|
+
func toRTCConfiguration() -> RTCAudioSessionConfiguration {
|
|
67
|
+
let config = RTCAudioSessionConfiguration()
|
|
68
|
+
config.category = category.rawValue
|
|
69
|
+
config.mode = mode.rawValue
|
|
70
|
+
config.categoryOptions = categoryOptions
|
|
71
|
+
return config
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/// Manages audio session configuration for CallKit integration with WebRTC.
|
|
76
|
+
///
|
|
77
|
+
/// This manager coordinates with WebRTC's RTCAudioSession to ensure proper audio
|
|
78
|
+
/// routing during VoIP calls. RTCAudioSession wraps and configures AVAudioSession
|
|
79
|
+
/// under the hood, so we only need to interact with RTCAudioSession directly.
|
|
80
|
+
///
|
|
81
|
+
/// Uses manual audio management to let CallKit control when audio is activated/deactivated.
|
|
82
|
+
///
|
|
83
|
+
/// ## Audio Session Lifecycle
|
|
84
|
+
///
|
|
85
|
+
/// 1. **Initialization**: `setRTCAudioSessionConfiguration(hasVideo:)` is called to set up
|
|
86
|
+
/// WebRTC's default audio configuration and enable manual audio management.
|
|
87
|
+
///
|
|
88
|
+
/// 2. **Call Start**: When a call is reported/started, `prepareAudioSessionForCall(hasVideo:)`
|
|
89
|
+
/// snapshots the current audio config and pre-heats the audio session for the call.
|
|
90
|
+
///
|
|
91
|
+
/// 3. **CallKit Activation**: When CallKit activates the audio session,
|
|
92
|
+
/// `onAVAudioSessionActivated()` notifies WebRTC and enables audio.
|
|
93
|
+
///
|
|
94
|
+
/// 4. **CallKit Deactivation**: When the call ends, `onAVAudioSessionDeactivated()`
|
|
95
|
+
/// disables audio and restores the pre-call audio configuration.
|
|
96
|
+
///
|
|
97
|
+
/// Note: The LiveKit SDK's AudioSession.ts can also be used to configure audio based on call state:
|
|
98
|
+
/// https://github.com/livekit/client-sdk-react-native/blob/main/src/audio/AudioSession.ts#L206
|
|
99
|
+
final class AudioManager {
|
|
100
|
+
static let shared = AudioManager()
|
|
101
|
+
|
|
102
|
+
private(set) var isActive = false
|
|
103
|
+
private var savedConfig: SavedAudioSessionConfig?
|
|
104
|
+
|
|
105
|
+
private init() {
|
|
106
|
+
setupRouteChangeObserver()
|
|
107
|
+
setRTCAudioSessionConfiguration(hasVideo: false)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private func setupRouteChangeObserver() {
|
|
111
|
+
NotificationCenter.default.addObserver(
|
|
112
|
+
self,
|
|
113
|
+
selector: #selector(handleRouteChange),
|
|
114
|
+
name: AVAudioSession.routeChangeNotification,
|
|
115
|
+
object: nil
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
@objc private func handleRouteChange(_ notification: Notification) {
|
|
120
|
+
let currentRoute = AVAudioSession.sharedInstance().currentRoute
|
|
121
|
+
|
|
122
|
+
let outputs = currentRoute.outputs.map { port in
|
|
123
|
+
[
|
|
124
|
+
"portType": port.portType.identifier,
|
|
125
|
+
"portName": port.portName,
|
|
126
|
+
"uid": port.uid,
|
|
127
|
+
]
|
|
128
|
+
}
|
|
129
|
+
let inputs = currentRoute.inputs.map { port in
|
|
130
|
+
[
|
|
131
|
+
"portType": port.portType.identifier,
|
|
132
|
+
"portName": port.portName,
|
|
133
|
+
"uid": port.uid,
|
|
134
|
+
]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
Log.audio.debug("Audio route changed to: \(outputs.first?["portType"] ?? "unknown")")
|
|
138
|
+
|
|
139
|
+
Task { @MainActor in
|
|
140
|
+
CallEventEmitter.shared.send(
|
|
141
|
+
AudioRouteChangedEvent(inputs: inputs, outputs: outputs)
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/// Sets the RTC audio session configuration
|
|
147
|
+
/// This sets configuration but does not apply it to the audio session
|
|
148
|
+
/// Enables manual audio management
|
|
149
|
+
func setRTCAudioSessionConfiguration(hasVideo: Bool) {
|
|
150
|
+
let rtcSession = RTCAudioSession.sharedInstance()
|
|
151
|
+
|
|
152
|
+
// Enable manual audio management - must be set before any audio session activation
|
|
153
|
+
rtcSession.useManualAudio = true
|
|
154
|
+
|
|
155
|
+
// Set the default WebRTC configuration
|
|
156
|
+
let config =
|
|
157
|
+
hasVideo ? getAudioSessionConfigurationForVideo() : getAudioSessionConfigurationAudioOnly()
|
|
158
|
+
RTCAudioSessionConfiguration.setWebRTC(config)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// MARK: - State
|
|
162
|
+
|
|
163
|
+
/// Returns the current audio session state.
|
|
164
|
+
/// - Returns: A dictionary containing the current audio state and configuration.
|
|
165
|
+
func getAudioSessionState() -> [String: Any] {
|
|
166
|
+
let rtcSession = RTCAudioSession.sharedInstance()
|
|
167
|
+
let avSession = AVAudioSession.sharedInstance()
|
|
168
|
+
|
|
169
|
+
// Get current route information
|
|
170
|
+
let currentRoute = avSession.currentRoute
|
|
171
|
+
let outputs = currentRoute.outputs.map { port in
|
|
172
|
+
return [
|
|
173
|
+
"portType": port.portType.identifier,
|
|
174
|
+
"portName": port.portName,
|
|
175
|
+
"uid": port.uid,
|
|
176
|
+
]
|
|
177
|
+
}
|
|
178
|
+
let inputs = currentRoute.inputs.map { port in
|
|
179
|
+
return [
|
|
180
|
+
"portType": port.portType.identifier,
|
|
181
|
+
"portName": port.portName,
|
|
182
|
+
"uid": port.uid,
|
|
183
|
+
]
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Get microphone permission status
|
|
187
|
+
let microphonePermission = permissionStatusString(avSession.recordPermission)
|
|
188
|
+
|
|
189
|
+
return [
|
|
190
|
+
"isActive": isActive,
|
|
191
|
+
"rtcSessionIsActive": rtcSession.isActive,
|
|
192
|
+
"avSessionIsActive": rtcSession.isActive,
|
|
193
|
+
"isAudioEnabled": rtcSession.isAudioEnabled,
|
|
194
|
+
"useManualAudio": rtcSession.useManualAudio,
|
|
195
|
+
"isOtherAudioPlaying": avSession.isOtherAudioPlaying,
|
|
196
|
+
"category": avSession.category.rawValue,
|
|
197
|
+
"mode": avSession.mode.rawValue,
|
|
198
|
+
"categoryOptions": avSession.categoryOptions.activeOptionNames,
|
|
199
|
+
"sampleRate": avSession.sampleRate,
|
|
200
|
+
"ioBufferDuration": avSession.ioBufferDuration,
|
|
201
|
+
"inputNumberOfChannels": avSession.inputNumberOfChannels,
|
|
202
|
+
"outputNumberOfChannels": avSession.outputNumberOfChannels,
|
|
203
|
+
"microphonePermission": microphonePermission,
|
|
204
|
+
"currentRoute": [
|
|
205
|
+
"inputs": inputs,
|
|
206
|
+
"outputs": outputs,
|
|
207
|
+
],
|
|
208
|
+
]
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// MARK: - Configuration
|
|
212
|
+
|
|
213
|
+
/// Returns the RTCAudioSessionConfiguration for audio-only calls.
|
|
214
|
+
/// Uses voiceChat mode without defaulting to speaker.
|
|
215
|
+
private func getAudioSessionConfigurationAudioOnly() -> RTCAudioSessionConfiguration {
|
|
216
|
+
let config = RTCAudioSessionConfiguration()
|
|
217
|
+
config.category = AVAudioSession.Category.playAndRecord.rawValue
|
|
218
|
+
config.categoryOptions = [.allowBluetoothHFP, .allowBluetoothA2DP, .allowAirPlay]
|
|
219
|
+
config.mode = AVAudioSession.Mode.voiceChat.rawValue
|
|
220
|
+
return config
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/// Returns the RTCAudioSessionConfiguration for video calls.
|
|
224
|
+
/// Uses videoChat mode and defaults to speaker output.
|
|
225
|
+
private func getAudioSessionConfigurationForVideo() -> RTCAudioSessionConfiguration {
|
|
226
|
+
let config = RTCAudioSessionConfiguration()
|
|
227
|
+
config.category = AVAudioSession.Category.playAndRecord.rawValue
|
|
228
|
+
config.categoryOptions = [
|
|
229
|
+
.allowBluetoothHFP, .allowBluetoothA2DP, .allowAirPlay, .defaultToSpeaker,
|
|
230
|
+
]
|
|
231
|
+
config.mode = AVAudioSession.Mode.videoChat.rawValue
|
|
232
|
+
return config
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/// Configures RTCAudioSession with the given configuration.
|
|
236
|
+
/// - Parameter config: The audio session configuration to apply.
|
|
237
|
+
private func configureAudioSession(_ config: RTCAudioSessionConfiguration) {
|
|
238
|
+
let session = RTCAudioSession.sharedInstance()
|
|
239
|
+
|
|
240
|
+
// Apply the configuration to the AVAudioSession
|
|
241
|
+
session.lockForConfiguration()
|
|
242
|
+
defer { session.unlockForConfiguration() }
|
|
243
|
+
|
|
244
|
+
do {
|
|
245
|
+
try session.setConfiguration(config)
|
|
246
|
+
Log.audio.debug(
|
|
247
|
+
"Audio session configured (category: \(config.category), mode: \(config.mode))"
|
|
248
|
+
)
|
|
249
|
+
} catch {
|
|
250
|
+
Log.audio.error("Failed to configure audio session: \(error.localizedDescription)")
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/// Prepares the audio session for an upcoming call.
|
|
255
|
+
/// Call this when reporting/starting a call to pre-heat the audio session.
|
|
256
|
+
/// - Parameter hasVideo: Whether the call includes video.
|
|
257
|
+
func prepareAudioSessionForCall(hasVideo: Bool) {
|
|
258
|
+
// Only snapshot if we don't already have a saved config (prevents overwriting)
|
|
259
|
+
if savedConfig == nil {
|
|
260
|
+
let avSession = AVAudioSession.sharedInstance()
|
|
261
|
+
savedConfig = SavedAudioSessionConfig(
|
|
262
|
+
category: avSession.category,
|
|
263
|
+
mode: avSession.mode,
|
|
264
|
+
categoryOptions: avSession.categoryOptions
|
|
265
|
+
)
|
|
266
|
+
Log.audio.debug("Saved audio session config for restoration")
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Configure the audio session for the call
|
|
270
|
+
let config =
|
|
271
|
+
hasVideo ? getAudioSessionConfigurationForVideo() : getAudioSessionConfigurationAudioOnly()
|
|
272
|
+
configureAudioSession(config)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/// Restores the audio session to its pre-call configuration.
|
|
276
|
+
/// Call this if a call fails to start after prepareAudioSession was called,
|
|
277
|
+
/// or when a call ends, to return the audio session to its original state.
|
|
278
|
+
func restoreAudioSession() {
|
|
279
|
+
guard let config = savedConfig else {
|
|
280
|
+
Log.audio.debug("No saved audio session config to restore")
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
configureAudioSession(config.toRTCConfiguration())
|
|
285
|
+
Log.audio.debug(
|
|
286
|
+
"Audio session restored to previous config (category: \(config.category.rawValue), mode: \(config.mode.rawValue))"
|
|
287
|
+
)
|
|
288
|
+
savedConfig = nil
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// MARK: - CallKit Audio Session Callbacks
|
|
292
|
+
|
|
293
|
+
/// Called when CallKit activates the audio session.
|
|
294
|
+
/// This happens after the user answers a call or starts an outgoing call.
|
|
295
|
+
/// Audio session should already be configured via prepareAudioSessionForCall() before this is called.
|
|
296
|
+
/// - Parameter calls: The current active call sessions.
|
|
297
|
+
func onAVAudioSessionActivated(calls: [CallSession]) {
|
|
298
|
+
isActive = true
|
|
299
|
+
|
|
300
|
+
let session = RTCAudioSession.sharedInstance()
|
|
301
|
+
|
|
302
|
+
// Notify WebRTC that CallKit activated the audio session
|
|
303
|
+
session.audioSessionDidActivate(AVAudioSession.sharedInstance())
|
|
304
|
+
|
|
305
|
+
// Enable the audio unit for VOIP processing (required with useManualAudio = true)
|
|
306
|
+
session.isAudioEnabled = true
|
|
307
|
+
|
|
308
|
+
Log.audio.debug("RTC audio session activated")
|
|
309
|
+
|
|
310
|
+
Task { @MainActor in
|
|
311
|
+
let callInfos = calls.map { AudioSessionCallInfo(from: $0) }
|
|
312
|
+
CallEventEmitter.shared.send(AudioSessionActivatedEvent(calls: callInfos))
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/// Called when CallKit deactivates the audio session.
|
|
317
|
+
/// This happens when the call ends.
|
|
318
|
+
/// - Parameter calls: The call sessions that were active when deactivation occurred.
|
|
319
|
+
func onAVAudioSessionDeactivated(calls: [CallSession]) {
|
|
320
|
+
isActive = false
|
|
321
|
+
|
|
322
|
+
let rtcSession = RTCAudioSession.sharedInstance()
|
|
323
|
+
|
|
324
|
+
// Disable the audio unit (required with useManualAudio = true)
|
|
325
|
+
rtcSession.isAudioEnabled = false
|
|
326
|
+
// Notify WebRTC that CallKit deactivated the audio session
|
|
327
|
+
rtcSession.audioSessionDidDeactivate(AVAudioSession.sharedInstance())
|
|
328
|
+
|
|
329
|
+
// Restore the audio session configuration from before the call
|
|
330
|
+
restoreAudioSession()
|
|
331
|
+
|
|
332
|
+
Log.audio.debug("RTC audio session deactivated")
|
|
333
|
+
|
|
334
|
+
Task { @MainActor in
|
|
335
|
+
let callInfos = calls.map { AudioSessionCallInfo(from: $0) }
|
|
336
|
+
CallEventEmitter.shared.send(AudioSessionDeactivatedEvent(calls: callInfos))
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// MARK: - Port Override
|
|
341
|
+
|
|
342
|
+
/// Sets the speaker override for the audio session.
|
|
343
|
+
/// Note that this only has an effect in voiceChat mode because voiceChat mode makes speaker the default
|
|
344
|
+
/// - Parameter enabled: Whether to route audio to the speaker.
|
|
345
|
+
func setAudioSessionPortOverride(_ enabled: Bool) {
|
|
346
|
+
let rtcSession = RTCAudioSession.sharedInstance()
|
|
347
|
+
rtcSession.lockForConfiguration()
|
|
348
|
+
defer { rtcSession.unlockForConfiguration() }
|
|
349
|
+
|
|
350
|
+
do {
|
|
351
|
+
if enabled {
|
|
352
|
+
try rtcSession.overrideOutputAudioPort(.speaker)
|
|
353
|
+
} else {
|
|
354
|
+
try rtcSession.overrideOutputAudioPort(.none)
|
|
355
|
+
}
|
|
356
|
+
Log.audio.debug("Audio port override set to \(enabled ? "speaker" : "none")")
|
|
357
|
+
} catch {
|
|
358
|
+
Log.audio.error(
|
|
359
|
+
"Failed to set audio port override to \(enabled ? "speaker" : "none"): \(error.localizedDescription)"
|
|
360
|
+
)
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
/// A queued event waiting to be sent to JS.
|
|
5
|
+
private struct QueuedEvent {
|
|
6
|
+
let body: [String: Any]
|
|
7
|
+
let timestamp: Date
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/// Manages event delivery to the Expo module, buffering events until JS is ready.
|
|
11
|
+
///
|
|
12
|
+
/// CallKit events can fire before the JS layer has mounted its listeners.
|
|
13
|
+
/// This class queues those events and flushes them once JS starts observing.
|
|
14
|
+
///
|
|
15
|
+
/// Supports per-event configuration:
|
|
16
|
+
/// - Track which events are being observed independently
|
|
17
|
+
/// - Configure queue limits per event (0 = no queueing, nil = unlimited)
|
|
18
|
+
@MainActor
|
|
19
|
+
final class CallEventEmitter {
|
|
20
|
+
static let shared = CallEventEmitter()
|
|
21
|
+
|
|
22
|
+
/// The Expo module to send events to. Set this when the module initializes.
|
|
23
|
+
weak var module: ExpoCallKitTelecomModule?
|
|
24
|
+
|
|
25
|
+
/// Events currently being observed by JS.
|
|
26
|
+
private var observingEvents: Set<String> = []
|
|
27
|
+
|
|
28
|
+
/// Per-event queues for buffering events before JS is ready.
|
|
29
|
+
private var eventQueues: [String: [QueuedEvent]] = [:]
|
|
30
|
+
|
|
31
|
+
/// Per-event queue limits. `nil` = unlimited, `0` = no queueing.
|
|
32
|
+
private var queueLimits: [String: Int] = [:]
|
|
33
|
+
|
|
34
|
+
/// Default queue limit for events without explicit configuration.
|
|
35
|
+
/// Set to `nil` for unlimited, `0` to disable queueing by default.
|
|
36
|
+
var defaultQueueLimit: Int? = 0
|
|
37
|
+
|
|
38
|
+
private init() {}
|
|
39
|
+
|
|
40
|
+
// MARK: - Configuration
|
|
41
|
+
|
|
42
|
+
/// Set the queue limit for a specific event.
|
|
43
|
+
/// - Parameters:
|
|
44
|
+
/// - eventName: The event name to configure
|
|
45
|
+
/// - limit: Maximum queued events. `nil` = unlimited, `0` = no queueing.
|
|
46
|
+
func setQueueLimit(for eventName: String, limit: Int?) {
|
|
47
|
+
if let limit = limit {
|
|
48
|
+
queueLimits[eventName] = limit
|
|
49
|
+
} else {
|
|
50
|
+
queueLimits.removeValue(forKey: eventName)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// Set the queue limit for a specific event type (type-safe).
|
|
55
|
+
/// - Parameters:
|
|
56
|
+
/// - eventType: The event type to configure
|
|
57
|
+
/// - limit: Maximum queued events. `nil` = unlimited, `0` = no queueing.
|
|
58
|
+
func setQueueLimit<E: CallEvent>(for eventType: E.Type, limit: Int?) {
|
|
59
|
+
setQueueLimit(for: E.name, limit: limit)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/// Set queue limits for multiple events at once.
|
|
63
|
+
func setQueueLimits(_ limits: [String: Int?]) {
|
|
64
|
+
for (eventName, limit) in limits {
|
|
65
|
+
setQueueLimit(for: eventName, limit: limit)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/// Disable queueing for events that should only be delivered in real-time.
|
|
70
|
+
/// Useful for events like mute/hold changes that are only relevant when observed.
|
|
71
|
+
func disableQueueing<E: CallEvent>(for eventType: E.Type) {
|
|
72
|
+
setQueueLimit(for: E.name, limit: 0)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// MARK: - Status
|
|
76
|
+
|
|
77
|
+
/// Check if a specific event is currently being observed.
|
|
78
|
+
func isObserving(eventName: String) -> Bool {
|
|
79
|
+
observingEvents.contains(eventName)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/// Check if a specific event type is currently being observed.
|
|
83
|
+
func isObserving<E: CallEvent>(_ eventType: E.Type) -> Bool {
|
|
84
|
+
isObserving(eventName: E.name)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// Get the current queue count for a specific event.
|
|
88
|
+
func queueCount(for eventName: String) -> Int {
|
|
89
|
+
eventQueues[eventName]?.count ?? 0
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/// Get the total count of all queued events.
|
|
93
|
+
var totalQueueCount: Int {
|
|
94
|
+
eventQueues.values.reduce(0) { $0 + $1.count }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// MARK: - Public API
|
|
98
|
+
|
|
99
|
+
/// Send a type-safe event to JS, or buffer it if JS isn't listening yet.
|
|
100
|
+
func send<E: CallEvent>(_ event: E) {
|
|
101
|
+
let eventName = E.name
|
|
102
|
+
let timestamp = Date()
|
|
103
|
+
|
|
104
|
+
if observingEvents.contains(eventName), let module = module {
|
|
105
|
+
Log.call.debug("Sending event to JS - name: \(eventName)")
|
|
106
|
+
let body = buildEventBody(
|
|
107
|
+
event.body,
|
|
108
|
+
flushed: false,
|
|
109
|
+
timestamp: timestamp
|
|
110
|
+
)
|
|
111
|
+
module.sendEvent(eventName, body)
|
|
112
|
+
} else {
|
|
113
|
+
queueEvent(name: eventName, body: event.body, timestamp: timestamp)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// MARK: - Expo Lifecycle
|
|
118
|
+
|
|
119
|
+
/// Called when JS mounts a listener for a specific event.
|
|
120
|
+
func startObserving(eventName: String) {
|
|
121
|
+
let queueCount = eventQueues[eventName]?.count ?? 0
|
|
122
|
+
Log.call.debug(
|
|
123
|
+
"Start observing - event: \(eventName), queuedEvents: \(queueCount)"
|
|
124
|
+
)
|
|
125
|
+
observingEvents.insert(eventName)
|
|
126
|
+
flushQueue(for: eventName)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/// Called when JS unmounts the listener for a specific event.
|
|
130
|
+
func stopObserving(eventName: String) {
|
|
131
|
+
Log.call.debug("Stop observing - event: \(eventName)")
|
|
132
|
+
observingEvents.remove(eventName)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// MARK: - Private
|
|
136
|
+
|
|
137
|
+
private func buildEventBody(
|
|
138
|
+
_ body: [String: Any],
|
|
139
|
+
flushed: Bool,
|
|
140
|
+
timestamp: Date
|
|
141
|
+
) -> [String: Any] {
|
|
142
|
+
var result = body
|
|
143
|
+
result["meta"] = [
|
|
144
|
+
"flushed": flushed,
|
|
145
|
+
"timestamp": ISO8601DateFormatter().string(from: timestamp),
|
|
146
|
+
]
|
|
147
|
+
return result
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private func queueEvent(name: String, body: [String: Any], timestamp: Date) {
|
|
151
|
+
let limit = queueLimits[name] ?? defaultQueueLimit
|
|
152
|
+
|
|
153
|
+
// If limit is 0, don't queue at all
|
|
154
|
+
if limit == 0 {
|
|
155
|
+
Log.call.debug("Dropping event (queueing disabled) - name: \(name)")
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
var queue = eventQueues[name] ?? []
|
|
160
|
+
queue.append(QueuedEvent(body: body, timestamp: timestamp))
|
|
161
|
+
|
|
162
|
+
// Enforce limit by dropping oldest events if necessary
|
|
163
|
+
if let limit = limit, queue.count > limit {
|
|
164
|
+
let dropCount = queue.count - limit
|
|
165
|
+
queue.removeFirst(dropCount)
|
|
166
|
+
Log.call.debug(
|
|
167
|
+
"Queueing event (dropped \(dropCount) old) - name: \(name), queueSize: \(queue.count)"
|
|
168
|
+
)
|
|
169
|
+
} else {
|
|
170
|
+
Log.call.debug(
|
|
171
|
+
"Queueing event (JS not listening) - name: \(name), queueSize: \(queue.count)"
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
eventQueues[name] = queue
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private func flushQueue(for eventName: String) {
|
|
179
|
+
guard let queue = eventQueues[eventName], !queue.isEmpty,
|
|
180
|
+
let module = module
|
|
181
|
+
else {
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
Log.call.debug(
|
|
186
|
+
"Flushing event queue - event: \(eventName), count: \(queue.count)"
|
|
187
|
+
)
|
|
188
|
+
for event in queue {
|
|
189
|
+
let body = buildEventBody(
|
|
190
|
+
event.body,
|
|
191
|
+
flushed: true,
|
|
192
|
+
timestamp: event.timestamp
|
|
193
|
+
)
|
|
194
|
+
module.sendEvent(eventName, body)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
eventQueues.removeValue(forKey: eventName)
|
|
198
|
+
}
|
|
199
|
+
}
|