@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.
Files changed (34) hide show
  1. package/android/src/main/java/dev/poolse/callcore/android/CallCore.kt +25 -8
  2. package/android/src/main/java/dev/poolse/callcore/android/CallVideo.kt +36 -0
  3. package/android/src/main/java/dev/poolse/callcore/android/SnapshotJson.kt +15 -0
  4. package/android/src/main/java/expo/modules/poolsecalls/PoolseCallVideoView.kt +64 -0
  5. package/android/src/main/java/expo/modules/poolsecalls/PoolseCallsModule.kt +10 -0
  6. package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitMediaPort.kt +142 -14
  7. package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitVideoSurface.kt +107 -0
  8. package/dist/index.cjs +34 -0
  9. package/dist/index.cjs.map +1 -1
  10. package/dist/index.d.cts +44 -2
  11. package/dist/index.d.ts +44 -2
  12. package/dist/index.js +33 -2
  13. package/dist/index.js.map +1 -1
  14. package/expo-module.config.json +6 -1
  15. package/ios/Core/CallCoreRuntime.swift +393 -0
  16. package/ios/Core/CallHttpClient.swift +210 -0
  17. package/ios/Core/CallMachine.swift +304 -0
  18. package/ios/Core/PORTS_READY.md +93 -0
  19. package/ios/Core/Ports.swift +193 -0
  20. package/ios/Core/SnapshotEncoding.swift +75 -0
  21. package/ios/Module/PoolseCallVideoView.swift +62 -0
  22. package/ios/Module/PoolseCallsAppDelegateSubscriber.swift +16 -0
  23. package/ios/Module/PoolseCallsModule.swift +111 -0
  24. package/ios/Platform/CallCore.swift +464 -0
  25. package/ios/Platform/CallKitController.swift +263 -0
  26. package/ios/Platform/LiveKitMediaPort.swift +445 -0
  27. package/ios/Platform/LiveKitVideoSurface.swift +67 -0
  28. package/ios/Platform/Ringback.swift +61 -0
  29. package/ios/Platform/VoipPush.swift +172 -0
  30. package/ios/PoolseCalls.podspec +64 -0
  31. package/package.json +7 -1
  32. package/src/expo-modules-core.d.ts +5 -0
  33. package/src/index.ts +85 -2
  34. 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
+ }