@poolse/react-native-calls 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/build.gradle +7 -0
- package/android/consumer-rules.pro +11 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/dev/poolse/callcore/CallCoreRuntime.kt +245 -110
- package/android/src/main/java/dev/poolse/callcore/CallMachine.kt +113 -18
- package/android/src/main/java/dev/poolse/callcore/Ports.kt +17 -2
- package/android/src/main/java/dev/poolse/callcore/android/AndroidRingSurface.kt +51 -4
- package/android/src/main/java/dev/poolse/callcore/android/CallActionReceiver.kt +4 -3
- package/android/src/main/java/dev/poolse/callcore/android/CallCore.kt +253 -26
- package/android/src/main/java/dev/poolse/callcore/android/CallForegroundService.kt +228 -47
- package/android/src/main/java/dev/poolse/callcore/android/CallHttpClient.kt +97 -46
- package/android/src/main/java/dev/poolse/callcore/android/CallMessagingService.kt +59 -13
- package/android/src/main/java/dev/poolse/callcore/android/IncomingCallActivity.kt +100 -12
- package/android/src/main/java/expo/modules/poolsecalls/PoolseCallsModule.kt +75 -17
- package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitMediaPort.kt +190 -16
- package/android/src/test/java/dev/poolse/callcore/android/CallHttpClientTest.kt +61 -0
- package/dist/index.cjs +10 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +29 -3
- package/dist/index.d.ts +29 -3
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/index.ts +39 -2
package/android/build.gradle
CHANGED
|
@@ -28,6 +28,8 @@ android {
|
|
|
28
28
|
targetSdkVersion safeExtGet("targetSdkVersion", 36)
|
|
29
29
|
versionCode 1
|
|
30
30
|
versionName "0.1.0"
|
|
31
|
+
// Keeps the reflectively-loaded LiveKit adapter in minified apps.
|
|
32
|
+
consumerProguardFiles "consumer-rules.pro"
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
if (useLiveKit) {
|
|
@@ -48,4 +50,9 @@ dependencies {
|
|
|
48
50
|
if (useLiveKit) {
|
|
49
51
|
implementation 'io.livekit:livekit-android:2.11.0'
|
|
50
52
|
}
|
|
53
|
+
|
|
54
|
+
// JVM unit tests (CallHttpClient's parser). The real org.json replaces
|
|
55
|
+
// android.jar's throwing stubs on the test classpath.
|
|
56
|
+
testImplementation 'junit:junit:4.13.2'
|
|
57
|
+
testImplementation 'org.json:json:20180813'
|
|
51
58
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Applied to the HOST app's minified release build.
|
|
2
|
+
|
|
3
|
+
# CallCore loads the LiveKit MediaPort reflectively
|
|
4
|
+
# (Class.forName("dev.poolse.callcore.livekit.LiveKitMediaPort") and its
|
|
5
|
+
# Companion.getFactory), so R8 cannot see the reference: keep it whole.
|
|
6
|
+
-keep class dev.poolse.callcore.livekit.** { *; }
|
|
7
|
+
|
|
8
|
+
# The core's entry points are named in the manifest / by the Expo
|
|
9
|
+
# module registry; keep them (and their companions) un-renamed too.
|
|
10
|
+
-keep class dev.poolse.callcore.android.** { *; }
|
|
11
|
+
-keep class expo.modules.poolsecalls.** { *; }
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
|
10
10
|
<uses-permission android:name="android.permission.VIBRATE" />
|
|
11
11
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
|
12
|
+
<!-- Android 14: a phoneCall-type foreground service requires it. -->
|
|
13
|
+
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
|
|
12
14
|
|
|
13
15
|
<application>
|
|
14
16
|
<activity
|
|
@@ -7,12 +7,19 @@ package dev.poolse.callcore
|
|
|
7
7
|
*
|
|
8
8
|
* The machine decides; this class merely obeys — there is deliberately
|
|
9
9
|
* no call logic here beyond bookkeeping the effects need (which grant
|
|
10
|
-
* answers which call, which control token hangs up
|
|
11
|
-
* wrong, the fix is in
|
|
10
|
+
* answers which call, which control token hangs up, which HTTP answer
|
|
11
|
+
* belongs to which call). If behavior is wrong, the fix is in
|
|
12
|
+
* CallMachine + vectors, not here.
|
|
12
13
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
14
|
+
* Threading: ONE serial queue, no lock. Every input — FCM, binder taps,
|
|
15
|
+
* OkHttp callbacks, SFU events, timers, JS commands — is [post]ed onto
|
|
16
|
+
* [executor] (on Android the main looper). The previous lock let a port
|
|
17
|
+
* that answered synchronously re-enter `dispatch` mid-effect-list, so
|
|
18
|
+
* a later effect ran against a state the earlier one had already moved
|
|
19
|
+
* on. Now a result produced while effects execute is QUEUED behind the
|
|
20
|
+
* current dispatch, never run inside it, and each dispatch emits its
|
|
21
|
+
* snapshot only after its last effect. Tests pass a direct executor;
|
|
22
|
+
* the internal trampoline gives them the same ordering.
|
|
16
23
|
*/
|
|
17
24
|
class CallCoreRuntime(
|
|
18
25
|
private val http: HttpPort,
|
|
@@ -21,10 +28,14 @@ class CallCoreRuntime(
|
|
|
21
28
|
private val session: CallSessionPort,
|
|
22
29
|
private val timers: TimerPort,
|
|
23
30
|
private val sink: SnapshotSink,
|
|
31
|
+
/** Runs a task on the single runtime thread. Must be serial. */
|
|
32
|
+
private val executor: (Runnable) -> Unit = { it.run() },
|
|
33
|
+
/** Where a crashing task is reported; the queue keeps draining regardless. */
|
|
34
|
+
private val onError: (Throwable) -> Unit = {},
|
|
24
35
|
) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
private var state: MachineState = MachineState.IDLE
|
|
36
|
+
// Read from any thread (the bridge's getCallState); written only on
|
|
37
|
+
// the runtime thread.
|
|
38
|
+
@Volatile private var state: MachineState = MachineState.IDLE
|
|
28
39
|
|
|
29
40
|
/** The ring being shown (its grant answers/declines it). */
|
|
30
41
|
private var ring: RingContext? = null
|
|
@@ -38,92 +49,182 @@ class CallCoreRuntime(
|
|
|
38
49
|
/** The session token place/join were called with, for one retry. */
|
|
39
50
|
private var sessionToken: String? = null
|
|
40
51
|
|
|
41
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Call generation: bumped whenever a call starts CONNECTING and
|
|
54
|
+
* whenever one ends. HTTP callbacks and timers capture it, so a late
|
|
55
|
+
* answer/place response or timer of a call that already ended can
|
|
56
|
+
* never be adopted by — or end — the next one.
|
|
57
|
+
*/
|
|
58
|
+
private var gen = 0
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* callIds already resolved on this device (ended, declined, timed
|
|
62
|
+
* out, rung-off remotely, busy-declined). Push + socket + FCM's own
|
|
63
|
+
* redelivery can hand us the same ring several times, sometimes after
|
|
64
|
+
* the call is over; a bounded LRU drops those instead of re-ringing.
|
|
65
|
+
*/
|
|
66
|
+
private val resolved = object : LinkedHashMap<String, Unit>(RESOLVED_LRU, 0.75f, true) {
|
|
67
|
+
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Unit>?) = size > RESOLVED_LRU
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── serial queue ────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
private val queue = ArrayDeque<() -> Unit>()
|
|
73
|
+
private var draining = false
|
|
74
|
+
|
|
75
|
+
/** Run [block] on the runtime thread, after anything already queued. */
|
|
76
|
+
fun post(block: () -> Unit) {
|
|
77
|
+
executor(Runnable { enqueue(block) })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private fun enqueue(block: () -> Unit) {
|
|
81
|
+
queue.addLast(block)
|
|
82
|
+
if (draining) return // a dispatch is executing: run after it, not inside it
|
|
83
|
+
draining = true
|
|
84
|
+
try {
|
|
85
|
+
while (queue.isNotEmpty()) {
|
|
86
|
+
val next = queue.removeFirst()
|
|
87
|
+
try { next() } catch (t: Throwable) { onError(t) }
|
|
88
|
+
}
|
|
89
|
+
} finally {
|
|
90
|
+
draining = false
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
companion object {
|
|
42
95
|
const val ENDED_LINGER_MS = 2_500L
|
|
43
96
|
const val RECONNECT_BUDGET_MS = 25_000L
|
|
44
|
-
|
|
45
|
-
const val
|
|
46
|
-
|
|
97
|
+
/** CONNECTING watchdog: two HTTP attempts plus an SFU connect fit inside. */
|
|
98
|
+
const val CONNECT_WATCHDOG_MS = 20_000L
|
|
99
|
+
/** Caller-side backstop if the server's finish/no-answer never arrives. */
|
|
100
|
+
const val RING_OUT_BACKSTOP_MS = 75_000L
|
|
101
|
+
/**
|
|
102
|
+
* Device clocks drift; the deadline is server epoch. A ring this
|
|
103
|
+
* close past its deadline still shows (for at least this long) —
|
|
104
|
+
* the server's ring_ended(timeout) is the real cut-off.
|
|
105
|
+
*/
|
|
106
|
+
const val CLOCK_SLACK_MS = 10_000L
|
|
107
|
+
const val RESOLVED_LRU = 32
|
|
108
|
+
private const val T_DEADLINE = "ring_deadline"
|
|
109
|
+
private const val T_LINGER = "linger"
|
|
110
|
+
private const val T_RECONNECT = "reconnect_budget"
|
|
111
|
+
private const val T_CONNECT = "connect_watchdog"
|
|
112
|
+
private const val T_RING_OUT = "ring_out_backstop"
|
|
47
113
|
}
|
|
48
114
|
|
|
49
115
|
// ── inputs from adapters ────────────────────────────────────────────
|
|
50
116
|
|
|
51
|
-
fun currentState(): MachineState =
|
|
117
|
+
fun currentState(): MachineState = state
|
|
118
|
+
|
|
119
|
+
/** Best name for the current call: the server's bundle, else the ring's. Runtime thread only. */
|
|
120
|
+
val conversationName: String? get() = bundle?.conversationName ?: ring?.conversationName
|
|
121
|
+
|
|
122
|
+
/** True if [callId] was already resolved here — its ring must never surface again. */
|
|
123
|
+
fun isResolved(callId: String): Boolean = synchronized(resolved) { resolved.containsKey(callId) }
|
|
124
|
+
|
|
125
|
+
private fun markResolved(callId: String?) {
|
|
126
|
+
if (callId == null || callId.startsWith("pending:")) return
|
|
127
|
+
synchronized(resolved) { resolved[callId] = Unit }
|
|
128
|
+
}
|
|
52
129
|
|
|
53
130
|
/** A ring arrived — push or socket, the machine treats them alike. */
|
|
54
|
-
fun onRing(ctx: RingContext) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
131
|
+
fun onRing(ctx: RingContext) = post {
|
|
132
|
+
if (isResolved(ctx.callId)) return@post
|
|
133
|
+
// A late duplicate of the ring for the call we are already IN must
|
|
134
|
+
// not reach the machine: it would read as "another call while busy"
|
|
135
|
+
// and busy-decline our own call.
|
|
136
|
+
val s = state
|
|
137
|
+
if (s.callId == ctx.callId && s.phase != Phase.IDLE && s.phase != Phase.RINGING_IN) return@post
|
|
138
|
+
|
|
139
|
+
val stale = ctx.ringDeadlineEpochMs + CLOCK_SLACK_MS <= timers.nowMs()
|
|
140
|
+
// Remember the context BEFORE dispatch: the effects need it —
|
|
141
|
+
// SHOW_RING for this call, or HTTP_DECLINE_BUSY for another.
|
|
142
|
+
if (!stale) busyRings[ctx.callId] = ctx
|
|
143
|
+
dispatch(
|
|
144
|
+
Event.Ring(
|
|
145
|
+
callId = ctx.callId,
|
|
146
|
+
conversationId = ctx.conversationId,
|
|
147
|
+
media = ctx.media,
|
|
148
|
+
stale = stale,
|
|
149
|
+
),
|
|
150
|
+
ringCtx = ctx,
|
|
151
|
+
)
|
|
152
|
+
busyRings.remove(ctx.callId)
|
|
71
153
|
}
|
|
72
154
|
|
|
73
|
-
fun onRingEnded(callId: String, reason: String) =
|
|
74
|
-
|
|
155
|
+
fun onRingEnded(callId: String, reason: String) = post {
|
|
156
|
+
dispatch(Event.RingEnded(callId, reason))
|
|
157
|
+
markResolved(callId)
|
|
158
|
+
}
|
|
75
159
|
|
|
76
|
-
fun onCallFinished(callId: String) =
|
|
77
|
-
|
|
160
|
+
fun onCallFinished(callId: String, reason: String? = null) = post {
|
|
161
|
+
dispatch(Event.CallFinished(callId, reason))
|
|
162
|
+
markResolved(callId)
|
|
163
|
+
}
|
|
78
164
|
|
|
79
|
-
fun answer() =
|
|
80
|
-
fun decline() =
|
|
81
|
-
fun hangUp() =
|
|
165
|
+
fun answer() = post { dispatch(Event.UserAnswer) }
|
|
166
|
+
fun decline() = post { dispatch(Event.UserDecline) }
|
|
167
|
+
fun hangUp() = post { dispatch(Event.UserHangup) }
|
|
82
168
|
|
|
83
|
-
fun place(conversationId: String, media: String, sessionToken: String) =
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
169
|
+
fun place(conversationId: String, media: String, sessionToken: String) = post {
|
|
170
|
+
if (state.phase != Phase.IDLE && state.phase != Phase.ENDED) return@post
|
|
171
|
+
this.sessionToken = sessionToken
|
|
172
|
+
// call_id is server-minted on place; a placeholder keys the state
|
|
173
|
+
// until the bundle arrives.
|
|
174
|
+
dispatch(Event.UserPlace(callId = "pending:$conversationId", conversationId = conversationId, media = media))
|
|
175
|
+
}
|
|
90
176
|
|
|
91
|
-
fun join(callId: String, conversationId: String?, sessionToken: String) =
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
177
|
+
fun join(callId: String, conversationId: String?, sessionToken: String) = post {
|
|
178
|
+
if (state.phase != Phase.IDLE && state.phase != Phase.ENDED) return@post
|
|
179
|
+
this.sessionToken = sessionToken
|
|
180
|
+
dispatch(Event.UserJoin(callId, conversationId))
|
|
181
|
+
}
|
|
96
182
|
|
|
97
183
|
/** Caller-side: the bridge resolved every ring from `ring_states`. */
|
|
98
|
-
fun allRingsResolved(outcome: String) =
|
|
99
|
-
synchronized(lock) { dispatch(Event.AllRingsResolved(outcome)) }
|
|
184
|
+
fun allRingsResolved(outcome: String) = post { dispatch(Event.AllRingsResolved(outcome)) }
|
|
100
185
|
|
|
101
|
-
fun onMediaConnected() =
|
|
102
|
-
fun onMediaFailed() =
|
|
103
|
-
fun onMediaLost() =
|
|
104
|
-
fun
|
|
105
|
-
fun
|
|
186
|
+
fun onMediaConnected() = post { dispatch(Event.MediaConnected) }
|
|
187
|
+
fun onMediaFailed() = post { dispatch(Event.MediaFailed) }
|
|
188
|
+
fun onMediaLost() = post { dispatch(Event.MediaLost) }
|
|
189
|
+
fun onMediaReconnecting() = post { dispatch(Event.MediaReconnecting) }
|
|
190
|
+
fun onMediaRecovered() = post { dispatch(Event.MediaRecovered) }
|
|
191
|
+
fun onRemoteJoined() = post { dispatch(Event.RemoteJoined) }
|
|
106
192
|
|
|
107
|
-
fun setMuted(muted: Boolean) = media.setMuted(muted)
|
|
108
|
-
fun setCamera(on: Boolean) = media.setCamera(on)
|
|
109
|
-
fun switchCamera() = media.switchCamera()
|
|
193
|
+
fun setMuted(muted: Boolean) = post { media.setMuted(muted) }
|
|
194
|
+
fun setCamera(on: Boolean) = post { media.setCamera(on) }
|
|
195
|
+
fun switchCamera() = post { media.switchCamera() }
|
|
196
|
+
fun setSpeaker(on: Boolean) = post { media.setSpeaker(on) }
|
|
197
|
+
|
|
198
|
+
/** Re-emit the current state (the bridge's roster/route changed). */
|
|
199
|
+
fun reemit() = post { if (state.phase != Phase.IDLE) sink.emit(state) }
|
|
110
200
|
|
|
111
201
|
// ── dispatch ────────────────────────────────────────────────────────
|
|
112
202
|
|
|
203
|
+
private fun isTerminal(p: Phase) = p == Phase.IDLE || p == Phase.ENDED
|
|
204
|
+
|
|
113
205
|
private fun dispatch(event: Event, ringCtx: RingContext? = null) {
|
|
114
206
|
val before = state
|
|
115
207
|
val (next, effects) = CallMachine.transition(before, event)
|
|
116
208
|
state = next
|
|
209
|
+
// A new call attempt, or the end of one: old callbacks go stale.
|
|
210
|
+
if (next.phase == Phase.CONNECTING && before.phase != Phase.CONNECTING) gen++
|
|
211
|
+
if (isTerminal(next.phase) && !isTerminal(before.phase)) {
|
|
212
|
+
gen++
|
|
213
|
+
markResolved(before.callId)
|
|
214
|
+
}
|
|
117
215
|
for (effect in effects) execute(effect, ringCtx)
|
|
118
216
|
// Retire the ring grant only after EVERY effect has had its chance
|
|
119
217
|
// to use it. CONNECTING keeps it (answer + the no-bundle hangup
|
|
120
218
|
// fallback still need it); CallSessionStop clears it with the rest.
|
|
121
|
-
if (next.phase
|
|
219
|
+
if (isTerminal(next.phase)) ring = null
|
|
220
|
+
// A bundle never outlives its call — whatever ended it.
|
|
221
|
+
if (isTerminal(next.phase)) { bundle = null; sessionToken = null }
|
|
122
222
|
runTimers(before, next)
|
|
123
223
|
if (next != before || effects.isNotEmpty()) sink.emit(next)
|
|
124
224
|
}
|
|
125
225
|
|
|
126
226
|
private fun execute(effect: Effect, ringCtx: RingContext?) {
|
|
227
|
+
val g = gen
|
|
127
228
|
when (effect) {
|
|
128
229
|
Effect.ShowRing -> {
|
|
129
230
|
ring = ringCtx
|
|
@@ -144,27 +245,28 @@ class CallCoreRuntime(
|
|
|
144
245
|
|
|
145
246
|
Effect.HttpAnswer -> {
|
|
146
247
|
val r = ring
|
|
147
|
-
if (r != null) http.answer(r.callId, r.grant,
|
|
148
|
-
else onHttpResult(HttpResult.AuthFailed("no_grant"))
|
|
248
|
+
if (r != null) http.answer(r.callId, r.grant) { res -> post { onHttpResult(g, res) } }
|
|
249
|
+
else post { onHttpResult(g, HttpResult.AuthFailed("no_grant")) }
|
|
149
250
|
}
|
|
150
251
|
Effect.HttpJoin -> {
|
|
151
252
|
val callId = state.callId
|
|
152
253
|
val token = sessionToken
|
|
153
|
-
if (callId != null && token != null) http.join(callId, token,
|
|
154
|
-
else onHttpResult(HttpResult.AuthFailed("no_session"))
|
|
254
|
+
if (callId != null && token != null) http.join(callId, token) { res -> post { onHttpResult(g, res) } }
|
|
255
|
+
else post { onHttpResult(g, HttpResult.AuthFailed("no_session")) }
|
|
155
256
|
}
|
|
156
257
|
Effect.HttpPlace -> {
|
|
157
258
|
val conversationId = state.conversationId
|
|
158
259
|
val token = sessionToken
|
|
159
260
|
if (conversationId != null && token != null) {
|
|
160
|
-
http.place(conversationId, state.media, token,
|
|
161
|
-
} else onHttpResult(HttpResult.AuthFailed("no_session"))
|
|
261
|
+
http.place(conversationId, state.media, token) { res -> post { onHttpResult(g, res) } }
|
|
262
|
+
} else post { onHttpResult(g, HttpResult.AuthFailed("no_session")) }
|
|
162
263
|
}
|
|
163
264
|
Effect.HttpDecline -> {
|
|
164
265
|
ring?.let { http.decline(it.callId, it.grant, busy = false) }
|
|
165
266
|
}
|
|
166
267
|
is Effect.HttpDeclineBusy -> {
|
|
167
268
|
busyRings[effect.callId]?.let { http.decline(it.callId, it.grant, busy = true) }
|
|
269
|
+
markResolved(effect.callId)
|
|
168
270
|
}
|
|
169
271
|
Effect.HttpHangup -> {
|
|
170
272
|
val b = bundle
|
|
@@ -173,14 +275,15 @@ class CallCoreRuntime(
|
|
|
173
275
|
b != null -> http.hangup(b.callId, b.controlToken)
|
|
174
276
|
// Hanging up a connecting answer that has no bundle yet:
|
|
175
277
|
// resolve the ring instead, so the caller is not left waiting.
|
|
278
|
+
// (The answer's late 2xx is hung up in onHttpResult.)
|
|
176
279
|
r != null -> http.decline(r.callId, r.grant, busy = false)
|
|
177
280
|
else -> Unit
|
|
178
281
|
}
|
|
179
282
|
}
|
|
180
283
|
Effect.HttpReconnect -> {
|
|
181
284
|
val b = bundle
|
|
182
|
-
if (b != null) http.reconnect(b
|
|
183
|
-
else
|
|
285
|
+
if (b != null) http.reconnect(b) { res -> post { onReconnectResult(g, res) } }
|
|
286
|
+
else post { if (g == gen) dispatch(Event.HttpGone) }
|
|
184
287
|
}
|
|
185
288
|
|
|
186
289
|
Effect.MediaJoin -> bundle?.let(media::join)
|
|
@@ -196,62 +299,94 @@ class CallCoreRuntime(
|
|
|
196
299
|
}
|
|
197
300
|
}
|
|
198
301
|
|
|
199
|
-
private fun onHttpResult(result: HttpResult) {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
302
|
+
private fun onHttpResult(requestGen: Int, result: HttpResult) {
|
|
303
|
+
if (requestGen != gen) {
|
|
304
|
+
// The call this request belonged to is over (the user hung up
|
|
305
|
+
// while it was in flight). The server may still have let us in:
|
|
306
|
+
// leave, with the token it just gave us, and keep nothing.
|
|
307
|
+
if (result is HttpResult.Ok) http.hangup(result.bundle.callId, result.bundle.controlToken)
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
when (result) {
|
|
311
|
+
is HttpResult.Ok -> {
|
|
312
|
+
val existing = bundle
|
|
313
|
+
if (existing != null) {
|
|
314
|
+
// The timeout retry's twin answered too. Same call: ignore.
|
|
315
|
+
// Somehow a different one: we are not in it — leave it.
|
|
316
|
+
if (existing.callId != result.bundle.callId) {
|
|
317
|
+
http.hangup(result.bundle.callId, result.bundle.controlToken)
|
|
207
318
|
}
|
|
208
|
-
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
if (state.phase != Phase.CONNECTING) {
|
|
322
|
+
http.hangup(result.bundle.callId, result.bundle.controlToken)
|
|
323
|
+
return
|
|
209
324
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
325
|
+
bundle = result.bundle
|
|
326
|
+
// The server is the authority on the call's identity: the real
|
|
327
|
+
// id minted on place, and — for a join, which starts with only a
|
|
328
|
+
// call id and an "audio" default — its conversation and media.
|
|
329
|
+
// A video join left as "audio" took the earpiece wake lock, said
|
|
330
|
+
// "Voice call" and hid the camera; a null conversationId broke JS.
|
|
331
|
+
val b = result.bundle
|
|
332
|
+
state = state.copy(callId = b.callId, conversationId = b.conversationId, media = b.media)
|
|
333
|
+
dispatch(Event.HttpOk(created = b.created))
|
|
213
334
|
}
|
|
335
|
+
HttpResult.Gone -> dispatch(Event.HttpGone)
|
|
336
|
+
HttpResult.Timeout -> dispatch(Event.HttpTimeout)
|
|
337
|
+
is HttpResult.AuthFailed -> dispatch(Event.HttpFailed(result.code))
|
|
338
|
+
is HttpResult.Failed -> dispatch(Event.HttpFailed(result.code))
|
|
214
339
|
}
|
|
215
340
|
}
|
|
216
341
|
|
|
217
|
-
private fun onReconnectResult(result: HttpResult) {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
HttpResult.Timeout -> Unit // budget timer is the backstop
|
|
342
|
+
private fun onReconnectResult(requestGen: Int, result: HttpResult) {
|
|
343
|
+
if (requestGen != gen || state.phase != Phase.RECONNECTING) return
|
|
344
|
+
when (result) {
|
|
345
|
+
is HttpResult.Ok -> {
|
|
346
|
+
// Fresh token (and possibly URL): rejoin with it. MEDIA_CONNECTED
|
|
347
|
+
// on the new room returns us to ACTIVE.
|
|
348
|
+
bundle = result.bundle
|
|
349
|
+
media.join(result.bundle)
|
|
226
350
|
}
|
|
351
|
+
HttpResult.Gone -> dispatch(Event.HttpGone)
|
|
352
|
+
is HttpResult.AuthFailed -> dispatch(Event.HttpFailed(result.code))
|
|
353
|
+
// Transient: the reconnect budget timer is the backstop.
|
|
354
|
+
HttpResult.Timeout, is HttpResult.Failed -> Unit
|
|
227
355
|
}
|
|
228
356
|
}
|
|
229
357
|
|
|
230
358
|
// ── timers ──────────────────────────────────────────────────────────
|
|
231
359
|
|
|
360
|
+
private fun schedule(key: String, delayMs: Long, event: Event) {
|
|
361
|
+
val g = gen
|
|
362
|
+
// The timer fires on its own thread; hop onto the queue, and only
|
|
363
|
+
// act if it is still the same call (a cancel can lose the race).
|
|
364
|
+
timers.schedule(key, delayMs) { post { if (g == gen) dispatch(event) } }
|
|
365
|
+
}
|
|
366
|
+
|
|
232
367
|
private fun runTimers(before: MachineState, after: MachineState) {
|
|
233
|
-
if (before.phase
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
when (before.phase) {
|
|
250
|
-
Phase.RINGING_IN -> timers.cancel(T_DEADLINE)
|
|
251
|
-
Phase.ENDED -> timers.cancel(T_LINGER)
|
|
252
|
-
Phase.RECONNECTING -> timers.cancel(T_RECONNECT)
|
|
253
|
-
else -> Unit
|
|
368
|
+
if (before.phase == after.phase) return
|
|
369
|
+
when (before.phase) {
|
|
370
|
+
Phase.RINGING_IN -> timers.cancel(T_DEADLINE)
|
|
371
|
+
Phase.ENDED -> timers.cancel(T_LINGER)
|
|
372
|
+
Phase.RECONNECTING -> timers.cancel(T_RECONNECT)
|
|
373
|
+
Phase.CONNECTING -> timers.cancel(T_CONNECT)
|
|
374
|
+
Phase.RINGING_OUT -> timers.cancel(T_RING_OUT)
|
|
375
|
+
else -> Unit
|
|
376
|
+
}
|
|
377
|
+
when (after.phase) {
|
|
378
|
+
Phase.RINGING_IN -> {
|
|
379
|
+
val deadline = ring?.ringDeadlineEpochMs ?: return
|
|
380
|
+
// Floor at the clock slack: a ring we decided is not stale
|
|
381
|
+
// still gets a moment on screen even if this clock runs ahead.
|
|
382
|
+
val delay = (deadline - timers.nowMs()).coerceAtLeast(CLOCK_SLACK_MS)
|
|
383
|
+
schedule(T_DEADLINE, delay, Event.RingDeadline)
|
|
254
384
|
}
|
|
385
|
+
Phase.ENDED -> schedule(T_LINGER, ENDED_LINGER_MS, Event.LingerElapsed)
|
|
386
|
+
Phase.RECONNECTING -> schedule(T_RECONNECT, RECONNECT_BUDGET_MS, Event.ReconnectBudgetElapsed)
|
|
387
|
+
Phase.CONNECTING -> schedule(T_CONNECT, CONNECT_WATCHDOG_MS, Event.ConnectTimeout)
|
|
388
|
+
Phase.RINGING_OUT -> schedule(T_RING_OUT, RING_OUT_BACKSTOP_MS, Event.AllRingsResolved("no_answer"))
|
|
389
|
+
else -> Unit
|
|
255
390
|
}
|
|
256
391
|
}
|
|
257
392
|
}
|