@poolse/react-native-calls 0.1.2 → 0.2.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.
@@ -2,11 +2,17 @@ package dev.poolse.callcore.android
2
2
 
3
3
  import android.animation.ValueAnimator
4
4
  import android.app.Activity
5
+ import android.content.BroadcastReceiver
6
+ import android.content.Context
7
+ import android.content.Intent
8
+ import android.content.IntentFilter
5
9
  import android.graphics.Color
6
10
  import android.graphics.Typeface
7
11
  import android.graphics.drawable.GradientDrawable
8
12
  import android.os.Build
9
13
  import android.os.Bundle
14
+ import android.os.Handler
15
+ import android.os.Looper
10
16
  import android.view.Gravity
11
17
  import android.view.View
12
18
  import android.view.WindowManager
@@ -14,6 +20,10 @@ import android.view.animation.LinearInterpolator
14
20
  import android.widget.FrameLayout
15
21
  import android.widget.LinearLayout
16
22
  import android.widget.TextView
23
+ import androidx.core.content.ContextCompat
24
+ import dev.poolse.callcore.RingContext
25
+ import dev.poolse.callcore.android.AndroidRingSurface.Companion.ACTION_RING_CLOSED
26
+ import dev.poolse.callcore.android.AndroidRingSurface.Companion.EXTRA_CALL_ID
17
27
  import dev.poolse.callcore.android.AndroidRingSurface.Companion.EXTRA_RING
18
28
 
19
29
  /**
@@ -23,31 +33,118 @@ import dev.poolse.callcore.android.AndroidRingSurface.Companion.EXTRA_RING
23
33
  * ground, brand glow, a breathing ring) in plain views. The buttons
24
34
  * resolve into [CallCore], the same entry point every other surface
25
35
  * uses; answering brings the app forward over the keyguard.
36
+ *
37
+ * It closes itself when the ring ends anywhere else (caller cancelled,
38
+ * answered on another device, deadline) via the package-local
39
+ * [ACTION_RING_CLOSED] broadcast — before, a remotely-ended ring left
40
+ * this screen up with dead buttons.
26
41
  */
