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,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
+ }