@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
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`)
|
|
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
|
-
/**
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
268
|
-
* headset or Bluetooth device first
|
|
269
|
-
*
|
|
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
|
-
|
|
290
|
-
//
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
/**
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
|
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
|
package/dist/index.cjs.map
CHANGED
|
@@ -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"]}
|