@qusaieilouti99/call-manager 0.1.129 → 0.1.131

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.
@@ -18,10 +18,21 @@ class AudioManager {
18
18
  private let session = AVAudioSession.sharedInstance()
19
19
  private var observers: [NSObjectProtocol] = []
20
20
 
21
+ // WebRTC compatibility flags
22
+ private var isWebRTCActive = false
23
+ private var isCallKitActive = false
24
+ private var pendingActivation = false
25
+
26
+ // Audio state management
27
+ private var currentCategory: AVAudioSession.Category = .playback
28
+ private var currentMode: AVAudioSession.Mode = .default
29
+ private var currentOptions: AVAudioSession.CategoryOptions = []
30
+
21
31
  init(delegate: AudioManagerDelegate) {
22
32
  self.delegate = delegate
23
33
  logger.info("AudioManager init")
24
34
  setupNotifications()
35
+ setupInitialAudioSession()
25
36
  }
26
37
 
27
38
  deinit {
@@ -44,83 +55,245 @@ class AudioManager {
44
55
  queue: nil
45
56
  ) { [weak self] n in self?.handleInterruption(n) }
46
57
  )
58
+ observers.append(
59
+ nc.addObserver(
60
+ forName: AVAudioSession.mediaServicesWereResetNotification,
61
+ object: nil,
62
+ queue: nil
63
+ ) { [weak self] _ in self?.handleMediaServicesReset() }
64
+ )
47
65
  logger.info("AudioManager notifications set")
48
66
  }
49
67
 
68
+ private func setupInitialAudioSession() {
69
+ logger.info("Setting up initial audio session")
70
+ do {
71
+ // Start with playback category for non-call state
72
+ try session.setCategory(.playback,
73
+ mode: .default,
74
+ options: [.mixWithOthers])
75
+ currentCategory = .playback
76
+ currentMode = .default
77
+ currentOptions = [.mixWithOthers]
78
+ logger.info("Initial audio session configured")
79
+ } catch {
80
+ logger.error("Failed to setup initial audio session: \(error.localizedDescription)")
81
+ }
82
+ }
83
+
84
+ // MARK: - WebRTC Compatible Audio Session Management
85
+
50
86
  func configureAudioSession(forCallType isVideo: Bool,
51
- isIncoming: Bool) {
52
- logger.info("configureAudioSession: video=\(isVideo), incoming=\(isIncoming)")
53
- var opts: AVAudioSession.CategoryOptions = [.allowBluetooth, .allowBluetoothA2DP]
54
- if isVideo || isIncoming { opts.insert(.defaultToSpeaker) }
87
+ isIncoming: Bool,
88
+ isWebRTCCall: Bool = true) {
89
+ logger.info("configureAudioSession: video=\(isVideo), incoming=\(isIncoming), webrtc=\(isWebRTCCall)")
90
+
91
+ isWebRTCActive = isWebRTCCall
92
+
93
+ // Define optimal configuration for VoIP calls
94
+ let category: AVAudioSession.Category = .playAndRecord
95
+ let mode: AVAudioSession.Mode = .voiceChat
96
+ var options: AVAudioSession.CategoryOptions = [
97
+ .allowBluetooth,
98
+ .allowBluetoothA2DP,
99
+ .mixWithOthers,
100
+ .duckOthers
101
+ ]
102
+
103
+ // Video calls should default to speaker
104
+ if isVideo {
105
+ options.insert(.defaultToSpeaker)
106
+ }
107
+
108
+ // Store configuration for later use
109
+ currentCategory = category
110
+ currentMode = mode
111
+ currentOptions = options
112
+
113
+ // Don't activate immediately if we're waiting for CallKit
114
+ if !isCallKitActive {
115
+ pendingActivation = true
116
+ logger.info("Audio session configured but not activated - waiting for CallKit")
117
+ return
118
+ }
119
+
120
+ applyAudioConfiguration()
121
+ }
122
+
123
+ private func applyAudioConfiguration() {
124
+ logger.info("Applying audio configuration: \(currentCategory), \(currentMode)")
125
+
55
126
  do {
56
- try session.setCategory(.playAndRecord,
57
- mode: .voiceChat,
58
- options: opts)
59
- logger.info("audioSession category set")
127
+ // First, ensure we're not active to avoid conflicts
128
+ if session.isOtherAudioPlaying {
129
+ logger.info("Other audio is playing, will configure without activation")
130
+ }
131
+
132
+ try session.setCategory(currentCategory,
133
+ mode: currentMode,
134
+ options: currentOptions)
135
+ logger.info("Audio session category applied successfully")
136
+
60
137
  } catch {
61
- logger.error("setCategory failed: \(error.localizedDescription)")
138
+ logger.error("Failed to apply audio configuration: \(error.localizedDescription)")
62
139
  }
63
140
  }