27
42
  class IncomingCallActivity : Activity() {
28
43
 
29
44
  private var pulse: ValueAnimator? = null
30
45
  private val density by lazy { resources.displayMetrics.density }
31
46
  private fun px(v: Int): Int = (v * density).toInt()
47
+ private val main = Handler(Looper.getMainLooper())
48
+
49
+ /** The ring on screen. */
50
+ private var ring: RingContext? = null
51
+
52
+ /**
53
+ * Answered: we are only waiting for the app to come forward over us.
54
+ * Our own DISMISS_RING broadcast must not close us before it does —
55
+ * finishing first left the app behind the keyguard.
56
+ */
57
+ private var awaitingForward = false
58
+
59
+ private val ringClosed = object : BroadcastReceiver() {
60
+ override fun onReceive(context: Context, intent: Intent) {
61
+ val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: return
62
+ if (callId == ring?.callId && !awaitingForward) finish()
63
+ }
64
+ }
32
65
 
33
66
  override fun onCreate(savedInstanceState: Bundle?) {
34
67
  super.onCreate(savedInstanceState)
35
68
  showOverKeyguard()
69
+ ContextCompat.registerReceiver(
70
+ this,
71
+ ringClosed,
72
+ IntentFilter(ACTION_RING_CLOSED),
73
+ ContextCompat.RECEIVER_NOT_EXPORTED,
74
+ )
75
+ handle(intent)
76
+ }
77
+
78
+ /** singleInstance: a new ring (or the answer action) arrives here, not in onCreate. */
79
+ override fun onNewIntent(intent: Intent) {
80
+ super.onNewIntent(intent)
81
+ setIntent(intent)
82
+ handle(intent)
83
+ }
36
84
 
37
- val ring = intent.getBundleExtra(EXTRA_RING)?.toRingContext()
38
- if (ring == null) { finish(); return }
85
+ private fun handle(intent: Intent) {
86
+ val next = intent.getBundleExtra(EXTRA_RING)?.toRingContext()
87
+ if (next == null) { if (ring == null) finish(); return }
88
+ val core = CallCore.getOrNull(this)
89
+ if (core == null) { finish(); return }
90
+
91
+ // Answered from the notification: this activity exists only to be the
92
+ // foreground context that is ALLOWED to bring the app up (a
93
+ // background receiver is not).
94
+ if (intent.getBooleanExtra(AndroidRingSurface.EXTRA_AUTO_ANSWER, false)) {
95
+ ring = next
96
+ answerAndForward(core, next)
97
+ return
98
+ }
39
99
 
40
- val title = ring.conversationName ?: ring.callerName ?: "Incoming call"
41
- val body = if (ring.media == "video") "Incoming video call" else "Incoming call"
42
- setContentView(buildScene(title, body, ring))
100
+ // A stale tap (the ring resolved, or the process restarted after it
101
+ // ended): there is nothing to answer.
102
+ if (!core.isRingLive(next)) {
103
+ if (ring == null || ring?.callId == next.callId) finish()
104
+ return
105
+ }
106
+ ring = next
107
+ awaitingForward = false
108
+ pulse?.cancel()
109
+ val title = next.conversationName ?: next.callerName ?: "Incoming call"
110
+ val body = if (next.media == "video") "Incoming video call" else "Incoming call"
111
+ setContentView(buildScene(title, body, next))
112
+ }
113
+
114
+ /**
115
+ * Answer, then bring the app forward FROM THIS ACTIVITY, and stay
116
+ * (as a plain dark window) until the app is in front — finishing at
117
+ * once dropped the start on some OEMs and left the call audible
118
+ * behind the lock screen. onStop (we got covered) finishes us; a
119
+ * timer is the fallback.
120
+ */
121
+ private fun answerAndForward(core: CallCore, ring: RingContext) {
122
+ awaitingForward = true
123
+ pulse?.cancel()
124
+ pulse = null
125
+ setContentView(View(this).apply { setBackgroundColor(Color.parseColor("#0B0B14")) })
126
+ val launched = core.onUserAnswer(ring, this)
127
+ if (!launched) { finish(); return }
128
+ main.postDelayed({ if (!isFinishing) finish() }, FORWARD_FALLBACK_MS)
129
+ }
130
+
131
+ override fun onStop() {
132
+ super.onStop()
133
+ if (awaitingForward && !isFinishing) finish()
43
134
  }
44
135
 
45
136
  override fun onDestroy() {
137
+ main.removeCallbacksAndMessages(null)
138
+ runCatching { unregisterReceiver(ringClosed) }
46
139
  pulse?.cancel()
47
140
  pulse = null
48
141
  super.onDestroy()
49
142
  }
50
143
 
144
+ private companion object {
145
+ const val FORWARD_FALLBACK_MS = 4_000L
146
+ }
147
+
51
148
  private fun showOverKeyguard() {
52
149
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
53
150
  setShowWhenLocked(true)
@@ -62,7 +159,7 @@ class IncomingCallActivity : Activity() {
62
159
  window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
63
160
  }
64
161
 
65
- private fun buildScene(title: String, body: String, ring: dev.poolse.callcore.RingContext): View {
162
+ private fun buildScene(title: String, body: String, ring: RingContext): View {
66
163
  val root = FrameLayout(this).apply { setBackgroundColor(Color.parseColor("#0B0B14")) }
67
164
 
68
165
  root.addView(
@@ -137,10 +234,11 @@ class IncomingCallActivity : Activity() {
137
234
  layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, wrap())
138
235
  }
139
236
  val decline = actionButton("Decline", "✕", "#EF4444") {
140
- CallCore.get(this).onUserDecline(ring); finish()
237
+ CallCore.getOrNull(this)?.onUserDecline(ring); finish()
141
238
  }
142
239
  val accept = actionButton("Answer", "✓", "#22C55E") {
143
- CallCore.get(this).onUserAnswer(ring); finish()
240
+ val core = CallCore.getOrNull(this)
241
+ if (core == null) finish() else answerAndForward(core, ring)
144
242
  }
145
243
  buttons.addView(decline, LinearLayout.LayoutParams(wrap(), wrap()).apply { rightMargin = px(52) })
146
244
  buttons.addView(accept, LinearLayout.LayoutParams(wrap(), wrap()).apply { leftMargin = px(52) })
@@ -1,7 +1,13 @@
1
1
  package expo.modules.poolsecalls
2
2
 
3
+ import android.app.NotificationManager
4
+ import android.content.Context
5
+ import android.content.Intent
6
+ import android.net.Uri
7
+ import android.os.Build
8
+ import android.provider.Settings
9
+ import android.util.Log
3
10
  import dev.poolse.callcore.android.CallCore
4
- import dev.poolse.callcore.android.toSnapshot
5
11
  import expo.modules.kotlin.modules.Module
6
12
  import expo.modules.kotlin.modules.ModuleDefinition
7
13
 
@@ -15,6 +21,9 @@ import expo.modules.kotlin.modules.ModuleDefinition
15
21
  * the JS side needs no R references. The SFU factory defaults to the
16
22
  * shipped LiveKit adapter when present, else a no-op (audio-less), so a
17
23
  * build without the LiveKit source set still rings and signals.
24
+ *
25
+ * Nothing here throws before `configure`: reads return idle, commands
26
+ * no-op (a fresh install renders its UI before JS has configured).
18
27
  */
19
28
  class PoolseCallsModule : Module() {
20
29
 
@@ -24,44 +33,93 @@ class PoolseCallsModule : Module() {
24
33
  Events("onCallState")
25
34
 
26
35
  // Snapshots → the single JS event. Null (idle) becomes an explicit
27
- // cleared payload so the JS side always has a definite state.
36
+ // cleared payload so the JS side always has a definite state. The
37
+ // listener is process-level, so subscribing before configure works.
28
38
  OnStartObserving {
29
- CallCore.get(context).snapshotListener = { state ->
30
- sendEvent("onCallState", mapOf("snapshot" to state.toSnapshot()))
31
- }
39
+ CallCore.listener = { snapshot -> sendEvent("onCallState", mapOf("snapshot" to snapshot)) }
32
40
  }
33
41
  OnStopObserving {
34
- CallCore.get(context).snapshotListener = null
42
+ CallCore.listener = null
35
43
  }
36
44
 
37
45
  AsyncFunction("configure") { apiBaseUrl: String, options: Map<String, Any?>? ->
46
+ val ctx = context ?: return@AsyncFunction null
38
47
  // Resource NAMES go to the core, which resolves + persists them —
39
48
  // so a killed app woken by a ring can rebuild this without JS.
40
49
  CallCore.configure(
41
- context,
50
+ ctx,
42
51
  apiBaseUrl,
43
52
  options?.get("smallIcon") as? String,
44
53
  options?.get("ringback") as? String,
45
54
  )
55
+ // Materialise the core now, so a call already in flight (cold
56
+ // answer) is visible to the first getCallState.
57
+ CallCore.getOrNull(ctx)
58
+ null
46
59
  }
47
60
 
48
61
  Function("getCallState") {
49
- mapOf("snapshot" to CallCore.get(context).snapshot().toSnapshot())
62
+ mapOf("snapshot" to (CallCore.peek() ?: context?.let(CallCore::getOrNull))?.snapshot())
50
63
  }
51
64
 
52
65
  AsyncFunction("place") { conversationId: String, media: String, sessionToken: String ->
53
- CallCore.get(context).place(conversationId, media, sessionToken)
66
+ core()?.place(conversationId, media, sessionToken)
54
67
  }
55
68
  AsyncFunction("join") { callId: String, conversationId: String?, sessionToken: String ->
56
- CallCore.get(context).join(callId, conversationId, sessionToken)
69
+ core()?.join(callId, conversationId, sessionToken)
70
+ }
71
+ Function("answer") { core()?.answer() }
72
+ Function("decline") { core()?.decline() }
73
+ Function("hangUp") { core()?.hangUp() }
74
+ Function("setMuted") { muted: Boolean -> core()?.setMuted(muted) }
75
+ Function("setCamera") { on: Boolean -> core()?.setCamera(on) }
76
+ Function("switchCamera") { core()?.switchCamera() }
77
+ Function("setSpeaker") { on: Boolean -> core()?.setSpeaker(on) }
78
+
79
+ // Socket forwarding (open app): idempotent with the FCM path.
80
+ Function("onRingEnded") { callId: String, reason: String -> core()?.onRingEnded(callId, reason) }
81
+ Function("onCallFinished") { callId: String, reason: String? ->
82
+ core()?.onCallFinished(callId, reason?.ifEmpty { null })
83
+ }
84
+
85
+ // Android 14+: full-screen intents need a user grant for non-dialer apps.
86
+ Function("canUseFullScreenIntent") {
87
+ val ctx = context ?: return@Function true
88
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
89
+ ctx.getSystemService(NotificationManager::class.java).canUseFullScreenIntent()
90
+ } else {
91
+ true
92
+ }
93
+ }
94
+ Function("openFullScreenIntentSettings") {
95
+ val ctx = context ?: return@Function null
96
+ val pkg = Uri.parse("package:${ctx.packageName}")
97
+ val intents = buildList {
98
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
99
+ add(Intent(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT, pkg))
100
+ }
101
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
102
+ add(Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).putExtra(Settings.EXTRA_APP_PACKAGE, ctx.packageName))
103
+ }
104
+ add(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, pkg))
105
+ }
106
+ val opener = appContext.currentActivity
107
+ for (intent in intents) {
108
+ val ok = runCatching {
109
+ if (opener != null) opener.startActivity(intent)
110
+ else ctx.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
111
+ }.onFailure { Log.w(TAG, "settings intent ${intent.action} unavailable", it) }.isSuccess
112
+ if (ok) break
113
+ }
114
+ null
57
115
  }
58
- Function("answer") { CallCore.get(context).answer() }
59
- Function("decline") { CallCore.get(context).decline() }
60
- Function("hangUp") { CallCore.get(context).hangUp() }
61
- Function("setMuted") { muted: Boolean -> CallCore.get(context).setMuted(muted) }
62
- Function("setCamera") { on: Boolean -> CallCore.get(context).setCamera(on) }
63
- Function("switchCamera") { CallCore.get(context).switchCamera() }
64
116
  }
