@poolse/react-native-calls 0.4.0 → 0.4.2

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.
@@ -17,18 +17,25 @@ public class PoolseCallsModule: Module {
17
17
  PoolseCallsSetup.registerVoipEarly()
18
18
  }
19
19
 
20
+ // The listeners are process-level statics read on main: assign and
21
+ // clear them on main too (these closures run on the module's queue).
20
22
  OnStartObserving {
21
- CallCore.listener = { [weak self] snapshot in
22
- self?.sendEvent("onCallState", ["snapshot": snapshot ?? NSNull()])
23
- }
24
- VoipPush.tokenListener = { [weak self] token in
25
- self?.sendEvent("onVoipToken", ["token": token, "environment": PoolseCallsModule.environment()])
23
+ DispatchQueue.main.async { [weak self] in
24
+ guard self != nil else { return }
25
+ CallCore.listener = { [weak self] snapshot in
26
+ self?.sendEvent("onCallState", ["snapshot": snapshot ?? NSNull()])
27
+ }
28
+ VoipPush.tokenListener = { [weak self] token in
29
+ self?.sendEvent("onVoipToken", ["token": token, "environment": PoolseCallsModule.environment()])
30
+ }
26
31
  }
27
32
  }
28
33
 
29
34
  OnStopObserving {
30
- CallCore.listener = nil
31
- VoipPush.tokenListener = nil
35
+ DispatchQueue.main.async {
36
+ CallCore.listener = nil
37
+ VoipPush.tokenListener = nil
38
+ }
32
39
  }
33
40
 
34
41
  AsyncFunction("configure") { (apiBaseUrl: String, options: [String: Any]?) in
@@ -45,6 +45,7 @@ final class CallCore {
45
45
  defaults.set(trimmed, forKey: "apiBaseUrl")
46
46
  defaults.set(ringback, forKey: "ringback")
47
47
  defaults.set(apnsEnvironment, forKey: "apnsEnvironment")
48
+ writeConfigFile(apiBaseUrl: trimmed, ringback: ringback, apnsEnvironment: apnsEnvironment)
48
49
  configLock.lock()
49
50
  _config = CallCoreConfig(apiBaseUrl: trimmed, ringback: ringback, apnsEnvironment: apnsEnvironment)
50
51
  configLock.unlock()
@@ -52,18 +53,65 @@ final class CallCore {
52
53
 
53
54
  /// The APNs environment last configured (persisted), if any.
54
55
  static var apnsEnvironment: String? {
55
- config?.apnsEnvironment ?? defaults.string(forKey: "apnsEnvironment")
56
+ config?.apnsEnvironment ?? defaults.string(forKey: "apnsEnvironment") ?? readConfigFile()?.apnsEnvironment
56
57
  }
57
58
 
58
59
  private static func restoreConfig() {
59
- guard config == nil, let url = defaults.string(forKey: "apiBaseUrl") else { return }
60
+ guard config == nil else { return }
61
+ let restored: CallCoreConfig
62
+ if let url = defaults.string(forKey: "apiBaseUrl") {
63
+ restored = CallCoreConfig(apiBaseUrl: url,
64
+ ringback: defaults.string(forKey: "ringback"),
65
+ apnsEnvironment: defaults.string(forKey: "apnsEnvironment"))
66
+ } else if let fromFile = readConfigFile() {
67
+ // Locked since reboot: UserDefaults is unreadable before first
68
+ // unlock, the unprotected file is not.
69
+ restored = fromFile
70
+ } else {
71
+ return
72
+ }
60
73
  configLock.lock()
61
- _config = CallCoreConfig(apiBaseUrl: url,
62
- ringback: defaults.string(forKey: "ringback"),
63
- apnsEnvironment: defaults.string(forKey: "apnsEnvironment"))
74
+ _config = restored
64
75
  configLock.unlock()
65
76
  }
66
77
 
78
+ // MARK: config file (readable before first unlock)
79
+
80
+ /// Application Support/dev.poolse.callcore/config.json, written with
81
+ /// FileProtectionType.none so a PushKit wake while the device has been
82
+ /// locked since reboot can still rebuild the core. Holds no secrets —
83
+ /// only the API origin, a sound name and the APNs environment.
84
+ private static var configFileURL: URL? {
85
+ guard let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil }
86
+ return base.appendingPathComponent("dev.poolse.callcore", isDirectory: true).appendingPathComponent("config.json")
87
+ }
88
+
89
+ private static func writeConfigFile(apiBaseUrl: String, ringback: String?, apnsEnvironment: String?) {
90
+ guard let url = configFileURL else { return }
91
+ var obj: [String: Any] = ["apiBaseUrl": apiBaseUrl]
92
+ if let ringback = ringback { obj["ringback"] = ringback }
93
+ if let apnsEnvironment = apnsEnvironment { obj["apnsEnvironment"] = apnsEnvironment }
94
+ do {
95
+ let fm = FileManager.default
96
+ let dir = url.deletingLastPathComponent()
97
+ try fm.createDirectory(at: dir, withIntermediateDirectories: true,
98
+ attributes: [.protectionKey: FileProtectionType.none])
99
+ let data = try JSONSerialization.data(withJSONObject: obj)
100
+ try data.write(to: url, options: [.atomic, .noFileProtection])
101
+ try? fm.setAttributes([.protectionKey: FileProtectionType.none], ofItemAtPath: url.path)
102
+ } catch {
103
+ NSLog("[PoolseCalls] config file write failed: %@", "\(error)")
104
+ }
105
+ }
106
+
107
+ private static func readConfigFile() -> CallCoreConfig? {
108
+ guard let url = configFileURL, let data = try? Data(contentsOf: url),
109
+ let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
110
+ let api = obj["apiBaseUrl"] as? String, !api.isEmpty else { return nil }
111
+ return CallCoreConfig(apiBaseUrl: api, ringback: obj["ringback"] as? String,
112
+ apnsEnvironment: obj["apnsEnvironment"] as? String)
113
+ }
114
+
67
115
  /// The core, or nil if this install was never configured. Main thread.
68
116
  static func getOrNull() -> CallCore? {
69
117
  if let i = instance { return i }
@@ -120,6 +168,9 @@ final class CallCore {
120
168
  eventsAdapter.core = self
121
169
  surfaceAdapter.core = self
122
170
  callKit.actions = self
171
+ // LiveKit's audio stays off until CallKit activates the session —
172
+ // configured now, at launch, before any activation can happen.
173
+ mediaPort.prepareAudio()
123
174
  }
124
175
 
125
176
  // MARK: - native entry points
@@ -89,7 +89,7 @@ final class CallKitController: NSObject, CXProviderDelegate {
89
89
  func reportIncoming(ring: RingContext) {
90
90
  let hasVideo = ring.media == "video"
91
91
  let title = ring.conversationName ?? ring.callerName
92
- if let existing = calls.values.first(where: { $0.callId == ring.callId }) {
92
+ if let existing = calls.values.first(where: { $0.callId == ring.callId && !$0.ended }) {
93
93
  provider.reportNewIncomingCall(with: existing.uuid, update: update(title: title, handle: ring.conversationId, video: hasVideo)) { _ in }
94
94
  return
95
95
  }
@@ -99,8 +99,13 @@ final class CallKitController: NSObject, CXProviderDelegate {
99
99
  guard let error = error else { return }
100
100
  NSLog("[PoolseCalls] CallKit refused incoming call %@: %@", ring.callId, error.localizedDescription)
101
101
  // Refused (DND, blocked, busy group): CallKit holds nothing for it.
102
- t.ended = true
103
- self?.calls.removeValue(forKey: t.uuid)
102
+ // End the ring in the core too — silently (no decline is sent), so
103
+ // the engine is not left RINGING_IN with no UI, counting as busy.
104
+ DispatchQueue.main.async {
105
+ t.ended = true
106
+ self?.calls.removeValue(forKey: t.uuid)
107
+ CallCore.peek()?.onRingEnded(callId: ring.callId, reason: "cancelled")
108
+ }
104
109
  }
105
110
  }
106
111
 
@@ -61,13 +61,24 @@ final class LiveKitMediaPort: NSObject, MediaPort {
61
61
  super.init()
62
62
  NotificationCenter.default.addObserver(self, selector: #selector(routeChanged),
63
63
  name: AVAudioSession.routeChangeNotification, object: nil)
64
+ NotificationCenter.default.addObserver(self, selector: #selector(interrupted),
65
+ name: AVAudioSession.interruptionNotification, object: nil)
66
+ NotificationCenter.default.addObserver(self, selector: #selector(mediaServicesReset),
67
+ name: AVAudioSession.mediaServicesWereResetNotification,
68
+ object: nil)
64
69
  }
65
70
 
66
71
  // MARK: - audio session (CallKit-driven)
67
72
 
68
73
  /// Before any room exists: LiveKit must not configure the session nor
69
74
  /// start its engine outside CallKit's activation window.
70
- private func prepareAudio() {
75
+ ///
76
+ /// Called at app LAUNCH (CallCore.init, from the app-delegate
77
+ /// subscriber), as LiveKit's CallKit integration requires — before any
78
+ /// audio session can be activated. Doing it inside didActivate meant a
79
+ /// call answered with the app closed created and reconfigured LiveKit's
80
+ /// audio manager mid-activation, and came up with no audio.
81
+ func prepareAudio() {
71
82
  guard !audioPrepared else { return }
72
83
  audioPrepared = true
73
84
  AudioManager.shared.audioSession.isAutomaticConfigurationEnabled = false
@@ -80,17 +91,31 @@ final class LiveKitMediaPort: NSObject, MediaPort {
80
91
  func audioActivated(_ session: AVAudioSession, media: String) {
81
92
  prepareAudio()
82
93
  audioActive = true
83
- let video = media == "video"
84
- do {
85
- try session.setCategory(.playAndRecord, mode: video && speakerWanted != false ? .videoChat : .voiceChat,
86
- options: [.allowBluetooth, .allowBluetoothA2DP])
87
- } catch {
88
- NSLog("[PoolseCalls] audio session category failed: %@", "\(error)")
94
+ if media != self.media && room == nil { self.media = media }
95
+ reassertAudio(session)
96
+ }
97
+
98
+ /// Put the call's audio back the way the call needs it: play-and-record
99
+ /// in the voice/video chat mode, LiveKit's engine available, the chosen
100
+ /// route. Idempotent — only touches what differs — so it is safe to run
101
+ /// whenever anything may have changed the session behind the call's
102
+ /// back (another audio library reconfiguring it as JS boots mid-call,
103
+ /// an interruption, a media-services reset). Only while CallKit (or the
104
+ /// no-CallKit fallback) holds the session active.
105
+ func reassertAudio(_ session: AVAudioSession = .sharedInstance()) {
106
+ guard audioActive else { return }
107
+ let mode: AVAudioSession.Mode = media == "video" && speakerWanted != false ? .videoChat : .voiceChat
108
+ if session.category != .playAndRecord || session.mode != mode {
109
+ do {
110
+ try session.setCategory(.playAndRecord, mode: mode,
111
+ options: [.allowBluetooth, .allowBluetoothA2DP])
112
+ } catch {
113
+ NSLog("[PoolseCalls] audio session category failed: %@", "\(error)")
114
+ }
89
115
  }
90
116
  do { try AudioManager.shared.setEngineAvailability(.default) } catch {
91
117
  NSLog("[PoolseCalls] setEngineAvailability(.default) failed: %@", "\(error)")
92
118
  }
93
- if media != self.media && room == nil { self.media = media }
94
119
  applyRoute()
95
120
  }
96
121
 
@@ -159,6 +184,9 @@ final class LiveKitMediaPort: NSObject, MediaPort {
159
184
  do { try await r.localParticipant.setMicrophone(enabled: !muted) } catch {
160
185
  NSLog("[PoolseCalls] microphone publish failed: %@", "\(error)")
161
186
  }
187
+ // Connected and publishing: make sure nothing changed the session
188
+ // while we connected (a cold answer boots JS in exactly this window).
189
+ await MainActor.run { self.reassertAudio() }
162
190
  let camera = await MainActor.run { self.cameraWanted ?? wantsVideo }
163
191
  if camera { await self.enableCamera(r, true) }
164
192
  await MainActor.run {
@@ -297,9 +325,28 @@ final class LiveKitMediaPort: NSObject, MediaPort {
297
325
  events?.onAudioRoute(currentRoute())
298
326
  }
299
327
 
328
+ /// An interruption (another app's audio, Siri) ended: the session may
329
+ /// have been reconfigured; restore the call's.
330
+ @objc private func interrupted(_ note: Notification) {
331
+ guard let raw = note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
332
+ AVAudioSession.InterruptionType(rawValue: raw) == .ended else { return }
333
+ DispatchQueue.main.async { [weak self] in self?.reassertAudio() }
334
+ }
335
+
336
+ @objc private func mediaServicesReset(_ note: Notification) {
337
+ DispatchQueue.main.async { [weak self] in self?.reassertAudio() }
338
+ }
339
+
300
340
  @objc private func routeChanged(_ note: Notification) {
341
+ let reason = (note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt)
342
+ .flatMap(AVAudioSession.RouteChangeReason.init(rawValue:))
301
343
  DispatchQueue.main.async { [weak self] in
302
344
  guard let self = self, self.audioActive else { return }
345
+ // Something reconfigured the shared session mid-call: put it back.
346
+ if reason == .categoryChange {
347
+ self.reassertAudio()
348
+ return
349
+ }
303
350
  // A user's speaker choice survives route changes, except that a
304
351
  // newly connected headset/Bluetooth device always wins.
305
352
  let route = self.currentRoute()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poolse/react-native-calls",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
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",