@poolse/react-native-calls 0.8.0 → 0.10.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 (28) hide show
  1. package/README.md +27 -0
  2. package/android/src/main/java/dev/poolse/callcore/CallCoreRuntime.kt +69 -6
  3. package/android/src/main/java/dev/poolse/callcore/CallMachine.kt +108 -3
  4. package/android/src/main/java/dev/poolse/callcore/Ports.kt +14 -0
  5. package/android/src/main/java/dev/poolse/callcore/android/AndroidRingSurface.kt +121 -0
  6. package/android/src/main/java/dev/poolse/callcore/android/CallActionReceiver.kt +16 -0
  7. package/android/src/main/java/dev/poolse/callcore/android/CallCore.kt +14 -2
  8. package/android/src/main/java/dev/poolse/callcore/android/CallHttpClient.kt +1 -0
  9. package/android/src/main/java/dev/poolse/callcore/android/SnapshotJson.kt +17 -0
  10. package/android/src/main/java/expo/modules/poolsecalls/PoolseCallsModule.kt +2 -0
  11. package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitMediaPort.kt +7 -3
  12. package/dist/index.cjs +4 -0
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +31 -1
  15. package/dist/index.d.ts +31 -1
  16. package/dist/index.js +3 -1
  17. package/dist/index.js.map +1 -1
  18. package/ios/Core/CallCoreRuntime.swift +76 -6
  19. package/ios/Core/CallHttpClient.swift +2 -1
  20. package/ios/Core/CallMachine.swift +91 -1
  21. package/ios/Core/Ports.swift +37 -2
  22. package/ios/Core/SnapshotEncoding.swift +16 -1
  23. package/ios/Module/PoolseCallsModule.swift +2 -0
  24. package/ios/Platform/CallCore.swift +69 -16
  25. package/ios/Platform/CallKitController.swift +7 -1
  26. package/ios/Platform/LiveKitMediaPort.swift +6 -2
  27. package/package.json +1 -1
  28. package/src/index.ts +35 -0
package/README.md CHANGED
@@ -44,6 +44,33 @@ await calls.join(call.id, conversationId, sessionToken);
44
44
  calls.hangUp(); // leaves — the room ends once it has been empty a moment
