@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,193 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
// The seams between the pure machine and the platform. Direct port of
|
|
4
|
+
// android/.../callcore/Ports.kt (plus the state value types from
|
|
5
|
+
// CallMachine.kt). Everything is main-thread confined: adapters may call
|
|
6
|
+
// back from any thread — the runtime hops onto DispatchQueue.main.
|
|
7
|
+
|
|
8
|
+
// MARK: - HTTP (CONTRACT.md §4 — the three doors)
|
|
9
|
+
|
|
10
|
+
public protocol HttpPort: AnyObject {
|
|
11
|
+
/// POST /calls/:id/answer with the ring grant.
|
|
12
|
+
func answer(callId: String, grant: String, cb: @escaping (HttpResult) -> Void)
|
|
13
|
+
/// POST /calls/:id/join with a session token.
|
|
14
|
+
func join(callId: String, sessionToken: String, cb: @escaping (HttpResult) -> Void)
|
|
15
|
+
/// POST /conversations/:id/calls with a session token.
|
|
16
|
+
func place(conversationId: String, media: String, sessionToken: String, cb: @escaping (HttpResult) -> Void)
|
|
17
|
+
/// POST /calls/:id/decline — grant or session; fire-and-forget with retry.
|
|
18
|
+
func decline(callId: String, auth: String, busy: Bool)
|
|
19
|
+
/// POST /calls/:id/hangup with the call-control token; fire-and-forget with retry.
|
|
20
|
+
func hangup(callId: String, controlToken: String)
|
|
21
|
+
/// POST /calls/:id/reconnect with the call-control token. Answers with the
|
|
22
|
+
/// FULL bundle; `current` lets an older server's `{livekit:{url,token}}`
|
|
23
|
+
/// refresh be applied onto it.
|
|
24
|
+
func reconnect(current: CallSessionBundle, cb: @escaping (HttpResult) -> Void)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
public enum HttpResult: Equatable {
|
|
28
|
+
/// 2xx: the CallSessionBundle (or reconnect's token refresh).
|
|
29
|
+
case ok(CallSessionBundle)
|
|
30
|
+
/// 404/410 — the call is finished (or this device was superseded).
|
|
31
|
+
case gone
|
|
32
|
+
/// Timeout / transient network failure.
|
|
33
|
+
case timeout
|
|
34
|
+
/// 401/403 — grant spent, expired, or wrong device. Terminal.
|
|
35
|
+
case authFailed(code: String)
|
|
36
|
+
/// Terminal but not auth: 503, another 4xx, or an unparseable 2xx.
|
|
37
|
+
case failed(code: String)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
public struct CallSessionBundle: Equatable {
|
|
41
|
+
public var callId: String
|
|
42
|
+
public var conversationId: String
|
|
43
|
+
public var conversationName: String?
|
|
44
|
+
public var media: String
|
|
45
|
+
public var created: Bool
|
|
46
|
+
public var livekitUrl: String
|
|
47
|
+
public var livekitToken: String
|
|
48
|
+
public var controlToken: String
|
|
49
|
+
|
|
50
|
+
public init(callId: String, conversationId: String, conversationName: String?, media: String,
|
|
51
|
+
created: Bool, livekitUrl: String, livekitToken: String, controlToken: String) {
|
|
52
|
+
self.callId = callId
|
|
53
|
+
self.conversationId = conversationId
|
|
54
|
+
self.conversationName = conversationName
|
|
55
|
+
self.media = media
|
|
56
|
+
self.created = created
|
|
57
|
+
self.livekitUrl = livekitUrl
|
|
58
|
+
self.livekitToken = livekitToken
|
|
59
|
+
self.controlToken = controlToken
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// MARK: - Media
|
|
64
|
+
|
|
65
|
+
/// LiveKit (or any SFU) — media only, no lifecycle opinions.
|
|
66
|
+
public protocol MediaPort: AnyObject {
|
|
67
|
+
func join(bundle: CallSessionBundle)
|
|
68
|
+
func leave()
|
|
69
|
+
func setMuted(_ muted: Bool)
|
|
70
|
+
func setCamera(_ on: Bool)
|
|
71
|
+
func switchCamera()
|
|
72
|
+
/// Loudspeaker (`true`) or back to the private route (`false`).
|
|
73
|
+
func setSpeaker(_ on: Bool)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// MARK: - Ring surface
|
|
77
|
+
|
|
78
|
+
/// The ring UX. iOS: CallKit report + tones.
|
|
79
|
+
public protocol RingSurfacePort: AnyObject {
|
|
80
|
+
func show(ring: RingContext)
|
|
81
|
+
func dismiss(callId: String)
|
|
82
|
+
func ringtoneStart()
|
|
83
|
+
func ringtoneStop()
|
|
84
|
+
func ringbackStart()
|
|
85
|
+
func ringbackStop()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
public struct RingContext: Equatable {
|
|
89
|
+
public var callId: String
|
|
90
|
+
public var conversationId: String
|
|
91
|
+
public var conversationName: String?
|
|
92
|
+
public var callerName: String?
|
|
93
|
+
public var media: String
|
|
94
|
+
public var grant: String
|
|
95
|
+
public var ringDeadlineEpochMs: Int64
|
|
96
|
+
|
|
97
|
+
public init(callId: String, conversationId: String, conversationName: String?, callerName: String?,
|
|
98
|
+
media: String, grant: String, ringDeadlineEpochMs: Int64) {
|
|
99
|
+
self.callId = callId
|
|
100
|
+
self.conversationId = conversationId
|
|
101
|
+
self.conversationName = conversationName
|
|
102
|
+
self.callerName = callerName
|
|
103
|
+
self.media = media
|
|
104
|
+
self.grant = grant
|
|
105
|
+
self.ringDeadlineEpochMs = ringDeadlineEpochMs
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// MARK: - Session / snapshots / time
|
|
110
|
+
|
|
111
|
+
/// Android: the phoneCall foreground service. iOS: the CallKit session / audio session.
|
|
112
|
+
public protocol CallSessionPort: AnyObject {
|
|
113
|
+
func start(callId: String, media: String)
|
|
114
|
+
func stop()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/// Snapshots out — the bridge turns these into JS events (IDLE → null).
|
|
118
|
+
public protocol SnapshotSink: AnyObject {
|
|
119
|
+
func emit(state: MachineState)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/// Closure-backed SnapshotSink convenience.
|
|
123
|
+
public final class BlockSnapshotSink: SnapshotSink {
|
|
124
|
+
private let block: (MachineState) -> Void
|
|
125
|
+
public init(_ block: @escaping (MachineState) -> Void) { self.block = block }
|
|
126
|
+
public func emit(state: MachineState) { block(state) }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/// Time, injectable: real adapter uses DispatchQueue.main; tests a fake.
|
|
130
|
+
public protocol TimerPort: AnyObject {
|
|
131
|
+
func nowMs() -> Int64
|
|
132
|
+
/// Scheduling an existing key replaces it.
|
|
133
|
+
func schedule(key: String, delayMs: Int64, fire: @escaping () -> Void)
|
|
134
|
+
func cancel(key: String)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// MARK: - Machine state value types (CallMachine.kt)
|
|
138
|
+
|
|
139
|
+
public enum Phase: String, CaseIterable {
|
|
140
|
+
case idle = "IDLE"
|
|
141
|
+
case ringingIn = "RINGING_IN"
|
|
142
|
+
case ringingOut = "RINGING_OUT"
|
|
143
|
+
case connecting = "CONNECTING"
|
|
144
|
+
case active = "ACTIVE"
|
|
145
|
+
case reconnecting = "RECONNECTING"
|
|
146
|
+
case ended = "ENDED"
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
public enum Direction: String {
|
|
150
|
+
case incoming = "INCOMING"
|
|
151
|
+
case outgoing = "OUTGOING"
|
|
152
|
+
case joined = "JOINED"
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
public enum EndReason: String {
|
|
156
|
+
case left = "LEFT"
|
|
157
|
+
case finished = "FINISHED"
|
|
158
|
+
case declinedAll = "DECLINED_ALL"
|
|
159
|
+
case busyAll = "BUSY_ALL"
|
|
160
|
+
case noAnswer = "NO_ANSWER"
|
|
161
|
+
case cancelled = "CANCELLED"
|
|
162
|
+
case connectionLost = "CONNECTION_LOST"
|
|
163
|
+
case failed = "FAILED"
|
|
164
|
+
|
|
165
|
+
/// Wire form: lowercase snake (e.g. "declined_all").
|
|
166
|
+
public var wire: String { rawValue.lowercased() }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/// Everything the machine knows. Immutable value; transitions return a new one.
|
|
170
|
+
public struct MachineState: Equatable {
|
|
171
|
+
public var phase: Phase
|
|
172
|
+
public var callId: String?
|
|
173
|
+
public var conversationId: String?
|
|
174
|
+
public var direction: Direction?
|
|
175
|
+
public var media: String
|
|
176
|
+
public var reason: EndReason?
|
|
177
|
+
/// HTTP attempts made for the current connecting request (retry budget = 2).
|
|
178
|
+
public var httpAttempts: Int
|
|
179
|
+
|
|
180
|
+
public init(phase: Phase = .idle, callId: String? = nil, conversationId: String? = nil,
|
|
181
|
+
direction: Direction? = nil, media: String = "audio", reason: EndReason? = nil,
|
|
182
|
+
httpAttempts: Int = 0) {
|
|
183
|
+
self.phase = phase
|
|
184
|
+
self.callId = callId
|
|
185
|
+
self.conversationId = conversationId
|
|
186
|
+
self.direction = direction
|
|
187
|
+
self.media = media
|
|
188
|
+
self.reason = reason
|
|
189
|
+
self.httpAttempts = httpAttempts
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
public static var IDLE: MachineState { MachineState() }
|
|
193
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// One remote participant as the JS `Snapshot.participants[]` entry.
|
|
4
|
+
public struct SnapshotParticipant: Equatable {
|
|
5
|
+
/// The LiveKit identity.
|
|
6
|
+
public var userId: String
|
|
7
|
+
public var externalId: String
|
|
8
|
+
public var speaking: Bool
|
|
9
|
+
public var muted: Bool
|
|
10
|
+
/// True only with a published, subscribed, unmuted camera track.
|
|
11
|
+
public var cameraOn: Bool
|
|
12
|
+
|
|
13
|
+
public init(userId: String, externalId: String, speaking: Bool, muted: Bool, cameraOn: Bool) {
|
|
14
|
+
self.userId = userId
|
|
15
|
+
self.externalId = externalId
|
|
16
|
+
self.speaking = speaking
|
|
17
|
+
self.muted = muted
|
|
18
|
+
self.cameraOn = cameraOn
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
public var dictionary: [String: Any] {
|
|
22
|
+
["userId": userId, "externalId": externalId, "speaking": speaking, "muted": muted, "cameraOn": cameraOn]
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
public extension Phase {
|
|
27
|
+
var wire: String {
|
|
28
|
+
switch self {
|
|
29
|
+
case .idle: return "idle"
|
|
30
|
+
case .ringingIn: return "ringing_in"
|
|
31
|
+
case .ringingOut: return "ringing_out"
|
|
32
|
+
case .connecting: return "connecting"
|
|
33
|
+
case .active: return "active"
|
|
34
|
+
case .reconnecting: return "reconnecting"
|
|
35
|
+
case .ended: return "ended"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
public extension Direction {
|
|
41
|
+
var wire: String { rawValue.lowercased() }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
public extension MachineState {
|
|
45
|
+
/// The one place a MachineState becomes the contract's `Snapshot`
|
|
46
|
+
/// (CONTRACT.md §8; mirrors SnapshotJson.kt and src/index.ts). Returns nil
|
|
47
|
+
/// for idle: the bridge sends `{ snapshot: null }`. The roster/route/self
|
|
48
|
+
/// fields are merged in by the bridge from the media adapter.
|
|
49
|
+
/// `conversationName` nil → NSNull; `reason`/`cameraFacing` omitted when nil.
|
|
50
|
+
func toSnapshot(conversationName: String? = nil,
|
|
51
|
+
participants: [SnapshotParticipant] = [],
|
|
52
|
+
audioRoute: String = "earpiece",
|
|
53
|
+
mutedSelf: Bool = false,
|
|
54
|
+
cameraOn: Bool = false,
|
|
55
|
+
cameraFacing: String? = nil) -> [String: Any]? {
|
|
56
|
+
if phase == .idle { return nil }
|
|
57
|
+
var out: [String: Any] = [
|
|
58
|
+
"cv": 1,
|
|
59
|
+
"callId": callId.map { $0 as Any } ?? NSNull(),
|
|
60
|
+
"conversationId": conversationId.map { $0 as Any } ?? NSNull(),
|
|
61
|
+
"conversationName": conversationName.map { $0 as Any } ?? NSNull(),
|
|
62
|
+
"direction": (direction ?? .incoming).wire,
|
|
63
|
+
"media": media,
|
|
64
|
+
"phase": phase.wire,
|
|
65
|
+
"participants": participants.map { $0.dictionary },
|
|
66
|
+
"audioRoute": audioRoute,
|
|
67
|
+
"mutedSelf": mutedSelf,
|
|
68
|
+
"cameraOn": cameraOn,
|
|
69
|
+
]
|
|
70
|
+
if let reason = reason { out["reason"] = reason.wire }
|
|
71
|
+
// Optional in the contract: present only while a camera is published.
|
|
72
|
+
if let cameraFacing = cameraFacing { out["cameraFacing"] = cameraFacing }
|
|
73
|
+
return out
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -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
|
+
}
|