@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,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
+ }
@@ -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
+ ```