@poolse/react-native-calls 0.9.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 +20 -0
- package/android/src/main/java/dev/poolse/callcore/CallCoreRuntime.kt +66 -6
- package/android/src/main/java/dev/poolse/callcore/CallMachine.kt +108 -3
- package/android/src/main/java/dev/poolse/callcore/Ports.kt +12 -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 +5 -0
- package/android/src/main/java/dev/poolse/callcore/android/SnapshotJson.kt +15 -0
- package/android/src/main/java/expo/modules/poolsecalls/PoolseCallsModule.kt +2 -0
- package/dist/index.cjs +4 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +25 -1
- package/dist/index.d.ts +25 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/ios/Core/CallCoreRuntime.swift +73 -6
- package/ios/Core/CallMachine.swift +91 -1
- package/ios/Core/Ports.swift +32 -1
- package/ios/Core/SnapshotEncoding.swift +14 -1
- package/ios/Module/PoolseCallsModule.swift +2 -0
- package/ios/Platform/CallCore.swift +63 -15
- package/ios/Platform/CallKitController.swift +7 -1
- package/package.json +1 -1
- package/src/index.ts +29 -0
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
|
|
235
|
-
ringCtx
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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,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
|
}
|
|
@@ -142,6 +142,10 @@ class CallCore private constructor(context: Context) {
|
|
|
142
142
|
fun answer() = runtime.answer()
|
|
143
143
|
fun decline() = runtime.decline()
|
|
144
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)
|
|
145
149
|
fun place(conversationId: String, media: String, sessionToken: String) =
|
|
146
150
|
runtime.place(conversationId, media, sessionToken)
|
|
147
151
|
fun join(callId: String, conversationId: String?, sessionToken: String) =
|
|
@@ -235,6 +239,7 @@ class CallCore private constructor(context: Context) {
|
|
|
235
239
|
mutedSelf = mutedSelf,
|
|
236
240
|
cameraOn = cameraOn,
|
|
237
241
|
cameraFacing = cameraFacing,
|
|
242
|
+
waitingRing = runtime.waitingRing,
|
|
238
243
|
)
|
|
239
244
|
lastSnapshot = snap
|
|
240
245
|
reconcileAndroid(state)
|
|
@@ -24,6 +24,8 @@ internal fun MachineState.toSnapshot(
|
|
|
24
24
|
mutedSelf: Boolean = false,
|
|
25
25
|
cameraOn: Boolean = false,
|
|
26
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,
|
|
27
29
|
): Map<String, Any?>? {
|
|
28
30
|
if (phase == Phase.IDLE) return null
|
|
29
31
|
return buildMap {
|
|
@@ -33,6 +35,19 @@ internal fun MachineState.toSnapshot(
|
|
|
33
35
|
put("audioOutputId", audioOutputId)
|
|
34
36
|
// Optional in the contract: present only while a camera is published.
|
|
35
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
|
+
)
|
|
36
51
|
}
|
|
37
52
|
}
|
|
38
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() }
|
package/dist/index.cjs
CHANGED
|
@@ -51,6 +51,8 @@ async function startRoom(conversationId, options) {
|
|
|
51
51
|
var answer = () => native?.answer();
|
|
52
52
|
var decline = () => native?.decline();
|
|
53
53
|
var hangUp = () => native?.hangUp();
|
|
54
|
+
var answerWaiting = (callId) => native?.answerWaiting?.(callId ?? null);
|
|
55
|
+
var declineWaiting = (callId) => native?.declineWaiting?.(callId ?? null);
|
|
54
56
|
var setMuted = (muted) => native?.setMuted(muted);
|
|
55
57
|
var setCamera = (on) => native?.setCamera(on);
|
|
56
58
|
var switchCamera = () => native?.switchCamera();
|
|
@@ -118,10 +120,12 @@ function CallVideoView(props) {
|
|
|
118
120
|
|
|
119
121
|
exports.CallVideoView = CallVideoView;
|
|
120
122
|
exports.answer = answer;
|
|
123
|
+
exports.answerWaiting = answerWaiting;
|
|
121
124
|
exports.canUseFullScreenIntent = canUseFullScreenIntent;
|
|
122
125
|
exports.configure = configure;
|
|
123
126
|
exports.conversationOfNotification = conversationOfNotification;
|
|
124
127
|
exports.decline = decline;
|
|
128
|
+
exports.declineWaiting = declineWaiting;
|
|
125
129
|
exports.getCallState = getCallState;
|
|
126
130
|
exports.getVoipToken = getVoipToken;
|
|
127
131
|
exports.hangUp = hangUp;
|