@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.
@@ -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
- /// Speaker forced by the user; nil = the media-type default.
70
- private var speakerWanted: Bool?
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" && speakerWanted != false ? .videoChat : .voiceChat
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 speakerWanted != nil { applyRoute() }
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
- speakerWanted = nil
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
- speakerWanted = on
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
- /// A voice call is held to the ear (earpiece, or a headset/Bluetooth
351
- /// route first); video defaults to the loudspeaker.
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 want = speakerWanted ?? (media == "video")
407
+ if case .port(let uid) = outputWanted, selectablePort(uid, s) == nil { outputWanted = nil }
408
+ let choice = resolvedChoice(s)
356
409
  do {
357
- if want {
410
+ switch choice {
411
+ case .speaker:
358
412
  try s.overrideOutputAudioPort(.speaker)
359
- } else {
360
- // .videoChat forces the speaker; the private route needs .voiceChat.
361
- if s.mode == .videoChat { try s.setMode(.voiceChat) }
362
- try s.overrideOutputAudioPort(.none)
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
- private func currentRoute() -> String {
371
- let outputs = AVAudioSession.sharedInstance().currentRoute.outputs
372
- guard let port = outputs.first?.portType else { return media == "video" ? "speaker" : "earpiece" }
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
- // A user's speaker choice survives route changes, except that a
413
- // newly connected headset/Bluetooth device always wins.
414
- let route = self.currentRoute()
415
- if let wanted = self.speakerWanted, route == "speaker" || route == "earpiece", (route == "speaker") != wanted {
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
- return
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.4.6",
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
- audioRoute: 'earpiece' | 'speaker' | 'bluetooth' | 'headset';
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
  /**