@poolse/react-native-calls 0.9.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 +20 -0
- package/android/src/main/java/dev/poolse/callcore/CallCoreRuntime.kt +66 -6
- package/android/src/main/java/dev/poolse/callcore/CallMachine.kt +108 -3
- package/android/src/main/java/dev/poolse/callcore/Ports.kt +12 -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 +5 -0
- package/android/src/main/java/dev/poolse/callcore/android/SnapshotJson.kt +15 -0
- package/android/src/main/java/expo/modules/poolsecalls/PoolseCallsModule.kt +2 -0
- package/dist/index.cjs +4 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +25 -1
- package/dist/index.d.ts +25 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/ios/Core/CallCoreRuntime.swift +73 -6
- package/ios/Core/CallMachine.swift +91 -1
- package/ios/Core/Ports.swift +32 -1
- package/ios/Core/SnapshotEncoding.swift +14 -1
- package/ios/Module/PoolseCallsModule.swift +2 -0
- package/ios/Platform/CallCore.swift +63 -15
- package/ios/Platform/CallKitController.swift +7 -1
- package/package.json +1 -1
- package/src/index.ts +29 -0
package/ios/Core/Ports.swift
CHANGED
|
@@ -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
|
|
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,24 @@ 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 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
|
+
|
|
236
254
|
func place(conversationId: String, media: String, sessionToken: String) {
|
|
237
255
|
onMain { [self] in
|
|
238
256
|
localEnd = false
|
|
@@ -300,6 +318,9 @@ final class CallCore {
|
|
|
300
318
|
if let old = extrasCallId, let new = state.callId { callKit.rename(from: old, to: new) }
|
|
301
319
|
extrasCallId = state.callId
|
|
302
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 }
|
|
303
324
|
resetExtras(state.callId, state.media)
|
|
304
325
|
}
|
|
305
326
|
}
|
|
@@ -320,7 +341,8 @@ final class CallCore {
|
|
|
320
341
|
audioOutputId: audioOutputId,
|
|
321
342
|
mutedSelf: mutedSelf,
|
|
322
343
|
cameraOn: cameraOn,
|
|
323
|
-
cameraFacing: cameraFacing
|
|
344
|
+
cameraFacing: cameraFacing,
|
|
345
|
+
waiting: runtime.waitingRing
|
|
324
346
|
)
|
|
325
347
|
snapshotLock.lock(); lastSnapshot = snap; snapshotLock.unlock()
|
|
326
348
|
reconcileCallKit(state)
|
|
@@ -345,25 +367,27 @@ final class CallCore {
|
|
|
345
367
|
/// CallKit follows the FINAL state of each dispatch.
|
|
346
368
|
private func reconcileCallKit(_ state: MachineState) {
|
|
347
369
|
let terminal = state.phase == .idle || state.phase == .ended
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
}
|
|
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 {
|
|
362
375
|
// Not the call the machine is in (ended, busy-declined, superseded).
|
|
363
376
|
// Terminal: whatever CallKit still shows is the call that just ended.
|
|
364
377
|
let reason = endReason(for: t, state: state, current: terminal || t.callId == state.callId)
|
|
365
378
|
callKit.end(t, reason: reason)
|
|
366
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
|
+
}
|
|
367
391
|
if terminal {
|
|
368
392
|
localEnd = false
|
|
369
393
|
if let id = state.callId { ringEndReasons.removeValue(forKey: id) }
|
|
@@ -419,6 +443,13 @@ final class CallCore {
|
|
|
419
443
|
callKit.reportIncoming(ring: ring)
|
|
420
444
|
}
|
|
421
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
|
+
|
|
422
453
|
fileprivate var ringbackPlayer: Ringback { ringback }
|
|
423
454
|
|
|
424
455
|
// MediaEvents → runtime
|
|
@@ -451,12 +482,24 @@ final class CallCore {
|
|
|
451
482
|
extension CallCore: CallKitActions {
|
|
452
483
|
func callKitAnswer(callId: String) {
|
|
453
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
|
+
}
|
|
454
492
|
guard s.callId == callId, s.phase == .ringingIn else { return }
|
|
455
493
|
runtime.answer()
|
|
456
494
|
}
|
|
457
495
|
|
|
458
496
|
func callKitEnd(callId: String) {
|
|
459
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
|
+
}
|
|
460
503
|
guard s.callId == callId || (s.callId?.hasPrefix("pending:") == true) else { return }
|
|
461
504
|
localEnd = true
|
|
462
505
|
if s.phase == .ringingIn { runtime.decline() } else { runtime.hangUp() }
|
|
@@ -494,6 +537,11 @@ private final class SurfaceAdapter: RingSurfacePort, CallSessionPort {
|
|
|
494
537
|
|
|
495
538
|
func show(ring: RingContext) { core?.showRing(ring) }
|
|
496
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) {}
|
|
497
545
|
func ringtoneStart() {}
|
|
498
546
|
func ringtoneStop() {}
|
|
499
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
|
}
|
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
|
@@ -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();
|