@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.
- package/android/src/main/java/dev/poolse/callcore/android/CallCore.kt +25 -8
- package/android/src/main/java/dev/poolse/callcore/android/CallVideo.kt +36 -0
- package/android/src/main/java/dev/poolse/callcore/android/SnapshotJson.kt +15 -0
- package/android/src/main/java/expo/modules/poolsecalls/PoolseCallVideoView.kt +64 -0
- package/android/src/main/java/expo/modules/poolsecalls/PoolseCallsModule.kt +10 -0
- package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitMediaPort.kt +142 -14
- package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitVideoSurface.kt +107 -0
- package/dist/index.cjs +34 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +44 -2
- package/dist/index.d.ts +44 -2
- package/dist/index.js +33 -2
- 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/expo-modules-core.d.ts +5 -0
- package/src/index.ts +85 -2
- 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
|
+
}
|