@poolse/react-native-calls 0.4.3 → 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)
|
|
@@ -224,9 +244,12 @@ final class CallKitController: NSObject, CXProviderDelegate {
|
|
|
224
244
|
}
|
|
225
245
|
|
|
226
246
|
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
|
|
247
|
+
pcTrace("CallKit answer action")
|
|
227
248
|
guard let t = calls[action.callUUID], !t.ended else { action.fail(); return }
|
|
228
249
|
let fromUs = t.answered
|
|
229
250
|
t.answered = true
|
|
251
|
+
// Before fulfil: CallKit activates the session right after.
|
|
252
|
+
Self.prepareSessionForCall(video: t.hasVideo)
|
|
230
253
|
if !fromUs {
|
|
231
254
|
guard let actions = actions else { action.fail(); return }
|
|
232
255
|
actions.callKitAnswer(callId: t.callId)
|
|
@@ -244,7 +267,8 @@ final class CallKitController: NSObject, CXProviderDelegate {
|
|
|
244
267
|
}
|
|
245
268
|
|
|
246
269
|
func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
|
|
247
|
-
guard calls[action.callUUID]
|
|
270
|
+
guard let t = calls[action.callUUID] else { action.fail(); return }
|
|
271
|
+
Self.prepareSessionForCall(video: t.hasVideo)
|
|
248
272
|
provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: nil)
|
|
249
273
|
action.fulfill()
|
|
250
274
|
}
|
|
@@ -259,10 +283,12 @@ final class CallKitController: NSObject, CXProviderDelegate {
|
|
|
259
283
|
}
|
|
260
284
|
|
|
261
285
|
func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
|
|
286
|
+
pcTrace("CallKit didActivate \(pcSessionDescription(audioSession))")
|
|
262
287
|
actions?.callKitAudioActivated(audioSession)
|
|
263
288
|
}
|
|
264
289
|
|
|
265
290
|
func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
|
|
291
|
+
pcTrace("CallKit didDeactivate")
|
|
266
292
|
actions?.callKitAudioDeactivated(audioSession)
|
|
267
293
|
}
|
|
268
294
|
}
|
|
@@ -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
|
|
@@ -26,6 +27,26 @@ protocol MediaEvents: AnyObject {
|
|
|
26
27
|
func onLocalCamera(on: Bool, facing: String?)
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
/// Step-by-step trace of the audio path, in the device log as
|
|
31
|
+
/// "[PoolseCalls] trace …" — the order of activation, engine availability,
|
|
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.
|
|
36
|
+
func pcTrace(_ message: @autoclosure () -> String) {
|
|
37
|
+
os_log("[PoolseCalls] trace %{public}@", log: pcLog, type: .default, message())
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let pcLog = OSLog(subsystem: "dev.poolse.callcore", category: "calls")
|
|
41
|
+
|
|
42
|
+
func pcSessionDescription(_ s: AVAudioSession = .sharedInstance()) -> String {
|
|
43
|
+
let outs = s.currentRoute.outputs.map { $0.portType.rawValue }.joined(separator: ",")
|
|
44
|
+
let ins = s.currentRoute.inputs.map { $0.portType.rawValue }.joined(separator: ",")
|
|
45
|
+
return "category=\(s.category.rawValue) mode=\(s.mode.rawValue) in=[\(ins)] out=[\(outs)] " +
|
|
46
|
+
"otherAudio=\(s.isOtherAudioPlaying) inputAvailable=\(s.isInputAvailable) " +
|
|
47
|
+
"record=\(s.recordPermission.rawValue)"
|
|
48
|
+
}
|
|
49
|
+
|
|
29
50
|
/// The shipped MediaPort over LiveKit's Swift SDK — LiveKitMediaPort.kt
|
|
30
51
|
/// for iOS. Joins with the server-minted token, publishes the mic (and
|
|
31
52
|
/// the camera for video), tracks the roster and reports room transitions.
|
|
@@ -57,6 +78,9 @@ final class LiveKitMediaPort: NSObject, MediaPort {
|
|
|
57
78
|
private var audioActive = false
|
|
58
79
|
/// The room whose audio engine has been kicked (once per room).
|
|
59
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)?
|
|
60
84
|
|
|
61
85
|
init(events: MediaEvents) {
|
|
62
86
|
self.events = events
|
|
@@ -83,6 +107,7 @@ final class LiveKitMediaPort: NSObject, MediaPort {
|
|
|
83
107
|
func prepareAudio() {
|
|
84
108
|
guard !audioPrepared else { return }
|
|
85
109
|
audioPrepared = true
|
|
110
|
+
pcTrace("prepareAudio (automatic config off, engine .none)")
|
|
86
111
|
AudioManager.shared.audioSession.isAutomaticConfigurationEnabled = false
|
|
87
112
|
do { try AudioManager.shared.setEngineAvailability(.none) } catch {
|
|
88
113
|
NSLog("[PoolseCalls] setEngineAvailability(.none) failed: %@", "\(error)")
|
|
@@ -91,6 +116,7 @@ final class LiveKitMediaPort: NSObject, MediaPort {
|
|
|
91
116
|
|
|
92
117
|
/// CallKit activated the session (or, without CallKit, we did).
|
|
93
118
|
func audioActivated(_ session: AVAudioSession, media: String) {
|
|
119
|
+
pcTrace("audioActivated media=\(media) room=\(room != nil) connected=\(connectedRoom != nil) \(pcSessionDescription(session))")
|
|
94
120
|
prepareAudio()
|
|
95
121
|
audioActive = true
|
|
96
122
|
if media != self.media && room == nil { self.media = media }
|
|
@@ -98,6 +124,29 @@ final class LiveKitMediaPort: NSObject, MediaPort {
|
|
|
98
124
|
kickAudioIfReady()
|
|
99
125
|
}
|
|
100
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
|
+
|
|
101
150
|
/// Once per call, the first moment the session is active AND the mic is
|
|
102
151
|
/// published (in whichever order they happen): make LiveKit's audio
|
|
103
152
|
/// engine rebuild around the live mic.
|
|
@@ -115,6 +164,7 @@ final class LiveKitMediaPort: NSObject, MediaPort {
|
|
|
115
164
|
audioKicked != ObjectIdentifier(r), !mutedWanted else { return }
|
|
116
165
|
guard r.localParticipant.isMicrophoneEnabled() else { return }
|
|
117
166
|
audioKicked = ObjectIdentifier(r)
|
|
167
|
+
pcTrace("kick: cycling engine availability + mic")
|
|
118
168
|
do {
|
|
119
169
|
try AudioManager.shared.setEngineAvailability(.none)
|
|
120
170
|
try AudioManager.shared.setEngineAvailability(.default)
|
|
@@ -134,6 +184,7 @@ final class LiveKitMediaPort: NSObject, MediaPort {
|
|
|
134
184
|
} catch {
|
|
135
185
|
NSLog("[PoolseCalls] mic cycle failed: %@", "\(error)")
|
|
136
186
|
}
|
|
187
|
+
pcTrace("kick done enabled=\(r.localParticipant.isMicrophoneEnabled()) \(pcSessionDescription())")
|
|
137
188
|
await MainActor.run { self?.reassertAudio() }
|
|
138
189
|
}
|
|
139
190
|
}
|
|
@@ -147,6 +198,7 @@ final class LiveKitMediaPort: NSObject, MediaPort {
|
|
|
147
198
|
/// no-CallKit fallback) holds the session active.
|
|
148
199
|
func reassertAudio(_ session: AVAudioSession = .sharedInstance()) {
|
|
149
200
|
guard audioActive else { return }
|
|
201
|
+
pcTrace("reassertAudio before: \(pcSessionDescription(session))")
|
|
150
202
|
let mode: AVAudioSession.Mode = media == "video" && speakerWanted != false ? .videoChat : .voiceChat
|
|
151
203
|
if session.category != .playAndRecord || session.mode != mode {
|
|
152
204
|
do {
|
|
@@ -163,6 +215,7 @@ final class LiveKitMediaPort: NSObject, MediaPort {
|
|
|
163
215
|
}
|
|
164
216
|
|
|
165
217
|
func audioDeactivated() {
|
|
218
|
+
pcTrace("audioDeactivated")
|
|
166
219
|
audioActive = false
|
|
167
220
|
guard audioPrepared else { return }
|
|
168
221
|
do { try AudioManager.shared.setEngineAvailability(.none) } catch {
|
|
@@ -181,6 +234,7 @@ final class LiveKitMediaPort: NSObject, MediaPort {
|
|
|
181
234
|
// MARK: - MediaPort
|
|
182
235
|
|
|
183
236
|
func join(bundle: CallSessionBundle) {
|
|
237
|
+
pcTrace("join call=\(bundle.callId) media=\(bundle.media) audioActive=\(audioActive)")
|
|
184
238
|
prepareAudio()
|
|
185
239
|
if let joined = joinedCallId, joined != bundle.callId { resetWanted() }
|
|
186
240
|
joinedCallId = bundle.callId
|
|
@@ -224,15 +278,19 @@ final class LiveKitMediaPort: NSObject, MediaPort {
|
|
|
224
278
|
return
|
|
225
279
|
}
|
|
226
280
|
let muted = await MainActor.run { self.mutedWanted }
|
|
281
|
+
let activeNow = await MainActor.run { self.audioActive }
|
|
282
|
+
pcTrace("connected; publishing mic muted=\(muted) audioActive=\(activeNow)")
|
|
227
283
|
do { try await r.localParticipant.setMicrophone(enabled: !muted) } catch {
|
|
228
284
|
NSLog("[PoolseCalls] microphone publish failed: %@", "\(error)")
|
|
229
285
|
}
|
|
230
286
|
// Connected and publishing: make sure nothing changed the session
|
|
287
|
+
pcTrace("mic published enabled=\(r.localParticipant.isMicrophoneEnabled()) \(pcSessionDescription())")
|
|
231
288
|
// while we connected (a cold answer boots JS in exactly this window),
|
|
232
289
|
// then make sure the engine is actually carrying the mic.
|
|
233
290
|
await MainActor.run {
|
|
234
291
|
self.reassertAudio()
|
|
235
292
|
self.kickAudioIfReady()
|
|
293
|
+
self.ensureAudioActivated(r, attempt: 0)
|
|
236
294
|
}
|
|
237
295
|
let camera = await MainActor.run { self.cameraWanted ?? wantsVideo }
|
|
238
296
|
if camera { await self.enableCamera(r, true) }
|
|
@@ -375,18 +433,21 @@ final class LiveKitMediaPort: NSObject, MediaPort {
|
|
|
375
433
|
/// An interruption (another app's audio, Siri) ended: the session may
|
|
376
434
|
/// have been reconfigured; restore the call's.
|
|
377
435
|
@objc private func interrupted(_ note: Notification) {
|
|
436
|
+
pcTrace("interruption \(note.userInfo ?? [:])")
|
|
378
437
|
guard let raw = note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
|
|
379
438
|
AVAudioSession.InterruptionType(rawValue: raw) == .ended else { return }
|
|
380
439
|
DispatchQueue.main.async { [weak self] in self?.reassertAudio() }
|
|
381
440
|
}
|
|
382
441
|
|
|
383
442
|
@objc private func mediaServicesReset(_ note: Notification) {
|
|
443
|
+
pcTrace("mediaServicesWereReset")
|
|
384
444
|
DispatchQueue.main.async { [weak self] in self?.reassertAudio() }
|
|
385
445
|
}
|
|
386
446
|
|
|
387
447
|
@objc private func routeChanged(_ note: Notification) {
|
|
388
448
|
let reason = (note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt)
|
|
389
449
|
.flatMap(AVAudioSession.RouteChangeReason.init(rawValue:))
|
|
450
|
+
pcTrace("routeChange reason=\(reason.map { String($0.rawValue) } ?? "?") \(pcSessionDescription())")
|
|
390
451
|
DispatchQueue.main.async { [weak self] in
|
|
391
452
|
guard let self = self, self.audioActive else { return }
|
|
392
453
|
// Something reconfigured the shared session mid-call: put it back.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import CallKit
|
|
2
2
|
import Foundation
|
|
3
3
|
import PushKit
|
|
4
|
+
import UIKit
|
|
4
5
|
|
|
5
6
|
/// Native PushKit intake — the iOS twin of CallMessagingService. The
|
|
6
7
|
/// registry is created at launch (PoolseCallsAppDelegateSubscriber →
|
|
@@ -59,6 +60,7 @@ final class VoipPush: NSObject, PKPushRegistryDelegate {
|
|
|
59
60
|
|
|
60
61
|
func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload,
|
|
61
62
|
for type: PKPushType, completion: @escaping () -> Void) {
|
|
63
|
+
pcTrace("VoIP push received appState=\(UIApplication.shared.applicationState.rawValue)")
|
|
62
64
|
if type == .voIP { handle(payload.dictionaryPayload) }
|
|
63
65
|
completion()
|
|
64
66
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poolse/react-native-calls",
|
|
3
|
-
"version": "0.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",
|