@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.
- package/dist/index.cjs +10 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +21 -2
- package/dist/index.d.ts +21 -2
- package/dist/index.js +9 -1
- 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/index.ts +33 -1
|
@@ -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
|
+
```
|