@poolse/react-native-calls 0.1.3 → 0.3.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/android/build.gradle +7 -0
- package/android/consumer-rules.pro +11 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/dev/poolse/callcore/CallCoreRuntime.kt +245 -110
- package/android/src/main/java/dev/poolse/callcore/CallMachine.kt +113 -18
- package/android/src/main/java/dev/poolse/callcore/Ports.kt +17 -2
- package/android/src/main/java/dev/poolse/callcore/android/AndroidRingSurface.kt +51 -4
- package/android/src/main/java/dev/poolse/callcore/android/CallActionReceiver.kt +4 -3
- package/android/src/main/java/dev/poolse/callcore/android/CallCore.kt +269 -25
- package/android/src/main/java/dev/poolse/callcore/android/CallForegroundService.kt +228 -47
- package/android/src/main/java/dev/poolse/callcore/android/CallHttpClient.kt +97 -46
- package/android/src/main/java/dev/poolse/callcore/android/CallMessagingService.kt +59 -13
- package/android/src/main/java/dev/poolse/callcore/android/CallVideo.kt +36 -0
- package/android/src/main/java/dev/poolse/callcore/android/IncomingCallActivity.kt +100 -12
- package/android/src/main/java/dev/poolse/callcore/android/SnapshotJson.kt +15 -0
- package/android/src/main/java/expo/modules/poolsecalls/PoolseCallVideoView.kt +64 -0
- package/android/src/main/java/expo/modules/poolsecalls/PoolseCallsModule.kt +85 -17
- package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitMediaPort.kt +325 -23
- package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitVideoSurface.kt +107 -0
- package/android/src/test/java/dev/poolse/callcore/android/CallHttpClientTest.kt +61 -0
- package/dist/index.cjs +34 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +52 -3
- package/dist/index.d.ts +52 -3
- package/dist/index.js +30 -2
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/expo-modules-core.d.ts +5 -0
- package/src/index.ts +91 -3
- package/src/react-shims.d.ts +22 -0
|
@@ -1,20 +1,38 @@
|
|
|
1
1
|
package dev.poolse.callcore.livekit
|
|
2
2
|
|
|
3
|
+
import android.Manifest
|
|
3
4
|
import android.content.Context
|
|
5
|
+
import android.content.pm.PackageManager
|
|
6
|
+
import android.util.Log
|
|
7
|
+
import androidx.core.content.ContextCompat
|
|
8
|
+
import com.twilio.audioswitch.AudioDevice
|
|
4
9
|
import dev.poolse.callcore.CallSessionBundle
|
|
5
10
|
import dev.poolse.callcore.MediaPort
|
|
11
|
+
import dev.poolse.callcore.android.CallVideoSource
|
|
12
|
+
import dev.poolse.callcore.android.CallVideoSurface
|
|
6
13
|
import dev.poolse.callcore.android.MediaEvents
|
|
7
14
|
import dev.poolse.callcore.android.MediaParticipant
|
|
15
|
+
import io.livekit.android.ConnectOptions
|
|
8
16
|
import io.livekit.android.LiveKit
|
|
17
|
+
import io.livekit.android.audio.AudioSwitchHandler
|
|
18
|
+
import io.livekit.android.events.DisconnectReason
|
|
9
19
|
import io.livekit.android.events.RoomEvent
|
|
10
20
|
import io.livekit.android.events.collect
|
|
11
21
|
import io.livekit.android.room.Room
|
|
22
|
+
import io.livekit.android.room.participant.Participant
|
|
23
|
+
import io.livekit.android.room.track.CameraPosition
|
|
24
|
+
import io.livekit.android.room.track.LocalVideoTrack
|
|
25
|
+
import io.livekit.android.room.track.RemoteTrackPublication
|
|
26
|
+
import io.livekit.android.room.track.Track
|
|
27
|
+
import io.livekit.android.room.track.VideoTrack
|
|
12
28
|
import kotlinx.coroutines.CoroutineScope
|
|
13
29
|
import kotlinx.coroutines.Dispatchers
|
|
14
30
|
import kotlinx.coroutines.Job
|
|
15
31
|
import kotlinx.coroutines.SupervisorJob
|
|
16
32
|
import kotlinx.coroutines.cancel
|
|
33
|
+
import kotlinx.coroutines.delay
|
|
17
34
|
import kotlinx.coroutines.launch
|
|
35
|
+
import org.json.JSONObject
|
|
18
36
|
|
|
19
37
|
/**
|
|
20
38
|
* The shipped [MediaPort], over LiveKit's native Android SDK. Wire it
|
|
@@ -24,84 +42,368 @@ import kotlinx.coroutines.launch
|
|
|
24
42
|
* lives in its own source set (`src/main/livekit`) kept off the core
|
|
25
43
|
* library's classpath. Lifecycle and signaling are not its concern — it
|
|
26
44
|
* joins a room with the server-minted token, publishes the mic (and
|
|
27
|
-
* camera for video), and reports room transitions back
|
|
28
|
-
* [MediaEvents]. The runtime decides everything else.
|
|
45
|
+
* camera for video), routes audio, and reports room transitions back
|
|
46
|
+
* through [MediaEvents]. The runtime decides everything else.
|
|
47
|
+
*
|
|
48
|
+
* Threading: every method is called on the main thread (the runtime's
|
|
49
|
+
* thread) and every coroutine runs on Main.immediate, so [room] is only
|
|
50
|
+
* ever touched on main. Coroutines still re-check that the room they
|
|
51
|
+
* captured is the current one — a join/leave may have replaced it
|
|
52
|
+
* while they were suspended.
|
|
29
53
|
*/
|
|
30
54
|
class LiveKitMediaPort(
|
|
31
55
|
private val context: Context,
|
|
32
56
|
private val events: MediaEvents,
|
|
33
|
-
) : MediaPort {
|
|
57
|
+
) : MediaPort, CallVideoSource {
|
|
34
58
|
|
|
35
59
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
|
36
|
-
private var room: Room? = null
|
|
60
|
+
@Volatile private var room: Room? = null
|
|
37
61
|
private var eventJob: Job? = null
|
|
62
|
+
private var media: String = "audio"
|
|
63
|
+
|
|
64
|
+
/** Speaker forced by the user; null = the media-type default. */
|
|
65
|
+
private var speakerWanted: Boolean? = null
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The user's mute / camera choices for the CURRENT call. They survive
|
|
69
|
+
* a rejoin: /reconnect rebuilds the room, and blindly re-enabling the
|
|
70
|
+
* mic there went live while the UI still showed "muted". Reset only
|
|
71
|
+
* when the call ends ([leave]) or a different call joins.
|
|
72
|
+
*/
|
|
73
|
+
private var mutedWanted = false
|
|
74
|
+
private var cameraWanted: Boolean? = null
|
|
75
|
+
private var joinedCallId: String? = null
|
|
76
|
+
|
|
77
|
+
/** Live `<CallVideoView>` slots; rebound whenever what they show may have changed. */
|
|
78
|
+
private val surfaces = linkedSetOf<LiveKitVideoSurface>()
|
|
38
79
|
|
|
39
80
|
override fun join(bundle: CallSessionBundle) {
|
|
40
|
-
|
|
81
|
+
// Rejoin of the same call keeps the user's choices; so do choices
|
|
82
|
+
// made while this call was still connecting (joinedCallId null). A
|
|
83
|
+
// different call joining without a leave starts clean.
|
|
84
|
+
if (joinedCallId != null && bundle.callId != joinedCallId) resetWanted()
|
|
85
|
+
joinedCallId = bundle.callId
|
|
86
|
+
detach()
|
|
87
|
+
media = bundle.media
|
|
41
88
|
val wantsVideo = bundle.media == "video"
|
|
42
89
|
val newRoom = LiveKit.create(appContext = context.applicationContext)
|
|
90
|
+
configureAudio(newRoom, wantsVideo)
|
|
91
|
+
// A 1:1/small call: never let the SFU pause a subscribed camera for
|
|
92
|
+
// "not visible" — a track whose view mounts late would stay black.
|
|
93
|
+
// (Renderers are still registered with visibility via addRenderer.)
|
|
94
|
+
newRoom.adaptiveStream = false
|
|
95
|
+
newRoom.dynacast = false
|
|
43
96
|
room = newRoom
|
|
97
|
+
refreshVideo() // surfaces re-initialise against the new room's EGL
|
|
44
98
|
eventJob = scope.launch { observe(newRoom) }
|
|
45
99
|
scope.launch {
|
|
46
|
-
runCatching {
|
|
47
|
-
newRoom.connect(bundle.livekitUrl, bundle.livekitToken)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
100
|
+
val connected = runCatching {
|
|
101
|
+
newRoom.connect(bundle.livekitUrl, bundle.livekitToken, ConnectOptions(autoSubscribe = true))
|
|
102
|
+
}.onFailure {
|
|
103
|
+
Log.w(TAG, "connect failed", it)
|
|
104
|
+
if (room === newRoom) {
|
|
105
|
+
// Don't leave a half-open room (and its audio mode) behind.
|
|
106
|
+
eventJob?.cancel()
|
|
107
|
+
eventJob = null
|
|
108
|
+
room = null
|
|
109
|
+
refreshVideo()
|
|
110
|
+
teardown(newRoom)
|
|
111
|
+
events.onFailed()
|
|
112
|
+
}
|
|
113
|
+
}.isSuccess
|
|
114
|
+
if (!connected || room !== newRoom) return@launch
|
|
115
|
+
// Publishing failures must not fail the call: audio-only is a call.
|
|
116
|
+
runCatching { newRoom.localParticipant.setMicrophoneEnabled(!mutedWanted) }
|
|
117
|
+
.onFailure { Log.w(TAG, "microphone publish failed", it) }
|
|
118
|
+
if (cameraWanted ?: wantsVideo) enableCamera(newRoom, true)
|
|
119
|
+
reportLocalCamera(newRoom)
|
|
120
|
+
refreshVideo()
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private fun cameraGranted() =
|
|
125
|
+
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
|
126
|
+
PackageManager.PERMISSION_GRANTED
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Publish / unpublish the camera. Without the CAMERA grant a video
|
|
130
|
+
* call joins audio-only instead of failing; a later setCamera(true)
|
|
131
|
+
* after the user grants it publishes then.
|
|
132
|
+
*/
|
|
133
|
+
private suspend fun enableCamera(r: Room, on: Boolean) {
|
|
134
|
+
if (on && !cameraGranted()) {
|
|
135
|
+
Log.i(TAG, "CAMERA not granted: staying audio-only")
|
|
136
|
+
return
|
|
51
137
|
}
|
|
138
|
+
runCatching { r.localParticipant.setCameraEnabled(on) }
|
|
139
|
+
.onFailure { Log.w(TAG, "camera ${if (on) "publish" else "unpublish"} failed", it) }
|
|
52
140
|
}
|
|
53
141
|
|
|
142
|
+
/** The call is over for this device: drop the room AND the call's choices. */
|
|
54
143
|
override fun leave() {
|
|
144
|
+
detach()
|
|
145
|
+
resetWanted()
|
|
146
|
+
joinedCallId = null
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private fun resetWanted() {
|
|
150
|
+
mutedWanted = false
|
|
151
|
+
cameraWanted = null
|
|
152
|
+
speakerWanted = null
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Drop the current room only (a rejoin replaces it). */
|
|
156
|
+
private fun detach() {
|
|
55
157
|
eventJob?.cancel()
|
|
56
158
|
eventJob = null
|
|
57
|
-
room
|
|
159
|
+
val r = room ?: return
|
|
58
160
|
room = null
|
|
161
|
+
// Unbind every view from this room's tracks BEFORE its EGL is released.
|
|
162
|
+
refreshVideo()
|
|
163
|
+
teardown(r)
|
|
164
|
+
events.onLocalCamera(false, null)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private fun teardown(r: Room) {
|
|
168
|
+
runCatching { r.disconnect() }
|
|
169
|
+
// disconnect() keeps the room's engine, capturer and audio handler
|
|
170
|
+
// alive for a later reconnect; this port never reuses a room, so
|
|
171
|
+
// release it or every call leaks a PeerConnectionFactory.
|
|
172
|
+
runCatching { r.release() }
|
|
59
173
|
}
|
|
60
174
|
|
|
61
175
|
override fun setMuted(muted: Boolean) {
|
|
62
|
-
|
|
176
|
+
mutedWanted = muted
|
|
177
|
+
val r = room ?: return
|
|
178
|
+
scope.launch { runCatching { r.localParticipant.setMicrophoneEnabled(!muted) } }
|
|
63
179
|
}
|
|
64
180
|
|
|
65
181
|
override fun setCamera(on: Boolean) {
|
|
66
|
-
|
|
182
|
+
cameraWanted = on
|
|
183
|
+
val r = room ?: return
|
|
184
|
+
scope.launch {
|
|
185
|
+
enableCamera(r, on)
|
|
186
|
+
if (room !== r) return@launch
|
|
187
|
+
reportLocalCamera(r)
|
|
188
|
+
refreshVideo()
|
|
189
|
+
}
|
|
67
190
|
}
|
|
68
191
|
|
|
192
|
+
/** Front ↔ back on the published camera track. */
|
|
69
193
|
override fun switchCamera() {
|
|
70
|
-
|
|
71
|
-
|
|
194
|
+
val r = room ?: return
|
|
195
|
+
val track = localCameraTrack(r) ?: return
|
|
196
|
+
val next = if (track.options.position == CameraPosition.BACK) CameraPosition.FRONT else CameraPosition.BACK
|
|
197
|
+
runCatching { track.switchCamera(null, next) }.onFailure { Log.w(TAG, "switchCamera failed", it) }
|
|
198
|
+
// The capturer switches asynchronously and updates the options when
|
|
199
|
+
// done: report (and re-mirror the self view) once it has.
|
|
200
|
+
scope.launch {
|
|
201
|
+
delay(SWITCH_SETTLE_MS)
|
|
202
|
+
if (room !== r) return@launch
|
|
203
|
+
reportLocalCamera(r)
|
|
204
|
+
refreshVideo()
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── video ───────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
override fun createVideoSurface(context: Context): CallVideoSurface =
|
|
211
|
+
LiveKitVideoSurface(context, this).also { surfaces += it }
|
|
212
|
+
|
|
213
|
+
internal fun unregister(surface: LiveKitVideoSurface) { surfaces -= surface }
|
|
214
|
+
|
|
215
|
+
internal fun currentRoom(): Room? = room
|
|
216
|
+
|
|
217
|
+
private fun refreshVideo() {
|
|
218
|
+
for (s in surfaces.toList()) s.rebind()
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private fun localCameraTrack(r: Room): LocalVideoTrack? =
|
|
222
|
+
r.localParticipant.getTrackPublication(Track.Source.CAMERA)
|
|
223
|
+
?.takeIf { !it.muted }?.track as? LocalVideoTrack
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The camera track a view should show: the local one, or the remote
|
|
227
|
+
* participant's — only once it is published, SUBSCRIBED and unmuted.
|
|
228
|
+
*/
|
|
229
|
+
internal fun cameraTrack(participantId: String?, local: Boolean): VideoTrack? {
|
|
230
|
+
val r = room ?: return null
|
|
231
|
+
if (local) return localCameraTrack(r)
|
|
232
|
+
val p = r.remoteParticipants.values.firstOrNull { it.identity?.value == participantId } ?: return null
|
|
233
|
+
return remoteCamera(p)
|
|
72
234
|
}
|
|
73
235
|
|
|
236
|
+
private fun remoteCamera(p: Participant): VideoTrack? {
|
|
237
|
+
val pub = p.getTrackPublication(Track.Source.CAMERA) as? RemoteTrackPublication ?: return null
|
|
238
|
+
if (!pub.subscribed || pub.muted) return null
|
|
239
|
+
return pub.track as? VideoTrack
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** `front` | `back` of the published local camera, or null when none. */
|
|
243
|
+
internal fun cameraFacing(): String? {
|
|
244
|
+
val r = room ?: return null
|
|
245
|
+
return when (localCameraTrack(r)?.options?.position) {
|
|
246
|
+
CameraPosition.FRONT -> "front"
|
|
247
|
+
CameraPosition.BACK -> "back"
|
|
248
|
+
null -> null
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private fun reportLocalCamera(r: Room) {
|
|
253
|
+
if (room !== r) return
|
|
254
|
+
val on = localCameraTrack(r) != null
|
|
255
|
+
events.onLocalCamera(on, if (on) cameraFacing() else null)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
override fun setSpeaker(on: Boolean) {
|
|
259
|
+
speakerWanted = on
|
|
260
|
+
val handler = room?.audioHandler as? AudioSwitchHandler ?: return
|
|
261
|
+
applyRoute(handler)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── audio routing ───────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
/**
|
|
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.
|
|
270
|
+
*/
|
|
271
|
+
private fun configureAudio(r: Room, video: Boolean) {
|
|
272
|
+
val handler = r.audioHandler as? AudioSwitchHandler ?: return
|
|
273
|
+
handler.preferredDeviceList = if (video) {
|
|
274
|
+
listOf(
|
|
275
|
+
AudioDevice.BluetoothHeadset::class.java,
|
|
276
|
+
AudioDevice.WiredHeadset::class.java,
|
|
277
|
+
AudioDevice.Speakerphone::class.java,
|
|
278
|
+
AudioDevice.Earpiece::class.java,
|
|
279
|
+
)
|
|
280
|
+
} else {
|
|
281
|
+
listOf(
|
|
282
|
+
AudioDevice.BluetoothHeadset::class.java,
|
|
283
|
+
AudioDevice.WiredHeadset::class.java,
|
|
284
|
+
AudioDevice.Earpiece::class.java,
|
|
285
|
+
AudioDevice.Speakerphone::class.java,
|
|
286
|
+
)
|
|
287
|
+
}
|
|
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))
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
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))
|
|
305
|
+
}
|
|
306
|
+
|
|
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 }
|
|
313
|
+
|
|
314
|
+
private fun routeOf(d: AudioDevice?): String = when (d) {
|
|
315
|
+
is AudioDevice.Speakerphone -> "speaker"
|
|
316
|
+
is AudioDevice.BluetoothHeadset -> "bluetooth"
|
|
317
|
+
is AudioDevice.WiredHeadset -> "headset"
|
|
318
|
+
is AudioDevice.Earpiece -> "earpiece"
|
|
319
|
+
null -> if (media == "video") "speaker" else "earpiece"
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ── room events ─────────────────────────────────────────────────────
|
|
323
|
+
|
|
74
324
|
private suspend fun observe(room: Room) {
|
|
75
325
|
room.events.collect { event ->
|
|
326
|
+
if (this.room !== room) return@collect
|
|
76
327
|
when (event) {
|
|
77
|
-
is RoomEvent.Connected ->
|
|
78
|
-
|
|
328
|
+
is RoomEvent.Connected -> {
|
|
329
|
+
(room.audioHandler as? AudioSwitchHandler)?.let { if (speakerWanted != null) applyRoute(it) }
|
|
330
|
+
events.onConnected()
|
|
331
|
+
// Somebody may already be in (a late join, or a callee who
|
|
332
|
+
// answered before our media came up): they will not "connect".
|
|
333
|
+
if (room.remoteParticipants.isNotEmpty()) events.onRemoteJoined()
|
|
334
|
+
emit(room)
|
|
335
|
+
refreshVideo()
|
|
336
|
+
}
|
|
337
|
+
// LiveKit is resuming the SAME session (ICE restart / signal
|
|
338
|
+
// resume). Only reflect it: tearing the room down here fought
|
|
339
|
+
// its own recovery and turned every network blip into a rejoin.
|
|
340
|
+
is RoomEvent.Reconnecting -> events.onReconnecting()
|
|
79
341
|
is RoomEvent.Reconnected -> events.onRecovered()
|
|
80
|
-
|
|
342
|
+
// LiveKit gave up (or the server removed us). Our own leave()
|
|
343
|
+
// never gets here — the collector is cancelled first.
|
|
344
|
+
is RoomEvent.Disconnected -> {
|
|
345
|
+
if (event.reason != DisconnectReason.CLIENT_INITIATED) events.onLost()
|
|
346
|
+
}
|
|
347
|
+
is RoomEvent.ParticipantConnected -> { events.onRemoteJoined(); emit(room); refreshVideo() }
|
|
348
|
+
// Anything that can make a camera appear, vanish or freeze:
|
|
349
|
+
// update the roster's cameraOn, the local camera state, and
|
|
350
|
+
// rebind every video view.
|
|
81
351
|
is RoomEvent.ParticipantDisconnected,
|
|
82
|
-
is RoomEvent.
|
|
352
|
+
is RoomEvent.TrackSubscribed,
|
|
353
|
+
is RoomEvent.TrackUnsubscribed,
|
|
83
354
|
is RoomEvent.TrackMuted,
|
|
84
|
-
is RoomEvent.TrackUnmuted
|
|
355
|
+
is RoomEvent.TrackUnmuted,
|
|
356
|
+
is RoomEvent.TrackPublished,
|
|
357
|
+
is RoomEvent.TrackUnpublished,
|
|
358
|
+
is RoomEvent.LocalTrackSubscribed -> {
|
|
359
|
+
emit(room)
|
|
360
|
+
reportLocalCamera(room)
|
|
361
|
+
refreshVideo()
|
|
362
|
+
}
|
|
363
|
+
is RoomEvent.ActiveSpeakersChanged,
|
|
364
|
+
is RoomEvent.ParticipantMetadataChanged,
|
|
365
|
+
is RoomEvent.ParticipantAttributesChanged -> emit(room)
|
|
85
366
|
else -> Unit
|
|
86
367
|
}
|
|
368
|
+
Unit
|
|
87
369
|
}
|
|
88
370
|
}
|
|
89
371
|
|
|
372
|
+
/** The REMOTE roster — the local participant is never included. */
|
|
90
373
|
private fun emit(room: Room) {
|
|
91
374
|
events.onParticipants(
|
|
92
375
|
room.remoteParticipants.values.map {
|
|
376
|
+
val identity = it.identity?.value ?: ""
|
|
93
377
|
MediaParticipant(
|
|
94
|
-
identity =
|
|
378
|
+
identity = identity,
|
|
95
379
|
speaking = it.isSpeaking,
|
|
96
|
-
muted = it.
|
|
380
|
+
muted = !it.isMicrophoneEnabled(),
|
|
381
|
+
// Video, not avatar, only when there is a frame to draw:
|
|
382
|
+
// published, subscribed and unmuted.
|
|
383
|
+
cameraOn = remoteCamera(it) != null,
|
|
384
|
+
externalId = externalIdOf(it) ?: identity,
|
|
97
385
|
)
|
|
98
386
|
},
|
|
99
387
|
)
|
|
100
388
|
}
|
|
101
389
|
|
|
102
|
-
|
|
390
|
+
/** The host's own user id, if the server put it in attributes/metadata. */
|
|
391
|
+
private fun externalIdOf(p: Participant): String? =
|
|
392
|
+
runCatching { p.attributes["external_id"] }.getOrNull()?.ifEmpty { null }
|
|
393
|
+
?: p.metadata?.let { m ->
|
|
394
|
+
runCatching { JSONObject(m).optString("external_id", "") }.getOrNull()?.ifEmpty { null }
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
fun dispose() {
|
|
398
|
+
leave()
|
|
399
|
+
for (s in surfaces.toList()) s.release()
|
|
400
|
+
scope.cancel()
|
|
401
|
+
}
|
|
103
402
|
|
|
104
403
|
companion object {
|
|
404
|
+
private const val TAG = "PoolseLiveKit"
|
|
405
|
+
private const val SWITCH_SETTLE_MS = 600L
|
|
406
|
+
|
|
105
407
|
/** Pass as `mediaFactory` in CallCoreConfig. */
|
|
106
408
|
val factory: (Context, MediaEvents) -> MediaPort = { ctx, ev -> LiveKitMediaPort(ctx, ev) }
|
|
107
409
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
package dev.poolse.callcore.livekit
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.util.Log
|
|
5
|
+
import android.view.View
|
|
6
|
+
import dev.poolse.callcore.android.CallVideoSurface
|
|
7
|
+
import io.livekit.android.renderer.TextureViewRenderer
|
|
8
|
+
import io.livekit.android.room.Room
|
|
9
|
+
import io.livekit.android.room.track.VideoTrack
|
|
10
|
+
import livekit.org.webrtc.RendererCommon
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A [CallVideoSurface] over LiveKit's [TextureViewRenderer]. TextureView,
|
|
14
|
+
* not SurfaceView: a local picture-in-picture overlapping a full-screen
|
|
15
|
+
* remote composes like any view, with no SurfaceView z-order fights.
|
|
16
|
+
*
|
|
17
|
+
* The renderer is initialised against the CURRENT room's EGL context
|
|
18
|
+
* ([Room.initVideoRenderer]); a rejoin builds a new room, so [rebind]
|
|
19
|
+
* re-initialises it. Tracks are attached with [VideoTrack.addRenderer]
|
|
20
|
+
* — for a remote track that registers the view's visibility with the
|
|
21
|
+
* SDK, so adaptive stream (if ever enabled) resumes the track as soon as
|
|
22
|
+
* the view is on screen instead of keeping it paused.
|
|
23
|
+
*
|
|
24
|
+
* Main thread only, like the port that owns it.
|
|
25
|
+
*/
|
|
26
|
+
internal class LiveKitVideoSurface(
|
|
27
|
+
context: Context,
|
|
28
|
+
private val port: LiveKitMediaPort,
|
|
29
|
+
) : CallVideoSurface {
|
|
30
|
+
|
|
31
|
+
private val renderer = TextureViewRenderer(context)
|
|
32
|
+
override val view: View get() = renderer
|
|
33
|
+
|
|
34
|
+
private var initRoom: Room? = null
|
|
35
|
+
private var track: VideoTrack? = null
|
|
36
|
+
private var released = false
|
|
37
|
+
|
|
38
|
+
private var participantId: String? = null
|
|
39
|
+
private var local = false
|
|
40
|
+
private var mirror: Boolean? = null
|
|
41
|
+
private var fit = "cover"
|
|
42
|
+
|
|
43
|
+
override fun configure(participantId: String?, local: Boolean, mirror: Boolean?, fit: String) {
|
|
44
|
+
this.participantId = participantId
|
|
45
|
+
this.local = local
|
|
46
|
+
this.mirror = mirror
|
|
47
|
+
this.fit = fit
|
|
48
|
+
rebind()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Re-resolve the track (and the room) this slot should show. */
|
|
52
|
+
fun rebind() {
|
|
53
|
+
if (released) return
|
|
54
|
+
val room = port.currentRoom()
|
|
55
|
+
if (room !== initRoom) {
|
|
56
|
+
// Detach from the old room's track BEFORE its EGL goes away.
|
|
57
|
+
detachTrack()
|
|
58
|
+
if (initRoom != null) runCatching { renderer.release() }
|
|
59
|
+
initRoom = null
|
|
60
|
+
if (room != null) {
|
|
61
|
+
runCatching { room.initVideoRenderer(renderer); initRoom = room }
|
|
62
|
+
.onFailure { Log.w(TAG, "initVideoRenderer failed", it) }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
val next = if (initRoom != null) port.cameraTrack(participantId, local) else null
|
|
67
|
+
if (next !== track) {
|
|
68
|
+
detachTrack()
|
|
69
|
+
if (next != null) {
|
|
70
|
+
runCatching { next.addRenderer(renderer); track = next }
|
|
71
|
+
.onFailure { Log.w(TAG, "addRenderer failed", it) }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// No camera: hide the renderer so its last frozen frame never shows;
|
|
75
|
+
// the UI draws the avatar from the snapshot's cameraOn.
|
|
76
|
+
renderer.visibility = if (track != null) View.VISIBLE else View.INVISIBLE
|
|
77
|
+
if (initRoom != null) {
|
|
78
|
+
runCatching {
|
|
79
|
+
renderer.setMirror(mirror ?: (local && port.cameraFacing() != "back"))
|
|
80
|
+
renderer.setScalingType(
|
|
81
|
+
if (fit == "contain") RendererCommon.ScalingType.SCALE_ASPECT_FIT
|
|
82
|
+
else RendererCommon.ScalingType.SCALE_ASPECT_FILL,
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private fun detachTrack() {
|
|
89
|
+
val t = track ?: return
|
|
90
|
+
track = null
|
|
91
|
+
runCatching { t.removeRenderer(renderer) }
|
|
92
|
+
runCatching { renderer.clearImage() }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
override fun release() {
|
|
96
|
+
if (released) return
|
|
97
|
+
released = true
|
|
98
|
+
detachTrack()
|
|
99
|
+
if (initRoom != null) runCatching { renderer.release() }
|
|
100
|
+
initRoom = null
|
|
101
|
+
port.unregister(this)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private companion object {
|
|
105
|
+
const val TAG = "PoolseCallVideo"
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
package dev.poolse.callcore.android
|
|
2
|
+
|
|
3
|
+
import dev.poolse.callcore.CallSessionBundle
|
|
4
|
+
import dev.poolse.callcore.HttpResult
|
|
5
|
+
import org.junit.Assert.assertEquals
|
|
6
|
+
import org.junit.Assert.assertTrue
|
|
7
|
+
import org.junit.Test
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The HTTP classifier/parser, without a network: status + body →
|
|
11
|
+
* HttpResult exactly as CONTRACT.md §4 maps them.
|
|
12
|
+
*/
|
|
13
|
+
class CallHttpClientTest {
|
|
14
|
+
|
|
15
|
+
private val full = """
|
|
16
|
+
{"cv":1,"created":false,
|
|
17
|
+
"call":{"id":"c1","conversation_id":"v1","conversation_name":"Boiler","media":"video"},
|
|
18
|
+
"livekit":{"url":"wss://sfu","token":"lk-new"},
|
|
19
|
+
"control_token":"ctl-new"}
|
|
20
|
+
""".trimIndent()
|
|
21
|
+
|
|
22
|
+
private val current = CallSessionBundle(
|
|
23
|
+
callId = "c1", conversationId = "v1", conversationName = "Boiler", media = "audio",
|
|
24
|
+
created = true, livekitUrl = "wss://old", livekitToken = "lk-old", controlToken = "ctl-old",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
@Test fun `full bundle parses (answer, join, place and now reconnect)`() {
|
|
28
|
+
val r = CallHttpClient.classify(200, full, current) as HttpResult.Ok
|
|
29
|
+
assertEquals("c1", r.bundle.callId)
|
|
30
|
+
assertEquals("lk-new", r.bundle.livekitToken)
|
|
31
|
+
assertEquals("ctl-new", r.bundle.controlToken)
|
|
32
|
+
assertEquals("video", r.bundle.media)
|
|
33
|
+
assertEquals(false, r.bundle.created)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@Test fun `old reconnect shape is applied onto the current bundle`() {
|
|
37
|
+
val r = CallHttpClient.classify(200, """{"cv":1,"livekit":{"url":"wss://sfu2","token":"lk-2"}}""", current)
|
|
38
|
+
as HttpResult.Ok
|
|
39
|
+
assertEquals(current.copy(livekitUrl = "wss://sfu2", livekitToken = "lk-2"), r.bundle)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@Test fun `unparseable 2xx is a failure, not a retry`() {
|
|
43
|
+
assertTrue(CallHttpClient.classify(200, "<html>", null) is HttpResult.Failed)
|
|
44
|
+
assertTrue(CallHttpClient.classify(200, """{"livekit":{"token":"x"}}""", null) is HttpResult.Failed)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@Test fun `error envelope code is read`() {
|
|
48
|
+
val r = CallHttpClient.classify(401, """{"error":{"code":"grant_spent","message":"…"}}""")
|
|
49
|
+
assertEquals(HttpResult.AuthFailed("grant_spent"), r)
|
|
50
|
+
assertEquals(HttpResult.AuthFailed("http_403"), CallHttpClient.classify(403, null))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@Test fun `status mapping`() {
|
|
54
|
+
assertEquals(HttpResult.Gone, CallHttpClient.classify(410, null))
|
|
55
|
+
assertEquals(HttpResult.Gone, CallHttpClient.classify(404, """{"error":{"code":"no_call"}}"""))
|
|
56
|
+
assertEquals(HttpResult.Failed("http_503"), CallHttpClient.classify(503, null))
|
|
57
|
+
assertEquals(HttpResult.Timeout, CallHttpClient.classify(502, null))
|
|
58
|
+
assertEquals(HttpResult.Timeout, CallHttpClient.classify(429, null))
|
|
59
|
+
assertEquals(HttpResult.Failed("bad"), CallHttpClient.classify(422, """{"error":{"code":"bad"}}"""))
|
|
60
|
+
}
|
|
61
|
+
}
|
package/dist/index.cjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
var react = require('react');
|
|
4
|
+
var reactNative = require('react-native');
|
|
3
5
|
var expoModulesCore = require('expo-modules-core');
|
|
4
6
|
|
|
5
7
|
// src/index.ts
|
|
@@ -28,18 +30,50 @@ var hangUp = () => native?.hangUp();
|
|
|
28
30
|
var setMuted = (muted) => native?.setMuted(muted);
|
|
29
31
|
var setCamera = (on) => native?.setCamera(on);
|
|
30
32
|
var switchCamera = () => native?.switchCamera();
|
|
33
|
+
var setSpeaker = (on) => native?.setSpeaker(on);
|
|
34
|
+
var onRingEnded = (callId, reason) => native?.onRingEnded(callId, reason);
|
|
35
|
+
var onCallFinished = (callId, reason) => native?.onCallFinished(callId, reason ?? null);
|
|
36
|
+
var canUseFullScreenIntent = () => native?.canUseFullScreenIntent() ?? true;
|
|
37
|
+
var openFullScreenIntentSettings = () => native?.openFullScreenIntentSettings();
|
|
38
|
+
var nativeVideo;
|
|
39
|
+
function nativeVideoView() {
|
|
40
|
+
if (nativeVideo !== void 0) return nativeVideo;
|
|
41
|
+
nativeVideo = null;
|
|
42
|
+
if (native) {
|
|
43
|
+
try {
|
|
44
|
+
nativeVideo = expoModulesCore.requireNativeViewManager("PoolseCalls", "PoolseCallVideo");
|
|
45
|
+
} catch {
|
|
46
|
+
nativeVideo = null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return nativeVideo;
|
|
50
|
+
}
|
|
51
|
+
function CallVideoView(props) {
|
|
52
|
+
const Native = nativeVideoView();
|
|
53
|
+
if (!Native) {
|
|
54
|
+
const { participantId: _p, local: _l, mirror: _m, fit: _f, ...rest } = props;
|
|
55
|
+
return react.createElement(reactNative.View, rest);
|
|
56
|
+
}
|
|
57
|
+
return react.createElement(Native, props);
|
|
58
|
+
}
|
|
31
59
|
|
|
60
|
+
exports.CallVideoView = CallVideoView;
|
|
32
61
|
exports.answer = answer;
|
|
62
|
+
exports.canUseFullScreenIntent = canUseFullScreenIntent;
|
|
33
63
|
exports.configure = configure;
|
|
34
64
|
exports.decline = decline;
|
|
35
65
|
exports.getCallState = getCallState;
|
|
36
66
|
exports.hangUp = hangUp;
|
|
37
67
|
exports.join = join;
|
|
38
68
|
exports.nativeCallsAvailable = nativeCallsAvailable;
|
|
69
|
+
exports.onCallFinished = onCallFinished;
|
|
39
70
|
exports.onCallState = onCallState;
|
|
71
|
+
exports.onRingEnded = onRingEnded;
|
|
72
|
+
exports.openFullScreenIntentSettings = openFullScreenIntentSettings;
|
|
40
73
|
exports.place = place;
|
|
41
74
|
exports.setCamera = setCamera;
|
|
42
75
|
exports.setMuted = setMuted;
|
|
76
|
+
exports.setSpeaker = setSpeaker;
|
|
43
77
|
exports.switchCamera = switchCamera;
|
|
44
78
|
//# sourceMappingURL=index.cjs.map
|
|
45
79
|
//# sourceMappingURL=index.cjs.map
|