@poolse/react-native-calls 0.4.6 → 0.6.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/LICENSE +21 -0
- package/README.md +5 -0
- package/android/src/main/java/dev/poolse/callcore/CallCoreRuntime.kt +1 -0
- package/android/src/main/java/dev/poolse/callcore/Ports.kt +7 -0
- package/android/src/main/java/dev/poolse/callcore/android/CallCore.kt +34 -0
- package/android/src/main/java/dev/poolse/callcore/android/CallMessagingService.kt +7 -2
- package/android/src/main/java/dev/poolse/callcore/android/MissedCallNotification.kt +103 -0
- package/android/src/main/java/dev/poolse/callcore/android/SnapshotJson.kt +4 -0
- package/android/src/main/java/expo/modules/poolsecalls/PoolseCallsModule.kt +36 -1
- package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitMediaPort.kt +91 -27
- package/dist/index.cjs +15 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +58 -2
- package/dist/index.d.ts +58 -2
- package/dist/index.js +13 -1
- package/dist/index.js.map +1 -1
- package/ios/Core/CallCoreRuntime.swift +1 -0
- package/ios/Core/Ports.swift +7 -0
- package/ios/Core/SnapshotEncoding.swift +22 -0
- package/ios/Module/PoolseCallsModule.swift +1 -0
- package/ios/Platform/CallCore.swift +14 -0
- package/ios/Platform/LiveKitMediaPort.swift +153 -24
- package/package.json +6 -7
- package/src/index.ts +77 -1
|
@@ -23,6 +23,8 @@ protocol MediaEvents: AnyObject {
|
|
|
23
23
|
func onParticipants(_ participants: [SnapshotParticipant])
|
|
24
24
|
/// `earpiece` | `speaker` | `bluetooth` | `headset`.
|
|
25
25
|
func onAudioRoute(_ route: String)
|
|
26
|
+
/// Every output call audio can play through right now, and the one it does.
|
|
27
|
+
func onAudioOutputs(_ outputs: [AudioOutput], selectedId: String?)
|
|
26
28
|
/// The local camera track is (not) published + enabled; facing `front` | `back`.
|
|
27
29
|
func onLocalCamera(on: Bool, facing: String?)
|
|
28
30
|
}
|
|
@@ -66,8 +68,19 @@ final class LiveKitMediaPort: NSObject, MediaPort {
|
|
|
66
68
|
private var media = "audio"
|
|
67
69
|
private var connectedRoom: ObjectIdentifier?
|
|
68
70
|
|
|
69
|
-
///
|
|
70
|
-
|
|
71
|
+
/// Where the user asked the audio to go, for the CURRENT call.
|
|
72
|
+
enum OutputChoice: Equatable {
|
|
73
|
+
case speaker
|
|
74
|
+
/// The best private route: Bluetooth, then wired, then the earpiece.
|
|
75
|
+
case privateRoute
|
|
76
|
+
/// The phone itself: earpiece (or wired headphones, which replace it).
|
|
77
|
+
case builtIn
|
|
78
|
+
/// One external input/output pair, by AVAudioSessionPortDescription uid.
|
|
79
|
+
case port(String)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/// The user's output choice; nil = automatic (see `resolvedChoice`).
|
|
83
|
+
private var outputWanted: OutputChoice?
|
|
71
84
|
/// The user's choices for the CURRENT call; survive a rejoin.
|
|
72
85
|
private var mutedWanted = false
|
|
73
86
|
private var cameraWanted: Bool?
|
|
@@ -154,7 +167,7 @@ final class LiveKitMediaPort: NSObject, MediaPort {
|
|
|
154
167
|
func reassertAudio(_ session: AVAudioSession = .sharedInstance()) {
|
|
155
168
|
guard audioActive else { return }
|
|
156
169
|
pcTrace("reassertAudio before: \(pcSessionDescription(session))")
|
|
157
|
-
let mode: AVAudioSession.Mode = media == "video" &&
|
|
170
|
+
let mode: AVAudioSession.Mode = media == "video" && resolvedChoice(session) == .speaker ? .videoChat : .voiceChat
|
|
158
171
|
if session.category != .playAndRecord || session.mode != mode {
|
|
159
172
|
do {
|
|
160
173
|
try session.setCategory(.playAndRecord, mode: mode,
|
|
@@ -259,7 +272,7 @@ final class LiveKitMediaPort: NSObject, MediaPort {
|
|
|
259
272
|
private func markConnected(_ r: Room) {
|
|
260
273
|
guard room === r, connectedRoom != ObjectIdentifier(r) else { return }
|
|
261
274
|
connectedRoom = ObjectIdentifier(r)
|
|
262
|
-
if
|
|
275
|
+
if outputWanted != nil { applyRoute() }
|
|
263
276
|
events?.onConnected()
|
|
264
277
|
if !r.remoteParticipants.isEmpty { events?.onRemoteJoined() }
|
|
265
278
|
emit(r)
|
|
@@ -293,7 +306,7 @@ final class LiveKitMediaPort: NSObject, MediaPort {
|
|
|
293
306
|
private func resetWanted() {
|
|
294
307
|
mutedWanted = false
|
|
295
308
|
cameraWanted = nil
|
|
296
|
-
|
|
309
|
+
outputWanted = nil
|
|
297
310
|
}
|
|
298
311
|
|
|
299
312
|
private func detach() {
|
|
@@ -341,25 +354,72 @@ final class LiveKitMediaPort: NSObject, MediaPort {
|
|
|
341
354
|
}
|
|
342
355
|
|
|
343
356
|
func setSpeaker(_ on: Bool) {
|
|
344
|
-
|
|
357
|
+
outputWanted = on ? .speaker : .privateRoute
|
|
358
|
+
applyRoute()
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/// An id from the snapshot's `audioOutputs`: `speaker`, `earpiece`,
|
|
362
|
+
/// `wired`, or `port:<uid>`. An id that is no longer connected is ignored.
|
|
363
|
+
func setAudioOutput(_ id: String) {
|
|
364
|
+
switch id {
|
|
365
|
+
case "speaker": outputWanted = .speaker
|
|
366
|
+
case "earpiece", "wired": outputWanted = .builtIn
|
|
367
|
+
default:
|
|
368
|
+
guard id.hasPrefix("port:") else { return }
|
|
369
|
+
let uid = String(id.dropFirst("port:".count))
|
|
370
|
+
// An output with no matching input (an A2DP-only speaker) cannot be
|
|
371
|
+
// chosen through the session; the system's own route wins there.
|
|
372
|
+
outputWanted = selectablePort(uid) != nil ? .port(uid) : .privateRoute
|
|
373
|
+
}
|
|
345
374
|
applyRoute()
|
|
346
375
|
}
|
|
347
376
|
|
|
348
377
|
// MARK: - audio routing
|
|
349
378
|
|
|
350
|
-
|
|
351
|
-
|
|
379
|
+
private static let externalInputs: Set<AVAudioSession.Port> =
|
|
380
|
+
[.bluetoothHFP, .bluetoothLE, .carAudio, .usbAudio, .headsetMic]
|
|
381
|
+
private static let externalOutputs: Set<AVAudioSession.Port> =
|
|
382
|
+
[.bluetoothHFP, .bluetoothA2DP, .bluetoothLE, .carAudio, .usbAudio, .headphones, .airPlay, .lineOut, .HDMI]
|
|
383
|
+
|
|
384
|
+
private func selectablePort(_ uid: String, _ s: AVAudioSession = .sharedInstance()) -> AVAudioSessionPortDescription? {
|
|
385
|
+
s.availableInputs?.first { $0.uid == uid && Self.externalInputs.contains($0.portType) }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/// A headset, Bluetooth device or car is connected — something the
|
|
389
|
+
/// user put on or plugged in, which should carry the call by default.
|
|
390
|
+
private func externalConnected(_ s: AVAudioSession) -> Bool {
|
|
391
|
+
(s.availableInputs ?? []).contains { Self.externalInputs.contains($0.portType) && $0.portType != .usbAudio }
|
|
392
|
+
|| s.currentRoute.outputs.contains { Self.externalOutputs.contains($0.portType) }
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/// What the call should do now. Automatic: a voice call is held to the
|
|
396
|
+
/// ear and a video call uses the loudspeaker — unless a headset or
|
|
397
|
+
/// Bluetooth device is connected, which wins either way (FaceTime does
|
|
398
|
+
/// the same; forcing the speaker over AirPods was a bug).
|
|
399
|
+
private func resolvedChoice(_ s: AVAudioSession = .sharedInstance()) -> OutputChoice {
|
|
400
|
+
if let wanted = outputWanted { return wanted }
|
|
401
|
+
return media == "video" && !externalConnected(s) ? .speaker : .privateRoute
|
|
402
|
+
}
|
|
403
|
+
|
|
352
404
|
private func applyRoute() {
|
|
353
405
|
guard audioActive else { return }
|
|
354
406
|
let s = AVAudioSession.sharedInstance()
|
|
355
|
-
let
|
|
407
|
+
if case .port(let uid) = outputWanted, selectablePort(uid, s) == nil { outputWanted = nil }
|
|
408
|
+
let choice = resolvedChoice(s)
|
|
356
409
|
do {
|
|
357
|
-
|
|
410
|
+
switch choice {
|
|
411
|
+
case .speaker:
|
|
358
412
|
try s.overrideOutputAudioPort(.speaker)
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
if s.
|
|
362
|
-
|
|
413
|
+
case .privateRoute:
|
|
414
|
+
try leaveSpeaker(s)
|
|
415
|
+
if s.preferredInput != nil { try s.setPreferredInput(nil) }
|
|
416
|
+
case .builtIn:
|
|
417
|
+
try leaveSpeaker(s)
|
|
418
|
+
let mic = s.availableInputs?.first { $0.portType == .builtInMic }
|
|
419
|
+
if let mic = mic, s.preferredInput?.uid != mic.uid { try s.setPreferredInput(mic) }
|
|
420
|
+
case .port(let uid):
|
|
421
|
+
try leaveSpeaker(s)
|
|
422
|
+
if let port = selectablePort(uid, s), s.preferredInput?.uid != uid { try s.setPreferredInput(port) }
|
|
363
423
|
}
|
|
364
424
|
} catch {
|
|
365
425
|
NSLog("[PoolseCalls] audio route failed: %@", "\(error)")
|
|
@@ -367,21 +427,77 @@ final class LiveKitMediaPort: NSObject, MediaPort {
|
|
|
367
427
|
reportRoute()
|
|
368
428
|
}
|
|
369
429
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
430
|
+
/// .videoChat forces the speaker; every private route needs .voiceChat.
|
|
431
|
+
private func leaveSpeaker(_ s: AVAudioSession) throws {
|
|
432
|
+
if s.mode == .videoChat { try s.setMode(.voiceChat) }
|
|
433
|
+
try s.overrideOutputAudioPort(.none)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private func kind(of port: AVAudioSession.Port) -> String {
|
|
373
437
|
switch port {
|
|
374
438
|
case .builtInSpeaker: return "speaker"
|
|
375
439
|
case .builtInReceiver: return "earpiece"
|
|
376
440
|
case .bluetoothHFP, .bluetoothA2DP, .bluetoothLE, .carAudio, .airPlay: return "bluetooth"
|
|
377
|
-
case .headphones, .usbAudio, .lineOut, .HDMI: return "headset"
|
|
378
441
|
default: return "headset"
|
|
379
442
|
}
|
|
380
443
|
}
|
|
381
444
|
|
|
445
|
+
private func currentRoute() -> String {
|
|
446
|
+
guard let port = AVAudioSession.sharedInstance().currentRoute.outputs.first?.portType else {
|
|
447
|
+
return media == "video" ? "speaker" : "earpiece"
|
|
448
|
+
}
|
|
449
|
+
return kind(of: port)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/// Every output the user can pick, and the one playing now.
|
|
453
|
+
///
|
|
454
|
+
/// iOS lists inputs, not outputs: a headset or Bluetooth device is
|
|
455
|
+
/// selectable through its microphone (its input and output share the
|
|
456
|
+
/// uid), while the earpiece and speaker are always there on a phone.
|
|
457
|
+
/// Wired headphones replace the earpiece rather than sitting beside it.
|
|
458
|
+
private func outputs() -> ([AudioOutput], String?) {
|
|
459
|
+
let s = AVAudioSession.sharedInstance()
|
|
460
|
+
let inputs = s.availableInputs ?? []
|
|
461
|
+
let current = s.currentRoute.outputs.first
|
|
462
|
+
var list: [AudioOutput] = []
|
|
463
|
+
|
|
464
|
+
let wiredOut = s.currentRoute.outputs.first { $0.portType == .headphones }
|
|
465
|
+
if inputs.contains(where: { $0.portType == .headsetMic }) || wiredOut != nil {
|
|
466
|
+
list.append(AudioOutput(id: "wired", kind: "headset", name: "Headphones"))
|
|
467
|
+
} else if UIDevice.current.userInterfaceIdiom == .phone {
|
|
468
|
+
list.append(AudioOutput(id: "earpiece", kind: "earpiece", name: UIDevice.current.model))
|
|
469
|
+
}
|
|
470
|
+
list.append(AudioOutput(id: "speaker", kind: "speaker", name: "Speaker"))
|
|
471
|
+
for p in inputs where Self.externalInputs.contains(p.portType) && p.portType != .headsetMic {
|
|
472
|
+
list.append(AudioOutput(id: "port:\(p.uid)", kind: p.portType == .usbAudio ? "headset" : "bluetooth", name: p.portName))
|
|
473
|
+
}
|
|
474
|
+
// Playing through something with no microphone (an A2DP speaker,
|
|
475
|
+
// AirPlay): show it so the selection is truthful, even though only
|
|
476
|
+
// the system picker can choose it.
|
|
477
|
+
if let c = current, Self.externalOutputs.contains(c.portType), c.portType != .headphones,
|
|
478
|
+
!list.contains(where: { $0.id == "port:\(c.uid)" || $0.name == c.portName }) {
|
|
479
|
+
list.append(AudioOutput(id: "port:\(c.uid)", kind: kind(of: c.portType), name: c.portName))
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
let selected: String? = current.map { c in
|
|
483
|
+
switch c.portType {
|
|
484
|
+
case .builtInSpeaker: return "speaker"
|
|
485
|
+
case .builtInReceiver: return "earpiece"
|
|
486
|
+
case .headphones: return "wired"
|
|
487
|
+
default:
|
|
488
|
+
return list.first { $0.id == "port:\(c.uid)" }?.id
|
|
489
|
+
?? list.first { $0.name == c.portName }?.id
|
|
490
|
+
?? "port:\(c.uid)"
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return (list, selected)
|
|
494
|
+
}
|
|
495
|
+
|
|
382
496
|
private func reportRoute() {
|
|
383
497
|
guard joinedCallId != nil || audioActive else { return }
|
|
384
498
|
events?.onAudioRoute(currentRoute())
|
|
499
|
+
let (list, selected) = outputs()
|
|
500
|
+
events?.onAudioOutputs(list, selectedId: selected)
|
|
385
501
|
}
|
|
386
502
|
|
|
387
503
|
/// An interruption (another app's audio, Siri) ended: the session may
|
|
@@ -409,14 +525,27 @@ final class LiveKitMediaPort: NSObject, MediaPort {
|
|
|
409
525
|
self.reassertAudio()
|
|
410
526
|
return
|
|
411
527
|
}
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
528
|
+
switch reason {
|
|
529
|
+
case .newDeviceAvailable:
|
|
530
|
+
// Putting on a headset or AirPods mid-call moves the call to it,
|
|
531
|
+
// whatever was picked before — as the Phone app does.
|
|
532
|
+
self.outputWanted = nil
|
|
416
533
|
self.applyRoute()
|
|
417
|
-
|
|
534
|
+
case .oldDeviceUnavailable:
|
|
535
|
+
// A device went away. If it was the chosen one, applyRoute drops
|
|
536
|
+
// the choice and falls back to automatic — speaker for video,
|
|
537
|
+
// earpiece for voice; any other choice stands.
|
|
538
|
+
self.applyRoute()
|
|
539
|
+
default:
|
|
540
|
+
// The override is cleared by any route change: put the speaker
|
|
541
|
+
// back (or take it away) if that is not what the call wants.
|
|
542
|
+
let onSpeaker = self.currentRoute() == "speaker"
|
|
543
|
+
if (self.resolvedChoice() == .speaker) != onSpeaker && reason != .override {
|
|
544
|
+
self.applyRoute()
|
|
545
|
+
} else {
|
|
546
|
+
self.reportRoute()
|
|
547
|
+
}
|
|
418
548
|
}
|
|
419
|
-
self.reportRoute()
|
|
420
549
|
}
|
|
421
550
|
}
|
|
422
551
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poolse/react-native-calls",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|
|
@@ -31,11 +31,6 @@
|
|
|
31
31
|
"expo-module.config.json",
|
|
32
32
|
"README.md"
|
|
33
33
|
],
|
|
34
|
-
"scripts": {
|
|
35
|
-
"build": "tsup",
|
|
36
|
-
"prepublishOnly": "tsup",
|
|
37
|
-
"typecheck": "tsc --noEmit"
|
|
38
|
-
},
|
|
39
34
|
"peerDependencies": {
|
|
40
35
|
"expo": "*",
|
|
41
36
|
"react-native": "*"
|
|
@@ -44,5 +39,9 @@
|
|
|
44
39
|
"expo-modules-core": "*",
|
|
45
40
|
"tsup": "^8.3.5",
|
|
46
41
|
"typescript": "^5.6.0"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsup",
|
|
45
|
+
"typecheck": "tsc --noEmit"
|
|
47
46
|
}
|
|
48
|
-
}
|
|
47
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -41,7 +41,17 @@ export interface Snapshot {
|
|
|
41
41
|
/** True only with a published, subscribed, unmuted camera track (show video, not avatar). */
|
|
42
42
|
cameraOn: boolean;
|
|
43
43
|
}>;
|
|
44
|
-
|
|
44
|
+
/** The kind of output playing now — the short answer to "where is the audio". */
|
|
45
|
+
audioRoute: AudioOutputKind;
|
|
46
|
+
/**
|
|
47
|
+
* Every output the call can play through right now: always the phone
|
|
48
|
+
* (earpiece, or wired headphones in its place) and the speaker, plus
|
|
49
|
+
* each connected Bluetooth / USB device by its own name. Empty until
|
|
50
|
+
* the call's audio is up. Pass an `id` to `setAudioOutput`.
|
|
51
|
+
*/
|
|
52
|
+
audioOutputs: AudioOutput[];
|
|
53
|
+
/** The `audioOutputs` entry playing now; null before audio is up. */
|
|
54
|
+
audioOutputId: string | null;
|
|
45
55
|
mutedSelf: boolean;
|
|
46
56
|
/** True only while the local camera track is actually published and enabled. */
|
|
47
57
|
cameraOn: boolean;
|
|
@@ -49,6 +59,21 @@ export interface Snapshot {
|
|
|
49
59
|
cameraFacing?: 'front' | 'back';
|
|
50
60
|
}
|
|
51
61
|
|
|
62
|
+
export type AudioOutputKind = 'earpiece' | 'speaker' | 'bluetooth' | 'headset';
|
|
63
|
+
|
|
64
|
+
/** One place call audio can play. */
|
|
65
|
+
export interface AudioOutput {
|
|
66
|
+
/** Opaque; stable while the device stays connected. */
|
|
67
|
+
id: string;
|
|
68
|
+
kind: AudioOutputKind;
|
|
69
|
+
/**
|
|
70
|
+
* The device's own name for Bluetooth/USB ("AirPods Pro"); a plain
|
|
71
|
+
* English default for built-ins ("iPhone"/"Phone", "Speaker",
|
|
72
|
+
* "Headphones") — relabel those by `kind` to localise.
|
|
73
|
+
*/
|
|
74
|
+
name: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
52
77
|
export interface ConfigureOptions {
|
|
53
78
|
/** Drawable resource NAME for the call notification's small icon. */
|
|
54
79
|
smallIcon?: string;
|
|
@@ -75,6 +100,18 @@ export interface PlaceOptions {
|
|
|
75
100
|
sessionToken: string;
|
|
76
101
|
}
|
|
77
102
|
|
|
103
|
+
/**
|
|
104
|
+
* What the user tapped on a missed-call notification: the notification
|
|
105
|
+
* itself (`open` the conversation) or its Call back button (`call_back`
|
|
106
|
+
* — place a call on the conversation with `media`).
|
|
107
|
+
*/
|
|
108
|
+
export interface CallAction {
|
|
109
|
+
type: 'open' | 'call_back';
|
|
110
|
+
conversationId: string;
|
|
111
|
+
callId: string | null;
|
|
112
|
+
media: 'audio' | 'video';
|
|
113
|
+
}
|
|
114
|
+
|
|
78
115
|
type NativeSnapshot = { snapshot: Snapshot | null };
|
|
79
116
|
|
|
80
117
|
interface PoolseCallsNative {
|
|
@@ -89,13 +126,16 @@ interface PoolseCallsNative {
|
|
|
89
126
|
setCamera(on: boolean): void;
|
|
90
127
|
switchCamera(): void;
|
|
91
128
|
setSpeaker(on: boolean): void;
|
|
129
|
+
setAudioOutput?(id: string): void;
|
|
92
130
|
onRingEnded(callId: string, reason: string): void;
|
|
93
131
|
onCallFinished(callId: string, reason: string | null): void;
|
|
94
132
|
canUseFullScreenIntent(): boolean;
|
|
95
133
|
openFullScreenIntentSettings(): void;
|
|
96
134
|
getVoipToken?(): VoipToken | null;
|
|
135
|
+
takeCallAction?(): CallAction | null;
|
|
97
136
|
addListener(event: 'onCallState', cb: (payload: NativeSnapshot) => void): { remove(): void };
|
|
98
137
|
addListener(event: 'onVoipToken', cb: (payload: VoipToken) => void): { remove(): void };
|
|
138
|
+
addListener(event: 'onCallAction', cb: (payload: CallAction) => void): { remove(): void };
|
|
99
139
|
}
|
|
100
140
|
|
|
101
141
|
const native = requireOptionalNativeModule<PoolseCallsNative>('PoolseCalls');
|
|
@@ -143,6 +183,18 @@ export const switchCamera = (): void => native?.switchCamera();
|
|
|
143
183
|
/** Route call audio to the loudspeaker (`true`) or back to earpiece / headset (`false`). */
|
|
144
184
|
export const setSpeaker = (on: boolean): void => native?.setSpeaker(on);
|
|
145
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Play call audio through one of `snapshot.audioOutputs`. The choice
|
|
188
|
+
* holds for the rest of the call, until that device disconnects or a new
|
|
189
|
+
* headset / Bluetooth device connects (which takes over, as in the
|
|
190
|
+
* phone's own dialer). Without a choice a connected headset wins, then
|
|
191
|
+
* the earpiece for voice calls or the speaker for video.
|
|
192
|
+
*/
|
|
193
|
+
export function setAudioOutput(id: string): void {
|
|
194
|
+
if (native?.setAudioOutput) native.setAudioOutput(id);
|
|
195
|
+
else native?.setSpeaker(id === 'speaker'); // an older native build
|
|
196
|
+
}
|
|
197
|
+
|
|
146
198
|
/**
|
|
147
199
|
* Socket forwarding for an open app: a `call:ring_ended` seen on the
|
|
148
200
|
* socket. Idempotent with the push path — the core dedupes.
|
|
@@ -168,6 +220,30 @@ export const canUseFullScreenIntent = (): boolean => native?.canUseFullScreenInt
|
|
|
168
220
|
/** Open the system screen where the user grants full-screen intents (falls back to notification settings). */
|
|
169
221
|
export const openFullScreenIntentSettings = (): void => native?.openFullScreenIntentSettings();
|
|
170
222
|
|
|
223
|
+
// ── missed-call notifications (Android) ─────────────────────────────────
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Android: the missed-call notification action the app was LAUNCHED
|
|
227
|
+
* with, once — read it on mount. `null` when the app opened any other
|
|
228
|
+
* way. The native core posts that notification itself from the push,
|
|
229
|
+
* so it works with the app closed.
|
|
230
|
+
*
|
|
231
|
+
* iOS shows missed calls as an ordinary alert push (to the token you
|
|
232
|
+
* register with `kind: 'alert'`); read its taps with your notification
|
|
233
|
+
* library — its `type` is `call_missed`, the category
|
|
234
|
+
* `poolse_missed_call`, the Call back action `call_back`.
|
|
235
|
+
*/
|
|
236
|
+
export function takeCallAction(): CallAction | null {
|
|
237
|
+
return native?.takeCallAction?.() ?? null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Android: a missed-call notification tapped while the app is running. */
|
|
241
|
+
export function onCallAction(cb: (action: CallAction) => void): Unsubscribe {
|
|
242
|
+
if (!native || !native.takeCallAction) return () => undefined;
|
|
243
|
+
const sub = native.addListener('onCallAction', cb);
|
|
244
|
+
return () => sub.remove();
|
|
245
|
+
}
|
|
246
|
+
|
|
171
247
|
// ── VoIP push (iOS) ───────────────────────────────────────────────────
|
|
172
248
|
|
|
173
249
|
/**
|