64
141
 
142
+ // MARK: - CallKit Integration Points
143
+
65
144
  func activateAudioSession() {
66
- logger.info("activateAudioSession")
145
+ logger.info("activateAudioSession - CallKit activation")
146
+ isCallKitActive = true
147
+
148
+ // Apply pending configuration if we have one
149
+ if pendingActivation {
150
+ applyAudioConfiguration()
151
+ pendingActivation = false
152
+ }
153
+
67
154
  do {
68
155
  try session.setActive(true)
156
+ logger.info("Audio session activated successfully")
69
157
  delegate?.audioManagerDidActivateAudioSession(self)
70
158
  } catch {
71
- logger.error("activate failed: \(error.localizedDescription)")
159
+ logger.error("Failed to activate audio session: \(error.localizedDescription)")
72
160
  }
73
161
  }
74
162
 
75
163
  func deactivateAudioSession() {
76
- logger.info("deactivateAudioSession")
164
+ logger.info("deactivateAudioSession - CallKit deactivation")
165
+ isCallKitActive = false
166
+ isWebRTCActive = false
167
+ pendingActivation = false
168
+
77
169
  do {
78
170
  try session.setActive(false, options: .notifyOthersOnDeactivation)
171
+ logger.info("Audio session deactivated successfully")
172
+
173
+ // Reset to default configuration for non-call state
174
+ try session.setCategory(.playback,
175
+ mode: .default,
176
+ options: [.mixWithOthers])
177
+ currentCategory = .playback
178
+ currentMode = .default
179
+ currentOptions = [.mixWithOthers]
180
+ logger.info("Audio session reset to default configuration")
181
+
79
182
  delegate?.audioManagerDidDeactivateAudioSession(self)
80
183
  } catch {
81
- logger.error("deactivate failed: \(error.localizedDescription)")
184
+ logger.error("Failed to deactivate audio session: \(error.localizedDescription)")
82
185
  }
83
186
  }
84
187
 
