@poolse/react-native-calls 0.9.0 → 0.10.1

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.
@@ -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 with another call: auto-decline the newcomer.
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
 
@@ -94,6 +94,20 @@ public protocol RingSurfacePort: AnyObject {
94
94
  func ringtoneStop()
95
95
  func ringbackStart()
96
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) {}
97
111
  }
98
112
 
99
113
  public struct RingContext: Equatable {
@@ -177,6 +191,20 @@ public enum EndReason: String {
177
191
  public var wire: String { rawValue.lowercased() }
178
192
  }
179
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
+
180
208
  /// Everything the machine knows. Immutable value; transitions return a new one.
181
209
  public struct MachineState: Equatable {
182
210
  public var phase: Phase
@@ -187,10 +215,12 @@ public struct MachineState: Equatable {
187
215
  public var reason: EndReason?
188
216
  /// HTTP attempts made for the current connecting request (retry budget = 2).
189
217
  public var httpAttempts: Int
218
+ /// A second call ringing beside this one, if any.
219
+ public var waiting: WaitingCall?
190
220
 
191
221
  public init(phase: Phase = .idle, callId: String? = nil, conversationId: String? = nil,
192
222
  direction: Direction? = nil, media: String = "audio", reason: EndReason? = nil,
193
- httpAttempts: Int = 0) {
223
+ httpAttempts: Int = 0, waiting: WaitingCall? = nil) {
194
224
  self.phase = phase
195
225
  self.callId = callId
196
226
  self.conversationId = conversationId
@@ -198,6 +228,7 @@ public struct MachineState: Equatable {
198
228
  self.media = media
199
229
  self.reason = reason
200
230
  self.httpAttempts = httpAttempts
231
+ self.waiting = waiting
201
232
  }
202
233
 
203
234
  public static var IDLE: MachineState { MachineState() }
@@ -73,7 +73,8 @@ public extension MachineState {
73
73
  audioOutputId: String? = nil,
74
74
  mutedSelf: Bool = false,
75
75
  cameraOn: Bool = false,
76
- cameraFacing: String? = nil) -> [String: Any]? {
76
+ cameraFacing: String? = nil,
77
+ waiting: RingContext? = nil) -> [String: Any]? {
77
78
  if phase == .idle { return nil }
78
79
  var out: [String: Any] = [
79
80
  "cv": 1,
@@ -91,6 +92,18 @@ public extension MachineState {
91
92
  "mutedSelf": mutedSelf,
92
93
  "cameraOn": cameraOn,
93
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
+ }
94
107
  if let reason = reason { out["reason"] = reason.wire }
95
108
  // Optional in the contract: present only while a camera is published.
96
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() } }
@@ -233,6 +233,21 @@ final class CallCore {
233
233
  }
234
234
  }
235
235
 
236
+ /// Call waiting: end this call, answer the waiting one. CallKit follows
237
+ /// from the resulting state: reconcileCallKit reports the old call
238
+ /// ended, then answers the new one.
239
+ func answerWaiting(callId: String? = nil) {
240
+ onMain { [self] in runtime.answerWaiting(callId: callId) }
241
+ }
242
+
243
+ /// Call waiting: turn the waiting call away (its caller hears busy).
244
+ func declineWaiting(callId: String? = nil) {
245
+ onMain { [self] in runtime.declineWaiting(callId: callId) }
246
+ }
247
+
248
+ /// True while `callId` is the call waiting beside the current one. Main thread.
249
+ func isWaiting(_ callId: String) -> Bool { runtime.currentState().waiting?.callId == callId }
250
+
236
251
  func place(conversationId: String, media: String, sessionToken: String) {
237
252
  onMain { [self] in
238
253
  localEnd = false
@@ -296,10 +311,18 @@ final class CallCore {
296
311
  if extrasCallId != nil { mediaPort.leave() }
297
312
  resetExtras(nil, nil)
298
313
  } else if state.callId != extrasCallId {
299
- if extrasCallId?.hasPrefix("pending:") == true {
300
- if let old = extrasCallId, let new = state.callId { callKit.rename(from: old, to: new) }
314
+ // `pending:<conv>` → the server-minted id is the same call — but only
315
+ // for that conversation's own placed call: a waiting call promoted
316
+ // over a still-pending one is a different call (renaming would give
317
+ // CallKit two calls with one id, and the incoming one never answered).
318
+ if let old = extrasCallId, old.hasPrefix("pending:"), state.direction != .incoming,
319
+ old == "pending:\(state.conversationId ?? "")" {
320
+ if let new = state.callId { callKit.rename(from: old, to: new) }
301
321
  extrasCallId = state.callId
302
322
  } else {
323
+ // A different call (a waiting one promoted): nothing of the last
324
+ // carries over — not even "the user ended it here".
325
+ if extrasCallId != nil { localEnd = false }
303
326
  resetExtras(state.callId, state.media)
304
327
  }
305
328
  }
@@ -320,7 +343,8 @@ final class CallCore {
320
343
  audioOutputId: audioOutputId,
321
344
  mutedSelf: mutedSelf,
322
345
  cameraOn: cameraOn,
323
- cameraFacing: cameraFacing
346
+ cameraFacing: cameraFacing,
347
+ waiting: runtime.waitingRing
324
348
  )
325
349
  snapshotLock.lock(); lastSnapshot = snap; snapshotLock.unlock()
326
350
  reconcileCallKit(state)
@@ -345,25 +369,27 @@ final class CallCore {
345
369
  /// CallKit follows the FINAL state of each dispatch.
346
370
  private func reconcileCallKit(_ state: MachineState) {
347
371
  let terminal = state.phase == .idle || state.phase == .ended
348
- for t in callKit.liveCalls {
349
- let isCurrent = !terminal && t.callId == state.callId
350
- if isCurrent {
351
- switch state.phase {
352
- case .connecting, .active, .reconnecting, .ringingOut:
353
- // Answered from the app's own UI: tell CallKit (audio follows).
354
- if !t.outgoing && !t.answered && state.direction == .incoming { callKit.requestAnswer(callId: t.callId) }
355
- if t.outgoing && state.phase == .active { callKit.reportConnected(callId: t.callId) }
356
- callKit.updateTitle(callId: t.callId, title: conversationName, video: state.media == "video")
357
- default:
358
- break
359
- }
360
- continue
361
- }
372
+ // The call waiting beside this one stays on CallKit's call-waiting UI.
373
+ let live = callKit.liveCalls.filter { terminal || $0.callId != state.waiting?.callId }
374
+ // Ends first: answering the next call (end-and-answer) while CallKit
375
+ // still holds the last one would ask it for two calls at once.
376
+ for t in live where terminal || t.callId != state.callId {
362
377
  // Not the call the machine is in (ended, busy-declined, superseded).
363
378
  // Terminal: whatever CallKit still shows is the call that just ended.
364
379
  let reason = endReason(for: t, state: state, current: terminal || t.callId == state.callId)
365
380
  callKit.end(t, reason: reason)
366
381
  }
382
+ for t in live where !terminal && t.callId == state.callId {
383
+ switch state.phase {
384
+ case .connecting, .active, .reconnecting, .ringingOut:
385
+ // Answered from the app's own UI: tell CallKit (audio follows).
386
+ if !t.outgoing && !t.answered && state.direction == .incoming { callKit.requestAnswer(callId: t.callId) }
387
+ if t.outgoing && state.phase == .active { callKit.reportConnected(callId: t.callId) }
388
+ callKit.updateTitle(callId: t.callId, title: conversationName, video: state.media == "video")
389
+ default:
390
+ break
391
+ }
392
+ }
367
393
  if terminal {
368
394
  localEnd = false
369
395
  if let id = state.callId { ringEndReasons.removeValue(forKey: id) }
@@ -419,6 +445,13 @@ final class CallCore {
419
445
  callKit.reportIncoming(ring: ring)
420
446
  }
421
447
 
448
+ /// CallKit shows a second incoming call over a live one as call
449
+ /// waiting (End & Accept / Decline). Usually already reported by the
450
+ /// push path; idempotent.
451
+ fileprivate func showWaiting(_ ring: RingContext) {
452
+ callKit.reportIncoming(ring: ring)
453
+ }
454
+
422
455
  fileprivate var ringbackPlayer: Ringback { ringback }
423
456
 
424
457
  // MediaEvents → runtime
@@ -451,12 +484,23 @@ final class CallCore {
451
484
  extension CallCore: CallKitActions {
452
485
  func callKitAnswer(callId: String) {
453
486
  let s = runtime.currentState()
487
+ // End & Accept may answer the waiting call before ending the current
488
+ // one: answering it ends the current call itself.
489
+ if s.waiting?.callId == callId {
490
+ runtime.answerWaiting(callId: callId)
491
+ return
492
+ }
454
493
  guard s.callId == callId, s.phase == .ringingIn else { return }
455
494
  runtime.answer()
456
495
  }
457
496
 
458
497
  func callKitEnd(callId: String) {
459
498
  let s = runtime.currentState()
499
+ // Decline on CallKit's call-waiting UI.
500
+ if s.waiting?.callId == callId {
501
+ runtime.declineWaiting(callId: callId)
502
+ return
503
+ }
460
504
  guard s.callId == callId || (s.callId?.hasPrefix("pending:") == true) else { return }
461
505
  localEnd = true
462
506
  if s.phase == .ringingIn { runtime.decline() } else { runtime.hangUp() }
@@ -480,6 +524,9 @@ extension CallCore: CallKitActions {
480
524
  func callKitReset() {
481
525
  let s = runtime.currentState()
482
526
  guard s.phase != .idle && s.phase != .ended else { return }
527
+ // CallKit dropped every call, the waiting one included: turn it away
528
+ // first, or ending the current call would ring it afresh.
529
+ if let w = s.waiting { runtime.declineWaiting(callId: w.callId) }
483
530
  localEnd = true
484
531
  if s.phase == .ringingIn { runtime.decline() } else { runtime.hangUp() }
485
532
  }
@@ -494,6 +541,11 @@ private final class SurfaceAdapter: RingSurfacePort, CallSessionPort {
494
541
 
495
542
  func show(ring: RingContext) { core?.showRing(ring) }
496
543
  func dismiss(callId: String) {}
544
+ func showWaiting(ring: RingContext) { core?.showWaiting(ring) }
545
+ // Dismissal is reconciled from the final state; promotion keeps the
546
+ // CallKit call, which SHOW_RING then finds already reported.
547
+ func dismissWaiting(callId: String) {}
548
+ func promoteWaiting(callId: String) {}
497
549
  func ringtoneStart() {}
498
550
  func ringtoneStop() {}
499
551
  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
- CallCore.peek()?.onRingEnded(callId: ring.callId, reason: "cancelled")
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poolse/react-native-calls",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
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
@@ -61,12 +61,29 @@ export interface Snapshot {
61
61
  mutedSelf: boolean;
62
62
  /** True only while the local camera track is actually published and enabled. */
63
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;
64
72
  /** Facing of the published local camera; absent when the camera is off. */
65
73
  cameraFacing?: 'front' | 'back';
66
74
  }
67
75
 
68
76
  export type AudioOutputKind = 'earpiece' | 'speaker' | 'bluetooth' | 'headset';
69
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
+
70
87
  /** One place call audio can play. */
71
88
  export interface AudioOutput {
72
89
  /** Opaque; stable while the device stays connected. */
@@ -128,6 +145,8 @@ interface PoolseCallsNative {
128
145
  answer(): void;
129
146
  decline(): void;
130
147
  hangUp(): void;
148
+ answerWaiting?(callId: string | null): void;
149
+ declineWaiting?(callId: string | null): void;
131
150
  setMuted(muted: boolean): void;
132
151
  setCamera(on: boolean): void;
133
152
  switchCamera(): void;
@@ -239,6 +258,16 @@ export async function startRoom(
239
258
  export const answer = (): void => native?.answer();
240
259
  export const decline = (): void => native?.decline();
241
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);
242
271
  export const setMuted = (muted: boolean): void => native?.setMuted(muted);
243
272
  export const setCamera = (on: boolean): void => native?.setCamera(on);
244
273
  export const switchCamera = (): void => native?.switchCamera();