@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.
Files changed (30) hide show
  1. package/android/build.gradle +7 -0
  2. package/android/consumer-rules.pro +11 -0
  3. package/android/src/main/AndroidManifest.xml +2 -0
  4. package/android/src/main/java/dev/poolse/callcore/CallCoreRuntime.kt +245 -110
  5. package/android/src/main/java/dev/poolse/callcore/CallMachine.kt +113 -18
  6. package/android/src/main/java/dev/poolse/callcore/Ports.kt +17 -2
  7. package/android/src/main/java/dev/poolse/callcore/android/AndroidRingSurface.kt +51 -4
  8. package/android/src/main/java/dev/poolse/callcore/android/CallActionReceiver.kt +4 -3
  9. package/android/src/main/java/dev/poolse/callcore/android/CallCore.kt +269 -25
  10. package/android/src/main/java/dev/poolse/callcore/android/CallForegroundService.kt +228 -47
  11. package/android/src/main/java/dev/poolse/callcore/android/CallHttpClient.kt +97 -46
  12. package/android/src/main/java/dev/poolse/callcore/android/CallMessagingService.kt +59 -13
  13. package/android/src/main/java/dev/poolse/callcore/android/CallVideo.kt +36 -0
  14. package/android/src/main/java/dev/poolse/callcore/android/IncomingCallActivity.kt +100 -12
  15. package/android/src/main/java/dev/poolse/callcore/android/SnapshotJson.kt +15 -0
  16. package/android/src/main/java/expo/modules/poolsecalls/PoolseCallVideoView.kt +64 -0
  17. package/android/src/main/java/expo/modules/poolsecalls/PoolseCallsModule.kt +85 -17
  18. package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitMediaPort.kt +325 -23
  19. package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitVideoSurface.kt +107 -0
  20. package/android/src/test/java/dev/poolse/callcore/android/CallHttpClientTest.kt +61 -0
  21. package/dist/index.cjs +34 -0
  22. package/dist/index.cjs.map +1 -1
  23. package/dist/index.d.cts +52 -3
  24. package/dist/index.d.ts +52 -3
  25. package/dist/index.js +30 -2
  26. package/dist/index.js.map +1 -1
  27. package/package.json +2 -1
  28. package/src/expo-modules-core.d.ts +5 -0
  29. package/src/index.ts +91 -3
  30. 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 through
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
- leave()
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
- newRoom.localParticipant.setMicrophoneEnabled(true)
49
- if (wantsVideo) newRoom.localParticipant.setCameraEnabled(true)
50
- }.onFailure { events.onFailed() }
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?.disconnect()
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
- scope.launch { room?.localParticipant?.setMicrophoneEnabled(!muted) }
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
- scope.launch { room?.localParticipant?.setCameraEnabled(on) }
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
- // The host's VideoTrackView performs the actual flip; exposing the
71
- // control here keeps the port surface complete.
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 -> events.onConnected()
78
- is RoomEvent.Reconnecting, is RoomEvent.Disconnected -> events.onLost()
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
- is RoomEvent.ParticipantConnected -> { events.onRemoteJoined(); emit(room) }
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.ActiveSpeakersChanged,
352
+ is RoomEvent.TrackSubscribed,
353
+ is RoomEvent.TrackUnsubscribed,
83
354
  is RoomEvent.TrackMuted,
84
- is RoomEvent.TrackUnmuted -> emit(room)
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 = it.identity?.value ?: "",
378
+ identity = identity,
95
379
  speaking = it.isSpeaking,
96
- muted = it.audioTrackPublications.firstOrNull()?.first?.muted ?: true,
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
- fun dispose() = scope.cancel()
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