@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Poolse
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -17,6 +17,11 @@ await calls.configure('https://api.poolse.dev', { smallIcon: 'ic_call', ringback
17
17
  const stop = calls.onCallState((s) => render(s)); // s: Snapshot | null
18
18
  await calls.place(conversationId, { media: 'audio', sessionToken });
19
19
  calls.answer(); calls.decline(); calls.hangUp();
20
+
21
+ // Audio output: s.audioOutputs lists the phone, the speaker and every
22
+ // connected headset/Bluetooth device by name; s.audioOutputId is the live one.
23
+ calls.setAudioOutput(s.audioOutputs[0].id);
24
+ calls.setSpeaker(true); // the one-tap toggle, still there
20
25
  ```
21
26
 
22
27
  ## Layout
@@ -194,6 +194,7 @@ class CallCoreRuntime(
194
194
  fun setCamera(on: Boolean) = post { media.setCamera(on) }
195
195
  fun switchCamera() = post { media.switchCamera() }
196
196
  fun setSpeaker(on: Boolean) = post { media.setSpeaker(on) }
197
+ fun setAudioOutput(id: String) = post { media.setAudioOutput(id) }
197
198
 
198
199
  /** Re-emit the current state (the bridge's roster/route changed). */
199
200
  fun reemit() = post { if (state.phase != Phase.IDLE) sink.emit(state) }
@@ -67,6 +67,13 @@ interface MediaPort {
67
67
  * route — earpiece, or a connected headset/Bluetooth device (`false`).
68
68
  */
69
69
  fun setSpeaker(on: Boolean)
70
+
71
+ /**
72
+ * Play through one specific output — an id from the snapshot's
73
+ * `audioOutputs`. Engines without device selection fall back to the
74
+ * speaker toggle.
75
+ */
76
+ fun setAudioOutput(id: String) = setSpeaker(id == "speaker")
70
77
  }
71
78
 
72
79
  /** The ring UX: CallStyle notification + full-screen activity + tones. */
@@ -69,6 +69,8 @@ class CallCore private constructor(context: Context) {
69
69
  private var cameraOn = false
70
70
  private var cameraFacing: String? = null
71
71
  private var audioRoute: String? = null
72
+ private var audioOutputs: List<AudioOutput> = emptyList()
73
+ private var audioOutputId: String? = null
72
74
 
73
75
  /** Last snapshot emitted; read by the bridge's getCallState from any thread. */
74
76
  @Volatile private var lastSnapshot: Map<String, Any?>? = null
@@ -170,6 +172,12 @@ class CallCore private constructor(context: Context) {
170
172
  runtime.reemit()
171
173
  }
172
174
 
175
+ /**
176
+ * Play through one output from the snapshot's `audioOutputs`. Not
177
+ * optimistic: the adapter reports the route once the OS has moved it.
178
+ */
179
+ fun setAudioOutput(id: String) = runtime.setAudioOutput(id)
180
+
173
181
  fun allRingsResolved(outcome: String) = runtime.allRingsResolved(outcome)
174
182
 
175
183
  fun state(): MachineState = runtime.currentState()
@@ -216,6 +224,8 @@ class CallCore private constructor(context: Context) {
216
224
  )
217
225
  },
218
226
  audioRoute = audioRoute ?: if (state.media == "video") "speaker" else "earpiece",
227
+ audioOutputs = audioOutputs.map { mapOf("id" to it.id, "kind" to it.kind, "name" to it.name) },
228
+ audioOutputId = audioOutputId,
219
229
  mutedSelf = mutedSelf,
220
230
  cameraOn = cameraOn,
221
231
  cameraFacing = cameraFacing,
@@ -235,6 +245,8 @@ class CallCore private constructor(context: Context) {
235
245
  cameraOn = false
236
246
  cameraFacing = null
237
247
  audioRoute = null
248
+ audioOutputs = emptyList()
249
+ audioOutputId = null
238
250
  }
239
251
 
240
252
  /**
@@ -300,6 +312,10 @@ class CallCore private constructor(context: Context) {
300
312
  runtime.post { cameraOn = on; cameraFacing = facing }
301
313
  runtime.reemit()
302
314
  }
315
+ override fun onAudioOutputs(outputs: List<AudioOutput>, selectedId: String?) {
316
+ runtime.post { audioOutputs = outputs; audioOutputId = selectedId }
317
+ runtime.reemit()
318
+ }
303
319
  }
304
320
 
305
321
  private fun bringAppToFront(callId: String, from: Context?): Boolean {
@@ -368,6 +384,12 @@ class CallCore private constructor(context: Context) {
368
384
  /** The live core if one exists — never creates (cheap reads, e.g. getCallState). */
369
385
  fun peek(): CallCore? = instance
370
386
 
387
+ /** The configured notification icon (restored from disk on a cold start). */
388
+ fun smallIcon(context: Context): Int {
389
+ if (config == null) restoreConfig(context.applicationContext)
390
+ return config?.smallIcon ?: context.applicationInfo.icon
391
+ }
392
+
371
393
  internal fun peekConfig(): CallCoreConfig? = config
372
394
 
373
395
  internal fun requireConfig(): CallCoreConfig =
@@ -466,8 +488,20 @@ interface MediaEvents {
466
488
  fun onAudioRoute(route: String)
467
489
  /** The local camera track is (not) published + enabled; [facing] `front` | `back`. */
468
490
  fun onLocalCamera(on: Boolean, facing: String?) {}
491
+ /** Every output call audio can play through right now, and the one it does. */
492
+ fun onAudioOutputs(outputs: List<AudioOutput>, selectedId: String?) {}
469
493
  }
470
494
 
495
+ /** One place call audio can play — a snapshot `audioOutputs[]` entry. */
496
+ data class AudioOutput(
497
+ /** Passed back to `setAudioOutput`; stable while the device stays connected. */
498
+ val id: String,
499
+ /** `earpiece` | `speaker` | `bluetooth` | `headset`. */
500
+ val kind: String,
501
+ /** What the device calls itself ("Galaxy Buds2"), or a plain default for built-ins. */
502
+ val name: String,
503
+ )
504
+
471
505
  data class MediaParticipant(
472
506
  /** The SFU identity — the poolse user id. */
473
507
  val identity: String,
@@ -13,8 +13,8 @@ import dev.poolse.callcore.RingContext
13
13
  * everything else keep working unchanged.
14
14
  *
15
15
  * Contract: data-only messages — rings with `type: "call"`, control
16
- * pushes (`ring_ended`, `finished`) with `type: "call_event"`
17
- * (CONTRACT.md §5).
16
+ * pushes (`ring_ended`, `finished`) and the missed-call notification
17
+ * (`missed`) with `type: "call_event"` (CONTRACT.md §5).
18
18
  */
19
19
  class CallMessagingService : FirebaseMessagingService() {
20
20
 
@@ -43,6 +43,11 @@ class CallMessagingService : FirebaseMessagingService() {
43
43
  // Never configured on this install: nothing can answer a call, and
44
44
  // throwing here would crash the messaging process.
45
45
  val core = CallCore.getOrNull(this) ?: return
46
+ if (data["event"] == "missed") {
47
+ // A notification, not call state: the call is already over.
48
+ MissedCallNotification.show(this, data, CallCore.smallIcon(this))
49
+ return
50
+ }
46
51
  // A type=call push without an event is a legacy-shaped ring.
47
52
  val event = data["event"]?.ifEmpty { null } ?: if (type == "call") "ring" else return
48
53
  when (event) {
@@ -0,0 +1,103 @@
1
+ package dev.poolse.callcore.android
2
+
3
+ import android.app.NotificationChannel
4
+ import android.app.NotificationManager
5
+ import android.app.PendingIntent
6
+ import android.content.Context
7
+ import android.content.Intent
8
+ import android.os.Build
9
+ import android.util.Log
10
+ import androidx.core.app.NotificationCompat
11
+ import androidx.core.app.NotificationManagerCompat
12
+
13
+ /**
14
+ * "Jasir — Missed voice call [Call back]", posted natively from the
15
+ * missed-call push (`event: "missed"`) so it shows with the app closed.
16
+ *
17
+ * Both the tap and Call back open the app through its launch activity,
18
+ * carrying the action in extras ([EXTRA_ACTION] …). The bridge reads
19
+ * them — `takeCallAction()` on a cold start, `onCallAction` while
20
+ * running — and the host opens the conversation or places the call.
21
+ * Placing needs the user's session, which only the app has; that is
22
+ * why Call back does not dial from here.
23
+ */
24
+ object MissedCallNotification {
25
+ const val CHANNEL_ID = "poolse_missed_calls"
26
+ const val EXTRA_ACTION = "poolse_call_action"
27
+ const val EXTRA_CONVERSATION_ID = "poolse_call_action_conversation"
28
+ const val EXTRA_CALL_ID = "poolse_call_action_call"
29
+ const val EXTRA_MEDIA = "poolse_call_action_media"
30
+ const val ACTION_OPEN = "open"
31
+ const val ACTION_CALL_BACK = "call_back"
32
+ private const val TAG = "PoolseMissedCall"
33
+
34
+ // A missed call FCM held while the phone was off for days is history.
35
+ private const val STALE_MS = 24 * 60 * 60 * 1000L
36
+
37
+ fun show(context: Context, data: Map<String, String>, smallIcon: Int) {
38
+ val callId = data["call_id"] ?: return
39
+ val conversationId = data["conversation_id"] ?: return
40
+ val sentAt = data["sent_at"]?.toLongOrNull()
41
+ if (sentAt != null && System.currentTimeMillis() - sentAt > STALE_MS) return
42
+ val media = if (data["media"] == "video") "video" else "audio"
43
+ val title = data["title"]?.ifBlank { null }
44
+ ?: data["caller_name"]?.ifBlank { null }
45
+ ?: "Missed call"
46
+ val body = data["body"]?.ifBlank { null }
47
+ ?: if (media == "video") "Missed video call" else "Missed voice call"
48
+
49
+ ensureChannel(context)
50
+ val id = notificationId(callId)
51
+
52
+ fun launch(action: String, requestOffset: Int): PendingIntent? {
53
+ val intent = context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return null
54
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
55
+ .putExtra(EXTRA_ACTION, action)
56
+ .putExtra(EXTRA_CONVERSATION_ID, conversationId)
57
+ .putExtra(EXTRA_CALL_ID, callId)
58
+ .putExtra(EXTRA_MEDIA, media)
59
+ return PendingIntent.getActivity(
60
+ context,
61
+ id + requestOffset,
62
+ intent,
63
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
64
+ )
65
+ }
66
+
67
+ val builder = NotificationCompat.Builder(context, CHANNEL_ID)
68
+ .setSmallIcon(smallIcon)
69
+ .setContentTitle(title)
70
+ .setContentText(body)
71
+ .setCategory(NotificationCompat.CATEGORY_MISSED_CALL)
72
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
73
+ .setAutoCancel(true)
74
+ .setWhen(sentAt ?: System.currentTimeMillis())
75
+ .setShowWhen(true)
76
+ launch(ACTION_OPEN, 0)?.let(builder::setContentIntent)
77
+ launch(ACTION_CALL_BACK, 1)?.let { builder.addAction(0, "Call back", it) }
78
+
79
+ try {
80
+ NotificationManagerCompat.from(context).notify(id, builder.build())
81
+ } catch (e: SecurityException) {
82
+ Log.w(TAG, "missed-call notification refused (POST_NOTIFICATIONS?)", e)
83
+ }
84
+ }
85
+
86
+ /** Clear it once the user acted on it from the app (its buttons do not auto-cancel). */
87
+ fun cancel(context: Context, callId: String) {
88
+ runCatching { NotificationManagerCompat.from(context).cancel(notificationId(callId)) }
89
+ }
90
+
91
+ private fun notificationId(callId: String) = ("missed:$callId").hashCode()
92
+
93
+ private fun ensureChannel(context: Context) {
94
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
95
+ val manager = context.getSystemService(NotificationManager::class.java)
96
+ if (manager.getNotificationChannel(CHANNEL_ID) != null) return
97
+ manager.createNotificationChannel(
98
+ NotificationChannel(CHANNEL_ID, "Missed calls", NotificationManager.IMPORTANCE_DEFAULT).apply {
99
+ lockscreenVisibility = NotificationCompat.VISIBILITY_PRIVATE
100
+ },
101
+ )
102
+ }
103
+ }
@@ -18,6 +18,8 @@ internal fun MachineState.toSnapshot(
18
18
  conversationName: String? = null,
19
19
  participants: List<Map<String, Any>> = emptyList(),
20
20
  audioRoute: String = "earpiece",
21
+ audioOutputs: List<Map<String, Any>> = emptyList(),
22
+ audioOutputId: String? = null,
21
23
  mutedSelf: Boolean = false,
22
24
  cameraOn: Boolean = false,
23
25
  cameraFacing: String? = null,
@@ -25,6 +27,8 @@ internal fun MachineState.toSnapshot(
25
27
  if (phase == Phase.IDLE) return null
26
28
  return buildMap {
27
29
  putAll(snapshotCore(conversationName, participants, audioRoute, mutedSelf, cameraOn))
30
+ put("audioOutputs", audioOutputs)
31
+ put("audioOutputId", audioOutputId)
28
32
  // Optional in the contract: present only while a camera is published.
29
33
  if (cameraFacing != null) put("cameraFacing", cameraFacing)
30
34
  }
@@ -8,6 +8,7 @@ import android.os.Build
8
8
  import android.provider.Settings
9
9
  import android.util.Log
10
10
  import dev.poolse.callcore.android.CallCore
11
+ import dev.poolse.callcore.android.MissedCallNotification
11
12
  import expo.modules.kotlin.modules.Module
12
13
  import expo.modules.kotlin.modules.ModuleDefinition
13
14
 
@@ -30,7 +31,7 @@ class PoolseCallsModule : Module() {
30
31
  override fun definition() = ModuleDefinition {
31
32
  Name("PoolseCalls")
32
33
 
33
- Events("onCallState")
34
+ Events("onCallState", "onCallAction")
34
35
 
35
36
  // <CallVideoView>: renders a participant's (or our own) camera.
36
37
  View(PoolseCallVideoView::class) {
@@ -85,6 +86,7 @@ class PoolseCallsModule : Module() {
85
86
  Function("setCamera") { on: Boolean -> core()?.setCamera(on) }
86
87
  Function("switchCamera") { core()?.switchCamera() }
87
88
  Function("setSpeaker") { on: Boolean -> core()?.setSpeaker(on) }
89
+ Function("setAudioOutput") { id: String -> core()?.setAudioOutput(id) }
88
90
 
89
91
  // Socket forwarding (open app): idempotent with the FCM path.
90
92
  Function("onRingEnded") { callId: String, reason: String -> core()?.onRingEnded(callId, reason) }
@@ -92,6 +94,14 @@ class PoolseCallsModule : Module() {
92
94
  core()?.onCallFinished(callId, reason?.ifEmpty { null })
93
95
  }
94
96
 
97
+ // A missed-call notification's tap / Call back. Cold start: the
98
+ // launch intent still carries it — the host reads it once on mount.
99
+ // Running app: the tap arrives as a new intent.
100
+ Function("takeCallAction") { appContext.currentActivity?.intent?.let(::consumeCallAction) }
101
+ OnNewIntent { intent ->
102
+ consumeCallAction(intent)?.let { sendEvent("onCallAction", it) }
103
+ }
104
+
95
105
  // Android 14+: full-screen intents need a user grant for non-dialer apps.
96
106
  Function("canUseFullScreenIntent") {
97
107
  val ctx = context ?: return@Function true
@@ -129,6 +139,31 @@ class PoolseCallsModule : Module() {
129
139
 
130
140
  private fun core(): CallCore? = context?.let(CallCore::getOrNull)
131
141
 
142
+ /**
143
+ * The call action an intent carries, removed from it so a later read
144
+ * (rotation, re-mount) cannot replay it. Acting on it from the app
145
+ * clears its notification too: action buttons do not auto-cancel.
146
+ */
147
+ private fun consumeCallAction(intent: Intent): Map<String, Any?>? {
148
+ val action = intent.getStringExtra(MissedCallNotification.EXTRA_ACTION) ?: return null
149
+ val conversationId = intent.getStringExtra(MissedCallNotification.EXTRA_CONVERSATION_ID) ?: return null
150
+ val callId = intent.getStringExtra(MissedCallNotification.EXTRA_CALL_ID)
151
+ val media = intent.getStringExtra(MissedCallNotification.EXTRA_MEDIA) ?: "audio"
152
+ for (key in listOf(
153
+ MissedCallNotification.EXTRA_ACTION,
154
+ MissedCallNotification.EXTRA_CONVERSATION_ID,
155
+ MissedCallNotification.EXTRA_CALL_ID,
156
+ MissedCallNotification.EXTRA_MEDIA,
157
+ )) intent.removeExtra(key)
158
+ if (callId != null) context?.let { MissedCallNotification.cancel(it, callId) }
159
+ return mapOf(
160
+ "type" to action,
161
+ "conversationId" to conversationId,
162
+ "callId" to callId,
163
+ "media" to media,
164
+ )
165
+ }
166
+
132
167
  private companion object {
133
168
  const val TAG = "PoolseCalls"
134
169
  }
@@ -8,6 +8,7 @@ import androidx.core.content.ContextCompat
8
8
  import com.twilio.audioswitch.AudioDevice
9
9
  import dev.poolse.callcore.CallSessionBundle
10
10
  import dev.poolse.callcore.MediaPort
11
+ import dev.poolse.callcore.android.AudioOutput
11
12
  import dev.poolse.callcore.android.CallVideoSource
12
13
  import dev.poolse.callcore.android.CallVideoSurface
13
14
  import dev.poolse.callcore.android.MediaEvents
@@ -61,8 +62,15 @@ class LiveKitMediaPort(
61
62
  private var eventJob: Job? = null
62
63
  private var media: String = "audio"
63
64
 
64
- /** Speaker forced by the user; null = the media-type default. */
65
- private var speakerWanted: Boolean? = null
65
+ /**
66
+ * The user's output choice for the CURRENT call: an output id
67
+ * (`speaker`, `earpiece`, `wired`, `bluetooth`) or [PRIVATE] — the
68
+ * best private route. null = automatic (see [resolve]).
69
+ */
70
+ private var outputWanted: String? = null
71
+
72
+ /** The current room's last device list; a newcomer in it takes the call. */
73
+ private var knownDevices: List<AudioDevice>? = null
66
74
 
67
75
  /**
68
76
  * The user's mute / camera choices for the CURRENT call. They survive
@@ -149,7 +157,7 @@ class LiveKitMediaPort(
149
157
  private fun resetWanted() {
150
158
  mutedWanted = false
151
159
  cameraWanted = null
152
- speakerWanted = null
160
+ outputWanted = null
153
161
  }
154
162
 
155
163
  /** Drop the current room only (a rejoin replaces it). */
@@ -256,7 +264,13 @@ class LiveKitMediaPort(
256
264
  }
257
265
 
258
266
  override fun setSpeaker(on: Boolean) {
259
- speakerWanted = on
267
+ outputWanted = if (on) SPEAKER else PRIVATE
268
+ val handler = room?.audioHandler as? AudioSwitchHandler ?: return
269
+ applyRoute(handler)
270
+ }
271
+
272
+ override fun setAudioOutput(id: String) {
273
+ outputWanted = id
260
274
  val handler = room?.audioHandler as? AudioSwitchHandler ?: return
261
275
  applyRoute(handler)
262
276
  }
@@ -264,12 +278,13 @@ class LiveKitMediaPort(
264
278
  // ── audio routing ───────────────────────────────────────────────────
265
279
 
266
280
  /**
267
- * A voice call is held to the ear: earpiece by default (a connected
268
- * headset or Bluetooth device first); video defaults to the speaker.
269
- * Set before connect — the handler starts during connect.
281
+ * Seed AudioSwitch's own order (it starts during connect, before any
282
+ * choice is applied): a connected headset or Bluetooth device first,
283
+ * then the earpiece for voice or the speaker for video.
270
284
  */
271
285
  private fun configureAudio(r: Room, video: Boolean) {
272
286
  val handler = r.audioHandler as? AudioSwitchHandler ?: return
287
+ knownDevices = null
273
288
  handler.preferredDeviceList = if (video) {
274
289
  listOf(
275
290
  AudioDevice.BluetoothHeadset::class.java,
@@ -285,31 +300,78 @@ class LiveKitMediaPort(
285
300
  AudioDevice.Speakerphone::class.java,
286
301
  )
287
302
  }
288
- handler.audioDeviceChangeListener = { devices, selected ->
289
- // A user's speaker choice survives device-list changes, except
290
- // that plugging in a headset always wins (as the dialer does).
291
- val wanted = speakerWanted
292
- if (wanted != null && selected != null && room?.audioHandler === handler) {
293
- val external = selected is AudioDevice.BluetoothHeadset || selected is AudioDevice.WiredHeadset
294
- val isSpeaker = selected is AudioDevice.Speakerphone
295
- if (!external && isSpeaker != wanted) devices.pick(wanted)?.let(handler::selectDevice)
296
- }
297
- events.onAudioRoute(routeOf(handler.selectedAudioDevice ?: selected))
303
+ handler.audioDeviceChangeListener = listener@{ devices, selected ->
304
+ if (room?.audioHandler !== handler) return@listener
305
+ // Putting on a headset or connecting Bluetooth mid-call moves the
306
+ // call to it, whatever was picked before — as the dialer does. The
307
+ // first list of a room is not "new": a choice made while
308
+ // connecting must survive it.
309
+ val known = knownDevices
310
+ if (known != null && devices.any { it.isExternal() && it !in known }) outputWanted = null
311
+ knownDevices = devices
312
+ val target = resolve(devices)
313
+ if (target != null && target != selected) handler.selectDevice(target)
314
+ report(handler, devices, handler.selectedAudioDevice ?: selected)
298
315
  }
299
316
  }
300
317
 
301
318
  private fun applyRoute(handler: AudioSwitchHandler) {
302
- val want = speakerWanted ?: (media == "video")
303
- handler.availableAudioDevices.pick(want)?.let(handler::selectDevice)
304
- events.onAudioRoute(routeOf(handler.selectedAudioDevice))
319
+ val devices = handler.availableAudioDevices
320
+ resolve(devices)?.let { if (it != handler.selectedAudioDevice) handler.selectDevice(it) }
321
+ report(handler, devices, handler.selectedAudioDevice)
305
322
  }
306
323
 
307
- /** Speaker, or the best private route: Bluetooth → wired → earpiece. */
308
- private fun List<AudioDevice>.pick(speaker: Boolean): AudioDevice? =
309
- if (speaker) firstOrNull { it is AudioDevice.Speakerphone }
310
- else firstOrNull { it is AudioDevice.BluetoothHeadset }
311
- ?: firstOrNull { it is AudioDevice.WiredHeadset }
312
- ?: firstOrNull { it is AudioDevice.Earpiece }
324
+ /**
325
+ * Where the call should play. A chosen device while it is connected
326
+ * (it is forgotten once it goes); otherwise a connected headset or
327
+ * Bluetooth device, then the speaker for video or the earpiece for
328
+ * voice — never the speaker over earbuds the user put on.
329
+ */
330
+ private fun resolve(devices: List<AudioDevice>): AudioDevice? {
331
+ val wanted = outputWanted
332
+ if (wanted != null && wanted != PRIVATE) {
333
+ devices.firstOrNull { idOf(it) == wanted }?.let { return it }
334
+ outputWanted = null
335
+ }
336
+ val external = devices.firstOrNull { it is AudioDevice.BluetoothHeadset }
337
+ ?: devices.firstOrNull { it is AudioDevice.WiredHeadset }
338
+ val earpiece = devices.firstOrNull { it is AudioDevice.Earpiece }
339
+ val speaker = devices.firstOrNull { it is AudioDevice.Speakerphone }
340
+ return when {
341
+ external != null -> external
342
+ outputWanted == PRIVATE -> earpiece ?: speaker
343
+ media == "video" -> speaker ?: earpiece
344
+ else -> earpiece ?: speaker
345
+ }
346
+ }
347
+
348
+ private fun report(handler: AudioSwitchHandler, devices: List<AudioDevice>, selected: AudioDevice?) {
349
+ if (room?.audioHandler !== handler) return
350
+ events.onAudioRoute(routeOf(selected))
351
+ events.onAudioOutputs(
352
+ devices.map { AudioOutput(id = idOf(it), kind = routeOf(it), name = nameOf(it)) },
353
+ selected?.let(::idOf),
354
+ )
355
+ }
356
+
357
+ private fun AudioDevice.isExternal() =
358
+ this is AudioDevice.BluetoothHeadset || this is AudioDevice.WiredHeadset
359
+
360
+ private fun idOf(d: AudioDevice): String = when (d) {
361
+ is AudioDevice.Speakerphone -> SPEAKER
362
+ is AudioDevice.Earpiece -> "earpiece"
363
+ is AudioDevice.WiredHeadset -> "wired"
364
+ // AudioSwitch carries one Bluetooth device: the active headset.
365
+ is AudioDevice.BluetoothHeadset -> "bluetooth"
366
+ }
367
+
368
+ /** The Bluetooth device's own name; plain defaults for the rest. */
369
+ private fun nameOf(d: AudioDevice): String = when (d) {
370
+ is AudioDevice.Speakerphone -> "Speaker"
371
+ is AudioDevice.Earpiece -> "Phone"
372
+ is AudioDevice.WiredHeadset -> "Headphones"
373
+ is AudioDevice.BluetoothHeadset -> d.name.ifBlank { "Bluetooth" }
374
+ }
313
375
 
314
376
  private fun routeOf(d: AudioDevice?): String = when (d) {
315
377
  is AudioDevice.Speakerphone -> "speaker"
@@ -326,7 +388,7 @@ class LiveKitMediaPort(
326
388
  if (this.room !== room) return@collect
327
389
  when (event) {
328
390
  is RoomEvent.Connected -> {
329
- (room.audioHandler as? AudioSwitchHandler)?.let { if (speakerWanted != null) applyRoute(it) }
391
+ (room.audioHandler as? AudioSwitchHandler)?.let(::applyRoute)
330
392
  events.onConnected()
331
393
  // Somebody may already be in (a late join, or a callee who
332
394
  // answered before our media came up): they will not "connect".
@@ -403,6 +465,8 @@ class LiveKitMediaPort(
403
465
  companion object {
404
466
  private const val TAG = "PoolseLiveKit"
405
467
  private const val SWITCH_SETTLE_MS = 600L
468
+ private const val SPEAKER = "speaker"
469
+ private const val PRIVATE = "private"
406
470
 
407
471
  /** Pass as `mediaFactory` in CallCoreConfig. */
408
472
  val factory: (Context, MediaEvents) -> MediaPort = { ctx, ev -> LiveKitMediaPort(ctx, ev) }
package/dist/index.cjs CHANGED
@@ -31,10 +31,22 @@ var setMuted = (muted) => native?.setMuted(muted);
31
31
  var setCamera = (on) => native?.setCamera(on);
32
32
  var switchCamera = () => native?.switchCamera();
33
33
  var setSpeaker = (on) => native?.setSpeaker(on);
34
+ function setAudioOutput(id) {
35
+ if (native?.setAudioOutput) native.setAudioOutput(id);
36
+ else native?.setSpeaker(id === "speaker");
37
+ }
34
38
  var onRingEnded = (callId, reason) => native?.onRingEnded(callId, reason);
35
39
  var onCallFinished = (callId, reason) => native?.onCallFinished(callId, reason ?? null);
36
40
  var canUseFullScreenIntent = () => native?.canUseFullScreenIntent() ?? true;
37
41
  var openFullScreenIntentSettings = () => native?.openFullScreenIntentSettings();
42
+ function takeCallAction() {
43
+ return native?.takeCallAction?.() ?? null;
44
+ }
45
+ function onCallAction(cb) {
46
+ if (!native || !native.takeCallAction) return () => void 0;
47
+ const sub = native.addListener("onCallAction", cb);
48
+ return () => sub.remove();
49
+ }
38
50
  function getVoipToken() {
39
51
  return native?.getVoipToken?.() ?? null;
40
52
  }
@@ -75,15 +87,18 @@ exports.getVoipToken = getVoipToken;
75
87
  exports.hangUp = hangUp;
76
88
  exports.join = join;
77
89
  exports.nativeCallsAvailable = nativeCallsAvailable;
90
+ exports.onCallAction = onCallAction;
78
91
  exports.onCallFinished = onCallFinished;
79
92
  exports.onCallState = onCallState;
80
93
  exports.onRingEnded = onRingEnded;
81
94
  exports.onVoipToken = onVoipToken;
82
95
  exports.openFullScreenIntentSettings = openFullScreenIntentSettings;
83
96
  exports.place = place;
97
+ exports.setAudioOutput = setAudioOutput;
84
98
  exports.setCamera = setCamera;
85
99
  exports.setMuted = setMuted;
86
100
  exports.setSpeaker = setSpeaker;
87
101
  exports.switchCamera = switchCamera;
102
+ exports.takeCallAction = takeCallAction;
88
103
  //# sourceMappingURL=index.cjs.map
89
104
  //# sourceMappingURL=index.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"names":["requireOptionalNativeModule","requireNativeViewManager","createElement","View"],"mappings":"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","file":"index.cjs","sourcesContent":["// @poolse/react-native-calls — the React Native face of the native\n// call core (CONTRACT.md §8). A thin, typed surface over the native\n// module: commands in, one snapshot event out. All call logic lives in\n// the native cores; nothing here decides anything about a call.\n//\n// On a platform or build without the native module (Expo Go, web,\n// unlinked), every command is a safe no-op and the state stays idle —\n// so a host can render against this unconditionally.\nimport { createElement, type ComponentType, type ReactElement } from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport { requireNativeViewManager, requireOptionalNativeModule } from 'expo-modules-core';\n\n/**\n * A call's complete state. `null` means idle — no call. After a call\n * ends the snapshot stays `phase: 'ended'` (with `reason`) for ~2.5 s,\n * then becomes `null`.\n */\nexport interface Snapshot {\n cv: 1;\n callId: string;\n conversationId: string;\n conversationName: string | null;\n direction: 'incoming' | 'outgoing' | 'joined';\n media: 'audio' | 'video';\n phase: 'ringing_in' | 'ringing_out' | 'connecting' | 'active' | 'reconnecting' | 'ended';\n reason?:\n | 'left'\n | 'finished'\n | 'declined_all'\n | 'busy_all'\n | 'no_answer'\n | 'cancelled'\n | 'connection_lost'\n | 'failed';\n /** REMOTE participants only; `userId` is the LiveKit identity. */\n participants: Array<{\n userId: string;\n externalId: string;\n speaking: boolean;\n muted: boolean;\n /** True only with a published, subscribed, unmuted camera track (show video, not avatar). */\n cameraOn: boolean;\n }>;\n audioRoute: 'earpiece' | 'speaker' | 'bluetooth' | 'headset';\n mutedSelf: boolean;\n /** True only while the local camera track is actually published and enabled. */\n cameraOn: boolean;\n /** Facing of the published local camera; absent when the camera is off. */\n cameraFacing?: 'front' | 'back';\n}\n\nexport interface ConfigureOptions {\n /** Drawable resource NAME for the call notification's small icon. */\n smallIcon?: string;\n /** Ringback tone: raw resource NAME (Android); main-bundle sound name, mp3/caf/wav (iOS). */\n ringback?: string;\n /**\n * iOS: the APNs environment of this build's `aps-environment`\n * entitlement. Echoed back with the VoIP token so the server pushes to\n * the right gateway. Ignored on Android.\n */\n apnsEnvironment?: 'production' | 'sandbox';\n}\n\n/** The iOS PushKit (VoIP) token and the APNs environment it belongs to. */\nexport interface VoipToken {\n /** Hex-encoded PushKit token. */\n token: string;\n environment: 'production' | 'sandbox' | string;\n}\n\nexport interface PlaceOptions {\n media: 'audio' | 'video';\n /** The user's session token — authorizes placing/joining (CONTRACT.md §3.2). */\n sessionToken: string;\n}\n\ntype NativeSnapshot = { snapshot: Snapshot | null };\n\ninterface PoolseCallsNative {\n configure(apiBaseUrl: string, options: ConfigureOptions | null): Promise<void>;\n getCallState(): NativeSnapshot;\n place(conversationId: string, media: string, sessionToken: string): Promise<void>;\n join(callId: string, conversationId: string | null, sessionToken: string): Promise<void>;\n answer(): void;\n decline(): void;\n hangUp(): void;\n setMuted(muted: boolean): void;\n setCamera(on: boolean): void;\n switchCamera(): void;\n setSpeaker(on: boolean): void;\n onRingEnded(callId: string, reason: string): void;\n onCallFinished(callId: string, reason: string | null): void;\n canUseFullScreenIntent(): boolean;\n openFullScreenIntentSettings(): void;\n getVoipToken?(): VoipToken | null;\n addListener(event: 'onCallState', cb: (payload: NativeSnapshot) => void): { remove(): void };\n addListener(event: 'onVoipToken', cb: (payload: VoipToken) => void): { remove(): void };\n}\n\nconst native = requireOptionalNativeModule<PoolseCallsNative>('PoolseCalls');\n\n/** True when the native call core is present in this build. */\nexport const nativeCallsAvailable = native != null;\n\n/** Configure once, before any call can arrive. No secrets — just the API origin. */\nexport async function configure(apiBaseUrl: string, options?: ConfigureOptions): Promise<void> {\n await native?.configure(apiBaseUrl, options ?? null);\n}\n\n/** The current snapshot — safe to read at any moment, including cold answer and before configure. */\nexport function getCallState(): Snapshot | null {\n return native?.getCallState().snapshot ?? null;\n}\n\nexport type Unsubscribe = () => void;\n\n/** Subscribe to call state. Fires a full snapshot on every change. */\nexport function onCallState(cb: (snapshot: Snapshot | null) => void): Unsubscribe {\n if (!native) return () => undefined;\n const sub = native.addListener('onCallState', (payload) => cb(payload.snapshot));\n return () => sub.remove();\n}\n\nexport function place(conversationId: string, options: PlaceOptions): Promise<void> {\n return native?.place(conversationId, options.media, options.sessionToken) ?? Promise.resolve();\n}\n\nexport function join(\n callId: string,\n conversationId: string | null,\n sessionToken: string,\n): Promise<void> {\n return native?.join(callId, conversationId, sessionToken) ?? Promise.resolve();\n}\n\nexport const answer = (): void => native?.answer();\nexport const decline = (): void => native?.decline();\nexport const hangUp = (): void => native?.hangUp();\nexport const setMuted = (muted: boolean): void => native?.setMuted(muted);\nexport const setCamera = (on: boolean): void => native?.setCamera(on);\nexport const switchCamera = (): void => native?.switchCamera();\n/** Route call audio to the loudspeaker (`true`) or back to earpiece / headset (`false`). */\nexport const setSpeaker = (on: boolean): void => native?.setSpeaker(on);\n\n/**\n * Socket forwarding for an open app: a `call:ring_ended` seen on the\n * socket. Idempotent with the push path — the core dedupes.\n */\nexport const onRingEnded = (callId: string, reason: string): void =>\n native?.onRingEnded(callId, reason);\n\n/**\n * Socket forwarding: `call:finished`. Pass the payload's `reason`\n * (`finished` | `declined_all` | `no_answer` | `cancelled`) so an\n * unanswered caller sees \"Declined\"/\"No answer\", not \"Call ended\".\n * Idempotent with the `finished` push.\n */\nexport const onCallFinished = (callId: string, reason?: string): void =>\n native?.onCallFinished(callId, reason ?? null);\n\n/**\n * Android 14+: whether the app may show the full-screen ring over the\n * lock screen. `true` on older Android and where the module is absent.\n */\nexport const canUseFullScreenIntent = (): boolean => native?.canUseFullScreenIntent() ?? true;\n\n/** Open the system screen where the user grants full-screen intents (falls back to notification settings). */\nexport const openFullScreenIntentSettings = (): void => native?.openFullScreenIntentSettings();\n\n// ── VoIP push (iOS) ───────────────────────────────────────────────────\n\n/**\n * iOS: the current PushKit VoIP token, registered natively at launch.\n * `null` until iOS issues one, and always on Android (FCM carries rings).\n */\nexport function getVoipToken(): VoipToken | null {\n return native?.getVoipToken?.() ?? null;\n}\n\n/** iOS: fires whenever PushKit issues (or rotates) the VoIP token. No-op on Android. */\nexport function onVoipToken(cb: (token: VoipToken) => void): Unsubscribe {\n if (!native || !native.getVoipToken) return () => undefined;\n const sub = native.addListener('onVoipToken', cb);\n return () => sub.remove();\n}\n\n// ── video ─────────────────────────────────────────────────────────────\n\nexport interface CallVideoViewProps extends ViewProps {\n /** Remote participant's userId (LiveKit identity). Omit with local=true for self view. */\n participantId?: string;\n local?: boolean;\n /** default: true for local front camera, false otherwise */\n mirror?: boolean;\n /** default 'cover' */\n fit?: 'cover' | 'contain';\n}\n\ntype NativeVideoProps = CallVideoViewProps;\n\nlet nativeVideo: ComponentType<NativeVideoProps> | null | undefined;\n\n/** Resolved lazily (first render) and only when the native module exists. */\nfunction nativeVideoView(): ComponentType<NativeVideoProps> | null {\n if (nativeVideo !== undefined) return nativeVideo;\n nativeVideo = null;\n if (native) {\n try {\n nativeVideo = requireNativeViewManager<NativeVideoProps>('PoolseCalls', 'PoolseCallVideo');\n } catch {\n nativeVideo = null; // an older native build without the view\n }\n }\n return nativeVideo;\n}\n\n/**\n * A participant's camera (or, with `local`, your own), rendered natively\n * by the call core. Shows nothing (stays transparent) while that camera\n * is off — draw the avatar from the snapshot's `cameraOn` underneath.\n * Without the native module it renders an empty View.\n */\nexport function CallVideoView(props: CallVideoViewProps): ReactElement | null {\n const Native = nativeVideoView();\n if (!Native) {\n const { participantId: _p, local: _l, mirror: _m, fit: _f, ...rest } = props;\n return createElement(View, rest);\n }\n return createElement(Native, props);\n}\n"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":["requireOptionalNativeModule","requireNativeViewManager","createElement","View"],"mappings":"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","file":"index.cjs","sourcesContent":["// @poolse/react-native-calls — the React Native face of the native\n// call core (CONTRACT.md §8). A thin, typed surface over the native\n// module: commands in, one snapshot event out. All call logic lives in\n// the native cores; nothing here decides anything about a call.\n//\n// On a platform or build without the native module (Expo Go, web,\n// unlinked), every command is a safe no-op and the state stays idle —\n// so a host can render against this unconditionally.\nimport { createElement, type ComponentType, type ReactElement } from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport { requireNativeViewManager, requireOptionalNativeModule } from 'expo-modules-core';\n\n/**\n * A call's complete state. `null` means idle — no call. After a call\n * ends the snapshot stays `phase: 'ended'` (with `reason`) for ~2.5 s,\n * then becomes `null`.\n */\nexport interface Snapshot {\n cv: 1;\n callId: string;\n conversationId: string;\n conversationName: string | null;\n direction: 'incoming' | 'outgoing' | 'joined';\n media: 'audio' | 'video';\n phase: 'ringing_in' | 'ringing_out' | 'connecting' | 'active' | 'reconnecting' | 'ended';\n reason?:\n | 'left'\n | 'finished'\n | 'declined_all'\n | 'busy_all'\n | 'no_answer'\n | 'cancelled'\n | 'connection_lost'\n | 'failed';\n /** REMOTE participants only; `userId` is the LiveKit identity. */\n participants: Array<{\n userId: string;\n externalId: string;\n speaking: boolean;\n muted: boolean;\n /** True only with a published, subscribed, unmuted camera track (show video, not avatar). */\n cameraOn: boolean;\n }>;\n /** The kind of output playing now — the short answer to \"where is the audio\". */\n audioRoute: AudioOutputKind;\n /**\n * Every output the call can play through right now: always the phone\n * (earpiece, or wired headphones in its place) and the speaker, plus\n * each connected Bluetooth / USB device by its own name. Empty until\n * the call's audio is up. Pass an `id` to `setAudioOutput`.\n */\n audioOutputs: AudioOutput[];\n /** The `audioOutputs` entry playing now; null before audio is up. */\n audioOutputId: string | null;\n mutedSelf: boolean;\n /** True only while the local camera track is actually published and enabled. */\n cameraOn: boolean;\n /** Facing of the published local camera; absent when the camera is off. */\n cameraFacing?: 'front' | 'back';\n}\n\nexport type AudioOutputKind = 'earpiece' | 'speaker' | 'bluetooth' | 'headset';\n\n/** One place call audio can play. */\nexport interface AudioOutput {\n /** Opaque; stable while the device stays connected. */\n id: string;\n kind: AudioOutputKind;\n /**\n * The device's own name for Bluetooth/USB (\"AirPods Pro\"); a plain\n * English default for built-ins (\"iPhone\"/\"Phone\", \"Speaker\",\n * \"Headphones\") — relabel those by `kind` to localise.\n */\n name: string;\n}\n\nexport interface ConfigureOptions {\n /** Drawable resource NAME for the call notification's small icon. */\n smallIcon?: string;\n /** Ringback tone: raw resource NAME (Android); main-bundle sound name, mp3/caf/wav (iOS). */\n ringback?: string;\n /**\n * iOS: the APNs environment of this build's `aps-environment`\n * entitlement. Echoed back with the VoIP token so the server pushes to\n * the right gateway. Ignored on Android.\n */\n apnsEnvironment?: 'production' | 'sandbox';\n}\n\n/** The iOS PushKit (VoIP) token and the APNs environment it belongs to. */\nexport interface VoipToken {\n /** Hex-encoded PushKit token. */\n token: string;\n environment: 'production' | 'sandbox' | string;\n}\n\nexport interface PlaceOptions {\n media: 'audio' | 'video';\n /** The user's session token — authorizes placing/joining (CONTRACT.md §3.2). */\n sessionToken: string;\n}\n\n/**\n * What the user tapped on a missed-call notification: the notification\n * itself (`open` the conversation) or its Call back button (`call_back`\n * — place a call on the conversation with `media`).\n */\nexport interface CallAction {\n type: 'open' | 'call_back';\n conversationId: string;\n callId: string | null;\n media: 'audio' | 'video';\n}\n\ntype NativeSnapshot = { snapshot: Snapshot | null };\n\ninterface PoolseCallsNative {\n configure(apiBaseUrl: string, options: ConfigureOptions | null): Promise<void>;\n getCallState(): NativeSnapshot;\n place(conversationId: string, media: string, sessionToken: string): Promise<void>;\n join(callId: string, conversationId: string | null, sessionToken: string): Promise<void>;\n answer(): void;\n decline(): void;\n hangUp(): void;\n setMuted(muted: boolean): void;\n setCamera(on: boolean): void;\n switchCamera(): void;\n setSpeaker(on: boolean): void;\n setAudioOutput?(id: string): void;\n onRingEnded(callId: string, reason: string): void;\n onCallFinished(callId: string, reason: string | null): void;\n canUseFullScreenIntent(): boolean;\n openFullScreenIntentSettings(): void;\n getVoipToken?(): VoipToken | null;\n takeCallAction?(): CallAction | null;\n addListener(event: 'onCallState', cb: (payload: NativeSnapshot) => void): { remove(): void };\n addListener(event: 'onVoipToken', cb: (payload: VoipToken) => void): { remove(): void };\n addListener(event: 'onCallAction', cb: (payload: CallAction) => void): { remove(): void };\n}\n\nconst native = requireOptionalNativeModule<PoolseCallsNative>('PoolseCalls');\n\n/** True when the native call core is present in this build. */\nexport const nativeCallsAvailable = native != null;\n\n/** Configure once, before any call can arrive. No secrets — just the API origin. */\nexport async function configure(apiBaseUrl: string, options?: ConfigureOptions): Promise<void> {\n await native?.configure(apiBaseUrl, options ?? null);\n}\n\n/** The current snapshot — safe to read at any moment, including cold answer and before configure. */\nexport function getCallState(): Snapshot | null {\n return native?.getCallState().snapshot ?? null;\n}\n\nexport type Unsubscribe = () => void;\n\n/** Subscribe to call state. Fires a full snapshot on every change. */\nexport function onCallState(cb: (snapshot: Snapshot | null) => void): Unsubscribe {\n if (!native) return () => undefined;\n const sub = native.addListener('onCallState', (payload) => cb(payload.snapshot));\n return () => sub.remove();\n}\n\nexport function place(conversationId: string, options: PlaceOptions): Promise<void> {\n return native?.place(conversationId, options.media, options.sessionToken) ?? Promise.resolve();\n}\n\nexport function join(\n callId: string,\n conversationId: string | null,\n sessionToken: string,\n): Promise<void> {\n return native?.join(callId, conversationId, sessionToken) ?? Promise.resolve();\n}\n\nexport const answer = (): void => native?.answer();\nexport const decline = (): void => native?.decline();\nexport const hangUp = (): void => native?.hangUp();\nexport const setMuted = (muted: boolean): void => native?.setMuted(muted);\nexport const setCamera = (on: boolean): void => native?.setCamera(on);\nexport const switchCamera = (): void => native?.switchCamera();\n/** Route call audio to the loudspeaker (`true`) or back to earpiece / headset (`false`). */\nexport const setSpeaker = (on: boolean): void => native?.setSpeaker(on);\n\n/**\n * Play call audio through one of `snapshot.audioOutputs`. The choice\n * holds for the rest of the call, until that device disconnects or a new\n * headset / Bluetooth device connects (which takes over, as in the\n * phone's own dialer). Without a choice a connected headset wins, then\n * the earpiece for voice calls or the speaker for video.\n */\nexport function setAudioOutput(id: string): void {\n if (native?.setAudioOutput) native.setAudioOutput(id);\n else native?.setSpeaker(id === 'speaker'); // an older native build\n}\n\n/**\n * Socket forwarding for an open app: a `call:ring_ended` seen on the\n * socket. Idempotent with the push path — the core dedupes.\n */\nexport const onRingEnded = (callId: string, reason: string): void =>\n native?.onRingEnded(callId, reason);\n\n/**\n * Socket forwarding: `call:finished`. Pass the payload's `reason`\n * (`finished` | `declined_all` | `no_answer` | `cancelled`) so an\n * unanswered caller sees \"Declined\"/\"No answer\", not \"Call ended\".\n * Idempotent with the `finished` push.\n */\nexport const onCallFinished = (callId: string, reason?: string): void =>\n native?.onCallFinished(callId, reason ?? null);\n\n/**\n * Android 14+: whether the app may show the full-screen ring over the\n * lock screen. `true` on older Android and where the module is absent.\n */\nexport const canUseFullScreenIntent = (): boolean => native?.canUseFullScreenIntent() ?? true;\n\n/** Open the system screen where the user grants full-screen intents (falls back to notification settings). */\nexport const openFullScreenIntentSettings = (): void => native?.openFullScreenIntentSettings();\n\n// ── missed-call notifications (Android) ─────────────────────────────────\n\n/**\n * Android: the missed-call notification action the app was LAUNCHED\n * with, once — read it on mount. `null` when the app opened any other\n * way. The native core posts that notification itself from the push,\n * so it works with the app closed.\n *\n * iOS shows missed calls as an ordinary alert push (to the token you\n * register with `kind: 'alert'`); read its taps with your notification\n * library — its `type` is `call_missed`, the category\n * `poolse_missed_call`, the Call back action `call_back`.\n */\nexport function takeCallAction(): CallAction | null {\n return native?.takeCallAction?.() ?? null;\n}\n\n/** Android: a missed-call notification tapped while the app is running. */\nexport function onCallAction(cb: (action: CallAction) => void): Unsubscribe {\n if (!native || !native.takeCallAction) return () => undefined;\n const sub = native.addListener('onCallAction', cb);\n return () => sub.remove();\n}\n\n// ── VoIP push (iOS) ───────────────────────────────────────────────────\n\n/**\n * iOS: the current PushKit VoIP token, registered natively at launch.\n * `null` until iOS issues one, and always on Android (FCM carries rings).\n */\nexport function getVoipToken(): VoipToken | null {\n return native?.getVoipToken?.() ?? null;\n}\n\n/** iOS: fires whenever PushKit issues (or rotates) the VoIP token. No-op on Android. */\nexport function onVoipToken(cb: (token: VoipToken) => void): Unsubscribe {\n if (!native || !native.getVoipToken) return () => undefined;\n const sub = native.addListener('onVoipToken', cb);\n return () => sub.remove();\n}\n\n// ── video ─────────────────────────────────────────────────────────────\n\nexport interface CallVideoViewProps extends ViewProps {\n /** Remote participant's userId (LiveKit identity). Omit with local=true for self view. */\n participantId?: string;\n local?: boolean;\n /** default: true for local front camera, false otherwise */\n mirror?: boolean;\n /** default 'cover' */\n fit?: 'cover' | 'contain';\n}\n\ntype NativeVideoProps = CallVideoViewProps;\n\nlet nativeVideo: ComponentType<NativeVideoProps> | null | undefined;\n\n/** Resolved lazily (first render) and only when the native module exists. */\nfunction nativeVideoView(): ComponentType<NativeVideoProps> | null {\n if (nativeVideo !== undefined) return nativeVideo;\n nativeVideo = null;\n if (native) {\n try {\n nativeVideo = requireNativeViewManager<NativeVideoProps>('PoolseCalls', 'PoolseCallVideo');\n } catch {\n nativeVideo = null; // an older native build without the view\n }\n }\n return nativeVideo;\n}\n\n/**\n * A participant's camera (or, with `local`, your own), rendered natively\n * by the call core. Shows nothing (stays transparent) while that camera\n * is off — draw the avatar from the snapshot's `cameraOn` underneath.\n * Without the native module it renders an empty View.\n */\nexport function CallVideoView(props: CallVideoViewProps): ReactElement | null {\n const Native = nativeVideoView();\n if (!Native) {\n const { participantId: _p, local: _l, mirror: _m, fit: _f, ...rest } = props;\n return createElement(View, rest);\n }\n return createElement(Native, props);\n}\n"]}