@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.
- package/README.md +27 -0
- package/android/src/main/java/dev/poolse/callcore/CallCoreRuntime.kt +69 -6
- package/android/src/main/java/dev/poolse/callcore/CallMachine.kt +108 -3
- package/android/src/main/java/dev/poolse/callcore/Ports.kt +14 -0
- package/android/src/main/java/dev/poolse/callcore/android/AndroidRingSurface.kt +121 -0
- package/android/src/main/java/dev/poolse/callcore/android/CallActionReceiver.kt +16 -0
- package/android/src/main/java/dev/poolse/callcore/android/CallCore.kt +14 -2
- package/android/src/main/java/dev/poolse/callcore/android/CallHttpClient.kt +1 -0
- package/android/src/main/java/dev/poolse/callcore/android/SnapshotJson.kt +17 -0
- package/android/src/main/java/expo/modules/poolsecalls/PoolseCallsModule.kt +2 -0
- package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitMediaPort.kt +7 -3
- package/dist/index.cjs +4 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +31 -1
- package/dist/index.d.ts +31 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/ios/Core/CallCoreRuntime.swift +76 -6
- package/ios/Core/CallHttpClient.swift +2 -1
- package/ios/Core/CallMachine.swift +91 -1
- package/ios/Core/Ports.swift +37 -2
- package/ios/Core/SnapshotEncoding.swift +16 -1
- package/ios/Module/PoolseCallsModule.swift +2 -0
- package/ios/Platform/CallCore.swift +69 -16
- package/ios/Platform/CallKitController.swift +7 -1
- package/ios/Platform/LiveKitMediaPort.swift +6 -2
- package/package.json +1 -1
- 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
|
|
232
|
-
ringCtx
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
373
|
-
//
|
|
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
|
|
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
|
|
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() }
|