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