@poolse/react-native-calls 0.1.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/android/build.gradle +7 -0
  2. package/android/consumer-rules.pro +11 -0
  3. package/android/src/main/AndroidManifest.xml +2 -0
  4. package/android/src/main/java/dev/poolse/callcore/CallCoreRuntime.kt +245 -110
  5. package/android/src/main/java/dev/poolse/callcore/CallMachine.kt +113 -18
  6. package/android/src/main/java/dev/poolse/callcore/Ports.kt +17 -2
  7. package/android/src/main/java/dev/poolse/callcore/android/AndroidRingSurface.kt +51 -4
  8. package/android/src/main/java/dev/poolse/callcore/android/CallActionReceiver.kt +4 -3
  9. package/android/src/main/java/dev/poolse/callcore/android/CallCore.kt +269 -25
  10. package/android/src/main/java/dev/poolse/callcore/android/CallForegroundService.kt +228 -47
  11. package/android/src/main/java/dev/poolse/callcore/android/CallHttpClient.kt +97 -46
  12. package/android/src/main/java/dev/poolse/callcore/android/CallMessagingService.kt +59 -13
  13. package/android/src/main/java/dev/poolse/callcore/android/CallVideo.kt +36 -0
  14. package/android/src/main/java/dev/poolse/callcore/android/IncomingCallActivity.kt +100 -12
  15. package/android/src/main/java/dev/poolse/callcore/android/SnapshotJson.kt +15 -0
  16. package/android/src/main/java/expo/modules/poolsecalls/PoolseCallVideoView.kt +64 -0
  17. package/android/src/main/java/expo/modules/poolsecalls/PoolseCallsModule.kt +85 -17
  18. package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitMediaPort.kt +325 -23
  19. package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitVideoSurface.kt +107 -0
  20. package/android/src/test/java/dev/poolse/callcore/android/CallHttpClientTest.kt +61 -0
  21. package/dist/index.cjs +34 -0
  22. package/dist/index.cjs.map +1 -1
  23. package/dist/index.d.cts +52 -3
  24. package/dist/index.d.ts +52 -3
  25. package/dist/index.js +30 -2
  26. package/dist/index.js.map +1 -1
  27. package/package.json +2 -1
  28. package/src/expo-modules-core.d.ts +5 -0
  29. package/src/index.ts +91 -3
  30. package/src/react-shims.d.ts +22 -0
@@ -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). If behavior is
11
- * wrong, the fix is in CallMachine + vectors, not here.
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
- * Thread-safe: adapters may call in from any thread; dispatch is
14
- * serialized on an internal lock. The Android bridge hops results back
15
- * to its own looper via TimerPort's scheduling.
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
- private val lock = Any()
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
- private companion object {
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
- const val T_DEADLINE = "ring_deadline"
45
- const val T_LINGER = "linger"
46
- const val T_RECONNECT = "reconnect_budget"
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 = synchronized(lock) { state }
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
- val stale = ctx.ringDeadlineEpochMs <= timers.nowMs()
56
- synchronized(lock) {
57
- // Remember the context BEFORE dispatch: the effects need it —
58
- // SHOW_RING for this call, or HTTP_DECLINE_BUSY for another.
59
- if (!stale) busyRings[ctx.callId] = ctx
60
- dispatch(
61
- Event.Ring(
62
- callId = ctx.callId,
63
- conversationId = ctx.conversationId,
64
- media = ctx.media,
65
- stale = stale,
66
- ),
67
- ringCtx = ctx,
68
- )
69
- busyRings.remove(ctx.callId)
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
- synchronized(lock) { dispatch(Event.RingEnded(callId, reason)) }
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
- synchronized(lock) { dispatch(Event.CallFinished(callId)) }
160
+ fun onCallFinished(callId: String, reason: String? = null) = post {
161
+ dispatch(Event.CallFinished(callId, reason))
162
+ markResolved(callId)
163
+ }
78
164
 
79
- fun answer() = synchronized(lock) { dispatch(Event.UserAnswer) }
80
- fun decline() = synchronized(lock) { dispatch(Event.UserDecline) }
81
- fun hangUp() = synchronized(lock) { dispatch(Event.UserHangup) }
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
- synchronized(lock) {
85
- this.sessionToken = sessionToken
86
- // call_id is server-minted on place; a placeholder keys the state
87
- // until the bundle arrives.
88
- dispatch(Event.UserPlace(callId = "pending:$conversationId", conversationId = conversationId, media = media))
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
- synchronized(lock) {
93
- this.sessionToken = sessionToken
94
- dispatch(Event.UserJoin(callId, conversationId))
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() = synchronized(lock) { dispatch(Event.MediaConnected) }
102
- fun onMediaFailed() = synchronized(lock) { dispatch(Event.MediaFailed) }
103
- fun onMediaLost() = synchronized(lock) { dispatch(Event.MediaLost) }
104
- fun onMediaRecovered() = synchronized(lock) { dispatch(Event.MediaRecovered) }
105
- fun onRemoteJoined() = synchronized(lock) { dispatch(Event.RemoteJoined) }
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 == Phase.IDLE || next.phase == Phase.ENDED) ring = null
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, ::onHttpResult)
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, ::onHttpResult)
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, ::onHttpResult)
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.callId, b.controlToken, ::onReconnectResult)
183
- else synchronized(lock) { dispatch(Event.HttpGone) }
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
- synchronized(lock) {
201
- when (result) {
202
- is HttpResult.Ok -> {
203
- bundle = result.bundle
204
- // The server minted the real call id on place.
205
- if (state.callId?.startsWith("pending:") == true) {
206
- state = state.copy(callId = result.bundle.callId, conversationId = result.bundle.conversationId)
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
- dispatch(Event.HttpOk(created = result.bundle.created))
319
+ return
320
+ }
321
+ if (state.phase != Phase.CONNECTING) {
322
+ http.hangup(result.bundle.callId, result.bundle.controlToken)
323
+ return
209
324
  }
210
- HttpResult.Gone -> dispatch(Event.HttpGone)
211
- HttpResult.Timeout -> dispatch(Event.HttpTimeout)
212
- is HttpResult.AuthFailed -> dispatch(Event.HttpGone)
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
- synchronized(lock) {
219
- when (result) {
220
- is HttpResult.Ok -> {
221
- bundle = result.bundle
222
- media.join(result.bundle)
223
- }
224
- HttpResult.Gone, is HttpResult.AuthFailed -> dispatch(Event.HttpGone)
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 != after.phase) {
234
- when (after.phase) {
235
- Phase.RINGING_IN -> {
236
- val deadline = ring?.ringDeadlineEpochMs ?: return
237
- timers.schedule(T_DEADLINE, (deadline - timers.nowMs()).coerceAtLeast(0)) {
238
- synchronized(lock) { dispatch(Event.RingDeadline) }
239
- }
240
- }
241
- Phase.ENDED -> timers.schedule(T_LINGER, ENDED_LINGER_MS) {
242
- synchronized(lock) { dispatch(Event.LingerElapsed) }
243
- }
244
- Phase.RECONNECTING -> timers.schedule(T_RECONNECT, RECONNECT_BUDGET_MS) {
245
- synchronized(lock) { dispatch(Event.ReconnectBudgetElapsed) }
246
- }
247
- else -> Unit
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
  }