188
+ // MARK: - Hold/Unhold Handling (Critical for WebRTC)
189
+
190
+ func handleCallUnhold(forCallType isVideo: Bool) {
191
+ logger.info("handleCallUnhold: video=\(isVideo)")
192
+
193
+ // For WebRTC calls, we need to be very careful about audio session management
194
+ if isWebRTCActive {
195
+ logger.info("Handling WebRTC call unhold")
196
+
197
+ // Briefly deactivate and reactivate to ensure clean state
198
+ do {
199
+ try session.setActive(false, options: [])
200
+ logger.info("Audio session briefly deactivated for unhold")
201
+
202
+ // Small delay to allow system to process
203
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
204
+ self?.reconfigureForUnhold(isVideo: isVideo)
205
+ }
206
+ } catch {
207
+ logger.error("Failed to deactivate for unhold: \(error.localizedDescription)")
208
+ // Fallback to direct reconfiguration
209
+ reconfigureForUnhold(isVideo: isVideo)
210
+ }
211
+ } else {
212
+ // Non-WebRTC calls can be handled normally
213
+ reconfigureForUnhold(isVideo: isVideo)
214
+ }
215
+ }
216
+
217
+ private func reconfigureForUnhold(isVideo: Bool) {
218
+ logger.info("Reconfiguring audio for unhold: video=\(isVideo)")
219
+
220
+ // Reconfigure with fresh settings
221
+ let category: AVAudioSession.Category = .playAndRecord
222
+ let mode: AVAudioSession.Mode = .voiceChat
223
+ var options: AVAudioSession.CategoryOptions = [
224
+ .allowBluetooth,
225
+ .allowBluetoothA2DP,
226
+ .mixWithOthers,
227
+ .duckOthers
228
+ ]
229
+
230
+ if isVideo {
231
+ options.insert(.defaultToSpeaker)
232
+ }
233
+
234
+ do {
235
+ try session.setCategory(category, mode: mode, options: options)
236
+ try session.setActive(true)
237
+
238
+ currentCategory = category
239
+ currentMode = mode
240
+ currentOptions = options
241
+
242
+ logger.info("Audio session reconfigured and activated for unhold")
243
+ } catch {
244
+ logger.error("Failed to reconfigure audio for unhold: \(error.localizedDescription)")
245
+ }
246
+ }
247
+
248
+ // MARK: - Audio Device Management
249
+
85
250
  func getAudioDevices() -> AudioRoutesInfo {
86
251
  let current = session.currentRoute
87
252
  var devices = ["Earpiece", "Speaker"]
253
+
88
254
  if let inputs = session.availableInputs {
89
- for i in inputs {
90
- switch i.portType {
255
+ for input in inputs {
256
+ switch input.portType {
91
257
  case .bluetoothHFP, .bluetoothA2DP, .bluetoothLE:
92
- if !devices.contains("Bluetooth") { devices.append("Bluetooth") }
258
+ if !devices.contains("Bluetooth") {
259
+ devices.append("Bluetooth")
260
+ }
93
261
  case .headphones, .headsetMic:
94
- if !devices.contains("Headset") { devices.append("Headset") }
95
- default: break
262
+ if !devices.contains("Headset") {
263
+ devices.append("Headset")
264
+ }
265
+ default:
266
+ break
96
267
  }
97
268
  }
98
269
  }
99
- let route = determineCurrentRoute()
100
270
 
101
- // Convert strings to StringHolder objects
271
+ let route = determineCurrentRoute()
102
272
  let deviceHolders = devices.map { StringHolder(value: $0) }
103
- let info = AudioRoutesInfo(devices: deviceHolders, currentRoute: route)
104
- return info
273
+ return AudioRoutesInfo(devices: deviceHolders, currentRoute: route)
105
274
  }
106
275
 
107
276
  func setAudioRoute(_ route: String) {
108
277
  logger.info("setAudioRoute: \(route)")
278
+
109
279
  do {
110
280
  switch route {
111
281
  case "Speaker":
112
282
  try session.overrideOutputAudioPort(.speaker)
283
+ case "Earpiece", "Headset", "Bluetooth":
284
+ try session.overrideOutputAudioPort(.none)
113
285
  default:
114
286
  try session.overrideOutputAudioPort(.none)
115
287
  }
288
+ logger.info("Audio route set to: \(route)")
116
289
  } catch {
117
- logger.error("overrideOutputAudioPort failed: \(error.localizedDescription)")
290
+ logger.error("Failed to set audio route to \(route): \(error.localizedDescription)")
118
291
  }
119
292
  }
120
293
 
121
294
  private func determineCurrentRoute() -> String {
122
- for o in session.currentRoute.outputs {
123
- switch o.portType {
295
+ for output in session.currentRoute.outputs {
296
+ switch output.portType {
124
297
  case .bluetoothHFP, .bluetoothA2DP, .bluetoothLE:
125
298
  return "Bluetooth"
126
299
  case .builtInSpeaker:
@@ -129,18 +302,25 @@ class AudioManager {
129
302
  return "Earpiece"
130
303
  case .headphones, .headsetMic:
131
304
  return "Headset"
132
- default: continue
305
+ default:
306
+ continue
133
307
  }
134
308
  }
135
309
  return "Earpiece"
136
310
  }
137
311
 
138
- private func handleRouteChange(_ n: Notification) {
139
- logger.info("routeChange notification")
140
- guard let rv = n.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt,
141
- let reason = AVAudioSession.RouteChangeReason(rawValue: rv)
142
- else { return }
312
+ // MARK: - Notification Handlers
313
+
314
+ private func handleRouteChange(_ notification: Notification) {
315
+ logger.info("Audio route change notification")
316
+ guard let reasonValue = notification.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt,
317
+ let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue)
318
+ else {
319
+ return
320
+ }
321
+
143
322
  let info = getAudioDevices()
323
+
144
324
  switch reason {
145
325
  case .newDeviceAvailable, .oldDeviceUnavailable:
146
326
  delegate?.audioManager(self, didChangeDevices: info)
@@ -149,23 +329,37 @@ class AudioManager {
149
329
  }
150
330
  }
151
331
 
152
- private func handleInterruption(_ n: Notification) {
153
- logger.info("interruption notification")
154
- guard let tv = n.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
155
- let type = AVAudioSession.InterruptionType(rawValue: tv)
156
- else { return }
332
+ private func handleInterruption(_ notification: Notification) {
333
+ logger.info("Audio interruption notification")
334
+ guard let typeValue = notification.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
335
+ let type = AVAudioSession.InterruptionType(rawValue: typeValue)
336
+ else {
337
+ return
338
+ }
339
+
157
340
  switch type {
158
341
  case .began:
159
- logger.info("interruption began")
342
+ logger.info("Audio interruption began")
160
343
  case .ended:
161
- logger.info("interruption ended")
162
- if let ov = n.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt {
163
- let opts = AVAudioSession.InterruptionOptions(rawValue: ov)
164
- if opts.contains(.shouldResume) {
344
+ logger.info("Audio interruption ended")
345
+ if let optionsValue = notification.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt {
346
+ let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
347
+ if options.contains(.shouldResume) && isCallKitActive {
348
+ logger.info("Resuming audio session after interruption")
165
349
  activateAudioSession()
166
350
  }
167
351
  }
168
- @unknown default: break
352
+ @unknown default:
353
+ break
354
+ }
355
+ }
356
+
357
+ private func handleMediaServicesReset() {
358
+ logger.warning("Media services were reset - reconfiguring audio")
359
+ if isCallKitActive {
360
+ // Reconfigure the audio session
361
+ applyAudioConfiguration()
362
+ activateAudioSession()
169
363
  }
170
364
  }
171
365
  }
@@ -169,63 +169,67 @@ class CallEngine {
169
169
 
170
170
  // MARK: Direct Active (Join Ongoing Call)
171
171
 
172
- func startCall(callId: String,
173
- callType: String,
174
- targetName: String,
175
- metadata: String? = nil)
176
- {
177
- logger.info("startCall (join ongoing): \(callId)")
178
- if let m = metadata {
179
- callMetadata[callId] = m
180
- logger.info("metadata cached for \(callId)")
181
- }
182
-
183
- guard activeCalls[callId] == nil else {
184
- logger.warning("call \(callId) already exists")
185
- return
186
- }
187
-
188
- guard validateOutgoingCallRequest() else {
189
- logger.warning("rejecting join call \(callId) - conflicts")
190
- emitEvent(.callRejected, data: ["callId": callId,
191
- "reason": "Conflict with existing call"])
192
- return
193
- }
194
-
195
- if canMakeMultipleCalls {
196
- activeCalls.values
197
- .filter { $0.state == .active }
198
- .forEach { call in
199
- logger.info("holding existing \(call.callId)")
200
- callKitManager.setCallOnHold(callId: call.callId, onHold: true)
201
- }
202
- }
203
-
204
- // Create call directly in active state
205
- var info = CallInfo(callId: callId,
206
- callType: callType,
207
- displayName: targetName,
208
- pictureUrl: nil,
209
- state: .active)
210
- activeCalls[callId] = info
211
- currentCallId = callId
212
-
213
- audioManager.configureAudioSession(forCallType: callType == "Video",
214
- isIncoming: false)
215
-
216
- // Report to CallKit as already connected outgoing call (no ringing)
217
- callKitManager.reportConnectedOutgoingCall(callInfo: info) { [weak self] error in
218
- if let e = error {
219
- self?.logger.error("startCall reportConnected failed: \(e.localizedDescription)")
220
- self?.endCallInternal(callId: callId)
221
- return
222
- }
223
-
224
- self?.logger.info("startCall reported as connected - activating audio")
225
- self?.audioManager.activateAudioSession()
226
- self?.emitOutgoingCallAnsweredWithMetadata(callId: callId)
227
- }
228
- }
172
+ func startCall(callId: String,
173
+ callType: String,
174
+ targetName: String,
175
+ metadata: String? = nil)
176
+ {
177
+ logger.info("startCall (modified for proper CallKit integration): \(callId)")
178
+ if let m = metadata {
179
+ callMetadata[callId] = m
180
+ logger.info("metadata cached for \(callId)")
181
+ }
182
+
183
+ guard activeCalls[callId] == nil else {
184
+ logger.warning("call \(callId) already exists")
185
+ return
186
+ }
187
+
188
+ guard validateOutgoingCallRequest() else {
189
+ logger.warning("rejecting join call \(callId) - conflicts")
190
+ emitEvent(.callRejected, data: ["callId": callId,
191
+ "reason": "Conflict with existing call"])
192
+ return
193
+ }
194
+
195
+ if canMakeMultipleCalls {
196
+ activeCalls.values
197
+ .filter { $0.state == .active }
198
+ .forEach { call in
199
+ logger.info("holding existing \(call.callId)")
200
+ callKitManager.setCallOnHold(callId: call.callId, onHold: true)
201
+ }
202
+ }
203
+
204
+ // Use standard outgoing call flow instead of trying to bypass it
205
+ var info = CallInfo(callId: callId,
206
+ callType: callType,
207
+ displayName: targetName,
208
+ pictureUrl: nil,
209
+ state: .dialing)
210
+ activeCalls[callId] = info
211
+ currentCallId = callId
212
+
213
+ audioManager.configureAudioSession(forCallType: callType == "Video",
214
+ isIncoming: false)
215
+
216
+ // Use standard outgoing call method
217
+ callKitManager.startOutgoingCall(callInfo: info) { [weak self] error in
218
+ if let e = error {
219
+ self?.logger.error("startCall failed: \(e.localizedDescription)")
220
+ self?.endCallInternal(callId: callId)
221
+ return
222
+ }
223
+
224
+ // Immediately report as connected since this is for joining ongoing calls
225
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
226
+ if let uuid = UUID(uuidString: callId) {
227
+ self?.callKitManager.provider.reportOutgoingCall(with: uuid, connectedAt: Date())
228
+ self?.logger.info("startCall: reported as connected")
229
+ }
230
+ }
231
+ }
232
+ }
229
233
  // MARK: JS Actions
230
234
 
231
235
  func answerCall(callId: String) {
@@ -415,25 +419,24 @@ extension CallEngine: CallKitManagerDelegate {
415
419
  didSetHeld callId: String,
416
420
  onHold: Bool)
417
421
  {
418
- logger.info("didSetHeld: \(callId), onHold=\(onHold)")
419
- guard var info = activeCalls[callId] else { return }
422
+ logger.info("didSetHeld: \(callId), onHold=\(onHold)")
423
+ guard var info = activeCalls[callId] else { return }
420
424
 
421
- if onHold {
422
- info.updateState(.held)
423
- info.wasHeldBySystem = true
424
- emitEvent(.callHeld, data: ["callId": callId])
425
- } else {
426
- info.updateState(.active)
427
- info.wasHeldBySystem = false
428
- currentCallId = callId
425
+ if onHold {
426
+ info.updateState(.held)
427
+ info.wasHeldBySystem = true
428
+ emitEvent(.callHeld, data: ["callId": callId])
429
+ } else {
430
+ info.updateState(.active)
431
+ info.wasHeldBySystem = false
432
+ currentCallId = callId
429
433
 
430
- // Re-configure audio when unheld
431
- audioManager.configureAudioSession(forCallType: info.callType == "Video",
432
- isIncoming: false)
434
+ // CRITICAL: Use the new WebRTC-compatible unhold handling
435
+ audioManager.handleCallUnhold(forCallType: info.callType == "Video")
433
436
 
434
- emitEvent(.callUnheld, data: ["callId": callId])
435
- }
436
- activeCalls[callId] = info
437
+ emitEvent(.callUnheld, data: ["callId": callId])
438
+ }
439
+ activeCalls[callId] = info
437
440
  }
438
441
 
439
442
  func callKitManager(_ manager: CallKitManager,
@@ -462,17 +465,16 @@ extension CallEngine: CallKitManagerDelegate {
462
465
  func callKitManager(_ manager: CallKitManager,
463
466
  didActivateAudioSession session: AVAudioSession)
464
467
  {
465
- logger.info("CallKit didActivate audioSession")
466
- audioManager.activateAudioSession()
468
+ logger.info("CallKit didActivate audioSession")
469
+ audioManager.activateAudioSession()
467
470
  }
468
471
 
469
472
  func callKitManager(_ manager: CallKitManager,
470
473
  didDeactivateAudioSession session: AVAudioSession)
471
474
  {
472
- logger.info("CallKit didDeactivate audioSession")
473
- audioManager.deactivateAudioSession()
475
+ logger.info("CallKit didDeactivate audioSession")
476
+ audioManager.deactivateAudioSession()
474
477
  }
475
- }
476
478
 
477
479
  // MARK: AudioManagerDelegate
478
480
 
@@ -74,46 +74,6 @@ class CallKitManager: NSObject {
74
74
  }
75
75
  }
76
76
 
77
- func reportConnectedOutgoingCall(callInfo: CallInfo,
78
- completion: @escaping (Error?) -> Void)
79
- {
80
- logger.info("reportConnectedOutgoingCall: \(callInfo.callId)")
81
- guard let uuid = UUID(uuidString: callInfo.callId) else {
82
- let err = NSError(domain: "CallKitManager",
83
- code: -1,
84
- userInfo: [NSLocalizedDescriptionKey: "Invalid UUID"])
85
- completion(err)
86
- return
87
- }
88
-
89
- let handle = CXHandle(type: .generic, value: callInfo.displayName)
90
- let action = CXStartCallAction(call: uuid, handle: handle)
91
- action.isVideo = callInfo.callType == "Video"
92
-
93
- let tx = CXTransaction(action: action)
94
- activeCallIds.insert(callInfo.callId)
95
-
96
- callController.request(tx) { [weak self] error in
97
- if let e = error {
98
- self?.logger.error("reportConnectedOutgoingCall error: \(e.localizedDescription)")
99
- self?.activeCallIds.remove(callInfo.callId)
100
- completion(e)
101
- return
102
- }
103
-
104
- // Immediately report the call as connected (no ringing phase)
105
- let update = CXCallUpdate()
106
- update.remoteHandle = handle
107
- update.localizedCallerName = callInfo.displayName
108
- update.hasVideo = callInfo.callType == "Video"
109
-
110
- // Report as connected at current time - this skips ringing
111
- self?.provider.reportOutgoingCall(with: uuid, connectedAt: Date())
112
- self?.logger.info("Call reported as immediately connected")
113
- completion(nil)
114
- }
115
- }
116
-
117
77
  func startOutgoingCall(callInfo: CallInfo,
118
78
  completion: @escaping (Error?) -> Void)
119
79
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qusaieilouti99/call-manager",
3
- "version": "0.1.129",
3
+ "version": "0.1.131",
4
4
  "description": "Call manager",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",