65
117
 
66
- private val context get() = requireNotNull(appContext.reactContext) { "no react context" }
118
+ private val context: Context? get() = appContext.reactContext
119
+
120
+ private fun core(): CallCore? = context?.let(CallCore::getOrNull)
121
+
122
+ private companion object {
123
+ const val TAG = "PoolseCalls"
124
+ }
67
125
  }
@@ -1,20 +1,26 @@
1
1
  package dev.poolse.callcore.livekit
2
2
 
3
3
  import android.content.Context
4
+ import android.util.Log
5
+ import com.twilio.audioswitch.AudioDevice
4
6
  import dev.poolse.callcore.CallSessionBundle
5
7
  import dev.poolse.callcore.MediaPort
6
8
  import dev.poolse.callcore.android.MediaEvents
7
9
  import dev.poolse.callcore.android.MediaParticipant
8
10
  import io.livekit.android.LiveKit
11
+ import io.livekit.android.audio.AudioSwitchHandler
12
+ import io.livekit.android.events.DisconnectReason
9
13
  import io.livekit.android.events.RoomEvent
10
14
  import io.livekit.android.events.collect
11
15
  import io.livekit.android.room.Room
16
+ import io.livekit.android.room.participant.Participant
12
17
  import kotlinx.coroutines.CoroutineScope
