@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,393 @@
1
+ import Foundation
2
+
3
+ /// The engine around the machine: owns the state, serializes events,
4
+ /// executes effects through the ports, runs the timers, and emits a
5
+ /// snapshot after every change. Direct port of CallCoreRuntime.kt.
6
+ ///
7
+ /// The machine decides; this class merely obeys. If behavior is wrong,
8
+ /// the fix is in CallMachine + vectors, not here.
9
+ ///
10
+ /// Threading: ONE serial queue (the main thread), no lock. Every input —
11
+ /// PushKit, CallKit actions, URLSession callbacks, SFU events, timers, JS
12
+ /// commands — is `post`ed through `executor` (default: hop to
13
+ /// DispatchQueue.main). A result produced while effects execute is QUEUED
14
+ /// behind the current dispatch, never run inside it, and each dispatch
15
+ /// emits its snapshot only after its last effect. Tests pass a direct
16
+ /// executor; the internal trampoline gives them the same ordering.
17
+ public final class CallCoreRuntime {
18
+
19
+ public static let ENDED_LINGER_MS: Int64 = 2_500
20
+ public static let RECONNECT_BUDGET_MS: Int64 = 25_000
21
+ /// CONNECTING watchdog: two HTTP attempts plus an SFU connect fit inside.
22
+ public static let CONNECT_WATCHDOG_MS: Int64 = 20_000
23
+ /// Caller-side backstop if the server's finish/no-answer never arrives.
24
+ public static let RING_OUT_BACKSTOP_MS: Int64 = 75_000
25
+ /// Device clocks drift; the deadline is server epoch.
26
+ public static let CLOCK_SLACK_MS: Int64 = 10_000
27
+ public static let RESOLVED_LRU = 32
28
+
29
+ private static let T_DEADLINE = "ring_deadline"
30
+ private static let T_LINGER = "linger"
31
+ private static let T_RECONNECT = "reconnect_budget"
32
+ private static let T_CONNECT = "connect_watchdog"
33
+ private static let T_RING_OUT = "ring_out_backstop"
34
+
35
+ private let http: HttpPort
36
+ private let media: MediaPort
37
+ private let surface: RingSurfacePort
38
+ private let session: CallSessionPort
39
+ private let timers: TimerPort
40
+ private let sink: SnapshotSink
41
+ private let executor: (@escaping () -> Void) -> Void
42
+
43
+ private var state: MachineState = .IDLE
44
+ /// The ring being shown (its grant answers/declines it).
45
+ private var ring: RingContext?
46
+ /// Grants of rings auto-declined busy, kept until the decline fires.
47
+ private var busyRings: [String: RingContext] = [:]
48
+ /// The session bundle of the current call, once the server accepted.
49
+ private var bundle: CallSessionBundle?
50
+ /// The session token place/join were called with, for one retry.
51
+ private var sessionToken: String?
52
+ /// Call generation: bumped when a call starts CONNECTING and when one ends.
53
+ private var gen = 0
54
+ /// callIds already resolved on this device; bounded LRU (insertion/refresh order).
55
+ private var resolved: [String] = []
56
+
57
+ private var queue: [() -> Void] = []
58
+ private var draining = false
59
+
60
+ /// `executor` runs a task on the single runtime thread; must be serial.
61
+ /// Default: inline when already on the main thread, else DispatchQueue.main.async.
62
+ public init(http: HttpPort, media: MediaPort, surface: RingSurfacePort, session: CallSessionPort,
63
+ timers: TimerPort, sink: SnapshotSink,
64
+ executor: ((@escaping () -> Void) -> Void)? = nil) {
65
+ self.http = http
66
+ self.media = media
67
+ self.surface = surface
68
+ self.session = session
69
+ self.timers = timers
70
+ self.sink = sink
71
+ self.executor = executor ?? { block in
72
+ if Thread.isMainThread { block() } else { DispatchQueue.main.async(execute: block) }
73
+ }
74
+ }
75
+
76
+ // MARK: serial queue
77
+
78
+ /// Run `block` on the runtime thread, after anything already queued.
79
+ public func post(_ block: @escaping () -> Void) {
80
+ executor { [weak self] in self?.enqueue(block) }
81
+ }
82
+
83
+ private func enqueue(_ block: @escaping () -> Void) {
84
+ queue.append(block)
85
+ if draining { return } // a dispatch is executing: run after it, not inside it
86
+ draining = true
87
+ defer { draining = false }
88
+ while !queue.isEmpty {
89
+ let next = queue.removeFirst()
90
+ next()
91
+ }
92
+ }
93
+
94
+ // MARK: inputs from adapters
95
+
96
+ public func currentState() -> MachineState { state }
97
+
98
+ /// Best name for the current call: the server's bundle, else the ring's. Runtime thread only.
99
+ public var conversationName: String? { bundle?.conversationName ?? ring?.conversationName }
100
+
101
+ /// True if `callId` was already resolved here — its ring must never surface again.
102
+ public func isResolved(_ callId: String) -> Bool { resolved.contains(callId) }
103
+
104
+ private func markResolved(_ callId: String?) {
105
+ guard let callId = callId, !callId.hasPrefix("pending:") else { return }
106
+ resolved.removeAll { $0 == callId }
107
+ resolved.append(callId)
108
+ if resolved.count > CallCoreRuntime.RESOLVED_LRU { resolved.removeFirst(resolved.count - CallCoreRuntime.RESOLVED_LRU) }
109
+ }
110
+
111
+ /// A ring arrived — push or socket, the machine treats them alike.
112
+ public func onRing(_ ctx: RingContext) {
113
+ post { [self] in
114
+ if isResolved(ctx.callId) { return }
115
+ // A late duplicate of the ring for the call we are already IN must
116
+ // not read as "another call while busy".
117
+ let s = state
118
+ if s.callId == ctx.callId && s.phase != .idle && s.phase != .ringingIn { return }
119
+ let stale = ctx.ringDeadlineEpochMs + CallCoreRuntime.CLOCK_SLACK_MS <= timers.nowMs()
120
+ if !stale { busyRings[ctx.callId] = ctx }
121
+ dispatch(.ring(callId: ctx.callId, conversationId: ctx.conversationId, media: ctx.media, stale: stale),
122
+ ringCtx: ctx)
123
+ busyRings.removeValue(forKey: ctx.callId)
124
+ }
125
+ }
126
+
127
+ public func onRingEnded(callId: String, reason: String) {
128
+ post { [self] in
129
+ dispatch(.ringEnded(callId: callId, reason: reason))
130
+ markResolved(callId)
131
+ }
132
+ }
133
+
134
+ public func onCallFinished(callId: String, reason: String? = nil) {
135
+ post { [self] in
136
+ dispatch(.callFinished(callId: callId, reason: reason))
137
+ markResolved(callId)
138
+ }
139
+ }
140
+
141
+ public func answer() { post { [self] in dispatch(.userAnswer) } }
142
+ public func decline() { post { [self] in dispatch(.userDecline) } }
143
+ public func hangUp() { post { [self] in dispatch(.userHangup) } }
144
+
145
+ public func place(conversationId: String, media: String, sessionToken: String) {
146
+ post { [self] in
147
+ if state.phase != .idle && state.phase != .ended { return }
148
+ self.sessionToken = sessionToken
149
+ // call_id is server-minted on place; a placeholder keys the state until the bundle arrives.
150
+ dispatch(.userPlace(callId: "pending:\(conversationId)", conversationId: conversationId, media: media))
151
+ }
152
+ }
153
+
154
+ public func join(callId: String, conversationId: String?, sessionToken: String) {
155
+ post { [self] in
156
+ if state.phase != .idle && state.phase != .ended { return }
157
+ self.sessionToken = sessionToken
158
+ dispatch(.userJoin(callId: callId, conversationId: conversationId))
159
+ }
160
+ }
161
+
162
+ /// Caller-side: the bridge resolved every ring from `ring_states`.
163
+ public func allRingsResolved(_ outcome: String) { post { [self] in dispatch(.allRingsResolved(outcome: outcome)) } }
164
+
165
+ public func onMediaConnected() { post { [self] in dispatch(.mediaConnected) } }
166
+ public func onMediaFailed() { post { [self] in dispatch(.mediaFailed) } }
167
+ public func onMediaLost() { post { [self] in dispatch(.mediaLost) } }
168
+ public func onMediaReconnecting() { post { [self] in dispatch(.mediaReconnecting) } }
169
+ public func onMediaRecovered() { post { [self] in dispatch(.mediaRecovered) } }
170
+ public func onRemoteJoined() { post { [self] in dispatch(.remoteJoined) } }
171
+
172
+ public func setMuted(_ muted: Bool) { post { [self] in media.setMuted(muted) } }
173
+ public func setCamera(_ on: Bool) { post { [self] in media.setCamera(on) } }
174
+ public func switchCamera() { post { [self] in media.switchCamera() } }
175
+ public func setSpeaker(_ on: Bool) { post { [self] in media.setSpeaker(on) } }
176
+
177
+ /// Re-emit the current state (the bridge's roster/route changed).
178
+ public func reemit() { post { [self] in if state.phase != .idle { sink.emit(state: state) } } }
179
+
180
+ // MARK: dispatch
181
+
182
+ private func isTerminal(_ p: Phase) -> Bool { p == .idle || p == .ended }
183
+
184
+ private func dispatch(_ event: Event, ringCtx: RingContext? = nil) {
185
+ let before = state
186
+ let t = CallMachine.transition(before, event)
187
+ let next = t.state
188
+ state = next
189
+ // A new call attempt, or the end of one: old callbacks go stale.
190
+ if next.phase == .connecting && before.phase != .connecting { gen += 1 }
191
+ if isTerminal(next.phase) && !isTerminal(before.phase) {
192
+ gen += 1
193
+ markResolved(before.callId)
194
+ }
195
+ for effect in t.effects { execute(effect, ringCtx: ringCtx) }
196
+ // Retire the ring grant / bundle only after EVERY effect had its chance.
197
+ if isTerminal(next.phase) {
198
+ ring = nil
199
+ bundle = nil
200
+ sessionToken = nil
201
+ }
202
+ runTimers(before, next)
203
+ if next != before || !t.effects.isEmpty { sink.emit(state: next) }
204
+ }
205
+
206
+ private func execute(_ effect: Effect, ringCtx: RingContext?) {
207
+ let g = gen
208
+ switch effect {
209
+ case .showRing:
210
+ ring = ringCtx
211
+ if let r = ringCtx { surface.show(ring: r) }
212
+ case .dismissRing:
213
+ // The grant is NOT retired here: HTTP_DECLINE may follow in this list.
214
+ if let r = ring ?? ringCtx { surface.dismiss(callId: r.callId) }
215
+ case .ringtoneStart: surface.ringtoneStart()
216
+ case .ringtoneStop: surface.ringtoneStop()
217
+ case .ringbackStart: surface.ringbackStart()
218
+ case .ringbackStop: surface.ringbackStop()
219
+
220
+ case .httpAnswer:
221
+ if let r = ring {
222
+ http.answer(callId: r.callId, grant: r.grant) { [weak self] res in
223
+ self?.post { self?.onHttpResult(g, res) }
224
+ }
225
+ } else {
226
+ post { [weak self] in self?.onHttpResult(g, .authFailed(code: "no_grant")) }
227
+ }
228
+ case .httpJoin:
229
+ if let callId = state.callId, let token = sessionToken {
230
+ http.join(callId: callId, sessionToken: token) { [weak self] res in
231
+ self?.post { self?.onHttpResult(g, res) }
232
+ }
233
+ } else {
234
+ post { [weak self] in self?.onHttpResult(g, .authFailed(code: "no_session")) }
235
+ }
236
+ case .httpPlace:
237
+ if let conversationId = state.conversationId, let token = sessionToken {
238
+ http.place(conversationId: conversationId, media: state.media, sessionToken: token) { [weak self] res in
239
+ self?.post { self?.onHttpResult(g, res) }
240
+ }
241
+ } else {
242
+ post { [weak self] in self?.onHttpResult(g, .authFailed(code: "no_session")) }
243
+ }
244
+ case .httpDecline:
245
+ if let r = ring { http.decline(callId: r.callId, auth: r.grant, busy: false) }
246
+ case let .httpDeclineBusy(callId):
247
+ if let r = busyRings[callId] { http.decline(callId: r.callId, auth: r.grant, busy: true) }
248
+ markResolved(callId)
249
+ case .httpHangup:
250
+ if let b = bundle {
251
+ http.hangup(callId: b.callId, controlToken: b.controlToken)
252
+ } else if let r = ring {
253
+ // Hanging up a connecting answer with no bundle yet: resolve the ring instead.
254
+ http.decline(callId: r.callId, auth: r.grant, busy: false)
255
+ }
256
+ case .httpReconnect:
257
+ if let b = bundle {
258
+ http.reconnect(current: b) { [weak self] res in
259
+ self?.post { self?.onReconnectResult(g, res) }
260
+ }
261
+ } else {
262
+ post { [weak self] in
263
+ guard let self = self else { return }
264
+ if g == self.gen { self.dispatch(.httpGone) }
265
+ }
266
+ }
267
+
268
+ case .mediaJoin:
269
+ if let b = bundle { media.join(bundle: b) }
270
+ case .mediaLeave:
271
+ media.leave()
272
+
273
+ case .callSessionStart:
274
+ session.start(callId: state.callId ?? "", media: state.media)
275
+ case .callSessionStop:
276
+ session.stop()
277
+ bundle = nil
278
+ ring = nil
279
+ sessionToken = nil
280
+ }
281
+ }
282
+
283
+ private func onHttpResult(_ requestGen: Int, _ result: HttpResult) {
284
+ if requestGen != gen {
285
+ // The call this request belonged to is over. The server may still have
286
+ // let us in: leave with the token it just gave us, and keep nothing.
287
+ if case let .ok(b) = result { http.hangup(callId: b.callId, controlToken: b.controlToken) }
288
+ return
289
+ }
290
+ switch result {
291
+ case let .ok(b):
292
+ if let existing = bundle {
293
+ // The timeout retry's twin answered too. Same call: ignore; different: leave it.
294
+ if existing.callId != b.callId { http.hangup(callId: b.callId, controlToken: b.controlToken) }
295
+ return
296
+ }
297
+ if state.phase != .connecting {
298
+ http.hangup(callId: b.callId, controlToken: b.controlToken)
299
+ return
300
+ }
301
+ bundle = b
302
+ // The server is the authority on the call's identity, conversation and media.
303
+ state.callId = b.callId
304
+ state.conversationId = b.conversationId
305
+ state.media = b.media
306
+ dispatch(.httpOk(created: b.created))
307
+ case .gone: dispatch(.httpGone)
308
+ case .timeout: dispatch(.httpTimeout)
309
+ case let .authFailed(code): dispatch(.httpFailed(code: code))
310
+ case let .failed(code): dispatch(.httpFailed(code: code))
311
+ }
312
+ }
313
+
314
+ private func onReconnectResult(_ requestGen: Int, _ result: HttpResult) {
315
+ if requestGen != gen || state.phase != .reconnecting { return }
316
+ switch result {
317
+ case let .ok(b):
318
+ // Fresh token (and possibly URL): rejoin. MEDIA_CONNECTED returns us to ACTIVE.
319
+ bundle = b
320
+ media.join(bundle: b)
321
+ case .gone: dispatch(.httpGone)
322
+ case let .authFailed(code): dispatch(.httpFailed(code: code))
323
+ case .timeout, .failed: break // transient: the reconnect budget timer is the backstop
324
+ }
325
+ }
326
+
327
+ // MARK: timers
328
+
329
+ private func schedule(_ key: String, _ delayMs: Int64, _ event: Event) {
330
+ let g = gen
331
+ timers.schedule(key: key, delayMs: delayMs) { [weak self] in
332
+ self?.post {
333
+ guard let self = self else { return }
334
+ if g == self.gen { self.dispatch(event) }
335
+ }
336
+ }
337
+ }
338
+
339
+ private func runTimers(_ before: MachineState, _ after: MachineState) {
340
+ if before.phase == after.phase { return }
341
+ switch before.phase {
342
+ case .ringingIn: timers.cancel(key: CallCoreRuntime.T_DEADLINE)
343
+ case .ended: timers.cancel(key: CallCoreRuntime.T_LINGER)
344
+ case .reconnecting: timers.cancel(key: CallCoreRuntime.T_RECONNECT)
345
+ case .connecting: timers.cancel(key: CallCoreRuntime.T_CONNECT)
346
+ case .ringingOut: timers.cancel(key: CallCoreRuntime.T_RING_OUT)
347
+ default: break
348
+ }
349
+ switch after.phase {
350
+ case .ringingIn:
351
+ guard let deadline = ring?.ringDeadlineEpochMs else { return }
352
+ // Floor at the clock slack: a non-stale ring still gets a moment on screen.
353
+ let delay = max(deadline - timers.nowMs(), CallCoreRuntime.CLOCK_SLACK_MS)
354
+ schedule(CallCoreRuntime.T_DEADLINE, delay, .ringDeadline)
355
+ case .ended:
356
+ schedule(CallCoreRuntime.T_LINGER, CallCoreRuntime.ENDED_LINGER_MS, .lingerElapsed)
357
+ case .reconnecting:
358
+ schedule(CallCoreRuntime.T_RECONNECT, CallCoreRuntime.RECONNECT_BUDGET_MS, .reconnectBudgetElapsed)
359
+ case .connecting:
360
+ schedule(CallCoreRuntime.T_CONNECT, CallCoreRuntime.CONNECT_WATCHDOG_MS, .connectTimeout)
361
+ case .ringingOut:
362
+ schedule(CallCoreRuntime.T_RING_OUT, CallCoreRuntime.RING_OUT_BACKSTOP_MS, .allRingsResolved(outcome: "no_answer"))
363
+ default: break
364
+ }
365
+ }
366
+ }
367
+
368
+ /// The real TimerPort: DispatchQueue.main work items keyed by token.
369
+ /// `nowMs` is wall-clock epoch (compared against the server's ring_deadline);
370
+ /// the delay itself counts down monotonically.
371
+ public final class MainQueueTimerPort: TimerPort {
372
+ private var pending: [String: DispatchWorkItem] = [:]
373
+ private let queue: DispatchQueue
374
+
375
+ public init(queue: DispatchQueue = .main) { self.queue = queue }
376
+
377
+ public func nowMs() -> Int64 { Int64((Date().timeIntervalSince1970 * 1000).rounded()) }
378
+
379
+ public func schedule(key: String, delayMs: Int64, fire: @escaping () -> Void) {
380
+ cancel(key: key)
381
+ var item: DispatchWorkItem!
382
+ item = DispatchWorkItem { [weak self] in
383
+ if let self = self, self.pending[key] === item { self.pending.removeValue(forKey: key) }
384
+ fire()
385
+ }
386
+ pending[key] = item
387
+ queue.asyncAfter(deadline: .now() + .milliseconds(Int(max(0, delayMs))), execute: item)
388
+ }
389
+
390
+ public func cancel(key: String) {
391
+ pending.removeValue(forKey: key)?.cancel()
392
+ }
393
+ }
@@ -0,0 +1,210 @@
1
+ import Foundation
2
+
3
+ /// The HTTP adapter for the contract's three token doors (CONTRACT.md §4).
4
+ /// Direct port of android/.../CallHttpClient.kt. Every call is a bare REST
5
+ /// request with a bearer token — no session state, no SDK coupling — so the
6
+ /// core can answer a call on a cold launch with nothing but the push's grant.
7
+ ///
8
+ /// Fire-and-forget methods (`decline`, `hangup`) retry transient failures a
9
+ /// few times; request/response methods surface `HttpResult` to the runtime,
10
+ /// which owns the retry policy for those (one retry).
11
+ ///
12
+ /// Callbacks arrive on URLSession's delegate queue; the runtime hops them
13
+ /// onto its own serial (main) queue.
14
+ public final class CallHttpClient: HttpPort {
15
+
16
+ public static let MAX_BEST_EFFORT_RETRIES = 3
17
+
18
+ /// Read per request: a later `configure()` with another origin takes effect on the next call.
19
+ private let baseUrl: () -> String
20
+ private let session: URLSession
21
+
22
+ public init(baseUrl: @escaping () -> String, session: URLSession = CallHttpClient.defaultSession()) {
23
+ self.baseUrl = baseUrl
24
+ self.session = session
25
+ }
26
+
27
+ /// A call action must not hang the ring surface: 9 s total ceiling (6 s
28
+ /// without any data); the runtime retries answer/place once on top, and
29
+ /// both attempts fit inside its 20 s CONNECTING watchdog.
30
+ public static func defaultSession() -> URLSession {
31
+ let config = URLSessionConfiguration.ephemeral
32
+ config.timeoutIntervalForRequest = 6
33
+ config.timeoutIntervalForResource = 9
34
+ config.waitsForConnectivity = false
35
+ config.requestCachePolicy = .reloadIgnoringLocalCacheData
36
+ return URLSession(configuration: config)
37
+ }
38
+
39
+ public func answer(callId: String, grant: String, cb: @escaping (HttpResult) -> Void) {
40
+ post("calls/\(callId)/answer", token: grant, body: nil, current: nil, cb: cb)
41
+ }
42
+
43
+ public func join(callId: String, sessionToken: String, cb: @escaping (HttpResult) -> Void) {
44
+ post("calls/\(callId)/join", token: sessionToken, body: nil, current: nil, cb: cb)
45
+ }
46
+
47
+ public func place(conversationId: String, media: String, sessionToken: String,
48
+ cb: @escaping (HttpResult) -> Void) {
49
+ post("conversations/\(conversationId)/calls", token: sessionToken, body: ["media": media], current: nil, cb: cb)
50
+ }
51
+
52
+ public func reconnect(current: CallSessionBundle, cb: @escaping (HttpResult) -> Void) {
53
+ post("calls/\(current.callId)/reconnect", token: current.controlToken, body: nil, current: current, cb: cb)
54
+ }
55
+
56
+ public func decline(callId: String, auth: String, busy: Bool) {
57
+ postBestEffort("calls/\(callId)/decline", token: auth, body: ["reason": busy ? "busy" : "declined"], attempt: 0)
58
+ }
59
+
60
+ public func hangup(callId: String, controlToken: String) {
61
+ postBestEffort("calls/\(callId)/hangup", token: controlToken, body: nil, attempt: 0)
62
+ }
63
+
64
+ // MARK: request/response
65
+
66
+ private func post(_ path: String, token: String, body: [String: Any]?, current: CallSessionBundle?,
67
+ cb: @escaping (HttpResult) -> Void) {
68
+ guard let req = request(path, token: token, body: body) else {
69
+ // A malformed base URL is a configuration error, not a timeout.
70
+ cb(.failed(code: "bad_url"))
71
+ return
72
+ }
73
+ session.dataTask(with: req) { data, response, error in
74
+ if error != nil {
75
+ cb(.timeout)
76
+ return
77
+ }
78
+ guard let http = response as? HTTPURLResponse else {
79
+ cb(.timeout)
80
+ return
81
+ }
82
+ let payload = data.flatMap { String(data: $0, encoding: .utf8) }
83
+ cb(CallHttpClient.classify(code: http.statusCode, payload: payload, current: current))
84
+ }.resume()
85
+ }
86
+
87
+ // MARK: fire-and-forget with bounded retry
88
+
89
+ private func postBestEffort(_ path: String, token: String, body: [String: Any]?, attempt: Int) {
90
+ guard let req = request(path, token: token, body: body) else { return }
91
+ session.dataTask(with: req) { [weak self] _, response, error in
92
+ guard let self = self else { return }
93
+ let retry: Bool
94
+ if error != nil || !(response is HTTPURLResponse) {
95
+ retry = true
96
+ } else {
97
+ let code = (response as! HTTPURLResponse).statusCode
98
+ // 2xx and 4xx are both terminal here. Only transient 5xx/408/429 retries.
99
+ retry = code == 408 || code == 429 || (500...599).contains(code)
100
+ }
101
+ if retry && attempt < CallHttpClient.MAX_BEST_EFFORT_RETRIES {
102
+ self.postBestEffort(path, token: token, body: body, attempt: attempt + 1)
103
+ }
104
+ }.resume()
105
+ }
106
+
107
+ private func request(_ path: String, token: String, body: [String: Any]?) -> URLRequest? {
108
+ var base = baseUrl()
109
+ while base.hasSuffix("/") { base.removeLast() }
110
+ guard let url = URL(string: "\(base)/v1/\(path)"), url.scheme != nil, url.host != nil else { return nil }
111
+ var req = URLRequest(url: url)
112
+ req.httpMethod = "POST"
113
+ req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
114
+ req.setValue("application/json", forHTTPHeaderField: "Accept")
115
+ req.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
116
+ req.httpBody = (try? JSONSerialization.data(withJSONObject: body ?? [String: Any]())) ?? Data("{}".utf8)
117
+ req.timeoutInterval = 9
118
+ return req
119
+ }
120
+
121
+ // MARK: pure classification (unit tested without a network)
122
+
123
+ private struct ParseError: Error { let what: String }
124
+
125
+ /// Status + body → HttpResult (CONTRACT.md §4).
126
+ /// 2xx that will not parse → failed("bad_body"); 404/410 → gone;
127
+ /// 401/403 → authFailed; 503 → failed; 408/429/5xx → timeout; other → failed.
128
+ public static func classify(code: Int, payload: String?, current: CallSessionBundle? = nil) -> HttpResult {
129
+ switch code {
130
+ case 200...299:
131
+ if let b = try? parseBundle(payload, current: current) { return .ok(b) }
132
+ return .failed(code: "bad_body")
133
+ case 404, 410:
134
+ return .gone
135
+ case 401, 403:
136
+ return .authFailed(code: errorCode(payload) ?? "http_\(code)")
137
+ case 503:
138
+ return .failed(code: errorCode(payload) ?? "http_503")
139
+ case 408, 429, 500...599:
140
+ return .timeout
141
+ default:
142
+ return .failed(code: errorCode(payload) ?? "http_\(code)")
143
+ }
144
+ }
145
+
146
+ /// One parser for answer, join, place AND reconnect. An older server's
147
+ /// reconnect answered only `{livekit:{url,token}}` (maybe a fresh
148
+ /// `control_token`); with `current`, that is applied onto the existing bundle.
149
+ public static func parseBundle(_ payload: String?, current: CallSessionBundle? = nil) throws -> CallSessionBundle {
150
+ guard let payload = payload, let data = payload.data(using: .utf8),
151
+ let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
152
+ else { throw ParseError(what: "empty or non-object body") }
153
+ guard let livekit = root["livekit"] as? [String: Any] else { throw ParseError(what: "no livekit") }
154
+ let call = root["call"] as? [String: Any]
155
+ guard let call = call else {
156
+ guard let current = current else { throw ParseError(what: "bundle without call") }
157
+ var b = current
158
+ b.livekitUrl = optString(livekit, "url") ?? current.livekitUrl
159
+ b.livekitToken = try getString(livekit, "token")
160
+ b.controlToken = optString(root, "control_token") ?? current.controlToken
161
+ return b
162
+ }
163
+ guard let controlToken = optString(root, "control_token") ?? current?.controlToken else {
164
+ throw ParseError(what: "bundle without control_token")
165
+ }
166
+ return CallSessionBundle(
167
+ callId: try getString(call, "id"),
168
+ conversationId: try getString(call, "conversation_id"),
169
+ conversationName: optString(call, "conversation_name") ?? current?.conversationName,
170
+ media: optString(call, "media") ?? current?.media ?? "audio",
171
+ created: optBool(root, "created", fallback: true),
172
+ livekitUrl: try getString(livekit, "url"),
173
+ livekitToken: try getString(livekit, "token"),
174
+ controlToken: controlToken
175
+ )
176
+ }
177
+
178
+ /// The server's envelope is `{"error":{"code":"…"}}`; tolerate a bare string too.
179
+ public static func errorCode(_ payload: String?) -> String? {
180
+ guard let payload = payload, let data = payload.data(using: .utf8),
181
+ let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
182
+ else { return nil }
183
+ if let err = root["error"] as? [String: Any] { return optString(err, "code") ?? nil }
184
+ return optString(root, "error")
185
+ }
186
+
187
+ /// org.json `optString`-or-null semantics: present, non-null, a String, non-empty.
188
+ private static func optString(_ o: [String: Any], _ key: String) -> String? {
189
+ guard let s = o[key] as? String, !s.isEmpty else { return nil }
190
+ return s
191
+ }
192
+
193
+ /// org.json (Android) `getString`: required, non-null; numbers/bools coerce to text.
194
+ private static func getString(_ o: [String: Any], _ key: String) throws -> String {
195
+ guard let v = o[key], !(v is NSNull) else { throw ParseError(what: "missing \(key)") }
196
+ if let s = v as? String { return s }
197
+ if let n = v as? NSNumber { return n.stringValue }
198
+ throw ParseError(what: "bad \(key)")
199
+ }
200
+
201
+ /// org.json `optBoolean(key, fallback)`: a Bool, or "true"/"false" text.
202
+ private static func optBool(_ o: [String: Any], _ key: String, fallback: Bool) -> Bool {
203
+ if let n = o[key] as? NSNumber, CFGetTypeID(n) == CFBooleanGetTypeID() { return n.boolValue }
204
+ if let s = (o[key] as? String)?.lowercased() {
205
+ if s == "true" { return true }
206
+ if s == "false" { return false }
207
+ }
208
+ return fallback
209
+ }
210
+ }