@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,304 @@
1
+ import Foundation
2
+
3
+ // The call state machine — contract cv 1, CONTRACT.md §7. Direct port of
4
+ // android/.../callcore/CallMachine.kt. Pure: no I/O, no platform, no time.
5
+ // Pinned by contracts/call-core/vectors.json — change the vectors first.
6
+
7
+ /// Inputs, named exactly as in the contract and vectors.
8
+ public enum Event: Equatable {
9
+ /// A ring from push or socket — treated identically. `stale`: deadline passed. Never surface.
10
+ case ring(callId: String, conversationId: String? = nil, media: String = "audio", stale: Bool = false)
11
+ case ringEnded(callId: String, reason: String)
12
+ /// The server finished the call; `reason` only colours an unanswered caller's ended screen.
13
+ case callFinished(callId: String, reason: String? = nil)
14
+ case userAnswer
15
+ case userDecline
16
+ case userPlace(callId: String, conversationId: String? = nil, media: String = "audio")
17
+ case userJoin(callId: String, conversationId: String? = nil)
18
+ case userHangup
19
+ /// Server accepted answer/join/place. `created=false` on place: an existing live call (became a join).
20
+ case httpOk(created: Bool = true)
21
+ case httpGone
22
+ case httpTimeout
23
+ /// Terminal refusal that is NOT "the call is over" (401/403, 503, unparseable 2xx).
24
+ case httpFailed(code: String = "failed")
25
+ case mediaConnected
26
+ case mediaFailed
27
+ case mediaLost
28
+ /// The SFU is recovering on its own. Show it; do not rejoin.
29
+ case mediaReconnecting
30
+ case mediaRecovered
31
+ case remoteJoined
32
+ /// Caller side: every ring resolved without anyone joining.
33
+ case allRingsResolved(outcome: String)
34
+ case ringDeadline
35
+ case reconnectBudgetElapsed
36
+ /// CONNECTING watchdog.
37
+ case connectTimeout
38
+ case lingerElapsed
39
+ }
40
+
41
+ /// Abstract effects — each adapter maps them to its platform.
42
+ public enum Effect: Equatable {
43
+ case showRing, dismissRing, ringtoneStart, ringtoneStop, ringbackStart, ringbackStop
44
+ case httpAnswer, httpJoin, httpPlace, httpDecline
45
+ case httpDeclineBusy(callId: String)
46
+ case httpHangup, httpReconnect, mediaJoin, mediaLeave, callSessionStart, callSessionStop
47
+
48
+ public var wire: String {
49
+ switch self {
50
+ case .showRing: return "SHOW_RING"
51
+ case .dismissRing: return "DISMISS_RING"
52
+ case .ringtoneStart: return "RINGTONE_START"
53
+ case .ringtoneStop: return "RINGTONE_STOP"
54
+ case .ringbackStart: return "RINGBACK_START"
55
+ case .ringbackStop: return "RINGBACK_STOP"
56
+ case .httpAnswer: return "HTTP_ANSWER"
57
+ case .httpJoin: return "HTTP_JOIN"
58
+ case .httpPlace: return "HTTP_PLACE"
59
+ case .httpDecline: return "HTTP_DECLINE"
60
+ case .httpDeclineBusy(let callId): return "HTTP_DECLINE_BUSY:\(callId)"
61
+ case .httpHangup: return "HTTP_HANGUP"
62
+ case .httpReconnect: return "HTTP_RECONNECT"
63
+ case .mediaJoin: return "MEDIA_JOIN"
64
+ case .mediaLeave: return "MEDIA_LEAVE"
65
+ case .callSessionStart: return "CALL_SESSION_START"
66
+ case .callSessionStop: return "CALL_SESSION_STOP"
67
+ }
68
+ }
69
+ }
70
+
71
+ public struct Transition: Equatable {
72
+ public let state: MachineState
73
+ public let effects: [Effect]
74
+ public init(_ state: MachineState, _ effects: [Effect]) {
75
+ self.state = state
76
+ self.effects = effects
77
+ }
78
+ }
79
+
80
+ private extension MachineState {
81
+ func with(phase: Phase? = nil, reason: EndReason?? = nil, direction: Direction?? = nil,
82
+ httpAttempts: Int? = nil) -> MachineState {
83
+ var c = self
84
+ if let phase = phase { c.phase = phase }
85
+ if let reason = reason { c.reason = reason }
86
+ if let direction = direction { c.direction = direction }
87
+ if let httpAttempts = httpAttempts { c.httpAttempts = httpAttempts }
88
+ return c
89
+ }
90
+ }
91
+
92
+ public enum CallMachine {
93
+
94
+ public static func transition(_ s: MachineState, _ e: Event) -> Transition {
95
+ switch e {
96
+
97
+ case let .ring(callId, conversationId, media, stale):
98
+ return ring(s, callId: callId, conversationId: conversationId, media: media, stale: stale)
99
+
100
+ case let .ringEnded(callId, _):
101
+ if s.phase == .ringingIn && callId == s.callId {
102
+ return Transition(.IDLE, [.ringtoneStop, .dismissRing])
103
+ }
104
+ return noop(s)
105
+
106
+ case let .callFinished(callId, reason):
107
+ guard callId == s.callId else { return noop(s) }
108
+ switch s.phase {
109
+ case .ringingIn:
110
+ return Transition(.IDLE, [.ringtoneStop, .dismissRing])
111
+ case .ringingOut:
112
+ return Transition(s.with(phase: .ended, reason: .some(ringOutcome(reason))),
113
+ [.ringbackStop, .mediaLeave, .callSessionStop])
114
+ case .active, .reconnecting, .connecting:
115
+ return Transition(s.with(phase: .ended, reason: .some(.finished)), [.mediaLeave, .callSessionStop])
116
+ case .ended:
117
+ let outcome = ringOutcome(reason)
118
+ if s.direction == .outgoing && s.reason == .finished && outcome != .finished {
119
+ return Transition(s.with(reason: .some(outcome)), [])
120
+ }
121
+ return noop(s)
122
+ default:
123
+ return noop(s)
124
+ }
125
+
126
+ case .userAnswer:
127
+ if s.phase == .ringingIn {
128
+ return Transition(s.with(phase: .connecting, direction: .some(.incoming), httpAttempts: 1),
129
+ [.ringtoneStop, .dismissRing, .httpAnswer, .callSessionStart])
130
+ }
131
+ return noop(s)
132
+
133
+ case .userDecline:
134
+ if s.phase == .ringingIn {
135
+ return Transition(.IDLE, [.ringtoneStop, .dismissRing, .httpDecline])
136
+ }
137
+ return noop(s)
138
+
139
+ case let .userPlace(callId, conversationId, media):
140
+ if s.phase == .idle || s.phase == .ended {
141
+ return Transition(MachineState(phase: .connecting, callId: callId, conversationId: conversationId,
142
+ direction: .outgoing, media: media, httpAttempts: 1),
143
+ [.httpPlace, .callSessionStart])
144
+ }
145
+ return noop(s)
146
+
147
+ case let .userJoin(callId, conversationId):
148
+ if s.phase == .idle || s.phase == .ended {
149
+ return Transition(MachineState(phase: .connecting, callId: callId, conversationId: conversationId,
150
+ direction: .joined, httpAttempts: 1),
151
+ [.httpJoin, .callSessionStart])
152
+ }
153
+ return noop(s)
154
+
155
+ case .userHangup:
156
+ switch s.phase {
157
+ case .ringingOut:
158
+ return Transition(s.with(phase: .ended, reason: .some(.cancelled)),
159
+ [.ringbackStop, .mediaLeave, .httpHangup, .callSessionStop])
160
+ case .active, .reconnecting, .connecting:
161
+ return Transition(s.with(phase: .ended, reason: .some(.left)),
162
+ [.mediaLeave, .httpHangup, .callSessionStop])
163
+ default:
164
+ return noop(s)
165
+ }
166
+
167
+ case let .httpOk(created):
168
+ if s.phase != .connecting { return noop(s) }
169
+ if s.direction == .outgoing && created {
170
+ return Transition(s.with(phase: .ringingOut), [.mediaJoin, .ringbackStart])
171
+ }
172
+ if s.direction == .outgoing && !created {
173
+ return Transition(s.with(direction: .some(.joined)), [.mediaJoin])
174
+ }
175
+ return Transition(s, [.mediaJoin])
176
+
177
+ case .httpGone:
178
+ switch s.phase {
179
+ case .connecting:
180
+ return Transition(s.with(phase: .ended, reason: .some(.finished)), [.callSessionStop])
181
+ case .reconnecting:
182
+ return Transition(s.with(phase: .ended, reason: .some(.left)), [.mediaLeave, .callSessionStop])
183
+ default:
184
+ return noop(s)
185
+ }
186
+
187
+ case .httpTimeout:
188
+ if s.phase != .connecting { return noop(s) }
189
+ if s.httpAttempts < 2 {
190
+ let retry: Effect
191
+ switch s.direction {
192
+ case .outgoing?: retry = .httpPlace
193
+ case .joined?: retry = .httpJoin
194
+ default: retry = .httpAnswer
195
+ }
196
+ return Transition(s.with(httpAttempts: s.httpAttempts + 1), [retry])
197
+ }
198
+ return Transition(s.with(phase: .ended, reason: .some(.failed)), [.httpHangup, .callSessionStop])
199
+
200
+ case .httpFailed:
201
+ switch s.phase {
202
+ case .connecting:
203
+ return Transition(s.with(phase: .ended, reason: .some(.failed)), [.httpHangup, .callSessionStop])
204
+ case .reconnecting:
205
+ return Transition(s.with(phase: .ended, reason: .some(.failed)), [.mediaLeave, .callSessionStop])
206
+ default:
207
+ return noop(s)
208
+ }
209
+
210
+ case .connectTimeout:
211
+ if s.phase == .connecting {
212
+ return Transition(s.with(phase: .ended, reason: .some(.failed)),
213
+ [.mediaLeave, .httpHangup, .callSessionStop])
214
+ }
215
+ return noop(s)
216
+
217
+ case .mediaConnected:
218
+ switch s.phase {
219
+ case .connecting, .reconnecting: return Transition(s.with(phase: .active), [])
220
+ default: return noop(s)
221
+ }
222
+
223
+ case .mediaFailed:
224
+ switch s.phase {
225
+ case .connecting:
226
+ return Transition(s.with(phase: .ended, reason: .some(.failed)), [.httpHangup, .callSessionStop])
227
+ case .ringingOut:
228
+ return Transition(s.with(phase: .ended, reason: .some(.failed)),
229
+ [.ringbackStop, .mediaLeave, .httpHangup, .callSessionStop])
230
+ default:
231
+ return noop(s)
232
+ }
233
+
234
+ case .mediaLost:
235
+ switch s.phase {
236
+ case .active: return Transition(s.with(phase: .reconnecting), [.httpReconnect])
237
+ case .reconnecting: return Transition(s, [.httpReconnect])
238
+ default: return noop(s)
239
+ }
240
+
241
+ case .mediaReconnecting:
242
+ return s.phase == .active ? Transition(s.with(phase: .reconnecting), []) : noop(s)
243
+
244
+ case .mediaRecovered:
245
+ return s.phase == .reconnecting ? Transition(s.with(phase: .active), []) : noop(s)
246
+
247
+ case .remoteJoined:
248
+ return s.phase == .ringingOut ? Transition(s.with(phase: .active), [.ringbackStop]) : noop(s)
249
+
250
+ case let .allRingsResolved(outcome):
251
+ guard s.phase == .ringingOut else { return noop(s) }
252
+ let reason: EndReason
253
+ switch outcome {
254
+ case "busy_all": reason = .busyAll
255
+ case "no_answer": reason = .noAnswer
256
+ case "cancelled": reason = .cancelled
257
+ default: reason = .declinedAll
258
+ }
259
+ return Transition(s.with(phase: .ended, reason: .some(reason)),
260
+ [.ringbackStop, .mediaLeave, .httpHangup, .callSessionStop])
261
+
262
+ case .ringDeadline:
263
+ return s.phase == .ringingIn ? Transition(.IDLE, [.ringtoneStop, .dismissRing]) : noop(s)
264
+
265
+ case .reconnectBudgetElapsed:
266
+ if s.phase == .reconnecting {
267
+ return Transition(s.with(phase: .ended, reason: .some(.connectionLost)),
268
+ [.mediaLeave, .httpHangup, .callSessionStop])
269
+ }
270
+ return noop(s)
271
+
272
+ case .lingerElapsed:
273
+ return s.phase == .ended ? Transition(.IDLE, []) : noop(s)
274
+ }
275
+ }
276
+
277
+ private static func ring(_ s: MachineState, callId: String, conversationId: String?, media: String,
278
+ stale: Bool) -> Transition {
279
+ if stale { return noop(s) }
280
+ // Already showing this very ring (push + socket race): one surface.
281
+ if s.phase == .ringingIn && callId == s.callId { return noop(s) }
282
+ // A fresh ring on idle — or over a lingering ended screen.
283
+ if s.phase == .idle || s.phase == .ended {
284
+ return Transition(MachineState(phase: .ringingIn, callId: callId, conversationId: conversationId,
285
+ direction: .incoming, media: media),
286
+ [.showRing, .ringtoneStart])
287
+ }
288
+ // Busy with another call: auto-decline the newcomer.
289
+ return Transition(s, [.httpDeclineBusy(callId: callId)])
290
+ }
291
+
292
+ /// A finish push's reason, read as the caller's unanswered outcome.
293
+ private static func ringOutcome(_ reason: String?) -> EndReason {
294
+ switch reason {
295
+ case "no_answer"?, "timeout"?: return .noAnswer
296
+ case "declined_all"?, "declined"?: return .declinedAll
297
+ case "busy_all"?: return .busyAll
298
+ case "cancelled"?: return .cancelled
299
+ default: return .finished
300
+ }
301
+ }
302
+
303
+ private static func noop(_ s: MachineState) -> Transition { Transition(s, []) }
304
+ }
@@ -0,0 +1,93 @@
1
+ # iOS call core — ports & runtime API (ready to code against)
2
+
3
+ Swift port of `android/.../callcore/{Ports,CallMachine,CallCoreRuntime}.kt`.
4
+ Foundation only, main-thread confined, no Sendable. All types `public`.
5
+
6
+ ## Ports (ios/Core/Ports.swift)
7
+
8
+ ```swift
9
+ protocol HttpPort: AnyObject {
10
+ func answer(callId: String, grant: String, cb: @escaping (HttpResult) -> Void)
11
+ func join(callId: String, sessionToken: String, cb: @escaping (HttpResult) -> Void)
12
+ func place(conversationId: String, media: String, sessionToken: String, cb: @escaping (HttpResult) -> Void)
13
+ func decline(callId: String, auth: String, busy: Bool)
14
+ func hangup(callId: String, controlToken: String)
15
+ func reconnect(current: CallSessionBundle, cb: @escaping (HttpResult) -> Void)
16
+ }
17
+ enum HttpResult { case ok(CallSessionBundle), gone, timeout, authFailed(code: String), failed(code: String) }
18
+ struct CallSessionBundle { callId, conversationId, conversationName?, media, created, livekitUrl, livekitToken, controlToken }
19
+
20
+ protocol MediaPort: AnyObject {
21
+ func join(bundle: CallSessionBundle); func leave()
22
+ func setMuted(_ muted: Bool); func setCamera(_ on: Bool); func switchCamera(); func setSpeaker(_ on: Bool)
23
+ }
24
+ protocol RingSurfacePort: AnyObject {
25
+ func show(ring: RingContext); func dismiss(callId: String)
26
+ func ringtoneStart(); func ringtoneStop(); func ringbackStart(); func ringbackStop()
27
+ }
28
+ struct RingContext { callId, conversationId, conversationName?, callerName?, media, grant, ringDeadlineEpochMs: Int64 }
29
+ protocol CallSessionPort: AnyObject { func start(callId: String, media: String); func stop() }
30
+ protocol SnapshotSink: AnyObject { func emit(state: MachineState) } // BlockSnapshotSink { state in ... } helper
31
+ protocol TimerPort: AnyObject {
32
+ func nowMs() -> Int64
33
+ func schedule(key: String, delayMs: Int64, fire: @escaping () -> Void) // same key replaces
34
+ func cancel(key: String)
35
+ }
36
+ // State values: Phase (.idle .ringingIn .ringingOut .connecting .active .reconnecting .ended),
37
+ // Direction (.incoming .outgoing .joined), EndReason (.left .finished .declinedAll .busyAll
38
+ // .noAnswer .cancelled .connectionLost .failed; `.wire` = "declined_all" etc.),
39
+ // MachineState { phase, callId?, conversationId?, direction?, media, reason?, httpAttempts }
40
+ ```
41
+
42
+ Provided implementations in Core: `CallHttpClient` (HttpPort, URLSession),
43
+ `MainQueueTimerPort()` (TimerPort on DispatchQueue.main, epoch nowMs), `BlockSnapshotSink { state in }`.
44
+
45
+ ## Runtime (ios/Core/CallCoreRuntime.swift)
46
+
47
+ ```swift
48
+ let rt = CallCoreRuntime(
49
+ http: HttpPort, media: MediaPort, surface: RingSurfacePort,
50
+ session: CallSessionPort, timers: TimerPort, sink: SnapshotSink,
51
+ executor: ((@escaping () -> Void) -> Void)? = nil) // default: inline if on main thread, else DispatchQueue.main.async
52
+
53
+ rt.currentState() -> MachineState
54
+ rt.conversationName: String? // bundle's, else the ring's (main thread)
55
+ rt.isResolved(_ callId: String) -> Bool
56
+ rt.post(_ block: @escaping () -> Void)
57
+
58
+ // inputs
59
+ rt.onRing(_ ctx: RingContext) // push (PushKit) or socket — same path
60
+ rt.onRingEnded(callId:reason:)
61
+ rt.onCallFinished(callId:reason:) // reason: String? = nil
62
+ rt.answer(); rt.decline(); rt.hangUp()
63
+ rt.place(conversationId:media:sessionToken:)
64
+ rt.join(callId:conversationId:sessionToken:) // conversationId: String?
65
+ rt.allRingsResolved(_ outcome: String)
66
+ rt.onMediaConnected(); rt.onMediaFailed(); rt.onMediaLost()
67
+ rt.onMediaReconnecting(); rt.onMediaRecovered(); rt.onRemoteJoined()
68
+ rt.setMuted(_:); rt.setCamera(_:); rt.switchCamera(); rt.setSpeaker(_:)
69
+ rt.reemit() // re-emit current state (roster/route changed)
70
+ ```
71
+
72
+ Constants: `CallCoreRuntime.ENDED_LINGER_MS` (2500), `RECONNECT_BUDGET_MS` (25000),
73
+ `CONNECT_WATCHDOG_MS` (20000), `RING_OUT_BACKSTOP_MS` (75000), `CLOCK_SLACK_MS` (10000), `RESOLVED_LRU` (32).
74
+
75
+ The sink receives the MachineState after every change; IDLE means "no call" (emit `snapshot: null`).
76
+ Ended state lingers 2.5 s, then IDLE is emitted.
77
+
78
+ ## HTTP client (ios/Core/CallHttpClient.swift)
79
+
80
+ ```swift
81
+ CallHttpClient(baseUrl: @escaping () -> String, session: URLSession = CallHttpClient.defaultSession())
82
+ // baseUrl read per request (staging/prod switch). 9 s total timeout. Static classify/parseBundle/errorCode are pure.
83
+ ```
84
+
85
+ ## Snapshot (ios/Core/SnapshotEncoding.swift)
86
+
87
+ ```swift
88
+ struct SnapshotParticipant { userId, externalId, speaking, muted, cameraOn }
89
+ MachineState.toSnapshot(conversationName: String? = nil,
90
+ participants: [SnapshotParticipant] = [], audioRoute: String = "earpiece",
91
+ mutedSelf: Bool = false, cameraOn: Bool = false, cameraFacing: String? = nil) -> [String: Any]?
92
+ // nil for IDLE. conversationName nil -> NSNull; reason / cameraFacing omitted when nil.
93
+ ```
@@ -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
+ }