@poolse/react-native-calls 0.9.0 → 0.10.1

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/README.md CHANGED
@@ -51,6 +51,26 @@ A room plays on the speaker unless a headset or Bluetooth device is in,
51
51
  and turning the camera on (`setCamera(true)`) is all it takes to go from
52
52
  voice to video.
53
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
+
54
74
  ### Chat notifications
55
75
 
56
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 ────────────────────────────────────────────
@@ -122,6 +126,9 @@ class CallCoreRuntime(
122
126
  /** `room` or `call`, once the server's bundle says; null before. Runtime thread only. */
123
127
  val callKind: String? get() = bundle?.kind
124
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
+
125
132
  /** True if [callId] was already resolved here — its ring must never surface again. */
126
133
  fun isResolved(callId: String): Boolean = synchronized(resolved) { resolved.containsKey(callId) }
127
134
 
@@ -168,6 +175,18 @@ class CallCoreRuntime(
168
175
  fun answer() = post { dispatch(Event.UserAnswer) }
169
176
  fun decline() = post { dispatch(Event.UserDecline) }
170
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
+ }
171
190
 
172
191
  fun place(conversationId: String, media: String, sessionToken: String) = post {
173
192
  if (state.phase != Phase.IDLE && state.phase != Phase.ENDED) return@post
@@ -216,23 +235,30 @@ class CallCoreRuntime(
216
235
  gen++
217
236
  markResolved(before.callId)
218
237
  }
219
- for (effect in effects) execute(effect, ringCtx)
238
+ for (effect in effects) execute(effect, ringCtx, before)
220
239
  // Retire the ring grant only after EVERY effect has had its chance
221
240
  // to use it. CONNECTING keeps it (answer + the no-bundle hangup
222
241
  // fallback still need it); CallSessionStop clears it with the rest.
223
242
  if (isTerminal(next.phase)) ring = null
224
243
  // A bundle never outlives its call — whatever ended it.
225
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
226
251
  runTimers(before, next)
227
252
  if (next != before || effects.isNotEmpty()) sink.emit(next)
228
253
  }
229
254
 
230
- private fun execute(effect: Effect, ringCtx: RingContext?) {
255
+ private fun execute(effect: Effect, ringCtx: RingContext?, before: MachineState) {
231
256
  val g = gen
232
257
  when (effect) {
233
258
  Effect.ShowRing -> {
234
- ring = ringCtx
235
- 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)
236
262
  }
237
263
  Effect.DismissRing -> {
238
264
  (ring ?: ringCtx)?.let { surface.dismiss(it.callId) }
@@ -269,7 +295,8 @@ class CallCoreRuntime(
269
295
  ring?.let { http.decline(it.callId, it.grant, busy = false) }
270
296
  }
271
297
  is Effect.HttpDeclineBusy -> {
272
- 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) }
273
300
  markResolved(effect.callId)
274
301
  }
275
302
  Effect.HttpHangup -> {
@@ -300,6 +327,23 @@ class CallCoreRuntime(
300
327
  ring = null
301
328
  sessionToken = null
302
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
+ }
303
347
  }
304
348
  }
305
349
 
@@ -369,7 +413,10 @@ class CallCoreRuntime(
369
413
  }
370
414
 
371
415
  private fun runTimers(before: MachineState, after: MachineState) {
372
- 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
373
420
  when (before.phase) {
374
421
  Phase.RINGING_IN -> timers.cancel(T_DEADLINE)
375
422
  Phase.ENDED -> timers.cancel(T_LINGER)
@@ -393,4 +440,17 @@ class CallCoreRuntime(
393
440
  else -> Unit
394
441
  }
395
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
+ }
396
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
 
@@ -86,6 +86,18 @@ interface RingSurfacePort {
86
86
  fun ringtoneStop()
87
87
  fun ringbackStart()
88
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) {}
89
101
  }
90
102
 
91
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,122 @@ 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
+ // End & answer launches an ACTIVITY, like the ring's Answer: only an
185
+ // activity started from the tap may bring the app forward (Android
186
+ // 10+), and the call that connects should be seen.
187
+ val answerWaiting = PendingIntent.getActivity(
188
+ context,
189
+ ring.callId.hashCode() + 4,
190
+ Intent(context, IncomingCallActivity::class.java)
191
+ .putExtra(EXTRA_RING, extras)
192
+ .putExtra(EXTRA_WAITING_ANSWER, true)
193
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP),
194
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
195
+ )
196
+
197
+ val who = ring.conversationName ?: ring.callerName ?: "Someone"
198
+ val what = if (ring.media == "video") "video call" else "call"
199
+ val notification = NotificationCompat.Builder(context, WAITING_CHANNEL_ID)
200
+ .setSmallIcon(config.smallIcon)
201
+ .setCategory(NotificationCompat.CATEGORY_CALL)
202
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
203
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
204
+ .setContentTitle("$who — incoming $what")
205
+ .setContentText("You're on a call. Answering ends it.")
206
+ .setOngoing(true)
207
+ .setAutoCancel(false)
208
+ .setOnlyAlertOnce(true)
209
+ .apply { if (open != null) setContentIntent(open) }
210
+ .addAction(0, "Decline", actionPending(ACTION_WAITING_DECLINE, 3))
211
+ .addAction(0, "End & answer", answerWaiting)
212
+ // Never outlive the ring: if its ring_ended/finished push is lost,
213
+ // the system takes the heads-up down at the deadline anyway.
214
+ .setTimeoutAfter((ring.ringDeadlineEpochMs - System.currentTimeMillis()).coerceAtLeast(10_000L))
215
+ .build()
216
+
217
+ waitingCallId = ring.callId
218
+ try {
219
+ NotificationManagerCompat.from(context).notify(ring.callId.notificationId(), notification)
220
+ } catch (e: SecurityException) {
221
+ Log.e(TAG, "call-waiting notification refused (POST_NOTIFICATIONS?)", e)
222
+ }
223
+ startWaitingBeep()
224
+ }
225
+
226
+ override fun dismissWaiting(callId: String) {
227
+ if (waitingCallId != callId) return
228
+ stopWaitingBeep()
229
+ runCatching { NotificationManagerCompat.from(context).cancel(callId.notificationId()) }
230
+ }
231
+
232
+ /** SHOW_RING follows, reposting under the same id as the full ring. */
233
+ override fun promoteWaiting(callId: String) = dismissWaiting(callId)
234
+
235
+ private fun startWaitingBeep() {
236
+ main.removeCallbacks(waitingBeep)
237
+ if (waitingTone == null) {
238
+ waitingTone = runCatching { ToneGenerator(AudioManager.STREAM_VOICE_CALL, WAITING_BEEP_VOLUME) }.getOrNull()
239
+ }
240
+ main.post(waitingBeep)
241
+ }
242
+
243
+ private fun stopWaitingBeep() {
244
+ waitingCallId = null
245
+ main.removeCallbacks(waitingBeep)
246
+ runCatching { waitingTone?.stopTone(); waitingTone?.release() }
247
+ waitingTone = null
248
+ }
249
+
250
+ private fun ensureWaitingChannel() {
251
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
252
+ val manager = context.getSystemService(NotificationManager::class.java)
253
+ if (manager.getNotificationChannel(WAITING_CHANNEL_ID) != null) return
254
+ // Silent: the beep is ours, through the call route. A channel sound
255
+ // would play on the notification stream over the conversation.
256
+ manager.createNotificationChannel(
257
+ NotificationChannel(WAITING_CHANNEL_ID, "Calls while on a call", NotificationManager.IMPORTANCE_HIGH).apply {
258
+ setSound(null, null)
259
+ enableVibration(false)
260
+ lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
261
+ },
262
+ )
263
+ }
264
+
137
265
  override fun dismiss(callId: String) {
266
+ // Every teardown for a call (ring_ended, finished) also ends its
267
+ // waiting surface, even after a restart lost the in-memory state.
268
+ if (waitingCallId == callId) stopWaitingBeep()
138
269
  if (shownRing?.first == callId) shownRing = null
139
270
  runCatching { NotificationManagerCompat.from(context).cancel(callId.notificationId()) }
140
271
  // The full-screen activity is its own window: tell it to close.
@@ -222,8 +353,15 @@ internal class AndroidRingSurface(
222
353
  const val ACTION_ANSWER = "ANSWER"
223
354
  const val ACTION_DECLINE = "DECLINE"
224
355
  const val ACTION_OPEN = "OPEN"
356
+ const val ACTION_WAITING_ANSWER = "WAITING_ANSWER"
357
+ const val ACTION_WAITING_DECLINE = "WAITING_DECLINE"
358
+ const val WAITING_CHANNEL_ID = "poolse_call_waiting"
359
+ private const val WAITING_BEEP_MS = 400
360
+ private const val WAITING_BEEP_EVERY_MS = 3_000L
361
+ private const val WAITING_BEEP_VOLUME = 70
225
362
  const val EXTRA_RING = "poolse_ring"
226
363
  const val EXTRA_AUTO_ANSWER = "poolse_auto_answer"
364
+ const val EXTRA_WAITING_ANSWER = "poolse_waiting_answer"
227
365
  /** Package-local broadcast: the ring for [EXTRA_CALL_ID] is over. */
228
366
  const val ACTION_RING_CLOSED = "dev.poolse.callcore.RING_CLOSED"
229
367
  const val EXTRA_CALL_ID = "poolse_call_id"
@@ -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
  }
@@ -122,6 +122,23 @@ class CallCore private constructor(context: Context) {
122
122
  return bringAppToFront(ring.callId, from)
123
123
  }
124
124
 
125
+ /**
126
+ * End & answer from the call-waiting heads-up. Only for the call still
127
+ * waiting (a stale notification is just taken down); brings the app
128
+ * forward from [from], like [onUserAnswer].
129
+ */
130
+ fun onUserAnswerWaiting(ring: RingContext, from: Context? = null): Boolean {
131
+ if (runtime.currentState().waiting?.callId != ring.callId) {
132
+ runCatching {
133
+ androidx.core.app.NotificationManagerCompat.from(appContext)
134
+ .cancel(with(AndroidRingSurface) { ring.callId.notificationId() })
135
+ }
136
+ return false
137
+ }
138
+ runtime.answerWaiting(ring.callId)
139
+ return bringAppToFront(ring.callId, from)
140
+ }
141
+
125
142
  fun onUserDecline(ring: RingContext) {
126
143
  runtime.onRing(ring)
127
144
  runtime.decline()
@@ -142,6 +159,10 @@ class CallCore private constructor(context: Context) {
142
159
  fun answer() = runtime.answer()
143
160
  fun decline() = runtime.decline()
144
161
  fun hangUp() = runtime.hangUp()
162
+ /** Call waiting: end this call, answer the waiting one ([callId]: only if still that one). */
163
+ fun answerWaiting(callId: String? = null) = runtime.answerWaiting(callId)
164
+ /** Call waiting: turn the waiting call away — its caller hears busy. */
165
+ fun declineWaiting(callId: String? = null) = runtime.declineWaiting(callId)
145
166
  fun place(conversationId: String, media: String, sessionToken: String) =
146
167
  runtime.place(conversationId, media, sessionToken)
147
168
  fun join(callId: String, conversationId: String?, sessionToken: String) =
@@ -201,8 +222,10 @@ class CallCore private constructor(context: Context) {
201
222
  }
202
223
  resetExtras(null, null)
203
224
  } else if (state.callId != extrasCallId) {
204
- // `pending:<conv>` → the server-minted id is the same call.
205
- if (extrasCallId?.startsWith("pending:") == true) extrasCallId = state.callId
225
+ // `pending:<conv>` → the server-minted id is the same call — but
226
+ // only for that conversation's own placed call: a waiting call
227
+ // promoted over a still-pending one is a different call.
228
+ if (isRenameOfPending(extrasCallId, state)) extrasCallId = state.callId
206
229
  else resetExtras(state.callId, state.media)
207
230
  }
208
231
  if (state.phase != Phase.IDLE && state.media != extrasMedia) {
@@ -235,12 +258,18 @@ class CallCore private constructor(context: Context) {
235
258
  mutedSelf = mutedSelf,
236
259
  cameraOn = cameraOn,
237
260
  cameraFacing = cameraFacing,
261
+ waitingRing = runtime.waitingRing,
238
262
  )
239
263
  lastSnapshot = snap
240
264
  reconcileAndroid(state)
241
265
  runCatching { listener?.invoke(snap) }.onFailure { Log.w(TAG, "snapshot listener failed", it) }
242
266
  }
243
267
 
268
+ private fun isRenameOfPending(previous: String?, state: MachineState): Boolean =
269
+ previous != null && previous.startsWith("pending:") &&
270
+ state.direction != dev.poolse.callcore.Direction.INCOMING &&
271
+ previous == "pending:${state.conversationId}"
272
+
244
273
  private fun resetExtras(callId: String?, media: String?) {
245
274
  extrasCallId = callId
246
275
  extrasMedia = media
@@ -220,14 +220,22 @@ class CallForegroundService : Service() {
220
220
 
221
221
  override fun start(callId: String, media: String) = onMain {
222
222
  desired = Desired(Mode.CALL, callId, media)
223
- launch()
223
+ launchOrSwitch()
224
224
  }
225
225
 
226
226
  override fun stop() = onMain {
227
227
  desired = null
228
- // Only a started instance with nothing pending may stop now; a
229
- // pending start stops itself after startForeground.
230
- if (pendingStarts == 0) instance?.stopNow()
228
+ // Deferred to the end of this main-thread turn: a call handing over
229
+ // to the next (call waiting: end & answer, or a promoted ring) stops
230
+ // and starts in one dispatch, and must switch the RUNNING service
231
+ // in place — a fresh start from the background is refused on
232
+ // Android 12+ (and loses the microphone type on 14+). A start in
233
+ // the same turn sets `desired` again and this does nothing.
234
+ main.post {
235
+ // Only a started instance with nothing pending may stop now; a
236
+ // pending start stops itself after startForeground.
237
+ if (desired == null && pendingStarts == 0) instance?.stopNow()
238
+ }
231
239
  }
232
240
 
233
241
  /** Ring mode for [callId]; a no-op while a call holds the service. */
@@ -236,7 +244,7 @@ class CallForegroundService : Service() {
236
244
  if (d?.mode == Mode.CALL) return@onMain
237
245
  if (d?.mode == Mode.RING && d.callId == callId) return@onMain
238
246
  desired = Desired(Mode.RING, callId, "audio")
239
- launch()
247
+ launchOrSwitch()
240
248
  }
241
249
 
242
250
  /**
@@ -255,6 +263,12 @@ class CallForegroundService : Service() {
255
263
  if (desired?.mode == Mode.RING) stop()
256
264
  }
257
265
 
266
+ /** A running, settled instance switches in place; otherwise start one. */
267
+ private fun launchOrSwitch() {
268
+ val running = instance
269
+ if (running != null && pendingStarts == 0) running.reconcile(desired) else launch()
270
+ }
271
+
258
272
  private fun launch() {
259
273
  if (!::appContext.isInitialized) return
260
274
  val intent = Intent(appContext, CallForegroundService::class.java)