@poolse/react-native-calls 0.3.0 → 0.4.1

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.
@@ -0,0 +1,512 @@
1
+ import AVFoundation
2
+ import CallKit
3
+ import Foundation
4
+ import UIKit
5
+
6
+ /// The host-supplied configuration, persisted so a PushKit cold wake
7
+ /// (no JS, no configure call) rebuilds the same core from disk.
8
+ struct CallCoreConfig {
9
+ var apiBaseUrl: String
10
+ var ringback: String?
11
+ /// `production` | `sandbox` — the APNs environment the VoIP token
12
+ /// belongs to. Not reliably readable at runtime, so the host says it.
13
+ var apnsEnvironment: String?
14
+ }
15
+
16
+ /// The composition root and the single native entry point — CallCore.kt
17
+ /// for iOS. PushKit, CallKit's provider actions and the React Native
18
+ /// bridge all come through here: one runtime, one answer/decline path.
19
+ ///
20
+ /// It also owns what the pure machine does not carry but a truthful
21
+ /// snapshot needs (roster, conversation name, self mute/camera, audio
22
+ /// route), and reconciles CallKit against every final snapshot.
23
+ ///
24
+ /// Main thread only, except `snapshot()` / `peek()`.
25
+ final class CallCore {
26
+ // MARK: statics
27
+
28
+ private static let defaults = UserDefaults(suiteName: "dev.poolse.callcore") ?? .standard
29
+ private static let configLock = NSLock()
30
+ private static var _config: CallCoreConfig?
31
+ private static var instance: CallCore?
32
+
33
+ static var config: CallCoreConfig? {
34
+ configLock.lock(); defer { configLock.unlock() }
35
+ return _config
36
+ }
37
+
38
+ /// The bridge's snapshot subscriber; process-level so JS may subscribe before configure.
39
+ static var listener: (([String: Any]?) -> Void)?
40
+
41
+ /// Configure and PERSIST (see CallCore.kt). Re-configuring swaps in place:
42
+ /// HTTP and the ringback read the config per use.
43
+ static func configure(apiBaseUrl: String, ringback: String?, apnsEnvironment: String?) {
44
+ let trimmed = apiBaseUrl.hasSuffix("/") ? String(apiBaseUrl.dropLast()) : apiBaseUrl
45
+ defaults.set(trimmed, forKey: "apiBaseUrl")
46
+ defaults.set(ringback, forKey: "ringback")
47
+ defaults.set(apnsEnvironment, forKey: "apnsEnvironment")
48
+ writeConfigFile(apiBaseUrl: trimmed, ringback: ringback, apnsEnvironment: apnsEnvironment)
49
+ configLock.lock()
50
+ _config = CallCoreConfig(apiBaseUrl: trimmed, ringback: ringback, apnsEnvironment: apnsEnvironment)
51
+ configLock.unlock()
52
+ }
53
+
54
+ /// The APNs environment last configured (persisted), if any.
55
+ static var apnsEnvironment: String? {
56
+ config?.apnsEnvironment ?? defaults.string(forKey: "apnsEnvironment") ?? readConfigFile()?.apnsEnvironment
57
+ }
58
+
59
+ private static func restoreConfig() {
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
+ }
73
+ configLock.lock()
74
+ _config = restored
75
+ configLock.unlock()
76
+ }
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
+
115
+ /// The core, or nil if this install was never configured. Main thread.
116
+ static func getOrNull() -> CallCore? {
117
+ if let i = instance { return i }
118
+ restoreConfig()
119
+ guard config != nil else { return nil }
120
+ let core = CallCore()
121
+ instance = core
122
+ return core
123
+ }
124
+
125
+ /// The live core if one exists — never creates.
126
+ static func peek() -> CallCore? { instance }
127
+
128
+ // MARK: parts
129
+
130
+ private let callKit = CallKitController.shared
131
+ private var mediaPort: LiveKitMediaPort!
132
+ private var runtime: CallCoreRuntime!
133
+ private var ringback: Ringback!
134
+ private var surfaceAdapter: SurfaceAdapter!
135
+ private var eventsAdapter: EventsAdapter!
136
+
137
+ // Snapshot extras (main thread).
138
+ private var extrasCallId: String?
139
+ private var extrasMedia: String?
140
+ private var roster: [SnapshotParticipant] = []
141
+ private var conversationName: String?
142
+ private var mutedSelf = false
143
+ private var cameraOn = false
144
+ private var cameraFacing: String?
145
+ private var audioRoute: String?
146
+
147
+ /// ring_ended reasons seen per call — the CallKit end reason.
148
+ private var ringEndReasons: [String: String] = [:]
149
+ /// The user ended from our UI (JS): end CallKit with a transaction.
150
+ private var localEnd = false
151
+
152
+ private let snapshotLock = NSLock()
153
+ private var lastSnapshot: [String: Any]?
154
+
155
+ private init() {
156
+ ringback = Ringback(resourceName: { CallCore.config?.ringback })
157
+ eventsAdapter = EventsAdapter()
158
+ mediaPort = LiveKitMediaPort(events: eventsAdapter)
159
+ surfaceAdapter = SurfaceAdapter()
160
+ runtime = CallCoreRuntime(
161
+ http: CallHttpClient(baseUrl: { CallCore.config?.apiBaseUrl ?? "" }),
162
+ media: mediaPort,
163
+ surface: surfaceAdapter,
164
+ session: surfaceAdapter,
165
+ timers: MainQueueTimerPort(),
166
+ sink: BlockSnapshotSink { [weak self] state in self?.onState(state) }
167
+ )
168
+ eventsAdapter.core = self
169
+ surfaceAdapter.core = self
170
+ callKit.actions = self
171
+ }
172
+
173
+ // MARK: - native entry points
174
+
175
+ func onRing(_ ring: RingContext) { runtime.onRing(ring) }
176
+
177
+ func onRingEnded(callId: String, reason: String) {
178
+ onMain { [self] in
179
+ ringEndReasons[callId] = reason
180
+ runtime.onRingEnded(callId: callId, reason: reason)
181
+ reconcileCallKitSoon()
182
+ }
183
+ }
184
+
185
+ func onCallFinished(callId: String, reason: String?) {
186
+ onMain { [self] in
187
+ runtime.onCallFinished(callId: callId, reason: reason)
188
+ reconcileCallKitSoon()
189
+ }
190
+ }
191
+
192
+ /// False once this ring is resolved here or past its deadline (+ clock slack).
193
+ func isRingLive(_ ring: RingContext) -> Bool {
194
+ let now = Int64(Date().timeIntervalSince1970 * 1000)
195
+ return !runtime.isResolved(ring.callId) && ring.ringDeadlineEpochMs + CallCoreRuntime.CLOCK_SLACK_MS > now
196
+ }
197
+
198
+ /// Reconcile CallKit after everything queued so far has run.
199
+ func reconcileCallKitSoon() {
200
+ runtime.post { [weak self] in
201
+ guard let self = self else { return }
202
+ self.reconcileCallKit(self.runtime.currentState())
203
+ }
204
+ }
205
+
206
+ // MARK: - bridge-driven actions (any thread)
207
+
208
+ func answer() { runtime.answer() }
209
+
210
+ func decline() {
211
+ onMain { [self] in
212
+ localEnd = true
213
+ runtime.decline()
214
+ }
215
+ }
216
+
217
+ func hangUp() {
218
+ onMain { [self] in
219
+ localEnd = true
220
+ runtime.hangUp()
221
+ }
222
+ }
223
+
224
+ func place(conversationId: String, media: String, sessionToken: String) {
225
+ onMain { [self] in
226
+ localEnd = false
227
+ runtime.place(conversationId: conversationId, media: media, sessionToken: sessionToken)
228
+ }
229
+ }
230
+
231
+ func join(callId: String, conversationId: String?, sessionToken: String) {
232
+ onMain { [self] in
233
+ localEnd = false
234
+ runtime.join(callId: callId, conversationId: conversationId, sessionToken: sessionToken)
235
+ }
236
+ }
237
+
238
+ func setMuted(_ muted: Bool) {
239
+ onMain { [self] in
240
+ applyMuted(muted)
241
+ if let id = runtime.currentState().callId { callKit.requestMute(callId: id, muted: muted) }
242
+ }
243
+ }
244
+
245
+ private func applyMuted(_ muted: Bool) {
246
+ mutedSelf = muted
247
+ runtime.setMuted(muted)
248
+ runtime.reemit()
249
+ }
250
+
251
+ /// Not optimistic: cameraOn flips when the media adapter reports it published.
252
+ func setCamera(_ on: Bool) { runtime.setCamera(on) }
253
+
254
+ func switchCamera() { runtime.switchCamera() }
255
+
256
+ func setSpeaker(_ on: Bool) {
257
+ onMain { [self] in
258
+ audioRoute = on ? "speaker" : "earpiece" // optimistic; the adapter reports the real one
259
+ runtime.setSpeaker(on)
260
+ runtime.reemit()
261
+ }
262
+ }
263
+
264
+ /// A video slot for the native view. Main thread.
265
+ func createVideoSurface() -> LiveKitVideoSurface { mediaPort.createVideoSurface() }
266
+
267
+ /// The contract Snapshot (nil = idle), safe from any thread.
268
+ func snapshot() -> [String: Any]? {
269
+ snapshotLock.lock(); defer { snapshotLock.unlock() }
270
+ return lastSnapshot
271
+ }
272
+
273
+ private func onMain(_ block: @escaping () -> Void) {
274
+ if Thread.isMainThread { block() } else { DispatchQueue.main.async(execute: block) }
275
+ }
276
+
277
+ // MARK: - snapshot assembly (main)
278
+
279
+ fileprivate func onState(_ state: MachineState) {
280
+ if state.phase == .idle {
281
+ if extrasCallId != nil { mediaPort.leave() }
282
+ resetExtras(nil, nil)
283
+ } else if state.callId != extrasCallId {
284
+ if extrasCallId?.hasPrefix("pending:") == true {
285
+ if let old = extrasCallId, let new = state.callId { callKit.rename(from: old, to: new) }
286
+ extrasCallId = state.callId
287
+ } else {
288
+ resetExtras(state.callId, state.media)
289
+ }
290
+ }
291
+ if state.phase != .idle && state.media != extrasMedia {
292
+ if extrasMedia != nil { audioRoute = nil }
293
+ extrasMedia = state.media
294
+ }
295
+ if let name = runtime.conversationName { conversationName = name }
296
+
297
+ let snap = state.toSnapshot(
298
+ conversationName: conversationName,
299
+ participants: roster,
300
+ audioRoute: audioRoute ?? (state.media == "video" ? "speaker" : "earpiece"),
301
+ mutedSelf: mutedSelf,
302
+ cameraOn: cameraOn,
303
+ cameraFacing: cameraFacing
304
+ )
305
+ snapshotLock.lock(); lastSnapshot = snap; snapshotLock.unlock()
306
+ reconcileCallKit(state)
307
+ reconcileDevice(state)
308
+ CallCore.listener?(snap)
309
+ }
310
+
311
+ private func resetExtras(_ callId: String?, _ media: String?) {
312
+ extrasCallId = callId
313
+ extrasMedia = media
314
+ roster = []
315
+ conversationName = nil
316
+ mutedSelf = false
317
+ cameraOn = false
318
+ cameraFacing = nil
319
+ audioRoute = nil
320
+ }
321
+
322
+ /// CallKit follows the FINAL state of each dispatch.
323
+ private func reconcileCallKit(_ state: MachineState) {
324
+ let terminal = state.phase == .idle || state.phase == .ended
325
+ for t in callKit.liveCalls {
326
+ let isCurrent = !terminal && t.callId == state.callId
327
+ if isCurrent {
328
+ switch state.phase {
329
+ case .connecting, .active, .reconnecting, .ringingOut:
330
+ // Answered from the app's own UI: tell CallKit (audio follows).
331
+ if !t.outgoing && !t.answered && state.direction == .incoming { callKit.requestAnswer(callId: t.callId) }
332
+ if t.outgoing && state.phase == .active { callKit.reportConnected(callId: t.callId) }
333
+ callKit.updateTitle(callId: t.callId, title: conversationName, video: state.media == "video")
334
+ default:
335
+ break
336
+ }
337
+ continue
338
+ }
339
+ // Not the call the machine is in (ended, busy-declined, superseded).
340
+ // Terminal: whatever CallKit still shows is the call that just ended.
341
+ let reason = endReason(for: t, state: state, current: terminal || t.callId == state.callId)
342
+ callKit.end(t, reason: reason)
343
+ }
344
+ if terminal {
345
+ localEnd = false
346
+ if let id = state.callId { ringEndReasons.removeValue(forKey: id) }
347
+ if ringEndReasons.count > 16 { ringEndReasons.removeAll() }
348
+ }
349
+ }
350
+
351
+ /// nil = ended locally (end transaction); else how it ended remotely.
352
+ private func endReason(for t: CallKitController.Tracked, state: MachineState, current: Bool) -> CXCallEndedReason? {
353
+ if current && localEnd && state.reason != .failed && state.reason != .connectionLost { return nil }
354
+ if !t.answered && !t.outgoing {
355
+ switch ringEndReasons[t.callId] {
356
+ case "answered_elsewhere": return .answeredElsewhere
357
+ case "declined": return .declinedElsewhere
358
+ case "timeout": return .unanswered
359
+ case "cancelled", "finished": return .remoteEnded
360
+ default: break
361
+ }
362
+ if !current { return .unanswered } // busy-declined / never taken
363
+ }
364
+ guard current else { return .remoteEnded }
365
+ switch state.reason {
366
+ case .noAnswer?, .busyAll?: return .unanswered
367
+ case .connectionLost?, .failed?: return .failed
368
+ case .left?: return localEnd ? nil : .remoteEnded
369
+ default: return .remoteEnded
370
+ }
371
+ }
372
+
373
+ /// Screen off at the ear: only for an audio call on the earpiece.
374
+ private func reconcileDevice(_ state: MachineState) {
375
+ let inCall = state.phase == .active || state.phase == .reconnecting ||
376
+ state.phase == .ringingOut || state.phase == .connecting
377
+ let route = audioRoute ?? (state.media == "video" ? "speaker" : "earpiece")
378
+ let want = inCall && state.media == "audio" && route == "earpiece"
379
+ if UIDevice.current.isProximityMonitoringEnabled != want { UIDevice.current.isProximityMonitoringEnabled = want }
380
+ }
381
+
382
+ // MARK: - ports' callbacks
383
+
384
+ fileprivate func sessionStart(callId: String, media: String) {
385
+ guard callKit.tracked(callId: callId) == nil else { return } // an answered incoming call
386
+ let state = runtime.currentState()
387
+ callKit.startOutgoing(callId: callId, handle: state.conversationId ?? callId, title: runtime.conversationName,
388
+ video: media == "video") { [weak self] in
389
+ // CallKit refused: the call goes on without the system UI.
390
+ self?.mediaPort.activateAudioWithoutCallKit(media: media)
391
+ }
392
+ }
393
+
394
+ fileprivate func showRing(_ ring: RingContext) {
395
+ // Usually already reported by the push path; idempotent.
396
+ callKit.reportIncoming(ring: ring)
397
+ }
398
+
399
+ fileprivate var ringbackPlayer: Ringback { ringback }
400
+
401
+ // MediaEvents → runtime
402
+ fileprivate func mediaConnected() { runtime.onMediaConnected() }
403
+ fileprivate func mediaFailed() { runtime.onMediaFailed() }
404
+ fileprivate func mediaLost() { runtime.onMediaLost() }
405
+ fileprivate func mediaReconnecting() { runtime.onMediaReconnecting() }
406
+ fileprivate func mediaRecovered() { runtime.onMediaRecovered() }
407
+ fileprivate func remoteJoined() { runtime.onRemoteJoined() }
408
+ fileprivate func participants(_ p: [SnapshotParticipant]) {
409
+ runtime.post { [weak self] in self?.roster = p }
410
+ runtime.reemit()
411
+ }
412
+ fileprivate func route(_ r: String) {
413
+ runtime.post { [weak self] in self?.audioRoute = r }
414
+ runtime.reemit()
415
+ }
416
+ fileprivate func localCamera(on: Bool, facing: String?) {
417
+ runtime.post { [weak self] in self?.cameraOn = on; self?.cameraFacing = facing }
418
+ runtime.reemit()
419
+ }
420
+ }
421
+
422
+ // MARK: - CallKit actions
423
+
424
+ extension CallCore: CallKitActions {
425
+ func callKitAnswer(callId: String) {
426
+ let s = runtime.currentState()
427
+ guard s.callId == callId, s.phase == .ringingIn else { return }
428
+ runtime.answer()
429
+ }
430
+
431
+ func callKitEnd(callId: String) {
432
+ let s = runtime.currentState()
433
+ guard s.callId == callId || (s.callId?.hasPrefix("pending:") == true) else { return }
434
+ localEnd = true
435
+ if s.phase == .ringingIn { runtime.decline() } else { runtime.hangUp() }
436
+ }
437
+
438
+ func callKitSetMuted(callId: String, muted: Bool) {
439
+ guard muted != mutedSelf else { return }
440
+ applyMuted(muted)
441
+ }
442
+
443
+ func callKitAudioActivated(_ session: AVAudioSession) {
444
+ mediaPort.audioActivated(session, media: runtime.currentState().media)
445
+ ringback.sessionActivated()
446
+ }
447
+
448
+ func callKitAudioDeactivated(_ session: AVAudioSession) {
449
+ ringback.stop()
450
+ mediaPort.audioDeactivated()
451
+ }
452
+
453
+ func callKitReset() {
454
+ let s = runtime.currentState()
455
+ guard s.phase != .idle && s.phase != .ended else { return }
456
+ localEnd = true
457
+ if s.phase == .ringingIn { runtime.decline() } else { runtime.hangUp() }
458
+ }
459
+ }
460
+
461
+ // MARK: - port adapters
462
+
463
+ /// RingSurfacePort + CallSessionPort: CallKit. The ringtone is CallKit's;
464
+ /// dismissal and session end are reconciled from the final state.
465
+ private final class SurfaceAdapter: RingSurfacePort, CallSessionPort {
466
+ weak var core: CallCore?
467
+
468
+ func show(ring: RingContext) { core?.showRing(ring) }
469
+ func dismiss(callId: String) {}
470
+ func ringtoneStart() {}
471
+ func ringtoneStop() {}
472
+ func ringbackStart() { core?.ringbackPlayer.start() }
473
+ func ringbackStop() { core?.ringbackPlayer.stop() }
474
+
475
+ func start(callId: String, media: String) { core?.sessionStart(callId: callId, media: media) }
476
+ func stop() { core?.ringbackPlayer.stop() }
477
+ }
478
+
479
+ private final class EventsAdapter: MediaEvents {
480
+ weak var core: CallCore?
481
+
482
+ func onConnected() { core?.mediaConnected() }
483
+ func onFailed() { core?.mediaFailed() }
484
+ func onLost() { core?.mediaLost() }
485
+ func onReconnecting() { core?.mediaReconnecting() }
486
+ func onRecovered() { core?.mediaRecovered() }
487
+ func onRemoteJoined() { core?.remoteJoined() }
488
+ func onParticipants(_ participants: [SnapshotParticipant]) { core?.participants(participants) }
489
+ func onAudioRoute(_ route: String) { core?.route(route) }
490
+ func onLocalCamera(on: Bool, facing: String?) { core?.localCamera(on: on, facing: facing) }
491
+ }
492
+
493
+ /// Host-facing entry points.
494
+ @objc(PoolseCalls)
495
+ public final class PoolseCallsSetup: NSObject {
496
+ /// Create the PushKit registry (and the CallKit provider, and the core
497
+ /// if this install was ever configured). Idempotent; main thread.
498
+ ///
499
+ /// Called automatically from `PoolseCallsAppDelegateSubscriber`'s
500
+ /// didFinishLaunching (registered through expo-module.config.json), so
501
+ /// an Expo host needs NO AppDelegate edit. A non-Expo host calls this
502
+ /// from its own `application(_:didFinishLaunchingWithOptions:)`.
503
+ @objc public static func registerVoipEarly() {
504
+ if !Thread.isMainThread {
505
+ DispatchQueue.main.async { registerVoipEarly() }
506
+ return
507
+ }
508
+ _ = CallKitController.shared
509
+ _ = CallCore.getOrNull()
510
+ VoipPush.shared.register()
511
+ }
512
+ }