@poolse/react-native-calls 0.8.0 → 0.10.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/README.md +27 -0
- package/android/src/main/java/dev/poolse/callcore/CallCoreRuntime.kt +69 -6
- package/android/src/main/java/dev/poolse/callcore/CallMachine.kt +108 -3
- package/android/src/main/java/dev/poolse/callcore/Ports.kt +14 -0
- package/android/src/main/java/dev/poolse/callcore/android/AndroidRingSurface.kt +121 -0
- package/android/src/main/java/dev/poolse/callcore/android/CallActionReceiver.kt +16 -0
- package/android/src/main/java/dev/poolse/callcore/android/CallCore.kt +14 -2
- package/android/src/main/java/dev/poolse/callcore/android/CallHttpClient.kt +1 -0
- package/android/src/main/java/dev/poolse/callcore/android/SnapshotJson.kt +17 -0
- package/android/src/main/java/expo/modules/poolsecalls/PoolseCallsModule.kt +2 -0
- package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitMediaPort.kt +7 -3
- package/dist/index.cjs +4 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +31 -1
- package/dist/index.d.ts +31 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/ios/Core/CallCoreRuntime.swift +76 -6
- package/ios/Core/CallHttpClient.swift +2 -1
- package/ios/Core/CallMachine.swift +91 -1
- package/ios/Core/Ports.swift +37 -2
- package/ios/Core/SnapshotEncoding.swift +16 -1
- package/ios/Module/PoolseCallsModule.swift +2 -0
- package/ios/Platform/CallCore.swift +69 -16
- package/ios/Platform/CallKitController.swift +7 -1
- package/ios/Platform/LiveKitMediaPort.swift +6 -2
- package/package.json +1 -1
- package/src/index.ts +35 -0
|
@@ -36,6 +36,12 @@ public enum Event: Equatable {
|
|
|
36
36
|
/// CONNECTING watchdog.
|
|
37
37
|
case connectTimeout
|
|
38
38
|
case lingerElapsed
|
|
39
|
+
/// Call waiting: leave the current call and answer the waiting one.
|
|
40
|
+
case userAnswerWaiting
|
|
41
|
+
/// Call waiting: turn the waiting call away — its caller hears busy.
|
|
42
|
+
case userDeclineWaiting
|
|
43
|
+
/// The waiting call's ring deadline passed.
|
|
44
|
+
case waitingDeadline
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
/// Abstract effects — each adapter maps them to its platform.
|
|
@@ -44,6 +50,10 @@ public enum Effect: Equatable {
|
|
|
44
50
|
case httpAnswer, httpJoin, httpPlace, httpDecline
|
|
45
51
|
case httpDeclineBusy(callId: String)
|
|
46
52
|
case httpHangup, httpReconnect, mediaJoin, mediaLeave, callSessionStart, callSessionStop
|
|
53
|
+
case showWaiting, dismissWaiting
|
|
54
|
+
/// The waiting call becomes the current one: retire the old call's
|
|
55
|
+
/// grant/bundle/generation and take the waiting ring's.
|
|
56
|
+
case promoteWaiting
|
|
47
57
|
|
|
48
58
|
public var wire: String {
|
|
49
59
|
switch self {
|
|
@@ -64,6 +74,9 @@ public enum Effect: Equatable {
|
|
|
64
74
|
case .mediaLeave: return "MEDIA_LEAVE"
|
|
65
75
|
case .callSessionStart: return "CALL_SESSION_START"
|
|
66
76
|
case .callSessionStop: return "CALL_SESSION_STOP"
|
|
77
|
+
case .showWaiting: return "SHOW_WAITING"
|
|
78
|
+
case .dismissWaiting: return "DISMISS_WAITING"
|
|
79
|
+
case .promoteWaiting: return "PROMOTE_WAITING"
|
|
67
80
|
}
|
|
68
81
|
}
|
|
69
82
|
}
|
|
@@ -91,7 +104,80 @@ private extension MachineState {
|
|
|
91
104
|
|
|
92
105
|
public enum CallMachine {
|
|
93
106
|
|
|
107
|
+
/// Phases that can hold a call waiting beside them: this device is on
|
|
108
|
+
/// (or placing, or joining) a call. A second ring while one is ringING
|
|
109
|
+
/// in is still busy — two rings at once is not a choice anyone can make.
|
|
110
|
+
private static func canHoldWaiting(_ s: MachineState) -> Bool {
|
|
111
|
+
switch s.phase {
|
|
112
|
+
case .ringingOut, .connecting, .active, .reconnecting: return true
|
|
113
|
+
default: return false
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/// The ring surface effects the end-and-answer composite skips: the
|
|
118
|
+
/// waiting call goes straight from waiting to connecting, never ringing.
|
|
119
|
+
private static let ringSurfaceEffects: [Effect] = [.showRing, .ringtoneStart, .ringtoneStop, .dismissRing]
|
|
120
|
+
|
|
94
121
|
public static func transition(_ s: MachineState, _ e: Event) -> Transition {
|
|
122
|
+
guard let w = s.waiting else {
|
|
123
|
+
switch e {
|
|
124
|
+
case let .ring(callId, conversationId, media, stale)
|
|
125
|
+
where !stale && canHoldWaiting(s) && callId != s.callId:
|
|
126
|
+
var next = s
|
|
127
|
+
next.waiting = WaitingCall(callId: callId, conversationId: conversationId, media: media)
|
|
128
|
+
return Transition(next, [.showWaiting])
|
|
129
|
+
case .userAnswerWaiting, .userDeclineWaiting, .waitingDeadline:
|
|
130
|
+
return noop(s)
|
|
131
|
+
default:
|
|
132
|
+
return single(s, e)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
var cleared = s
|
|
137
|
+
cleared.waiting = nil
|
|
138
|
+
switch e {
|
|
139
|
+
case let .ring(callId, _, _, stale):
|
|
140
|
+
if callId == w.callId || stale { return noop(s) }
|
|
141
|
+
if callId == s.callId { return carry(single(cleared, e), w) }
|
|
142
|
+
return Transition(s, [.httpDeclineBusy(callId: callId)])
|
|
143
|
+
|
|
144
|
+
case let .ringEnded(callId, _) where callId == w.callId:
|
|
145
|
+
return Transition(cleared, [.dismissWaiting])
|
|
146
|
+
case let .callFinished(callId, _) where callId == w.callId:
|
|
147
|
+
return Transition(cleared, [.dismissWaiting])
|
|
148
|
+
case .waitingDeadline:
|
|
149
|
+
return Transition(cleared, [.dismissWaiting])
|
|
150
|
+
case .userDeclineWaiting:
|
|
151
|
+
return Transition(cleared, [.httpDeclineBusy(callId: w.callId), .dismissWaiting])
|
|
152
|
+
|
|
153
|
+
case .userAnswerWaiting:
|
|
154
|
+
// Leaving rings the waiting call (below); answering it at once
|
|
155
|
+
// makes that ring a no-op the surface never sees.
|
|
156
|
+
let left = transition(s, .userHangup)
|
|
157
|
+
guard left.state.phase == .ringingIn, left.state.callId == w.callId else { return noop(s) }
|
|
158
|
+
let answered = single(left.state, .userAnswer)
|
|
159
|
+
let effects = (left.effects + answered.effects).filter { !ringSurfaceEffects.contains($0) }
|
|
160
|
+
return Transition(answered.state, effects)
|
|
161
|
+
|
|
162
|
+
default:
|
|
163
|
+
let t = single(cleared, e)
|
|
164
|
+
guard t.state.phase == .ended || t.state.phase == .idle else { return carry(t, w) }
|
|
165
|
+
// The current call is over while another rings beside it: that
|
|
166
|
+
// ring is now simply THE ring (no ended screen in between).
|
|
167
|
+
let ringing = MachineState(phase: .ringingIn, callId: w.callId, conversationId: w.conversationId,
|
|
168
|
+
direction: .incoming, media: w.media)
|
|
169
|
+
return Transition(ringing, t.effects + [.promoteWaiting, .showRing, .ringtoneStart])
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private static func carry(_ t: Transition, _ w: WaitingCall) -> Transition {
|
|
174
|
+
var state = t.state
|
|
175
|
+
state.waiting = w
|
|
176
|
+
return Transition(state, t.effects)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/// The one-call machine: everything that is not about a waiting call.
|
|
180
|
+
private static func single(_ s: MachineState, _ e: Event) -> Transition {
|
|
95
181
|
switch e {
|
|
96
182
|
|
|
97
183
|
case let .ring(callId, conversationId, media, stale):
|
|
@@ -271,6 +357,9 @@ public enum CallMachine {
|
|
|
271
357
|
|
|
272
358
|
case .lingerElapsed:
|
|
273
359
|
return s.phase == .ended ? Transition(.IDLE, []) : noop(s)
|
|
360
|
+
|
|
361
|
+
case .userAnswerWaiting, .userDeclineWaiting, .waitingDeadline:
|
|
362
|
+
return noop(s) // handled by `transition`, which owns the waiting slot
|
|
274
363
|
}
|
|
275
364
|
}
|
|
276
365
|
|
|
@@ -285,7 +374,8 @@ public enum CallMachine {
|
|
|
285
374
|
direction: .incoming, media: media),
|
|
286
375
|
[.showRing, .ringtoneStart])
|
|
287
376
|
}
|
|
288
|
-
// Busy
|
|
377
|
+
// Busy ringing another call (a call in progress holds it waiting
|
|
378
|
+
// instead — see `transition`): auto-decline the newcomer.
|
|
289
379
|
return Transition(s, [.httpDeclineBusy(callId: callId)])
|
|
290
380
|
}
|
|
291
381
|
|
package/ios/Core/Ports.swift
CHANGED
|
@@ -46,9 +46,12 @@ public struct CallSessionBundle: Equatable {
|
|
|
46
46
|
public var livekitUrl: String
|
|
47
47
|
public var livekitToken: String
|
|
48
48
|
public var controlToken: String
|
|
49
|
+
/// `room` (rings nobody, CONTRACT.md §5.6) or `call`.
|
|
50
|
+
public var kind: String
|
|
49
51
|
|
|
50
52
|
public init(callId: String, conversationId: String, conversationName: String?, media: String,
|
|
51
|
-
created: Bool, livekitUrl: String, livekitToken: String, controlToken: String
|
|
53
|
+
created: Bool, livekitUrl: String, livekitToken: String, controlToken: String,
|
|
54
|
+
kind: String = "call") {
|
|
52
55
|
self.callId = callId
|
|
53
56
|
self.conversationId = conversationId
|
|
54
57
|
self.conversationName = conversationName
|
|
@@ -57,6 +60,7 @@ public struct CallSessionBundle: Equatable {
|
|
|
57
60
|
self.livekitUrl = livekitUrl
|
|
58
61
|
self.livekitToken = livekitToken
|
|
59
62
|
self.controlToken = controlToken
|
|
63
|
+
self.kind = kind
|
|
60
64
|
}
|
|
61
65
|
}
|
|
62
66
|
|
|
@@ -90,6 +94,20 @@ public protocol RingSurfacePort: AnyObject {
|
|
|
90
94
|
func ringtoneStop()
|
|
91
95
|
func ringbackStart()
|
|
92
96
|
func ringbackStop()
|
|
97
|
+
/// A call waiting beside the current one: its own quiet surface (iOS:
|
|
98
|
+
/// CallKit's call-waiting UI; Android: a heads-up with a waiting tone).
|
|
99
|
+
func showWaiting(ring: RingContext)
|
|
100
|
+
/// The waiting call is over (declined, cancelled, timed out).
|
|
101
|
+
func dismissWaiting(callId: String)
|
|
102
|
+
/// The waiting call becomes THE ring (SHOW_RING follows): drop its
|
|
103
|
+
/// waiting surface without resolving it.
|
|
104
|
+
func promoteWaiting(callId: String)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
public extension RingSurfacePort {
|
|
108
|
+
func showWaiting(ring: RingContext) {}
|
|
109
|
+
func dismissWaiting(callId: String) {}
|
|
110
|
+
func promoteWaiting(callId: String) {}
|
|
93
111
|
}
|
|
94
112
|
|
|
95
113
|
public struct RingContext: Equatable {
|
|
@@ -173,6 +191,20 @@ public enum EndReason: String {
|
|
|
173
191
|
public var wire: String { rawValue.lowercased() }
|
|
174
192
|
}
|
|
175
193
|
|
|
194
|
+
/// A second call ringing while this device is on one (CONTRACT.md §7,
|
|
195
|
+
/// call waiting): shown beside the current call, never instead of it.
|
|
196
|
+
public struct WaitingCall: Equatable {
|
|
197
|
+
public var callId: String
|
|
198
|
+
public var conversationId: String?
|
|
199
|
+
public var media: String
|
|
200
|
+
|
|
201
|
+
public init(callId: String, conversationId: String?, media: String) {
|
|
202
|
+
self.callId = callId
|
|
203
|
+
self.conversationId = conversationId
|
|
204
|
+
self.media = media
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
176
208
|
/// Everything the machine knows. Immutable value; transitions return a new one.
|
|
177
209
|
public struct MachineState: Equatable {
|
|
178
210
|
public var phase: Phase
|
|
@@ -183,10 +215,12 @@ public struct MachineState: Equatable {
|
|
|
183
215
|
public var reason: EndReason?
|
|
184
216
|
/// HTTP attempts made for the current connecting request (retry budget = 2).
|
|
185
217
|
public var httpAttempts: Int
|
|
218
|
+
/// A second call ringing beside this one, if any.
|
|
219
|
+
public var waiting: WaitingCall?
|
|
186
220
|
|
|
187
221
|
public init(phase: Phase = .idle, callId: String? = nil, conversationId: String? = nil,
|
|
188
222
|
direction: Direction? = nil, media: String = "audio", reason: EndReason? = nil,
|
|
189
|
-
httpAttempts: Int = 0) {
|
|
223
|
+
httpAttempts: Int = 0, waiting: WaitingCall? = nil) {
|
|
190
224
|
self.phase = phase
|
|
191
225
|
self.callId = callId
|
|
192
226
|
self.conversationId = conversationId
|
|
@@ -194,6 +228,7 @@ public struct MachineState: Equatable {
|
|
|
194
228
|
self.media = media
|
|
195
229
|
self.reason = reason
|
|
196
230
|
self.httpAttempts = httpAttempts
|
|
231
|
+
self.waiting = waiting
|
|
197
232
|
}
|
|
198
233
|
|
|
199
234
|
public static var IDLE: MachineState { MachineState() }
|
|
@@ -66,19 +66,22 @@ public extension MachineState {
|
|
|
66
66
|
/// fields are merged in by the bridge from the media adapter.
|
|
67
67
|
/// `conversationName` nil → NSNull; `reason`/`cameraFacing` omitted when nil.
|
|
68
68
|
func toSnapshot(conversationName: String? = nil,
|
|
69
|
+
kind: String = "call",
|
|
69
70
|
participants: [SnapshotParticipant] = [],
|
|
70
71
|
audioRoute: String = "earpiece",
|
|
71
72
|
audioOutputs: [AudioOutput] = [],
|
|
72
73
|
audioOutputId: String? = nil,
|
|
73
74
|
mutedSelf: Bool = false,
|
|
74
75
|
cameraOn: Bool = false,
|
|
75
|
-
cameraFacing: String? = nil
|
|
76
|
+
cameraFacing: String? = nil,
|
|
77
|
+
waiting: RingContext? = nil) -> [String: Any]? {
|
|
76
78
|
if phase == .idle { return nil }
|
|
77
79
|
var out: [String: Any] = [
|
|
78
80
|
"cv": 1,
|
|
79
81
|
"callId": callId.map { $0 as Any } ?? NSNull(),
|
|
80
82
|
"conversationId": conversationId.map { $0 as Any } ?? NSNull(),
|
|
81
83
|
"conversationName": conversationName.map { $0 as Any } ?? NSNull(),
|
|
84
|
+
"kind": kind,
|
|
82
85
|
"direction": (direction ?? .incoming).wire,
|
|
83
86
|
"media": media,
|
|
84
87
|
"phase": phase.wire,
|
|
@@ -89,6 +92,18 @@ public extension MachineState {
|
|
|
89
92
|
"mutedSelf": mutedSelf,
|
|
90
93
|
"cameraOn": cameraOn,
|
|
91
94
|
]
|
|
95
|
+
// The call waiting beside this one: null when none.
|
|
96
|
+
if let w = self.waiting {
|
|
97
|
+
out["waiting"] = [
|
|
98
|
+
"callId": w.callId,
|
|
99
|
+
"conversationId": (waiting?.conversationId ?? w.conversationId).map { $0 as Any } ?? NSNull(),
|
|
100
|
+
"conversationName": waiting?.conversationName.map { $0 as Any } ?? NSNull(),
|
|
101
|
+
"callerName": waiting?.callerName.map { $0 as Any } ?? NSNull(),
|
|
102
|
+
"media": w.media,
|
|
103
|
+
] as [String: Any]
|
|
104
|
+
} else {
|
|
105
|
+
out["waiting"] = NSNull()
|
|
106
|
+
}
|
|
92
107
|
if let reason = reason { out["reason"] = reason.wire }
|
|
93
108
|
// Optional in the contract: present only while a camera is published.
|
|
94
109
|
if let cameraFacing = cameraFacing { out["cameraFacing"] = cameraFacing }
|
|
@@ -66,6 +66,8 @@ public class PoolseCallsModule: Module {
|
|
|
66
66
|
Function("answer") { onCore { $0.answer() } }
|
|
67
67
|
Function("decline") { onCore { $0.decline() } }
|
|
68
68
|
Function("hangUp") { onCore { $0.hangUp() } }
|
|
69
|
+
Function("answerWaiting") { (callId: String?) in onCore { $0.answerWaiting(callId: callId) } }
|
|
70
|
+
Function("declineWaiting") { (callId: String?) in onCore { $0.declineWaiting(callId: callId) } }
|
|
69
71
|
Function("setMuted") { (muted: Bool) in onCore { $0.setMuted(muted) } }
|
|
70
72
|
Function("setCamera") { (on: Bool) in onCore { $0.setCamera(on) } }
|
|
71
73
|
Function("switchCamera") { onCore { $0.switchCamera() } }
|
|
@@ -139,6 +139,7 @@ final class CallCore {
|
|
|
139
139
|
private var extrasMedia: String?
|
|
140
140
|
private var roster: [SnapshotParticipant] = []
|
|
141
141
|
private var conversationName: String?
|
|
142
|
+
private var callKind = "call"
|
|
142
143
|
private var mutedSelf = false
|
|
143
144
|
private var cameraOn = false
|
|
144
145
|
private var cameraFacing: String?
|
|
@@ -232,6 +233,24 @@ final class CallCore {
|
|
|
232
233
|
}
|
|
233
234
|
}
|
|
234
235
|
|
|
236
|
+
/// Call waiting: end this call, answer the waiting one. CallKit follows
|
|
237
|
+
/// from the resulting state (reconcileCallKit ends the old call, then
|
|
238
|
+
/// answers the new one).
|
|
239
|
+
func answerWaiting(callId: String? = nil) {
|
|
240
|
+
onMain { [self] in
|
|
241
|
+
localEnd = true
|
|
242
|
+
runtime.answerWaiting(callId: callId)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/// Call waiting: turn the waiting call away (its caller hears busy).
|
|
247
|
+
func declineWaiting(callId: String? = nil) {
|
|
248
|
+
onMain { [self] in runtime.declineWaiting(callId: callId) }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/// True while `callId` is the call waiting beside the current one. Main thread.
|
|
252
|
+
func isWaiting(_ callId: String) -> Bool { runtime.currentState().waiting?.callId == callId }
|
|
253
|
+
|
|
235
254
|
func place(conversationId: String, media: String, sessionToken: String) {
|
|
236
255
|
onMain { [self] in
|
|
237
256
|
localEnd = false
|
|
@@ -299,6 +318,9 @@ final class CallCore {
|
|
|
299
318
|
if let old = extrasCallId, let new = state.callId { callKit.rename(from: old, to: new) }
|
|
300
319
|
extrasCallId = state.callId
|
|
301
320
|
} else {
|
|
321
|
+
// A different call (a waiting one promoted): nothing of the last
|
|
322
|
+
// carries over — not even "the user ended it here".
|
|
323
|
+
if extrasCallId != nil { localEnd = false }
|
|
302
324
|
resetExtras(state.callId, state.media)
|
|
303
325
|
}
|
|
304
326
|
}
|
|
@@ -307,16 +329,20 @@ final class CallCore {
|
|
|
307
329
|
extrasMedia = state.media
|
|
308
330
|
}
|
|
309
331
|
if let name = runtime.conversationName { conversationName = name }
|
|
332
|
+
if let kind = runtime.callKind { callKind = kind }
|
|
310
333
|
|
|
311
334
|
let snap = state.toSnapshot(
|
|
312
335
|
conversationName: conversationName,
|
|
336
|
+
kind: callKind,
|
|
313
337
|
participants: roster,
|
|
314
|
-
|
|
338
|
+
// Video calls and rooms default to the speaker (the media port agrees).
|
|
339
|
+
audioRoute: audioRoute ?? (state.media == "video" || callKind == "room" ? "speaker" : "earpiece"),
|
|
315
340
|
audioOutputs: audioOutputs,
|
|
316
341
|
audioOutputId: audioOutputId,
|
|
317
342
|
mutedSelf: mutedSelf,
|
|
318
343
|
cameraOn: cameraOn,
|
|
319
|
-
cameraFacing: cameraFacing
|
|
344
|
+
cameraFacing: cameraFacing,
|
|
345
|
+
waiting: runtime.waitingRing
|
|
320
346
|
)
|
|
321
347
|
snapshotLock.lock(); lastSnapshot = snap; snapshotLock.unlock()
|
|
322
348
|
reconcileCallKit(state)
|
|
@@ -329,6 +355,7 @@ final class CallCore {
|
|
|
329
355
|
extrasMedia = media
|
|
330
356
|
roster = []
|
|
331
357
|
conversationName = nil
|
|
358
|
+
callKind = "call"
|
|
332
359
|
mutedSelf = false
|
|
333
360
|
cameraOn = false
|
|
334
361
|
cameraFacing = nil
|
|
@@ -340,25 +367,27 @@ final class CallCore {
|
|
|
340
367
|
/// CallKit follows the FINAL state of each dispatch.
|
|
341
368
|
private func reconcileCallKit(_ state: MachineState) {
|
|
342
369
|
let terminal = state.phase == .idle || state.phase == .ended
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
// Answered from the app's own UI: tell CallKit (audio follows).
|
|
349
|
-
if !t.outgoing && !t.answered && state.direction == .incoming { callKit.requestAnswer(callId: t.callId) }
|
|
350
|
-
if t.outgoing && state.phase == .active { callKit.reportConnected(callId: t.callId) }
|
|
351
|
-
callKit.updateTitle(callId: t.callId, title: conversationName, video: state.media == "video")
|
|
352
|
-
default:
|
|
353
|
-
break
|
|
354
|
-
}
|
|
355
|
-
continue
|
|
356
|
-
}
|
|
370
|
+
// The call waiting beside this one stays on CallKit's call-waiting UI.
|
|
371
|
+
let live = callKit.liveCalls.filter { terminal || $0.callId != state.waiting?.callId }
|
|
372
|
+
// Ends first: answering the next call (end-and-answer) while CallKit
|
|
373
|
+
// still holds the last one would ask it for two calls at once.
|
|
374
|
+
for t in live where terminal || t.callId != state.callId {
|
|
357
375
|
// Not the call the machine is in (ended, busy-declined, superseded).
|
|
358
376
|
// Terminal: whatever CallKit still shows is the call that just ended.
|
|
359
377
|
let reason = endReason(for: t, state: state, current: terminal || t.callId == state.callId)
|
|
360
378
|
callKit.end(t, reason: reason)
|
|
361
379
|
}
|
|
380
|
+
for t in live where !terminal && t.callId == state.callId {
|
|
381
|
+
switch state.phase {
|
|
382
|
+
case .connecting, .active, .reconnecting, .ringingOut:
|
|
383
|
+
// Answered from the app's own UI: tell CallKit (audio follows).
|
|
384
|
+
if !t.outgoing && !t.answered && state.direction == .incoming { callKit.requestAnswer(callId: t.callId) }
|
|
385
|
+
if t.outgoing && state.phase == .active { callKit.reportConnected(callId: t.callId) }
|
|
386
|
+
callKit.updateTitle(callId: t.callId, title: conversationName, video: state.media == "video")
|
|
387
|
+
default:
|
|
388
|
+
break
|
|
389
|
+
}
|
|
390
|
+
}
|
|
362
391
|
if terminal {
|
|
363
392
|
localEnd = false
|
|
364
393
|
if let id = state.callId { ringEndReasons.removeValue(forKey: id) }
|
|
@@ -414,6 +443,13 @@ final class CallCore {
|
|
|
414
443
|
callKit.reportIncoming(ring: ring)
|
|
415
444
|
}
|
|
416
445
|
|
|
446
|
+
/// CallKit shows a second incoming call over a live one as call
|
|
447
|
+
/// waiting (End & Accept / Decline). Usually already reported by the
|
|
448
|
+
/// push path; idempotent.
|
|
449
|
+
fileprivate func showWaiting(_ ring: RingContext) {
|
|
450
|
+
callKit.reportIncoming(ring: ring)
|
|
451
|
+
}
|
|
452
|
+
|
|
417
453
|
fileprivate var ringbackPlayer: Ringback { ringback }
|
|
418
454
|
|
|
419
455
|
// MediaEvents → runtime
|
|
@@ -446,12 +482,24 @@ final class CallCore {
|
|
|
446
482
|
extension CallCore: CallKitActions {
|
|
447
483
|
func callKitAnswer(callId: String) {
|
|
448
484
|
let s = runtime.currentState()
|
|
485
|
+
// End & Accept may answer the waiting call before ending the current
|
|
486
|
+
// one: answering it ends the current call itself.
|
|
487
|
+
if s.waiting?.callId == callId {
|
|
488
|
+
localEnd = true
|
|
489
|
+
runtime.answerWaiting(callId: callId)
|
|
490
|
+
return
|
|
491
|
+
}
|
|
449
492
|
guard s.callId == callId, s.phase == .ringingIn else { return }
|
|
450
493
|
runtime.answer()
|
|
451
494
|
}
|
|
452
495
|
|
|
453
496
|
func callKitEnd(callId: String) {
|
|
454
497
|
let s = runtime.currentState()
|
|
498
|
+
// Decline on CallKit's call-waiting UI.
|
|
499
|
+
if s.waiting?.callId == callId {
|
|
500
|
+
runtime.declineWaiting(callId: callId)
|
|
501
|
+
return
|
|
502
|
+
}
|
|
455
503
|
guard s.callId == callId || (s.callId?.hasPrefix("pending:") == true) else { return }
|
|
456
504
|
localEnd = true
|
|
457
505
|
if s.phase == .ringingIn { runtime.decline() } else { runtime.hangUp() }
|
|
@@ -489,6 +537,11 @@ private final class SurfaceAdapter: RingSurfacePort, CallSessionPort {
|
|
|
489
537
|
|
|
490
538
|
func show(ring: RingContext) { core?.showRing(ring) }
|
|
491
539
|
func dismiss(callId: String) {}
|
|
540
|
+
func showWaiting(ring: RingContext) { core?.showWaiting(ring) }
|
|
541
|
+
// Dismissal is reconciled from the final state; promotion keeps the
|
|
542
|
+
// CallKit call, which SHOW_RING then finds already reported.
|
|
543
|
+
func dismissWaiting(callId: String) {}
|
|
544
|
+
func promoteWaiting(callId: String) {}
|
|
492
545
|
func ringtoneStart() {}
|
|
493
546
|
func ringtoneStop() {}
|
|
494
547
|
func ringbackStart() { core?.ringbackPlayer.start() }
|
|
@@ -124,7 +124,13 @@ final class CallKitController: NSObject, CXProviderDelegate {
|
|
|
124
124
|
DispatchQueue.main.async {
|
|
125
125
|
t.ended = true
|
|
126
126
|
self?.calls.removeValue(forKey: t.uuid)
|
|
127
|
-
|
|
127
|
+
// A refused call-waiting report: the caller would ring on into
|
|
128
|
+
// a void — turn it away as busy instead.
|
|
129
|
+
if let core = CallCore.peek(), core.isWaiting(ring.callId) {
|
|
130
|
+
core.declineWaiting(callId: ring.callId)
|
|
131
|
+
} else {
|
|
132
|
+
CallCore.peek()?.onRingEnded(callId: ring.callId, reason: "cancelled")
|
|
133
|
+
}
|
|
128
134
|
}
|
|
129
135
|
}
|
|
130
136
|
}
|
|
@@ -66,6 +66,9 @@ final class LiveKitMediaPort: NSObject, MediaPort {
|
|
|
66
66
|
private(set) var room: Room?
|
|
67
67
|
private var observer: RoomObserver?
|
|
68
68
|
private var media = "audio"
|
|
69
|
+
/// Speaker unless a headset is in: a video call, or a room (a drop-in
|
|
70
|
+
/// room is held like a meeting, not to the ear).
|
|
71
|
+
private var loud = false
|
|
69
72
|
private var connectedRoom: ObjectIdentifier?
|
|
70
73
|
|
|
71
74
|
/// Where the user asked the audio to go, for the CURRENT call.
|
|
@@ -209,6 +212,7 @@ final class LiveKitMediaPort: NSObject, MediaPort {
|
|
|
209
212
|
detach()
|
|
210
213
|
media = bundle.media
|
|
211
214
|
let wantsVideo = bundle.media == "video"
|
|
215
|
+
loud = wantsVideo || bundle.kind == "room"
|
|
212
216
|
|
|
213
217
|
// A 1:1/small call: never let the SFU pause a subscribed camera for
|
|
214
218
|
// "not visible" — a view that mounts late would stay black.
|
|
@@ -398,7 +402,7 @@ final class LiveKitMediaPort: NSObject, MediaPort {
|
|
|
398
402
|
/// the same; forcing the speaker over AirPods was a bug).
|
|
399
403
|
private func resolvedChoice(_ s: AVAudioSession = .sharedInstance()) -> OutputChoice {
|
|
400
404
|
if let wanted = outputWanted { return wanted }
|
|
401
|
-
return
|
|
405
|
+
return loud && !externalConnected(s) ? .speaker : .privateRoute
|
|
402
406
|
}
|
|
403
407
|
|
|
404
408
|
private func applyRoute() {
|
|
@@ -444,7 +448,7 @@ final class LiveKitMediaPort: NSObject, MediaPort {
|
|
|
444
448
|
|
|
445
449
|
private func currentRoute() -> String {
|
|
446
450
|
guard let port = AVAudioSession.sharedInstance().currentRoute.outputs.first?.portType else {
|
|
447
|
-
return
|
|
451
|
+
return loud ? "speaker" : "earpiece"
|
|
448
452
|
}
|
|
449
453
|
return kind(of: port)
|
|
450
454
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poolse/react-native-calls",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Native call core for poolse — Kotlin/Swift calling engine with a thin React Native bridge. Ring, answer and carry audio with the app killed and the phone locked.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/index.ts
CHANGED
|
@@ -21,6 +21,12 @@ export interface Snapshot {
|
|
|
21
21
|
conversationId: string;
|
|
22
22
|
conversationName: string | null;
|
|
23
23
|
direction: 'incoming' | 'outgoing' | 'joined';
|
|
24
|
+
/**
|
|
25
|
+
* `room` — rings nobody, joined and left at will (`startRoom`); `call`
|
|
26
|
+
* otherwise. Known once the server answered: a room being joined reads
|
|
27
|
+
* `call` while `connecting`. Absent before 0.9.0: treat as `call`.
|
|
28
|
+
*/
|
|
29
|
+
kind?: 'call' | 'room';
|
|
24
30
|
media: 'audio' | 'video';
|
|
25
31
|
phase: 'ringing_in' | 'ringing_out' | 'connecting' | 'active' | 'reconnecting' | 'ended';
|
|
26
32
|
reason?:
|
|
@@ -55,12 +61,29 @@ export interface Snapshot {
|
|
|
55
61
|
mutedSelf: boolean;
|
|
56
62
|
/** True only while the local camera track is actually published and enabled. */
|
|
57
63
|
cameraOn: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* A second call ringing while this one is in progress (call waiting).
|
|
66
|
+
* Offer `answerWaiting` ("End & answer" — this call ends) and
|
|
67
|
+
* `declineWaiting` (its caller hears busy). If this call ends first,
|
|
68
|
+
* the waiting one simply becomes the ringing call. null when none;
|
|
69
|
+
* absent before 0.10.0.
|
|
70
|
+
*/
|
|
71
|
+
waiting?: WaitingCall | null;
|
|
58
72
|
/** Facing of the published local camera; absent when the camera is off. */
|
|
59
73
|
cameraFacing?: 'front' | 'back';
|
|
60
74
|
}
|
|
61
75
|
|
|
62
76
|
export type AudioOutputKind = 'earpiece' | 'speaker' | 'bluetooth' | 'headset';
|
|
63
77
|
|
|
78
|
+
/** The call waiting beside the current one. */
|
|
79
|
+
export interface WaitingCall {
|
|
80
|
+
callId: string;
|
|
81
|
+
conversationId: string | null;
|
|
82
|
+
conversationName: string | null;
|
|
83
|
+
callerName: string | null;
|
|
84
|
+
media: 'audio' | 'video';
|
|
85
|
+
}
|
|
86
|
+
|
|
64
87
|
/** One place call audio can play. */
|
|
65
88
|
export interface AudioOutput {
|
|
66
89
|
/** Opaque; stable while the device stays connected. */
|
|
@@ -122,6 +145,8 @@ interface PoolseCallsNative {
|
|
|
122
145
|
answer(): void;
|
|
123
146
|
decline(): void;
|
|
124
147
|
hangUp(): void;
|
|
148
|
+
answerWaiting?(callId: string | null): void;
|
|
149
|
+
declineWaiting?(callId: string | null): void;
|
|
125
150
|
setMuted(muted: boolean): void;
|
|
126
151
|
setCamera(on: boolean): void;
|
|
127
152
|
switchCamera(): void;
|
|
@@ -233,6 +258,16 @@ export async function startRoom(
|
|
|
233
258
|
export const answer = (): void => native?.answer();
|
|
234
259
|
export const decline = (): void => native?.decline();
|
|
235
260
|
export const hangUp = (): void => native?.hangUp();
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Call waiting: end the current call and answer `snapshot.waiting`.
|
|
264
|
+
* Pass its `callId`: a tap on a card for a call that is no longer the
|
|
265
|
+
* one waiting is then ignored instead of answering a different call.
|
|
266
|
+
*/
|
|
267
|
+
export const answerWaiting = (callId?: string): void => native?.answerWaiting?.(callId ?? null);
|
|
268
|
+
|
|
269
|
+
/** Call waiting: turn `snapshot.waiting` away — its caller hears busy. */
|
|
270
|
+
export const declineWaiting = (callId?: string): void => native?.declineWaiting?.(callId ?? null);
|
|
236
271
|
export const setMuted = (muted: boolean): void => native?.setMuted(muted);
|
|
237
272
|
export const setCamera = (on: boolean): void => native?.setCamera(on);
|
|
238
273
|
export const switchCamera = (): void => native?.switchCamera();
|