@poolse/react-native-calls 0.4.4 → 0.4.5

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.
@@ -171,6 +171,12 @@ final class CallCore {
171
171
  // LiveKit's audio stays off until CallKit activates the session —
172
172
  // configured now, at launch, before any activation can happen.
173
173
  mediaPort.prepareAudio()
174
+ // A no-op mute transaction (same state) makes CallKit retry a failed
175
+ // audio-session activation.
176
+ mediaPort.requestAudioActivation = { [weak self] in
177
+ guard let self = self, let callId = self.runtime.currentState().callId else { return }
178
+ self.callKit.requestMute(callId: callId, muted: self.mutedSelf)
179
+ }
174
180
  }
175
181
 
176
182
  // MARK: - native entry points
@@ -86,6 +86,25 @@ final class CallKitController: NSObject, CXProviderDelegate {
86
86
  /// or the runtime's SHOW_RING after the push path) is re-reported with
87
87
  /// the SAME UUID: CallKit rejects it as a duplicate and nothing changes,
88
88
  /// which still satisfies PushKit's "report a call for every push".
89
+ /// Put the shared session in the call's category BEFORE CallKit activates
90
+ /// it — at ring time, in the answer action, in the start action (Apple's
91
+ /// CallKit sample does the same). Setting the category does not activate
92
+ /// anything. Without it the session is still the default
93
+ /// SoloAmbientSound when CallKit activates it, and with the phone locked
94
+ /// and the app in the background iOS REFUSES that activation (seen in a
95
+ /// device log as an activate "early exit due to failure", -16980): no
96
+ /// didActivate, so no audio at all on a call answered with the app closed.
97
+ static func prepareSessionForCall(video: Bool) {
98
+ let s = AVAudioSession.sharedInstance()
99
+ let mode: AVAudioSession.Mode = video ? .videoChat : .voiceChat
100
+ guard s.category != .playAndRecord || s.mode != mode else { return }
101
+ do {
102
+ try s.setCategory(.playAndRecord, mode: mode, options: [.allowBluetooth, .allowBluetoothA2DP])
103
+ } catch {
104
+ NSLog("[PoolseCalls] pre-activation category failed: %@", "\(error)")
105
+ }
106
+ }
107
+
89
108
  func reportIncoming(ring: RingContext) {
90
109
  let hasVideo = ring.media == "video"
91
110
  let title = ring.conversationName ?? ring.callerName
@@ -95,6 +114,7 @@ final class CallKitController: NSObject, CXProviderDelegate {
95
114
  }
96
115
  let t = Tracked(uuid: UUID(), callId: ring.callId, outgoing: false, hasVideo: hasVideo, title: title)
97
116
  calls[t.uuid] = t
117
+ Self.prepareSessionForCall(video: hasVideo)
98
118
  provider.reportNewIncomingCall(with: t.uuid, update: update(title: title, handle: ring.conversationId, video: hasVideo)) { [weak self] error in
99
119
  guard let error = error else { return }
100
120
  NSLog("[PoolseCalls] CallKit refused incoming call %@: %@", ring.callId, error.localizedDescription)
@@ -228,6 +248,8 @@ final class CallKitController: NSObject, CXProviderDelegate {
228
248
  guard let t = calls[action.callUUID], !t.ended else { action.fail(); return }
229
249
  let fromUs = t.answered
230
250
  t.answered = true
251
+ // Before fulfil: CallKit activates the session right after.
252
+ Self.prepareSessionForCall(video: t.hasVideo)
231
253
  if !fromUs {
232
254
  guard let actions = actions else { action.fail(); return }
233
255
  actions.callKitAnswer(callId: t.callId)
@@ -245,7 +267,8 @@ final class CallKitController: NSObject, CXProviderDelegate {
245
267
  }
246
268
 
247
269
  func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
248
- guard calls[action.callUUID] != nil else { action.fail(); return }
270
+ guard let t = calls[action.callUUID] else { action.fail(); return }
271
+ Self.prepareSessionForCall(video: t.hasVideo)
249
272
  provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: nil)
250
273
  action.fulfill()
251
274
  }
@@ -1,4 +1,5 @@
1
1
  import AVFoundation
2
+ import os
2
3
  import Foundation
3
4
  #if canImport(LiveKitClient)
4
5
  import LiveKitClient // CocoaPods module name
@@ -29,10 +30,15 @@ protocol MediaEvents: AnyObject {
29
30
  /// Step-by-step trace of the audio path, in the device log as
30
31
  /// "[PoolseCalls] trace …" — the order of activation, engine availability,
31
32
  /// connect and publish is what cold-answer audio failures hinge on.
33
+ ///
34
+ /// os_log with a PUBLIC argument: NSLog's text is redacted to "<private>"
35
+ /// on release builds, which made the first device trace unreadable.
32
36
  func pcTrace(_ message: @autoclosure () -> String) {
33
- NSLog("[PoolseCalls] trace %@", message())
37
+ os_log("[PoolseCalls] trace %{public}@", log: pcLog, type: .default, message())
34
38
  }
35
39
 
40
+ let pcLog = OSLog(subsystem: "dev.poolse.callcore", category: "calls")
41
+
36
42
  func pcSessionDescription(_ s: AVAudioSession = .sharedInstance()) -> String {
37
43
  let outs = s.currentRoute.outputs.map { $0.portType.rawValue }.joined(separator: ",")
38
44
  let ins = s.currentRoute.inputs.map { $0.portType.rawValue }.joined(separator: ",")
@@ -72,6 +78,9 @@ final class LiveKitMediaPort: NSObject, MediaPort {
72
78
  private var audioActive = false
73
79
  /// The room whose audio engine has been kicked (once per room).
74
80
  private var audioKicked: ObjectIdentifier?
81
+ /// Asks CallKit to (re)try activating the audio session — set by
82
+ /// CallCore. Used when a room is live but activation never arrived.
83
+ var requestAudioActivation: (() -> Void)?
75
84
 
76
85
  init(events: MediaEvents) {
77
86
  self.events = events
@@ -115,6 +124,29 @@ final class LiveKitMediaPort: NSObject, MediaPort {
115
124
  kickAudioIfReady()
116
125
  }
117
126
 
127
+ /// Connected with a live mic but CallKit never activated the session
128
+ /// (iOS refused the activation): nudge CallKit to retry — a transaction
129
+ /// on the call makes it attempt activation again (in the device log a
130
+ /// mute toggle did exactly that, and audio came up). Three tries, then
131
+ /// activate the session ourselves as a last resort.
132
+ private func ensureAudioActivated(_ r: Room, attempt: Int) {
133
+ guard room === r, !audioActive else { return }
134
+ let delays: [Double] = [1.2, 2.5, 4.0]
135
+ guard attempt <= delays.count else { return }
136
+ if attempt == delays.count {
137
+ pcTrace("audio never activated by CallKit; activating ourselves")
138
+ activateAudioWithoutCallKit(media: media)
139
+ return
140
+ }
141
+ DispatchQueue.main.asyncAfter(deadline: .now() + delays[attempt]) { [weak self] in
142
+ guard let self = self, self.room === r, !self.audioActive else { return }
143
+ pcTrace("audio not active \(attempt + 1)x after connect; nudging CallKit")
144
+ CallKitController.prepareSessionForCall(video: self.media == "video")
145
+ self.requestAudioActivation?()
146
+ self.ensureAudioActivated(r, attempt: attempt + 1)
147
+ }
148
+ }
149
+
118
150
  /// Once per call, the first moment the session is active AND the mic is
119
151
  /// published (in whichever order they happen): make LiveKit's audio
120
152
  /// engine rebuild around the live mic.
@@ -258,6 +290,7 @@ final class LiveKitMediaPort: NSObject, MediaPort {
258
290
  await MainActor.run {
259
291
  self.reassertAudio()
260
292
  self.kickAudioIfReady()
293
+ self.ensureAudioActivated(r, attempt: 0)
261
294
  }
262
295
  let camera = await MainActor.run { self.cameraWanted ?? wantsVideo }
263
296
  if camera { await self.enableCamera(r, true) }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poolse/react-native-calls",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "description": "Native call core for poolse — Kotlin/Swift calling engine with a thin React Native bridge. Ring, answer and carry audio with the app killed and the phone locked.",
5
5
  "license": "MIT",
6
6
  "type": "module",