@poolse/react-native-calls 0.3.0 → 0.4.0
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.
- package/dist/index.cjs +10 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +21 -2
- package/dist/index.d.ts +21 -2
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/expo-module.config.json +6 -1
- package/ios/Core/CallCoreRuntime.swift +393 -0
- package/ios/Core/CallHttpClient.swift +210 -0
- package/ios/Core/CallMachine.swift +304 -0
- package/ios/Core/PORTS_READY.md +93 -0
- package/ios/Core/Ports.swift +193 -0
- package/ios/Core/SnapshotEncoding.swift +75 -0
- package/ios/Module/PoolseCallVideoView.swift +62 -0
- package/ios/Module/PoolseCallsAppDelegateSubscriber.swift +16 -0
- package/ios/Module/PoolseCallsModule.swift +111 -0
- package/ios/Platform/CallCore.swift +464 -0
- package/ios/Platform/CallKitController.swift +263 -0
- package/ios/Platform/LiveKitMediaPort.swift +445 -0
- package/ios/Platform/LiveKitVideoSurface.swift +67 -0
- package/ios/Platform/Ringback.swift +61 -0
- package/ios/Platform/VoipPush.swift +172 -0
- package/ios/PoolseCalls.podspec +64 -0
- package/package.json +7 -1
- package/src/index.ts +33 -1
|
@@ -0,0 +1,464 @@
|
|
|
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
|
+
configLock.lock()
|
|
49
|
+
_config = CallCoreConfig(apiBaseUrl: trimmed, ringback: ringback, apnsEnvironment: apnsEnvironment)
|
|
50
|
+
configLock.unlock()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/// The APNs environment last configured (persisted), if any.
|
|
54
|
+
static var apnsEnvironment: String? {
|
|
55
|
+
config?.apnsEnvironment ?? defaults.string(forKey: "apnsEnvironment")
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private static func restoreConfig() {
|
|
59
|
+
guard config == nil, let url = defaults.string(forKey: "apiBaseUrl") else { return }
|
|
60
|
+
configLock.lock()
|
|
61
|
+
_config = CallCoreConfig(apiBaseUrl: url,
|
|
62
|
+
ringback: defaults.string(forKey: "ringback"),
|
|
63
|
+
apnsEnvironment: defaults.string(forKey: "apnsEnvironment"))
|
|
64
|
+
configLock.unlock()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// The core, or nil if this install was never configured. Main thread.
|
|
68
|
+
static func getOrNull() -> CallCore? {
|
|
69
|
+
if let i = instance { return i }
|
|
70
|
+
restoreConfig()
|
|
71
|
+
guard config != nil else { return nil }
|
|
72
|
+
let core = CallCore()
|
|
73
|
+
instance = core
|
|
74
|
+
return core
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/// The live core if one exists — never creates.
|
|
78
|
+
static func peek() -> CallCore? { instance }
|
|
79
|
+
|
|
80
|
+
// MARK: parts
|
|
81
|
+
|
|
82
|
+
private let callKit = CallKitController.shared
|
|
83
|
+
private var mediaPort: LiveKitMediaPort!
|
|
84
|
+
private var runtime: CallCoreRuntime!
|
|
85
|
+
private var ringback: Ringback!
|
|
86
|
+
private var surfaceAdapter: SurfaceAdapter!
|
|
87
|
+
private var eventsAdapter: EventsAdapter!
|
|
88
|
+
|
|
89
|
+
// Snapshot extras (main thread).
|
|
90
|
+
private var extrasCallId: String?
|
|
91
|
+
private var extrasMedia: String?
|
|
92
|
+
private var roster: [SnapshotParticipant] = []
|
|
93
|
+
private var conversationName: String?
|
|
94
|
+
private var mutedSelf = false
|
|
95
|
+
private var cameraOn = false
|
|
96
|
+
private var cameraFacing: String?
|
|
97
|
+
private var audioRoute: String?
|
|
98
|
+
|
|
99
|
+
/// ring_ended reasons seen per call — the CallKit end reason.
|
|
100
|
+
private var ringEndReasons: [String: String] = [:]
|
|
101
|
+
/// The user ended from our UI (JS): end CallKit with a transaction.
|
|
102
|
+
private var localEnd = false
|
|
103
|
+
|
|
104
|
+
private let snapshotLock = NSLock()
|
|
105
|
+
private var lastSnapshot: [String: Any]?
|
|
106
|
+
|
|
107
|
+
private init() {
|
|
108
|
+
ringback = Ringback(resourceName: { CallCore.config?.ringback })
|
|
109
|
+
eventsAdapter = EventsAdapter()
|
|
110
|
+
mediaPort = LiveKitMediaPort(events: eventsAdapter)
|
|
111
|
+
surfaceAdapter = SurfaceAdapter()
|
|
112
|
+
runtime = CallCoreRuntime(
|
|
113
|
+
http: CallHttpClient(baseUrl: { CallCore.config?.apiBaseUrl ?? "" }),
|
|
114
|
+
media: mediaPort,
|
|
115
|
+
surface: surfaceAdapter,
|
|
116
|
+
session: surfaceAdapter,
|
|
117
|
+
timers: MainQueueTimerPort(),
|
|
118
|
+
sink: BlockSnapshotSink { [weak self] state in self?.onState(state) }
|
|
119
|
+
)
|
|
120
|
+
eventsAdapter.core = self
|
|
121
|
+
surfaceAdapter.core = self
|
|
122
|
+
callKit.actions = self
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// MARK: - native entry points
|
|
126
|
+
|
|
127
|
+
func onRing(_ ring: RingContext) { runtime.onRing(ring) }
|
|
128
|
+
|
|
129
|
+
func onRingEnded(callId: String, reason: String) {
|
|
130
|
+
onMain { [self] in
|
|
131
|
+
ringEndReasons[callId] = reason
|
|
132
|
+
runtime.onRingEnded(callId: callId, reason: reason)
|
|
133
|
+
reconcileCallKitSoon()
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
func onCallFinished(callId: String, reason: String?) {
|
|
138
|
+
onMain { [self] in
|
|
139
|
+
runtime.onCallFinished(callId: callId, reason: reason)
|
|
140
|
+
reconcileCallKitSoon()
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/// False once this ring is resolved here or past its deadline (+ clock slack).
|
|
145
|
+
func isRingLive(_ ring: RingContext) -> Bool {
|
|
146
|
+
let now = Int64(Date().timeIntervalSince1970 * 1000)
|
|
147
|
+
return !runtime.isResolved(ring.callId) && ring.ringDeadlineEpochMs + CallCoreRuntime.CLOCK_SLACK_MS > now
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/// Reconcile CallKit after everything queued so far has run.
|
|
151
|
+
func reconcileCallKitSoon() {
|
|
152
|
+
runtime.post { [weak self] in
|
|
153
|
+
guard let self = self else { return }
|
|
154
|
+
self.reconcileCallKit(self.runtime.currentState())
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// MARK: - bridge-driven actions (any thread)
|
|
159
|
+
|
|
160
|
+
func answer() { runtime.answer() }
|
|
161
|
+
|
|
162
|
+
func decline() {
|
|
163
|
+
onMain { [self] in
|
|
164
|
+
localEnd = true
|
|
165
|
+
runtime.decline()
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
func hangUp() {
|
|
170
|
+
onMain { [self] in
|
|
171
|
+
localEnd = true
|
|
172
|
+
runtime.hangUp()
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
func place(conversationId: String, media: String, sessionToken: String) {
|
|
177
|
+
onMain { [self] in
|
|
178
|
+
localEnd = false
|
|
179
|
+
runtime.place(conversationId: conversationId, media: media, sessionToken: sessionToken)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
func join(callId: String, conversationId: String?, sessionToken: String) {
|
|
184
|
+
onMain { [self] in
|
|
185
|
+
localEnd = false
|
|
186
|
+
runtime.join(callId: callId, conversationId: conversationId, sessionToken: sessionToken)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
func setMuted(_ muted: Bool) {
|
|
191
|
+
onMain { [self] in
|
|
192
|
+
applyMuted(muted)
|
|
193
|
+
if let id = runtime.currentState().callId { callKit.requestMute(callId: id, muted: muted) }
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private func applyMuted(_ muted: Bool) {
|
|
198
|
+
mutedSelf = muted
|
|
199
|
+
runtime.setMuted(muted)
|
|
200
|
+
runtime.reemit()
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/// Not optimistic: cameraOn flips when the media adapter reports it published.
|
|
204
|
+
func setCamera(_ on: Bool) { runtime.setCamera(on) }
|
|
205
|
+
|
|
206
|
+
func switchCamera() { runtime.switchCamera() }
|
|
207
|
+
|
|
208
|
+
func setSpeaker(_ on: Bool) {
|
|
209
|
+
onMain { [self] in
|
|
210
|
+
audioRoute = on ? "speaker" : "earpiece" // optimistic; the adapter reports the real one
|
|
211
|
+
runtime.setSpeaker(on)
|
|
212
|
+
runtime.reemit()
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/// A video slot for the native view. Main thread.
|
|
217
|
+
func createVideoSurface() -> LiveKitVideoSurface { mediaPort.createVideoSurface() }
|
|
218
|
+
|
|
219
|
+
/// The contract Snapshot (nil = idle), safe from any thread.
|
|
220
|
+
func snapshot() -> [String: Any]? {
|
|
221
|
+
snapshotLock.lock(); defer { snapshotLock.unlock() }
|
|
222
|
+
return lastSnapshot
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private func onMain(_ block: @escaping () -> Void) {
|
|
226
|
+
if Thread.isMainThread { block() } else { DispatchQueue.main.async(execute: block) }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// MARK: - snapshot assembly (main)
|
|
230
|
+
|
|
231
|
+
fileprivate func onState(_ state: MachineState) {
|
|
232
|
+
if state.phase == .idle {
|
|
233
|
+
if extrasCallId != nil { mediaPort.leave() }
|
|
234
|
+
resetExtras(nil, nil)
|
|
235
|
+
} else if state.callId != extrasCallId {
|
|
236
|
+
if extrasCallId?.hasPrefix("pending:") == true {
|
|
237
|
+
if let old = extrasCallId, let new = state.callId { callKit.rename(from: old, to: new) }
|
|
238
|
+
extrasCallId = state.callId
|
|
239
|
+
} else {
|
|
240
|
+
resetExtras(state.callId, state.media)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if state.phase != .idle && state.media != extrasMedia {
|
|
244
|
+
if extrasMedia != nil { audioRoute = nil }
|
|
245
|
+
extrasMedia = state.media
|
|
246
|
+
}
|
|
247
|
+
if let name = runtime.conversationName { conversationName = name }
|
|
248
|
+
|
|
249
|
+
let snap = state.toSnapshot(
|
|
250
|
+
conversationName: conversationName,
|
|
251
|
+
participants: roster,
|
|
252
|
+
audioRoute: audioRoute ?? (state.media == "video" ? "speaker" : "earpiece"),
|
|
253
|
+
mutedSelf: mutedSelf,
|
|
254
|
+
cameraOn: cameraOn,
|
|
255
|
+
cameraFacing: cameraFacing
|
|
256
|
+
)
|
|
257
|
+
snapshotLock.lock(); lastSnapshot = snap; snapshotLock.unlock()
|
|
258
|
+
reconcileCallKit(state)
|
|
259
|
+
reconcileDevice(state)
|
|
260
|
+
CallCore.listener?(snap)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private func resetExtras(_ callId: String?, _ media: String?) {
|
|
264
|
+
extrasCallId = callId
|
|
265
|
+
extrasMedia = media
|
|
266
|
+
roster = []
|
|
267
|
+
conversationName = nil
|
|
268
|
+
mutedSelf = false
|
|
269
|
+
cameraOn = false
|
|
270
|
+
cameraFacing = nil
|
|
271
|
+
audioRoute = nil
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/// CallKit follows the FINAL state of each dispatch.
|
|
275
|
+
private func reconcileCallKit(_ state: MachineState) {
|
|
276
|
+
let terminal = state.phase == .idle || state.phase == .ended
|
|
277
|
+
for t in callKit.liveCalls {
|
|
278
|
+
let isCurrent = !terminal && t.callId == state.callId
|
|
279
|
+
if isCurrent {
|
|
280
|
+
switch state.phase {
|
|
281
|
+
case .connecting, .active, .reconnecting, .ringingOut:
|
|
282
|
+
// Answered from the app's own UI: tell CallKit (audio follows).
|
|
283
|
+
if !t.outgoing && !t.answered && state.direction == .incoming { callKit.requestAnswer(callId: t.callId) }
|
|
284
|
+
if t.outgoing && state.phase == .active { callKit.reportConnected(callId: t.callId) }
|
|
285
|
+
callKit.updateTitle(callId: t.callId, title: conversationName, video: state.media == "video")
|
|
286
|
+
default:
|
|
287
|
+
break
|
|
288
|
+
}
|
|
289
|
+
continue
|
|
290
|
+
}
|
|
291
|
+
// Not the call the machine is in (ended, busy-declined, superseded).
|
|
292
|
+
// Terminal: whatever CallKit still shows is the call that just ended.
|
|
293
|
+
let reason = endReason(for: t, state: state, current: terminal || t.callId == state.callId)
|
|
294
|
+
callKit.end(t, reason: reason)
|
|
295
|
+
}
|
|
296
|
+
if terminal {
|
|
297
|
+
localEnd = false
|
|
298
|
+
if let id = state.callId { ringEndReasons.removeValue(forKey: id) }
|
|
299
|
+
if ringEndReasons.count > 16 { ringEndReasons.removeAll() }
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/// nil = ended locally (end transaction); else how it ended remotely.
|
|
304
|
+
private func endReason(for t: CallKitController.Tracked, state: MachineState, current: Bool) -> CXCallEndedReason? {
|
|
305
|
+
if current && localEnd && state.reason != .failed && state.reason != .connectionLost { return nil }
|
|
306
|
+
if !t.answered && !t.outgoing {
|
|
307
|
+
switch ringEndReasons[t.callId] {
|
|
308
|
+
case "answered_elsewhere": return .answeredElsewhere
|
|
309
|
+
case "declined": return .declinedElsewhere
|
|
310
|
+
case "timeout": return .unanswered
|
|
311
|
+
case "cancelled", "finished": return .remoteEnded
|
|
312
|
+
default: break
|
|
313
|
+
}
|
|
314
|
+
if !current { return .unanswered } // busy-declined / never taken
|
|
315
|
+
}
|
|
316
|
+
guard current else { return .remoteEnded }
|
|
317
|
+
switch state.reason {
|
|
318
|
+
case .noAnswer?, .busyAll?: return .unanswered
|
|
319
|
+
case .connectionLost?, .failed?: return .failed
|
|
320
|
+
case .left?: return localEnd ? nil : .remoteEnded
|
|
321
|
+
default: return .remoteEnded
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/// Screen off at the ear: only for an audio call on the earpiece.
|
|
326
|
+
private func reconcileDevice(_ state: MachineState) {
|
|
327
|
+
let inCall = state.phase == .active || state.phase == .reconnecting ||
|
|
328
|
+
state.phase == .ringingOut || state.phase == .connecting
|
|
329
|
+
let route = audioRoute ?? (state.media == "video" ? "speaker" : "earpiece")
|
|
330
|
+
let want = inCall && state.media == "audio" && route == "earpiece"
|
|
331
|
+
if UIDevice.current.isProximityMonitoringEnabled != want { UIDevice.current.isProximityMonitoringEnabled = want }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// MARK: - ports' callbacks
|
|
335
|
+
|
|
336
|
+
fileprivate func sessionStart(callId: String, media: String) {
|
|
337
|
+
guard callKit.tracked(callId: callId) == nil else { return } // an answered incoming call
|
|
338
|
+
let state = runtime.currentState()
|
|
339
|
+
callKit.startOutgoing(callId: callId, handle: state.conversationId ?? callId, title: runtime.conversationName,
|
|
340
|
+
video: media == "video") { [weak self] in
|
|
341
|
+
// CallKit refused: the call goes on without the system UI.
|
|
342
|
+
self?.mediaPort.activateAudioWithoutCallKit(media: media)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
fileprivate func showRing(_ ring: RingContext) {
|
|
347
|
+
// Usually already reported by the push path; idempotent.
|
|
348
|
+
callKit.reportIncoming(ring: ring)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
fileprivate var ringbackPlayer: Ringback { ringback }
|
|
352
|
+
|
|
353
|
+
// MediaEvents → runtime
|
|
354
|
+
fileprivate func mediaConnected() { runtime.onMediaConnected() }
|
|
355
|
+
fileprivate func mediaFailed() { runtime.onMediaFailed() }
|
|
356
|
+
fileprivate func mediaLost() { runtime.onMediaLost() }
|
|
357
|
+
fileprivate func mediaReconnecting() { runtime.onMediaReconnecting() }
|
|
358
|
+
fileprivate func mediaRecovered() { runtime.onMediaRecovered() }
|
|
359
|
+
fileprivate func remoteJoined() { runtime.onRemoteJoined() }
|
|
360
|
+
fileprivate func participants(_ p: [SnapshotParticipant]) {
|
|
361
|
+
runtime.post { [weak self] in self?.roster = p }
|
|
362
|
+
runtime.reemit()
|
|
363
|
+
}
|
|
364
|
+
fileprivate func route(_ r: String) {
|
|
365
|
+
runtime.post { [weak self] in self?.audioRoute = r }
|
|
366
|
+
runtime.reemit()
|
|
367
|
+
}
|
|
368
|
+
fileprivate func localCamera(on: Bool, facing: String?) {
|
|
369
|
+
runtime.post { [weak self] in self?.cameraOn = on; self?.cameraFacing = facing }
|
|
370
|
+
runtime.reemit()
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// MARK: - CallKit actions
|
|
375
|
+
|
|
376
|
+
extension CallCore: CallKitActions {
|
|
377
|
+
func callKitAnswer(callId: String) {
|
|
378
|
+
let s = runtime.currentState()
|
|
379
|
+
guard s.callId == callId, s.phase == .ringingIn else { return }
|
|
380
|
+
runtime.answer()
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
func callKitEnd(callId: String) {
|
|
384
|
+
let s = runtime.currentState()
|
|
385
|
+
guard s.callId == callId || (s.callId?.hasPrefix("pending:") == true) else { return }
|
|
386
|
+
localEnd = true
|
|
387
|
+
if s.phase == .ringingIn { runtime.decline() } else { runtime.hangUp() }
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
func callKitSetMuted(callId: String, muted: Bool) {
|
|
391
|
+
guard muted != mutedSelf else { return }
|
|
392
|
+
applyMuted(muted)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
func callKitAudioActivated(_ session: AVAudioSession) {
|
|
396
|
+
mediaPort.audioActivated(session, media: runtime.currentState().media)
|
|
397
|
+
ringback.sessionActivated()
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
func callKitAudioDeactivated(_ session: AVAudioSession) {
|
|
401
|
+
ringback.stop()
|
|
402
|
+
mediaPort.audioDeactivated()
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
func callKitReset() {
|
|
406
|
+
let s = runtime.currentState()
|
|
407
|
+
guard s.phase != .idle && s.phase != .ended else { return }
|
|
408
|
+
localEnd = true
|
|
409
|
+
if s.phase == .ringingIn { runtime.decline() } else { runtime.hangUp() }
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// MARK: - port adapters
|
|
414
|
+
|
|
415
|
+
/// RingSurfacePort + CallSessionPort: CallKit. The ringtone is CallKit's;
|
|
416
|
+
/// dismissal and session end are reconciled from the final state.
|
|
417
|
+
private final class SurfaceAdapter: RingSurfacePort, CallSessionPort {
|
|
418
|
+
weak var core: CallCore?
|
|
419
|
+
|
|
420
|
+
func show(ring: RingContext) { core?.showRing(ring) }
|
|
421
|
+
func dismiss(callId: String) {}
|
|
422
|
+
func ringtoneStart() {}
|
|
423
|
+
func ringtoneStop() {}
|
|
424
|
+
func ringbackStart() { core?.ringbackPlayer.start() }
|
|
425
|
+
func ringbackStop() { core?.ringbackPlayer.stop() }
|
|
426
|
+
|
|
427
|
+
func start(callId: String, media: String) { core?.sessionStart(callId: callId, media: media) }
|
|
428
|
+
func stop() { core?.ringbackPlayer.stop() }
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private final class EventsAdapter: MediaEvents {
|
|
432
|
+
weak var core: CallCore?
|
|
433
|
+
|
|
434
|
+
func onConnected() { core?.mediaConnected() }
|
|
435
|
+
func onFailed() { core?.mediaFailed() }
|
|
436
|
+
func onLost() { core?.mediaLost() }
|
|
437
|
+
func onReconnecting() { core?.mediaReconnecting() }
|
|
438
|
+
func onRecovered() { core?.mediaRecovered() }
|
|
439
|
+
func onRemoteJoined() { core?.remoteJoined() }
|
|
440
|
+
func onParticipants(_ participants: [SnapshotParticipant]) { core?.participants(participants) }
|
|
441
|
+
func onAudioRoute(_ route: String) { core?.route(route) }
|
|
442
|
+
func onLocalCamera(on: Bool, facing: String?) { core?.localCamera(on: on, facing: facing) }
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/// Host-facing entry points.
|
|
446
|
+
@objc(PoolseCalls)
|
|
447
|
+
public final class PoolseCallsSetup: NSObject {
|
|
448
|
+
/// Create the PushKit registry (and the CallKit provider, and the core
|
|
449
|
+
/// if this install was ever configured). Idempotent; main thread.
|
|
450
|
+
///
|
|
451
|
+
/// Called automatically from `PoolseCallsAppDelegateSubscriber`'s
|
|
452
|
+
/// didFinishLaunching (registered through expo-module.config.json), so
|
|
453
|
+
/// an Expo host needs NO AppDelegate edit. A non-Expo host calls this
|
|
454
|
+
/// from its own `application(_:didFinishLaunchingWithOptions:)`.
|
|
455
|
+
@objc public static func registerVoipEarly() {
|
|
456
|
+
if !Thread.isMainThread {
|
|
457
|
+
DispatchQueue.main.async { registerVoipEarly() }
|
|
458
|
+
return
|
|
459
|
+
}
|
|
460
|
+
_ = CallKitController.shared
|
|
461
|
+
_ = CallCore.getOrNull()
|
|
462
|
+
VoipPush.shared.register()
|
|
463
|
+
}
|
|
464
|
+
}
|