@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.
@@ -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
+ }