13
18
  import kotlinx.coroutines.Dispatchers
14
19
  import kotlinx.coroutines.Job
15
20
  import kotlinx.coroutines.SupervisorJob
16
21
  import kotlinx.coroutines.cancel
17
22
  import kotlinx.coroutines.launch
23
+ import org.json.JSONObject
18
24
 
19
25
  /**
20
26
  * The shipped [MediaPort], over LiveKit's native Android SDK. Wire it
@@ -24,8 +30,14 @@ import kotlinx.coroutines.launch
24
30
  * lives in its own source set (`src/main/livekit`) kept off the core
25
31
  * library's classpath. Lifecycle and signaling are not its concern — it
26
32
  * 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.
33
+ * camera for video), routes audio, and reports room transitions back
34
+ * through [MediaEvents]. The runtime decides everything else.
35
+ *
36
+ * Threading: every method is called on the main thread (the runtime's
37
+ * thread) and every coroutine runs on Main.immediate, so [room] is only
38
+ * ever touched on main. Coroutines still re-check that the room they
39
+ * captured is the current one — a join/leave may have replaced it
40
+ * while they were suspended.
29
41
  */
30
42
  class LiveKitMediaPort(
31
43
  private val context: Context,
@@ -33,37 +45,96 @@ class LiveKitMediaPort(
33
45
  ) : MediaPort {
34
46
 
35
47
  private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
36
- private var room: Room? = null
48
+ @Volatile private var room: Room? = null
37
49
  private var eventJob: Job? = null
50
+ private var media: String = "audio"
51
+
52
+ /** Speaker forced by the user; null = the media-type default. */
53
+ private var speakerWanted: Boolean? = null
54
+
55
+ /**
56
+ * The user's mute / camera choices for the CURRENT call. They survive
57
+ * a rejoin: /reconnect rebuilds the room, and blindly re-enabling the
58
+ * mic there went live while the UI still showed "muted". Reset only
59
+ * when the call ends ([leave]) or a different call joins.
60
+ */
61
+ private var mutedWanted = false
62
+ private var cameraWanted: Boolean? = null
63
+ private var joinedCallId: String? = null
38
64
 
39
65
  override fun join(bundle: CallSessionBundle) {
40
- leave()
66
+ // Rejoin of the same call keeps the user's choices; so do choices
67
+ // made while this call was still connecting (joinedCallId null). A
68
+ // different call joining without a leave starts clean.
69
+ if (joinedCallId != null && bundle.callId != joinedCallId) resetWanted()
70
+ joinedCallId = bundle.callId
71
+ detach()
72
+ media = bundle.media
41
73
  val wantsVideo = bundle.media == "video"
42
74
  val newRoom = LiveKit.create(appContext = context.applicationContext)
75
+ configureAudio(newRoom, wantsVideo)
43
76
  room = newRoom
44
77
  eventJob = scope.launch { observe(newRoom) }
45
78
  scope.launch {
46
79
  runCatching {
47
80
  newRoom.connect(bundle.livekitUrl, bundle.livekitToken)
48
- newRoom.localParticipant.setMicrophoneEnabled(true)
49
- if (wantsVideo) newRoom.localParticipant.setCameraEnabled(true)
50
- }.onFailure { events.onFailed() }
81
+ if (room !== newRoom) return@runCatching
82
+ newRoom.localParticipant.setMicrophoneEnabled(!mutedWanted)
83
+ if (cameraWanted ?: wantsVideo) newRoom.localParticipant.setCameraEnabled(true)
84
+ }.onFailure {
85
+ Log.w(TAG, "connect failed", it)
86
+ if (room === newRoom) {
87
+ // Don't leave a half-open room (and its audio mode) behind.
88
+ eventJob?.cancel()
89
+ eventJob = null
90
+ teardown(newRoom)
91
+ room = null
92
+ events.onFailed()
93
+ }
94
+ }
51
95
  }
52
96
  }
53
97
 
98
+ /** The call is over for this device: drop the room AND the call's choices. */
54
99
  override fun leave() {
100
+ detach()
101
+ resetWanted()
102
+ joinedCallId = null
103
+ }
104
+
105
+ private fun resetWanted() {
106
+ mutedWanted = false
107
+ cameraWanted = null
108
+ speakerWanted = null
109
+ }
110
+
111
+ /** Drop the current room only (a rejoin replaces it). */
112
+ private fun detach() {
55
113
  eventJob?.cancel()
56
114
  eventJob = null
57
- room?.disconnect()
115
+ val r = room ?: return
58
116
  room = null
117
+ teardown(r)
118
+ }
119
+
120
+ private fun teardown(r: Room) {
121
+ runCatching { r.disconnect() }
122
+ // disconnect() keeps the room's engine, capturer and audio handler
123
+ // alive for a later reconnect; this port never reuses a room, so
124
+ // release it or every call leaks a PeerConnectionFactory.
125
+ runCatching { r.release() }
59
126
  }
60
127
 
61
128
  override fun setMuted(muted: Boolean) {
62
- scope.launch { room?.localParticipant?.setMicrophoneEnabled(!muted) }
129
+ mutedWanted = muted
130
+ val r = room ?: return
131
+ scope.launch { runCatching { r.localParticipant.setMicrophoneEnabled(!muted) } }
63
132
  }
64
133
 
65
134
  override fun setCamera(on: Boolean) {
66
- scope.launch { room?.localParticipant?.setCameraEnabled(on) }
135
+ cameraWanted = on
136
+ val r = room ?: return
137
+ scope.launch { runCatching { r.localParticipant.setCameraEnabled(on) } }
67
138
  }
68
139
 
69
140
  override fun switchCamera() {
@@ -71,37 +142,140 @@ class LiveKitMediaPort(
71
142
  // control here keeps the port surface complete.
72
143
  }
73
144
 
145
+ override fun setSpeaker(on: Boolean) {
146
+ speakerWanted = on
147
+ val handler = room?.audioHandler as? AudioSwitchHandler ?: return
148
+ applyRoute(handler)
149
+ }
150
+
151
+ // ── audio routing ───────────────────────────────────────────────────
152
+
153
+ /**
154
+ * A voice call is held to the ear: earpiece by default (a connected
155
+ * headset or Bluetooth device first); video defaults to the speaker.
156
+ * Set before connect — the handler starts during connect.
157
+ */
158
+ private fun configureAudio(r: Room, video: Boolean) {
159
+ val handler = r.audioHandler as? AudioSwitchHandler ?: return
160
+ handler.preferredDeviceList = if (video) {
161
+ listOf(
162
+ AudioDevice.BluetoothHeadset::class.java,
163
+ AudioDevice.WiredHeadset::class.java,
164
+ AudioDevice.Speakerphone::class.java,
165
+ AudioDevice.Earpiece::class.java,
166
+ )
167
+ } else {
168
+ listOf(
169
+ AudioDevice.BluetoothHeadset::class.java,
170
+ AudioDevice.WiredHeadset::class.java,
171
+ AudioDevice.Earpiece::class.java,
172
+ AudioDevice.Speakerphone::class.java,
173
+ )
174
+ }
175
+ handler.audioDeviceChangeListener = { devices, selected ->
176
+ // A user's speaker choice survives device-list changes, except
177
+ // that plugging in a headset always wins (as the dialer does).
178
+ val wanted = speakerWanted
179
+ if (wanted != null && selected != null && room?.audioHandler === handler) {
180
+ val external = selected is AudioDevice.BluetoothHeadset || selected is AudioDevice.WiredHeadset
181
+ val isSpeaker = selected is AudioDevice.Speakerphone
182
+ if (!external && isSpeaker != wanted) devices.pick(wanted)?.let(handler::selectDevice)
183
+ }
184
+ events.onAudioRoute(routeOf(handler.selectedAudioDevice ?: selected))
185
+ }
186
+ }
187
+
188
+ private fun applyRoute(handler: AudioSwitchHandler) {
189
+ val want = speakerWanted ?: (media == "video")
190
+ handler.availableAudioDevices.pick(want)?.let(handler::selectDevice)
191
+ events.onAudioRoute(routeOf(handler.selectedAudioDevice))
192
+ }
193
+
194
+ /** Speaker, or the best private route: Bluetooth → wired → earpiece. */
195
+ private fun List<AudioDevice>.pick(speaker: Boolean): AudioDevice? =
196
+ if (speaker) firstOrNull { it is AudioDevice.Speakerphone }
197
+ else firstOrNull { it is AudioDevice.BluetoothHeadset }
198
+ ?: firstOrNull { it is AudioDevice.WiredHeadset }
199
+ ?: firstOrNull { it is AudioDevice.Earpiece }
200
+
201
+ private fun routeOf(d: AudioDevice?): String = when (d) {
202
+ is AudioDevice.Speakerphone -> "speaker"
203
+ is AudioDevice.BluetoothHeadset -> "bluetooth"
204
+ is AudioDevice.WiredHeadset -> "headset"
205
+ is AudioDevice.Earpiece -> "earpiece"
206
+ null -> if (media == "video") "speaker" else "earpiece"
207
+ }
208
+
209
+ // ── room events ─────────────────────────────────────────────────────
210
+
74
211
  private suspend fun observe(room: Room) {
75
212
  room.events.collect { event ->
213
+ if (this.room !== room) return@collect
76
214
  when (event) {
77
- is RoomEvent.Connected -> events.onConnected()
78
- is RoomEvent.Reconnecting, is RoomEvent.Disconnected -> events.onLost()
215
+ is RoomEvent.Connected -> {
216
+ (room.audioHandler as? AudioSwitchHandler)?.let { if (speakerWanted != null) applyRoute(it) }
217
+ events.onConnected()
218
+ // Somebody may already be in (a late join, or a callee who
219
+ // answered before our media came up): they will not "connect".
220
+ if (room.remoteParticipants.isNotEmpty()) events.onRemoteJoined()
221
+ emit(room)
222
+ }
223
+ // LiveKit is resuming the SAME session (ICE restart / signal
224
+ // resume). Only reflect it: tearing the room down here fought
225
+ // its own recovery and turned every network blip into a rejoin.
226
+ is RoomEvent.Reconnecting -> events.onReconnecting()
79
227
  is RoomEvent.Reconnected -> events.onRecovered()
228
+ // LiveKit gave up (or the server removed us). Our own leave()
229
+ // never gets here — the collector is cancelled first.
230
+ is RoomEvent.Disconnected -> {
231
+ if (event.reason != DisconnectReason.CLIENT_INITIATED) events.onLost()
232
+ }
80
233
  is RoomEvent.ParticipantConnected -> { events.onRemoteJoined(); emit(room) }
81
234
  is RoomEvent.ParticipantDisconnected,
82
235
  is RoomEvent.ActiveSpeakersChanged,
83
236
  is RoomEvent.TrackMuted,
84
- is RoomEvent.TrackUnmuted -> emit(room)
237
+ is RoomEvent.TrackUnmuted,
238
+ is RoomEvent.TrackPublished,
239
+ is RoomEvent.TrackUnpublished,
240
+ is RoomEvent.ParticipantMetadataChanged,
241
+ is RoomEvent.ParticipantAttributesChanged -> emit(room)
85
242
  else -> Unit
86
243
  }
244
+ Unit
87
245
  }
88
246
  }
89
247
 
248
+ /** The REMOTE roster — the local participant is never included. */
90
249
  private fun emit(room: Room) {
91
250
  events.onParticipants(
92
251
  room.remoteParticipants.values.map {
252
+ val identity = it.identity?.value ?: ""
93
253
  MediaParticipant(
94
- identity = it.identity?.value ?: "",
254
+ identity = identity,
95
255
  speaking = it.isSpeaking,
96
- muted = it.audioTrackPublications.firstOrNull()?.first?.muted ?: true,
256
+ muted = !it.isMicrophoneEnabled(),
257
+ cameraOn = it.isCameraEnabled(),
258
+ externalId = externalIdOf(it) ?: identity,
97
259
  )
98
260
  },
99
261
  )
100
262
  }
101
263
 
102
- fun dispose() = scope.cancel()
264
+ /** The host's own user id, if the server put it in attributes/metadata. */
265
+ private fun externalIdOf(p: Participant): String? =
266
+ runCatching { p.attributes["external_id"] }.getOrNull()?.ifEmpty { null }
267
+ ?: p.metadata?.let { m ->
268
+ runCatching { JSONObject(m).optString("external_id", "") }.getOrNull()?.ifEmpty { null }
269
+ }
270
+
271
+ fun dispose() {
272
+ leave()
273
+ scope.cancel()
274
+ }
103
275
 
104
276
  companion object {
277
+ private const val TAG = "PoolseLiveKit"
278
+
105
279
  /** Pass as `mediaFactory` in CallCoreConfig. */
106
280
  val factory: (Context, MediaEvents) -> MediaPort = { ctx, ev -> LiveKitMediaPort(ctx, ev) }
107
281
  }
@@ -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
+ }