45
45
  ```
46
46
 
47
+ `snapshot.kind` is `'room'` for a room (`'call'` otherwise), so a host
48
+ can show it differently from a call — Loop shows a bar over the chat
49
+ with mute, camera, audio output and Leave rather than the call screen.
50
+ A room plays on the speaker unless a headset or Bluetooth device is in,
51
+ and turning the camera on (`setCamera(true)`) is all it takes to go from
52
+ voice to video.
53
+
54
+ ### Call waiting
55
+
56
+ A call that rings while one is in progress (or while you sit in a room)
57
+ waits beside it instead of being refused: `snapshot.waiting` carries it
58
+ (`callId`, names, media), and the native side shows its own surface —
59
+ CallKit's call-waiting screen on iOS, a heads-up with Decline / End &
60
+ answer and the call-waiting beep on Android.
61
+
62
+ ```ts
63
+ if (s.waiting) {
64
+ calls.answerWaiting(s.waiting.callId); // ends this call, answers that one
65
+ calls.declineWaiting(s.waiting.callId); // its caller hears "Busy"
66
+ }
67
+ ```
68
+
69
+ Pass the `callId`: a tap that lands after the waiting call changed is
70
+ then ignored rather than acting on a different one. If the current call
71
+ ends on its own, the waiting call simply becomes the ringing call; left
72
+ alone it times out as missed. A third call while one waits is busy.
73
+
47
74
  ### Chat notifications
48
75
 
49
76
  poolse sends message notifications itself, with the push credentials you
@@ -43,6 +43,9 @@ class CallCoreRuntime(
43
43
  /** Grants of rings auto-declined busy, kept until the decline fires. */
44
44
  private val busyRings = mutableMapOf<String, RingContext>()
45
45
 
46
+ /** The ring of the call waiting beside the current one (its grant answers or busy-declines it). */
47
+ private var waitingRingCtx: RingContext? = null
48
+
46
49
  /** The session bundle of the current call, once the server accepted. */
47
50
  private var bundle: CallSessionBundle? = null
48
51
 
@@ -110,6 +113,7 @@ class CallCoreRuntime(
110
113
  private const val T_RECONNECT = "reconnect_budget"
111
114
  private const val T_CONNECT = "connect_watchdog"
112
115
  private const val T_RING_OUT = "ring_out_backstop"
116
+ private const val T_WAITING = "waiting_deadline"
113
117
  }
114
118
 
115
119
  // ── inputs from adapters ────────────────────────────────────────────
@@ -119,6 +123,12 @@ class CallCoreRuntime(
119
123
  /** Best name for the current call: the server's bundle, else the ring's. Runtime thread only. */
120
124
  val conversationName: String? get() = bundle?.conversationName ?: ring?.conversationName
121
125
 
126
+ /** `room` or `call`, once the server's bundle says; null before. Runtime thread only. */
127
+ val callKind: String? get() = bundle?.kind
128
+
129
+ /** The call waiting beside the current one, with its caller's names. Runtime thread only. */
130
+ val waitingRing: RingContext? get() = if (state.waiting == null) null else waitingRingCtx
131
+
122
132
  /** True if [callId] was already resolved here — its ring must never surface again. */
123
133
  fun isResolved(callId: String): Boolean = synchronized(resolved) { resolved.containsKey(callId) }
124
134
 
@@ -165,6 +175,18 @@ class CallCoreRuntime(
165
175
  fun answer() = post { dispatch(Event.UserAnswer) }
166
176
  fun decline() = post { dispatch(Event.UserDecline) }
167
177
  fun hangUp() = post { dispatch(Event.UserHangup) }
178
+ /**
179
+ * Call waiting: end this call and answer the one waiting. With
180
+ * [callId], only if that is still the call waiting (a stale tap on a
181
+ * surface for an earlier one must not answer a different call).
182
+ */
183
+ fun answerWaiting(callId: String? = null) = post {
184
+ if (callId == null || state.waiting?.callId == callId) dispatch(Event.UserAnswerWaiting)
185
+ }
186
+ /** Call waiting: turn the waiting call away (its caller hears busy). */
187
+ fun declineWaiting(callId: String? = null) = post {
188
+ if (callId == null || state.waiting?.callId == callId) dispatch(Event.UserDeclineWaiting)
189
+ }
168
190
 
169
191
  fun place(conversationId: String, media: String, sessionToken: String) = post {
170
192
  if (state.phase != Phase.IDLE && state.phase != Phase.ENDED) return@post
@@ -213,23 +235,30 @@ class CallCoreRuntime(
213
235
  gen++
214
236
  markResolved(before.callId)
215
237
  }
216
- for (effect in effects) execute(effect, ringCtx)
238
+ for (effect in effects) execute(effect, ringCtx, before)
217
239
  // Retire the ring grant only after EVERY effect has had its chance
218
240
  // to use it. CONNECTING keeps it (answer + the no-bundle hangup
219
241
  // fallback still need it); CallSessionStop clears it with the rest.
220
242
  if (isTerminal(next.phase)) ring = null
221
243
  // A bundle never outlives its call — whatever ended it.
222
244
  if (isTerminal(next.phase)) { bundle = null; sessionToken = null }
245
+ // A waiting call that went away without becoming the current one
246
+ // (declined, cancelled, timed out) is resolved: its ring never again.
247
+ before.waiting?.let { w ->
248
+ if (next.waiting?.callId != w.callId && next.callId != w.callId) markResolved(w.callId)
249
+ }
250
+ if (next.waiting == null) waitingRingCtx = null
223
251
  runTimers(before, next)
224
252
  if (next != before || effects.isNotEmpty()) sink.emit(next)
225
253
  }
226
254
 
227
- private fun execute(effect: Effect, ringCtx: RingContext?) {
255
+ private fun execute(effect: Effect, ringCtx: RingContext?, before: MachineState) {
228
256
  val g = gen
229
257
  when (effect) {
230
258
  Effect.ShowRing -> {
231
- ring = ringCtx
232
- ringCtx?.let(surface::show)
259
+ // A promoted waiting call rings with the ring already on file.
260
+ if (ringCtx != null) ring = ringCtx
261
+ ring?.let(surface::show)
233
262
  }
234
263
  Effect.DismissRing -> {
235
264
  (ring ?: ringCtx)?.let { surface.dismiss(it.callId) }
@@ -266,7 +295,8 @@ class CallCoreRuntime(
266
295
  ring?.let { http.decline(it.callId, it.grant, busy = false) }
267
296
  }
268
297
  is Effect.HttpDeclineBusy -> {
269
- busyRings[effect.callId]?.let { http.decline(it.callId, it.grant, busy = true) }
298
+ val waitingMatch = waitingRingCtx?.takeIf { it.callId == effect.callId }
299
+ (busyRings[effect.callId] ?: waitingMatch)?.let { http.decline(it.callId, it.grant, busy = true) }
270
300
  markResolved(effect.callId)
271
301
  }
272
302
  Effect.HttpHangup -> {
@@ -297,6 +327,23 @@ class CallCoreRuntime(
297
327
  ring = null
298
328
  sessionToken = null
299
329
  }
330
+
331
+ Effect.ShowWaiting -> {
332
+ waitingRingCtx = ringCtx
333
+ ringCtx?.let(surface::showWaiting)
334
+ }
335
+ // The grant is NOT retired here: HTTP_DECLINE_BUSY may precede or follow.
336
+ Effect.DismissWaiting -> before.waiting?.let { surface.dismissWaiting(it.callId) }
337
+ Effect.PromoteWaiting -> {
338
+ // The old call is over: nothing it started may land on this one.
339
+ markResolved(before.callId)
340
+ gen++
341
+ bundle = null
342
+ sessionToken = null
343
+ ring = waitingRingCtx
344
+ waitingRingCtx = null
345
+ before.waiting?.let { surface.promoteWaiting(it.callId) }
346
+ }
300
347
  }
301
348
  }
302
349
 
@@ -366,7 +413,10 @@ class CallCoreRuntime(
366
413
  }
367
414
 
368
415
  private fun runTimers(before: MachineState, after: MachineState) {
369
- if (before.phase == after.phase) return
416
+ runWaitingTimer(before, after)
417
+ // A different call in the same phase (end-and-answer while connecting)
418
+ // needs its own timers: the old ones belong to a retired generation.
419
+ if (before.phase == after.phase && before.callId == after.callId) return
370
420
  when (before.phase) {
371
421
  Phase.RINGING_IN -> timers.cancel(T_DEADLINE)
372
422
  Phase.ENDED -> timers.cancel(T_LINGER)
@@ -390,4 +440,17 @@ class CallCoreRuntime(
390
440
  else -> Unit
391
441
  }
392
442
  }
443
+
444
+ private fun runWaitingTimer(before: MachineState, after: MachineState) {
445
+ val was = before.waiting?.callId
446
+ val now = after.waiting?.callId
447
+ if (was == now) return
448
+ if (was != null) timers.cancel(T_WAITING)
449
+ val deadline = waitingRingCtx?.ringDeadlineEpochMs
450
+ if (now == null || deadline == null) return
451
+ val delay = (deadline - timers.nowMs()).coerceAtLeast(CLOCK_SLACK_MS)
452
+ // Keyed to the waiting call, not the generation: the current call may
453
+ // move on (connect, reconnect) while this one keeps waiting.
454
+ timers.schedule(T_WAITING, delay) { post { if (state.waiting?.callId == now) dispatch(Event.WaitingDeadline) } }
455
+ }
393
456
  }
@@ -24,6 +24,16 @@ enum class EndReason {
24
24
  val wire: String get() = name.lowercase()
25
25
  }
26
26
 
27
+ /**
28
+ * A second call ringing while this device is on one (CONTRACT.md §7,
29
+ * call waiting): shown beside the current call, never instead of it.
30
+ */
31
+ data class WaitingCall(
32
+ val callId: String,
33
+ val conversationId: String?,
34
+ val media: String,
35
+ )
36
+
27
37
  /** Everything the machine knows. Immutable; transitions return a new one. */
28
38
  data class MachineState(
29
39
  val phase: Phase = Phase.IDLE,
@@ -34,6 +44,8 @@ data class MachineState(
34
44
  val reason: EndReason? = null,
35
45
  /** HTTP attempts made for the current connecting request (retry budget = 2). */
36
46
  val httpAttempts: Int = 0,
47
+ /** A second call ringing beside this one, if any. */
48
+ val waiting: WaitingCall? = null,
37
49
  ) {
38
50
  companion object { val IDLE = MachineState() }
39
51
  }
@@ -90,6 +102,13 @@ sealed interface Event {
90
102
  /** CONNECTING watchdog: neither the server nor the SFU got us in. */
91
103
  data object ConnectTimeout : Event
92
104
  data object LingerElapsed : Event
105
+
106
+ /** Call waiting: leave the current call and answer the waiting one. */
107
+ data object UserAnswerWaiting : Event
108
+ /** Call waiting: turn the waiting call away — its caller hears busy. */
109
+ data object UserDeclineWaiting : Event
110
+ /** The waiting call's ring deadline passed. */
111
+ data object WaitingDeadline : Event
93
112
  }
94
113
 
95
114
  /** Abstract effects — each adapter maps them to its platform. */
@@ -113,13 +132,95 @@ sealed interface Effect {
113
132
  data object MediaLeave : Effect { override val wire = "MEDIA_LEAVE" }
114
133
  data object CallSessionStart : Effect { override val wire = "CALL_SESSION_START" }
115
134
  data object CallSessionStop : Effect { override val wire = "CALL_SESSION_STOP" }
135
+ data object ShowWaiting : Effect { override val wire = "SHOW_WAITING" }
136
+ data object DismissWaiting : Effect { override val wire = "DISMISS_WAITING" }
137
+ /**
138
+ * The waiting call becomes the current one: retire the old call's
139
+ * grant/bundle/generation and take the waiting ring's.
140
+ */
141
+ data object PromoteWaiting : Effect { override val wire = "PROMOTE_WAITING" }
116
142
  }
117
143
 
118
144
  data class Transition(val state: MachineState, val effects: List<Effect>)
119
145
 
120
146
  object CallMachine {
121
147
 
122
- fun transition(s: MachineState, e: Event): Transition = when (e) {
148
+ /**
149
+ * Phases that can hold a call waiting beside them: this device is on
150
+ * (or placing, or joining) a call. A second ring while one is ringING
151
+ * in is still busy — two rings at once is not a choice anyone can make.
152
+ */
153
+ private fun canHoldWaiting(s: MachineState) = when (s.phase) {
154
+ Phase.RINGING_OUT, Phase.CONNECTING, Phase.ACTIVE, Phase.RECONNECTING -> true
155
+ else -> false
156
+ }
157
+
158
+ /**
159
+ * The ring surface effects the end-and-answer composite skips: the
160
+ * waiting call goes straight from waiting to connecting, never ringing.
161
+ */
162
+ private val ringSurfaceEffects =
163
+ setOf<Effect>(Effect.ShowRing, Effect.RingtoneStart, Effect.RingtoneStop, Effect.DismissRing)
164
+
165
+ fun transition(s: MachineState, e: Event): Transition {
166
+ val w = s.waiting
167
+ if (w == null) {
168
+ return when {
169
+ e is Event.Ring && !e.stale && canHoldWaiting(s) && e.callId != s.callId -> Transition(
170
+ s.copy(waiting = WaitingCall(e.callId, e.conversationId, e.media)),
171
+ listOf(Effect.ShowWaiting),
172
+ )
173
+ e == Event.UserAnswerWaiting || e == Event.UserDeclineWaiting || e == Event.WaitingDeadline -> noop(s)
174
+ else -> single(s, e)
175
+ }
176
+ }
177
+
178
+ val cleared = s.copy(waiting = null)
179
+ return when {
180
+ e is Event.Ring -> when (e.callId) {
181
+ w.callId -> noop(s)
182
+ s.callId -> carry(single(cleared, e), w)
183
+ else -> if (e.stale) noop(s) else Transition(s, listOf(Effect.HttpDeclineBusy(e.callId)))
184
+ }
185
+ e is Event.RingEnded && e.callId == w.callId -> Transition(cleared, listOf(Effect.DismissWaiting))
186
+ e is Event.CallFinished && e.callId == w.callId -> Transition(cleared, listOf(Effect.DismissWaiting))
187
+ e == Event.WaitingDeadline -> Transition(cleared, listOf(Effect.DismissWaiting))
188
+ e == Event.UserDeclineWaiting ->
189
+ Transition(cleared, listOf(Effect.HttpDeclineBusy(w.callId), Effect.DismissWaiting))
190
+ e == Event.UserAnswerWaiting -> {
191
+ // Leaving rings the waiting call (below); answering it at once
192
+ // makes that ring a no-op the surface never sees.
193
+ val left = transition(s, Event.UserHangup)
194
+ if (left.state.phase != Phase.RINGING_IN || left.state.callId != w.callId) {
195
+ noop(s)
196
+ } else {
197
+ val answered = single(left.state, Event.UserAnswer)
198
+ Transition(answered.state, (left.effects + answered.effects).filterNot { it in ringSurfaceEffects })
199
+ }
200
+ }
201
+ else -> {
202
+ val t = single(cleared, e)
203
+ if (t.state.phase != Phase.ENDED && t.state.phase != Phase.IDLE) {
204
+ carry(t, w)
205
+ } else {
206
+ // The current call is over while another rings beside it: that
207
+ // ring is now simply THE ring (no ended screen in between).
208
+ Transition(
209
+ MachineState(
210
+ phase = Phase.RINGING_IN, callId = w.callId, conversationId = w.conversationId,
211
+ direction = Direction.INCOMING, media = w.media,
212
+ ),
213
+ t.effects + listOf(Effect.PromoteWaiting, Effect.ShowRing, Effect.RingtoneStart),
214
+ )
215
+ }
216
+ }
217
+ }
218
+ }
219
+
220
+ private fun carry(t: Transition, w: WaitingCall) = Transition(t.state.copy(waiting = w), t.effects)
221
+
222
+ /** The one-call machine: everything that is not about a waiting call. */
223
+ private fun single(s: MachineState, e: Event): Transition = when (e) {
123
224
 
124
225
  is Event.Ring -> ring(s, e)
125
226
 
@@ -351,6 +452,9 @@ object CallMachine {
351
452
 
352
453
  Event.LingerElapsed ->
353
454
  if (s.phase == Phase.ENDED) Transition(MachineState.IDLE, emptyList()) else noop(s)
455
+
456
+ // Handled by `transition`, which owns the waiting slot.
457
+ Event.UserAnswerWaiting, Event.UserDeclineWaiting, Event.WaitingDeadline -> noop(s)
354
458
  }
355
459
 
356
460
  private fun ring(s: MachineState, e: Event.Ring): Transition = when {
@@ -369,8 +473,9 @@ object CallMachine {
369
473
  listOf(Effect.ShowRing, Effect.RingtoneStart),
370
474
  )
371
475
 
372
- // Busy with another call, in any phase: auto-decline the newcomer,
373
- // whose banner still offers Join later. (Contract §7, scenario s9.)
476
+ // Busy ringing another call (a call in progress holds it waiting
477
+ // instead — see `transition`): auto-decline the newcomer, whose
478
+ // banner still offers Join later. (Contract §7, scenario s9.)
374
479
  else -> Transition(s, listOf(Effect.HttpDeclineBusy(e.callId)))
375
480
  }
376
481
 
@@ -53,6 +53,8 @@ data class CallSessionBundle(
53
53
  val livekitUrl: String,
54
54
  val livekitToken: String,
55
55
  val controlToken: String,
56
+ /** `room` (rings nobody, CONTRACT.md §5.6) or `call`. */
57
+ val kind: String = "call",
56
58
  )
57
59
 
58
60
  /** LiveKit (or any SFU) — media only, no lifecycle opinions. */
@@ -84,6 +86,18 @@ interface RingSurfacePort {
84
86
  fun ringtoneStop()
85
87
  fun ringbackStart()
86
88
  fun ringbackStop()
89
+ /**
90
+ * A call waiting beside the current one: its own quiet surface (a
91
+ * heads-up with a waiting tone; iOS: CallKit's call-waiting UI).
92
+ */
93
+ fun showWaiting(ring: RingContext) {}
94
+ /** The waiting call is over (declined, cancelled, timed out). */
95
+ fun dismissWaiting(callId: String) {}
96
+ /**
97
+ * The waiting call becomes THE ring (SHOW_RING follows): drop its
98
+ * waiting surface without resolving it.
99
+ */
100
+ fun promoteWaiting(callId: String) {}
87
101
  }
88
102
 
89
103
  data class RingContext(
@@ -6,7 +6,11 @@ import android.app.PendingIntent
6
6
  import android.content.Context
7
7
  import android.content.Intent
8
8
  import android.media.AudioAttributes
9
+ import android.media.AudioManager
9
10
  import android.media.MediaPlayer
11
+ import android.media.ToneGenerator
12
+ import android.os.Handler
13
+ import android.os.Looper
10
14
  import android.media.Ringtone
11
15
  import android.media.RingtoneManager
12
16
  import android.os.Build
@@ -50,6 +54,18 @@ internal class AndroidRingSurface(
50
54
  private var ringbackWanted = false
51
55
  private var communicationReady = false
52
56
 
57
+ /** The call waiting beside the current one, while its surface is up. */
58
+ private var waitingCallId: String? = null
59
+ private var waitingTone: ToneGenerator? = null
60
+ private val main = Handler(Looper.getMainLooper())
61
+ private val waitingBeep = object : Runnable {
62
+ override fun run() {
63
+ if (waitingCallId == null) return
64
+ runCatching { waitingTone?.startTone(ToneGenerator.TONE_SUP_CALL_WAITING, WAITING_BEEP_MS) }
65
+ main.postDelayed(this, WAITING_BEEP_EVERY_MS)
66
+ }
67
+ }
68
+
53
69
  /**
54
70
  * The call's audio path is (or is no longer) in communication mode.
55
71
  * A ringback requested earlier starts now, through the call route.
@@ -134,7 +150,106 @@ internal class AndroidRingSurface(
134
150
  }
135
151
  }
136
152
 
153
+ /**
154
+ * A call waiting beside the one in progress: a quiet heads-up — not
155
+ * the full-screen ring, which would cover the call — with Decline and
156
+ * End & answer, and the call-waiting beep through the call's own
157
+ * audio route (the ringtone would blare over the conversation).
158
+ */
159
+ override fun showWaiting(ring: RingContext) {
160
+ ensureWaitingChannel()
161
+ val extras = ring.toBundle()
162
+
163
+ fun actionPending(action: String, requestOffset: Int): PendingIntent {
164
+ val intent = Intent(context, CallActionReceiver::class.java)
165
+ .setAction("$ACTION_PREFIX.$action")
166
+ .putExtra(EXTRA_RING, extras)
167
+ return PendingIntent.getBroadcast(
168
+ context,
169
+ ring.callId.hashCode() + requestOffset,
170
+ intent,
171
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
172
+ )
173
+ }
174
+
175
+ val open = context.packageManager.getLaunchIntentForPackage(context.packageName)?.let {
176
+ PendingIntent.getActivity(
177
+ context,
178
+ ring.callId.hashCode() + 5,
179
+ it.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
180
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
181
+ )
182
+ }
183
+
184
+ val who = ring.conversationName ?: ring.callerName ?: "Someone"
185
+ val what = if (ring.media == "video") "video call" else "call"
186
+ val notification = NotificationCompat.Builder(context, WAITING_CHANNEL_ID)
187
+ .setSmallIcon(config.smallIcon)
188
+ .setCategory(NotificationCompat.CATEGORY_CALL)
189
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
190
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
191
+ .setContentTitle("$who — incoming $what")
192
+ .setContentText("You're on a call. Answering ends it.")
193
+ .setOngoing(true)
194
+ .setAutoCancel(false)
195
+ .setOnlyAlertOnce(true)
196
+ .apply { if (open != null) setContentIntent(open) }
197
+ .addAction(0, "Decline", actionPending(ACTION_WAITING_DECLINE, 3))
198
+ .addAction(0, "End & answer", actionPending(ACTION_WAITING_ANSWER, 4))
199
+ .build()
200
+
201
+ waitingCallId = ring.callId
202
+ try {
203
+ NotificationManagerCompat.from(context).notify(ring.callId.notificationId(), notification)
204
+ } catch (e: SecurityException) {
205
+ Log.e(TAG, "call-waiting notification refused (POST_NOTIFICATIONS?)", e)
206
+ }
207
+ startWaitingBeep()
208
+ }
209
+
210
+ override fun dismissWaiting(callId: String) {
211
+ if (waitingCallId != callId) return
212
+ stopWaitingBeep()
213
+ runCatching { NotificationManagerCompat.from(context).cancel(callId.notificationId()) }
214
+ }
215
+
216
+ /** SHOW_RING follows, reposting under the same id as the full ring. */
217
+ override fun promoteWaiting(callId: String) = dismissWaiting(callId)
218
+
219
+ private fun startWaitingBeep() {
220
+ main.removeCallbacks(waitingBeep)
221
+ if (waitingTone == null) {
222
+ waitingTone = runCatching { ToneGenerator(AudioManager.STREAM_VOICE_CALL, WAITING_BEEP_VOLUME) }.getOrNull()
223
+ }
224
+ main.post(waitingBeep)
225
+ }
226
+
227
+ private fun stopWaitingBeep() {
228
+ waitingCallId = null
229
+ main.removeCallbacks(waitingBeep)
230
+ runCatching { waitingTone?.stopTone(); waitingTone?.release() }
231
+ waitingTone = null
232
+ }
233
+
234
+ private fun ensureWaitingChannel() {
235
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
236
+ val manager = context.getSystemService(NotificationManager::class.java)
237
+ if (manager.getNotificationChannel(WAITING_CHANNEL_ID) != null) return
238
+ // Silent: the beep is ours, through the call route. A channel sound
239
+ // would play on the notification stream over the conversation.
240
+ manager.createNotificationChannel(
241
+ NotificationChannel(WAITING_CHANNEL_ID, "Calls while on a call", NotificationManager.IMPORTANCE_HIGH).apply {
242
+ setSound(null, null)
243
+ enableVibration(false)
244
+ lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
245
+ },
246
+ )
247
+ }
248
+
137
249
  override fun dismiss(callId: String) {
250
+ // Every teardown for a call (ring_ended, finished) also ends its
251
+ // waiting surface, even after a restart lost the in-memory state.
252
+ if (waitingCallId == callId) stopWaitingBeep()
138
253
  if (shownRing?.first == callId) shownRing = null
139
254
  runCatching { NotificationManagerCompat.from(context).cancel(callId.notificationId()) }
140
255
  // The full-screen activity is its own window: tell it to close.
@@ -222,6 +337,12 @@ internal class AndroidRingSurface(
222
337
  const val ACTION_ANSWER = "ANSWER"
223
338
  const val ACTION_DECLINE = "DECLINE"
224
339
  const val ACTION_OPEN = "OPEN"
340
+ const val ACTION_WAITING_ANSWER = "WAITING_ANSWER"
341
+ const val ACTION_WAITING_DECLINE = "WAITING_DECLINE"
342
+ const val WAITING_CHANNEL_ID = "poolse_call_waiting"
343
+ private const val WAITING_BEEP_MS = 400
344
+ private const val WAITING_BEEP_EVERY_MS = 3_000L
345
+ private const val WAITING_BEEP_VOLUME = 70
225
346
  const val EXTRA_RING = "poolse_ring"
226
347
  const val EXTRA_AUTO_ANSWER = "poolse_auto_answer"
227
348
  /** Package-local broadcast: the ring for [EXTRA_CALL_ID] is over. */
@@ -3,11 +3,15 @@ package dev.poolse.callcore.android
3
3
  import android.content.BroadcastReceiver
4
4
  import android.content.Context
5
5
  import android.content.Intent
6
+ import androidx.core.app.NotificationManagerCompat
6
7
  import dev.poolse.callcore.android.AndroidRingSurface.Companion.ACTION_ANSWER
7
8
  import dev.poolse.callcore.android.AndroidRingSurface.Companion.ACTION_DECLINE
8
9
  import dev.poolse.callcore.android.AndroidRingSurface.Companion.ACTION_OPEN
9
10
  import dev.poolse.callcore.android.AndroidRingSurface.Companion.ACTION_PREFIX
11
+ import dev.poolse.callcore.android.AndroidRingSurface.Companion.ACTION_WAITING_ANSWER
12
+ import dev.poolse.callcore.android.AndroidRingSurface.Companion.ACTION_WAITING_DECLINE
10
13
  import dev.poolse.callcore.android.AndroidRingSurface.Companion.EXTRA_RING
14
+ import dev.poolse.callcore.android.AndroidRingSurface.Companion.notificationId
11
15
 
12
16
  /**
13
17
  * Answer / Decline taps on the CallStyle notification land here, as do
@@ -24,6 +28,18 @@ class CallActionReceiver : BroadcastReceiver() {
24
28
  "$ACTION_PREFIX.$ACTION_ANSWER" -> core.onUserAnswer(ring)
25
29
  "$ACTION_PREFIX.$ACTION_DECLINE" -> core.onUserDecline(ring)
26
30
  "$ACTION_PREFIX.$ACTION_OPEN" -> core.onUserOpen(ring)
31
+ // Call waiting: keyed to THIS call, so a stale notification can
32
+ // never act on a different one — and one outliving its call (a
33
+ // process restart) is simply taken down.
34
+ "$ACTION_PREFIX.$ACTION_WAITING_ANSWER", "$ACTION_PREFIX.$ACTION_WAITING_DECLINE" -> {
35
+ if (core.state().waiting?.callId != ring.callId) {
36
+ NotificationManagerCompat.from(context).cancel(ring.callId.notificationId())
37
+ } else if (intent.action == "$ACTION_PREFIX.$ACTION_WAITING_ANSWER") {
38
+ core.answerWaiting(ring.callId)
39
+ } else {
40
+ core.declineWaiting(ring.callId)
41
+ }
42
+ }
27
43
  }
28
44
  }
29
45
  }
@@ -65,6 +65,10 @@ class CallCore private constructor(context: Context) {
65
65
  private var extrasMedia: String? = null
66
66
  private var roster: List<MediaParticipant> = emptyList()
67
67
  private var conversationName: String? = null
68
+ private var callKind: String = "call"
69
+
70
+ // Video calls and rooms default to the speaker (the media port agrees).
71
+ private fun loud(state: MachineState) = state.media == "video" || callKind == "room"
68
72
  private var mutedSelf = false
69
73
  private var cameraOn = false
70
74
  private var cameraFacing: String? = null
@@ -138,6 +142,10 @@ class CallCore private constructor(context: Context) {
138
142
  fun answer() = runtime.answer()
139
143
  fun decline() = runtime.decline()
140
144
  fun hangUp() = runtime.hangUp()
145
+ /** Call waiting: end this call, answer the waiting one ([callId]: only if still that one). */
146
+ fun answerWaiting(callId: String? = null) = runtime.answerWaiting(callId)
147
+ /** Call waiting: turn the waiting call away — its caller hears busy. */
148
+ fun declineWaiting(callId: String? = null) = runtime.declineWaiting(callId)
141
149
  fun place(conversationId: String, media: String, sessionToken: String) =
142
150
  runtime.place(conversationId, media, sessionToken)
143
151
  fun join(callId: String, conversationId: String?, sessionToken: String) =
@@ -211,9 +219,11 @@ class CallCore private constructor(context: Context) {
211
219
  extrasMedia = state.media
212
220
  }
213
221
  runtime.conversationName?.let { conversationName = it }
222
+ runtime.callKind?.let { callKind = it }
214
223
 
215
224
  val snap = state.toSnapshot(
216
225
  conversationName = conversationName,
226
+ kind = callKind,
217
227
  participants = roster.map {
218
228
  mapOf(
219
229
  "userId" to it.identity,
@@ -223,12 +233,13 @@ class CallCore private constructor(context: Context) {
223
233
  "cameraOn" to it.cameraOn,
224
234
  )
225
235
  },
226
- audioRoute = audioRoute ?: if (state.media == "video") "speaker" else "earpiece",
236
+ audioRoute = audioRoute ?: if (loud(state)) "speaker" else "earpiece",
227
237
  audioOutputs = audioOutputs.map { mapOf("id" to it.id, "kind" to it.kind, "name" to it.name) },
228
238
  audioOutputId = audioOutputId,
229
239
  mutedSelf = mutedSelf,
230
240
  cameraOn = cameraOn,
231
241
  cameraFacing = cameraFacing,
242
+ waitingRing = runtime.waitingRing,
232
243
  )
233
244
  lastSnapshot = snap
234
245
  reconcileAndroid(state)
@@ -240,6 +251,7 @@ class CallCore private constructor(context: Context) {
240
251
  extrasMedia = media
241
252
  roster = emptyList()
242
253
  conversationName = null
254
+ callKind = "call"
243
255
  mutedSelf = false
244
256
  // Reported by the media adapter once the camera is really published.
245
257
  cameraOn = false
@@ -268,7 +280,7 @@ class CallCore private constructor(context: Context) {
268
280
  // Screen off at the ear: only for an audio call on the earpiece.
269
281
  val inCall = state.phase == Phase.ACTIVE || state.phase == Phase.RECONNECTING ||
270
282
  state.phase == Phase.RINGING_OUT || state.phase == Phase.CONNECTING
271
- val earpiece = (audioRoute ?: if (state.media == "video") "speaker" else "earpiece") == "earpiece"
283
+ val earpiece = (audioRoute ?: if (loud(state)) "speaker" else "earpiece") == "earpiece"
272
284
  setProximity(inCall && state.media == "audio" && earpiece)
273
285
  }
274
286
 
@@ -185,6 +185,7 @@ internal class CallHttpClient(
185
185
  livekitToken = livekit.getString("token"),
186
186
  controlToken = root.optStringOrNull("control_token")
187
187
  ?: requireNotNull(current?.controlToken) { "bundle without control_token" },
188
+ kind = call.optStringOrNull("kind") ?: current?.kind ?: "call",
188
189
  )
189
190
  }
190
191
 
@@ -16,6 +16,7 @@ import dev.poolse.callcore.Phase
16
16
  */
17
17
  internal fun MachineState.toSnapshot(
18
18
  conversationName: String? = null,
19
+ kind: String = "call",
19
20
  participants: List<Map<String, Any>> = emptyList(),
20
21
  audioRoute: String = "earpiece",
21
22
  audioOutputs: List<Map<String, Any>> = emptyList(),
@@ -23,14 +24,30 @@ internal fun MachineState.toSnapshot(
23
24
  mutedSelf: Boolean = false,
24
25
  cameraOn: Boolean = false,
25
26
  cameraFacing: String? = null,
27
+ /** The waiting call's ring (names); the machine's [MachineState.waiting] says whether there is one. */
28
+ waitingRing: dev.poolse.callcore.RingContext? = null,
26
29
  ): Map<String, Any?>? {
27
30
  if (phase == Phase.IDLE) return null
28
31
  return buildMap {
29
32
  putAll(snapshotCore(conversationName, participants, audioRoute, mutedSelf, cameraOn))
33
+ put("kind", kind)
30
34
  put("audioOutputs", audioOutputs)
31
35
  put("audioOutputId", audioOutputId)
32
36
  // Optional in the contract: present only while a camera is published.
33
37
  if (cameraFacing != null) put("cameraFacing", cameraFacing)
38
+ // The call waiting beside this one: null when none.
39
+ put(
40
+ "waiting",
41
+ waiting?.let { w ->
42
+ mapOf(
43
+ "callId" to w.callId,
44
+ "conversationId" to (waitingRing?.conversationId ?: w.conversationId),
45
+ "conversationName" to waitingRing?.conversationName,
46
+ "callerName" to waitingRing?.callerName,
47
+ "media" to w.media,
48
+ )
49
+ },
50
+ )
34
51
  }
35
52
  }
36
53
 
@@ -83,6 +83,8 @@ class PoolseCallsModule : Module() {
83
83
  Function("answer") { core()?.answer() }
84
84
  Function("decline") { core()?.decline() }
85
85
  Function("hangUp") { core()?.hangUp() }
86
+ Function("answerWaiting") { callId: String? -> core()?.answerWaiting(callId) }
87
+ Function("declineWaiting") { callId: String? -> core()?.declineWaiting(callId) }
86
88
  Function("setMuted") { muted: Boolean -> core()?.setMuted(muted) }
87
89
  Function("setCamera") { on: Boolean -> core()?.setCamera(on) }
88
90
  Function("switchCamera") { core()?.switchCamera() }