@poolse/react-native-calls 0.2.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/android/src/main/java/dev/poolse/callcore/android/CallCore.kt +25 -8
- package/android/src/main/java/dev/poolse/callcore/android/CallVideo.kt +36 -0
- package/android/src/main/java/dev/poolse/callcore/android/SnapshotJson.kt +15 -0
- package/android/src/main/java/expo/modules/poolsecalls/PoolseCallVideoView.kt +64 -0
- package/android/src/main/java/expo/modules/poolsecalls/PoolseCallsModule.kt +10 -0
- package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitMediaPort.kt +142 -14
- package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitVideoSurface.kt +107 -0
- package/dist/index.cjs +34 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +44 -2
- package/dist/index.d.ts +44 -2
- package/dist/index.js +33 -2
- 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/expo-modules-core.d.ts +5 -0
- package/src/index.ts +85 -2
- package/src/react-shims.d.ts +22 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import UIKit
|
|
3
|
+
|
|
4
|
+
/// `<CallVideoView>` on iOS: a thin host for the core's LiveKitVideoSurface.
|
|
5
|
+
/// All binding logic lives in the media adapter; this view forwards props
|
|
6
|
+
/// and ties the surface's lifetime to the window.
|
|
7
|
+
public final class PoolseCallVideoView: ExpoView {
|
|
8
|
+
private var surface: LiveKitVideoSurface?
|
|
9
|
+
|
|
10
|
+
var participantId: String?
|
|
11
|
+
var local = false
|
|
12
|
+
var mirror: Bool?
|
|
13
|
+
var fit = "cover"
|
|
14
|
+
|
|
15
|
+
public required init(appContext: AppContext? = nil) {
|
|
16
|
+
super.init(appContext: appContext)
|
|
17
|
+
backgroundColor = .clear
|
|
18
|
+
clipsToBounds = true
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
public override func didMoveToWindow() {
|
|
22
|
+
super.didMoveToWindow()
|
|
23
|
+
if window != nil { attachSurface() } else { detachSurface() }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
public override func layoutSubviews() {
|
|
27
|
+
super.layoutSubviews()
|
|
28
|
+
surface?.frame = bounds
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private func attachSurface() {
|
|
32
|
+
guard surface == nil else { return }
|
|
33
|
+
// No core yet (never configured): stay empty; a later prop update retries.
|
|
34
|
+
guard let core = CallCore.peek() else { return }
|
|
35
|
+
let s = core.createVideoSurface()
|
|
36
|
+
s.frame = bounds
|
|
37
|
+
s.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
38
|
+
addSubview(s)
|
|
39
|
+
surface = s
|
|
40
|
+
apply()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func detachSurface() {
|
|
44
|
+
guard let s = surface else { return }
|
|
45
|
+
surface = nil
|
|
46
|
+
s.release()
|
|
47
|
+
s.removeFromSuperview()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func apply() {
|
|
51
|
+
guard let s = surface else {
|
|
52
|
+
if window != nil { attachSurface() }
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
s.configure(participantId: participantId, local: local, mirror: mirror, fit: fit == "contain" ? "contain" : "cover")
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
deinit {
|
|
59
|
+
// UIKit objects must be torn down on main; deinit of a view runs there.
|
|
60
|
+
surface?.release()
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import UIKit
|
|
3
|
+
|
|
4
|
+
/// Registers PushKit at launch, before React Native starts — the only
|
|
5
|
+
/// point early enough for a VoIP push that cold-launches a killed app.
|
|
6
|
+
/// Registered via expo-module.config.json "appDelegateSubscribers", so an
|
|
7
|
+
/// Expo host (AppDelegate: ExpoAppDelegate) needs no AppDelegate edit.
|
|
8
|
+
public class PoolseCallsAppDelegateSubscriber: ExpoAppDelegateSubscriber {
|
|
9
|
+
public func application(
|
|
10
|
+
_ application: UIApplication,
|
|
11
|
+
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
|
12
|
+
) -> Bool {
|
|
13
|
+
PoolseCallsSetup.registerVoipEarly()
|
|
14
|
+
return true
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
|
|
3
|
+
/// The React Native face of the native call core on iOS — PoolseCallsModule.kt's
|
|
4
|
+
/// twin, with the same names, arity and return shapes (src/index.ts is shared).
|
|
5
|
+
/// Deliberately thin: commands go to CallCore, snapshots stream out.
|
|
6
|
+
///
|
|
7
|
+
/// Nothing here throws before `configure`: reads return idle, commands no-op.
|
|
8
|
+
public class PoolseCallsModule: Module {
|
|
9
|
+
public func definition() -> ModuleDefinition {
|
|
10
|
+
Name("PoolseCalls")
|
|
11
|
+
|
|
12
|
+
Events("onCallState", "onVoipToken")
|
|
13
|
+
|
|
14
|
+
// Normally done at launch by PoolseCallsAppDelegateSubscriber; this is
|
|
15
|
+
// the backstop for a host whose AppDelegate does not forward to Expo.
|
|
16
|
+
OnCreate {
|
|
17
|
+
PoolseCallsSetup.registerVoipEarly()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
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()])
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
OnStopObserving {
|
|
30
|
+
CallCore.listener = nil
|
|
31
|
+
VoipPush.tokenListener = nil
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
AsyncFunction("configure") { (apiBaseUrl: String, options: [String: Any]?) in
|
|
35
|
+
let env = (options?["apnsEnvironment"] as? String).flatMap { $0 == "production" || $0 == "sandbox" ? $0 : nil }
|
|
36
|
+
CallCore.configure(
|
|
37
|
+
apiBaseUrl: apiBaseUrl,
|
|
38
|
+
ringback: options?["ringback"] as? String,
|
|
39
|
+
apnsEnvironment: env
|
|
40
|
+
)
|
|
41
|
+
// Materialise the core now (and PushKit, if launch did not), so a
|
|
42
|
+
// call already in flight (cold answer) is visible to getCallState.
|
|
43
|
+
PoolseCallsSetup.registerVoipEarly()
|
|
44
|
+
_ = CallCore.getOrNull()
|
|
45
|
+
}.runOnQueue(.main)
|
|
46
|
+
|
|
47
|
+
Function("getCallState") { () -> [String: Any] in
|
|
48
|
+
["snapshot": CallCore.peek()?.snapshot() ?? NSNull()]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
AsyncFunction("place") { (conversationId: String, media: String, sessionToken: String) in
|
|
52
|
+
CallCore.getOrNull()?.place(conversationId: conversationId, media: media, sessionToken: sessionToken)
|
|
53
|
+
}.runOnQueue(.main)
|
|
54
|
+
|
|
55
|
+
AsyncFunction("join") { (callId: String, conversationId: String?, sessionToken: String) in
|
|
56
|
+
CallCore.getOrNull()?.join(callId: callId, conversationId: conversationId, sessionToken: sessionToken)
|
|
57
|
+
}.runOnQueue(.main)
|
|
58
|
+
|
|
59
|
+
Function("answer") { onCore { $0.answer() } }
|
|
60
|
+
Function("decline") { onCore { $0.decline() } }
|
|
61
|
+
Function("hangUp") { onCore { $0.hangUp() } }
|
|
62
|
+
Function("setMuted") { (muted: Bool) in onCore { $0.setMuted(muted) } }
|
|
63
|
+
Function("setCamera") { (on: Bool) in onCore { $0.setCamera(on) } }
|
|
64
|
+
Function("switchCamera") { onCore { $0.switchCamera() } }
|
|
65
|
+
Function("setSpeaker") { (on: Bool) in onCore { $0.setSpeaker(on) } }
|
|
66
|
+
|
|
67
|
+
// Socket forwarding (open app): idempotent with the push path.
|
|
68
|
+
Function("onRingEnded") { (callId: String, reason: String) in
|
|
69
|
+
onCore { $0.onRingEnded(callId: callId, reason: reason) }
|
|
70
|
+
}
|
|
71
|
+
Function("onCallFinished") { (callId: String, reason: String?) in
|
|
72
|
+
let r = (reason?.isEmpty ?? true) ? nil : reason
|
|
73
|
+
onCore { $0.onCallFinished(callId: callId, reason: r) }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Android-only concepts: CallKit owns the lock-screen ring on iOS.
|
|
77
|
+
Function("canUseFullScreenIntent") { () -> Bool in true }
|
|
78
|
+
Function("openFullScreenIntentSettings") { () -> Void in }
|
|
79
|
+
|
|
80
|
+
Function("getVoipToken") { () -> [String: Any]? in
|
|
81
|
+
guard let token = VoipPush.shared.token else { return nil }
|
|
82
|
+
return ["token": token, "environment": PoolseCallsModule.environment()]
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// <CallVideoView>: a participant's (or our own) camera.
|
|
86
|
+
View(PoolseCallVideoView.self) {
|
|
87
|
+
ViewName("PoolseCallVideo")
|
|
88
|
+
Prop("participantId") { (view: PoolseCallVideoView, id: String?) in view.participantId = id }
|
|
89
|
+
Prop("local") { (view: PoolseCallVideoView, local: Bool?) in view.local = local ?? false }
|
|
90
|
+
Prop("mirror") { (view: PoolseCallVideoView, mirror: Bool?) in view.mirror = mirror }
|
|
91
|
+
Prop("fit") { (view: PoolseCallVideoView, fit: String?) in view.fit = fit ?? "cover" }
|
|
92
|
+
OnViewDidUpdateProps { (view: PoolseCallVideoView) in view.apply() }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/// The configured APNs environment; unconfigured → the build's default.
|
|
97
|
+
static func environment() -> String {
|
|
98
|
+
if let env = CallCore.apnsEnvironment { return env }
|
|
99
|
+
#if DEBUG
|
|
100
|
+
return "sandbox"
|
|
101
|
+
#else
|
|
102
|
+
return "production"
|
|
103
|
+
#endif
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/// Run on main against the core; a no-op when never configured.
|
|
108
|
+
private func onCore(_ block: @escaping (CallCore) -> Void) {
|
|
109
|
+
let run = { if let core = CallCore.getOrNull() { block(core) } }
|
|
110
|
+
if Thread.isMainThread { run() } else { DispatchQueue.main.async(execute: run) }
|
|
111
|
+
}
|
|
@@ -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
|
+
}
|