@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
|
-
|
|
22
|
-
self
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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.
|
|
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",
|