@poolse/react-native-calls 0.0.0-stage → 0.1.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 (28) hide show
  1. package/README.md +33 -2
  2. package/android/build.gradle +51 -0
  3. package/android/src/main/AndroidManifest.xml +40 -0
  4. package/android/src/main/java/dev/poolse/callcore/CallCoreRuntime.kt +257 -0
  5. package/android/src/main/java/dev/poolse/callcore/CallMachine.kt +292 -0
  6. package/android/src/main/java/dev/poolse/callcore/Ports.kt +94 -0
  7. package/android/src/main/java/dev/poolse/callcore/android/AndroidRingSurface.kt +183 -0
  8. package/android/src/main/java/dev/poolse/callcore/android/CallActionReceiver.kt +28 -0
  9. package/android/src/main/java/dev/poolse/callcore/android/CallCore.kt +166 -0
  10. package/android/src/main/java/dev/poolse/callcore/android/CallForegroundService.kt +80 -0
  11. package/android/src/main/java/dev/poolse/callcore/android/CallHttpClient.kt +151 -0
  12. package/android/src/main/java/dev/poolse/callcore/android/CallMessagingService.kt +99 -0
  13. package/android/src/main/java/dev/poolse/callcore/android/HandlerTimerPort.kt +42 -0
  14. package/android/src/main/java/dev/poolse/callcore/android/IncomingCallActivity.kt +209 -0
  15. package/android/src/main/java/dev/poolse/callcore/android/RingBundle.kt +42 -0
  16. package/android/src/main/java/dev/poolse/callcore/android/SnapshotJson.kt +57 -0
  17. package/android/src/main/java/expo/modules/poolsecalls/PoolseCallsModule.kt +109 -0
  18. package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitMediaPort.kt +108 -0
  19. package/dist/index.cjs +45 -0
  20. package/dist/index.cjs.map +1 -0
  21. package/dist/index.d.cts +51 -0
  22. package/dist/index.d.ts +51 -0
  23. package/dist/index.js +32 -0
  24. package/dist/index.js.map +1 -0
  25. package/expo-module.config.json +6 -0
  26. package/package.json +39 -4
  27. package/src/expo-modules-core.d.ts +11 -0
  28. package/src/index.ts +111 -0
package/README.md CHANGED
@@ -1,3 +1,34 @@
1
- # Temporary Holding Version
1
+ # @poolse/react-native-calls
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The React Native bridge over poolse's native call core — the Kotlin
4
+ (`dev.poolse:call-core`) and, later, Swift engines that ring, answer and
5
+ carry audio with the app killed and the phone locked.
6
+
7
+ This package is thin on purpose: commands go in, one `Snapshot` event
8
+ comes out, and every call decision lives in the native core (behaviour
9
+ pinned by `contracts/call-core/vectors.json`). On a build without the
10
+ native module it degrades to safe no-ops with an idle state, so a host
11
+ can render against it unconditionally.
12
+
13
+ ```ts
14
+ import * as calls from '@poolse/react-native-calls';
15
+
16
+ await calls.configure('https://api.poolse.dev', { smallIcon: 'ic_call', ringback: 'ringback' });
17
+ const stop = calls.onCallState((s) => render(s)); // s: Snapshot | null
18
+ await calls.place(conversationId, { media: 'audio', sessionToken });
19
+ calls.answer(); calls.decline(); calls.hangUp();
20
+ ```
21
+
22
+ ## Layout
23
+ - `src/` — the TypeScript API (this is what apps import).
24
+ - `android/src/main/java/dev/poolse/callcore/` — the pure state machine
25
+ and ports (contract cv 1).
26
+ - `android/src/main/java/dev/poolse/callcore/android/` — the Android
27
+ adapters (HTTP, timers, ring surface, foreground service, FCM intake,
28
+ composition root).
29
+ - `android/src/main/java/expo/modules/poolsecalls/` — the Expo bridge
30
+ module (compiled by the app's autolinking).
31
+ - `android/src/main/livekit/` — the shipped LiveKit `MediaPort`, opt-in
32
+ via the `poolse.livekit` gradle property; the core is SFU-agnostic.
33
+
34
+ Stage 3 adds the Swift core under `ios/`.
@@ -0,0 +1,51 @@
1
+ apply plugin: 'com.android.library'
2
+ apply plugin: 'kotlin-android'
3
+
4
+ group = 'dev.poolse.callcore'
5
+ version = '0.1.0'
6
+
7
+ def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
8
+ apply from: expoModulesCorePlugin
9
+ applyKotlinExpoModulesCorePlugin()
10
+ useCoreDependencies()
11
+
12
+ def safeExtGet(prop, fallback) {
13
+ rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
14
+ }
15
+
16
+ // The shipped LiveKit MediaPort lives in its own source set, off the
17
+ // core classpath, so the library carries no SFU dependency unless the
18
+ // app opts in with `-Ppoolse.livekit` (or ext.poolseLiveKit = true).
19
+ def useLiveKit = project.hasProperty('poolse.livekit') ||
20
+ (rootProject.ext.has('poolseLiveKit') && rootProject.ext.get('poolseLiveKit'))
21
+
22
+ android {
23
+ namespace "dev.poolse.callcore"
24
+ compileSdk safeExtGet("compileSdkVersion", 36)
25
+
26
+ defaultConfig {
27
+ minSdkVersion safeExtGet("minSdkVersion", 24)
28
+ targetSdkVersion safeExtGet("targetSdkVersion", 36)
29
+ versionCode 1
30
+ versionName "0.1.0"
31
+ }
32
+
33
+ if (useLiveKit) {
34
+ sourceSets {
35
+ main {
36
+ java.srcDir "src/main/livekit/java"
37
+ }
38
+ }
39
+ }
40
+ }
41
+
42
+ dependencies {
43
+ implementation 'androidx.core:core-ktx:1.13.1'
44
+ implementation 'com.squareup.okhttp3:okhttp:4.12.0'
45
+ implementation 'com.google.firebase:firebase-messaging:24.0.3'
46
+ implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
47
+
48
+ if (useLiveKit) {
49
+ implementation 'io.livekit:livekit-android:2.11.0'
50
+ }
51
+ }
@@ -0,0 +1,40 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+
3
+ <uses-permission android:name="android.permission.INTERNET" />
4
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
5
+ <uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
6
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
7
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
8
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
9
+ <uses-permission android:name="android.permission.RECORD_AUDIO" />
10
+ <uses-permission android:name="android.permission.VIBRATE" />
11
+ <uses-permission android:name="android.permission.WAKE_LOCK" />
12
+
13
+ <application>
14
+ <activity
15
+ android:name="dev.poolse.callcore.android.IncomingCallActivity"
16
+ android:exported="false"
17
+ android:showWhenLocked="true"
18
+ android:turnScreenOn="true"
19
+ android:excludeFromRecents="true"
20
+ android:launchMode="singleInstance"
21
+ android:theme="@android:style/Theme.Black.NoTitleBar.Fullscreen" />
22
+
23
+ <receiver
24
+ android:name="dev.poolse.callcore.android.CallActionReceiver"
25
+ android:exported="false" />
26
+
27
+ <service
28
+ android:name="dev.poolse.callcore.android.CallForegroundService"
29
+ android:exported="false"
30
+ android:foregroundServiceType="phoneCall|microphone" />
31
+
32
+ <service
33
+ android:name="dev.poolse.callcore.android.CallMessagingService"
34
+ android:exported="false">
35
+ <intent-filter>
36
+ <action android:name="com.google.firebase.MESSAGING_EVENT" />
37
+ </intent-filter>
38
+ </service>
39
+ </application>
40
+ </manifest>
@@ -0,0 +1,257 @@
1
+ package dev.poolse.callcore
2
+
3
+ /**
4
+ * The engine around the machine: owns the state, serializes events,
5
+ * executes effects through the ports, runs the timers, and emits a
6
+ * snapshot after every change.
7
+ *
8
+ * The machine decides; this class merely obeys — there is deliberately
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.
12
+ *
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.
16
+ */
17
+ class CallCoreRuntime(
18
+ private val http: HttpPort,
19
+ private val media: MediaPort,
20
+ private val surface: RingSurfacePort,
21
+ private val session: CallSessionPort,
22
+ private val timers: TimerPort,
23
+ private val sink: SnapshotSink,
24
+ ) {
25
+ private val lock = Any()
26
+
27
+ private var state: MachineState = MachineState.IDLE
28
+
29
+ /** The ring being shown (its grant answers/declines it). */
30
+ private var ring: RingContext? = null
31
+
32
+ /** Grants of rings auto-declined busy, kept until the decline fires. */
33
+ private val busyRings = mutableMapOf<String, RingContext>()
34
+
35
+ /** The session bundle of the current call, once the server accepted. */
36
+ private var bundle: CallSessionBundle? = null
37
+
38
+ /** The session token place/join were called with, for one retry. */
39
+ private var sessionToken: String? = null
40
+
41
+ private companion object {
42
+ const val ENDED_LINGER_MS = 2_500L
43
+ 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"
47
+ }
48
+
49
+ // ── inputs from adapters ────────────────────────────────────────────
50
+
51
+ fun currentState(): MachineState = synchronized(lock) { state }
52
+
53
+ /** 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
+ }
71
+ }
72
+
73
+ fun onRingEnded(callId: String, reason: String) =
74
+ synchronized(lock) { dispatch(Event.RingEnded(callId, reason)) }
75
+
76
+ fun onCallFinished(callId: String) =
77
+ synchronized(lock) { dispatch(Event.CallFinished(callId)) }
78
+
79
+ fun answer() = synchronized(lock) { dispatch(Event.UserAnswer) }
80
+ fun decline() = synchronized(lock) { dispatch(Event.UserDecline) }
81
+ fun hangUp() = synchronized(lock) { dispatch(Event.UserHangup) }
82
+
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
+ }
90
+
91
+ fun join(callId: String, conversationId: String?, sessionToken: String) =
92
+ synchronized(lock) {
93
+ this.sessionToken = sessionToken
94
+ dispatch(Event.UserJoin(callId, conversationId))
95
+ }
96
+
97
+ /** Caller-side: the bridge resolved every ring from `ring_states`. */
98
+ fun allRingsResolved(outcome: String) =
99
+ synchronized(lock) { dispatch(Event.AllRingsResolved(outcome)) }
100
+
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) }
106
+
107
+ fun setMuted(muted: Boolean) = media.setMuted(muted)
108
+ fun setCamera(on: Boolean) = media.setCamera(on)
109
+ fun switchCamera() = media.switchCamera()
110
+
111
+ // ── dispatch ────────────────────────────────────────────────────────
112
+
113
+ private fun dispatch(event: Event, ringCtx: RingContext? = null) {
114
+ val before = state
115
+ val (next, effects) = CallMachine.transition(before, event)
116
+ state = next
117
+ for (effect in effects) execute(effect, ringCtx)
118
+ // Retire the ring grant only after EVERY effect has had its chance
119
+ // to use it. CONNECTING keeps it (answer + the no-bundle hangup
120
+ // fallback still need it); CallSessionStop clears it with the rest.
121
+ if (next.phase == Phase.IDLE || next.phase == Phase.ENDED) ring = null
122
+ runTimers(before, next)
123
+ if (next != before || effects.isNotEmpty()) sink.emit(next)
124
+ }
125
+
126
+ private fun execute(effect: Effect, ringCtx: RingContext?) {
127
+ when (effect) {
128
+ Effect.ShowRing -> {
129
+ ring = ringCtx
130
+ ringCtx?.let(surface::show)
131
+ }
132
+ Effect.DismissRing -> {
133
+ (ring ?: ringCtx)?.let { surface.dismiss(it.callId) }
134
+ // The grant is NOT retired here: HTTP_DECLINE may still be
135
+ // later in this very effect list (dismiss-then-decline), and
136
+ // clearing mid-list silently dropped the decline — the exact
137
+ // "rejection never reached the caller" bug, reborn. Retirement
138
+ // happens once, after the whole list, in dispatch().
139
+ }
140
+ Effect.RingtoneStart -> surface.ringtoneStart()
141
+ Effect.RingtoneStop -> surface.ringtoneStop()
142
+ Effect.RingbackStart -> surface.ringbackStart()
143
+ Effect.RingbackStop -> surface.ringbackStop()
144
+
145
+ Effect.HttpAnswer -> {
146
+ val r = ring
147
+ if (r != null) http.answer(r.callId, r.grant, ::onHttpResult)
148
+ else onHttpResult(HttpResult.AuthFailed("no_grant"))
149
+ }
150
+ Effect.HttpJoin -> {
151
+ val callId = state.callId
152
+ val token = sessionToken
153
+ if (callId != null && token != null) http.join(callId, token, ::onHttpResult)
154
+ else onHttpResult(HttpResult.AuthFailed("no_session"))
155
+ }
156
+ Effect.HttpPlace -> {
157
+ val conversationId = state.conversationId
158
+ val token = sessionToken
159
+ if (conversationId != null && token != null) {
160
+ http.place(conversationId, state.media, token, ::onHttpResult)
161
+ } else onHttpResult(HttpResult.AuthFailed("no_session"))
162
+ }
163
+ Effect.HttpDecline -> {
164
+ ring?.let { http.decline(it.callId, it.grant, busy = false) }
165
+ }
166
+ is Effect.HttpDeclineBusy -> {
167
+ busyRings[effect.callId]?.let { http.decline(it.callId, it.grant, busy = true) }
168
+ }
169
+ Effect.HttpHangup -> {
170
+ val b = bundle
171
+ val r = ring
172
+ when {
173
+ b != null -> http.hangup(b.callId, b.controlToken)
174
+ // Hanging up a connecting answer that has no bundle yet:
175
+ // resolve the ring instead, so the caller is not left waiting.
176
+ r != null -> http.decline(r.callId, r.grant, busy = false)
177
+ else -> Unit
178
+ }
179
+ }
180
+ Effect.HttpReconnect -> {
181
+ val b = bundle
182
+ if (b != null) http.reconnect(b.callId, b.controlToken, ::onReconnectResult)
183
+ else synchronized(lock) { dispatch(Event.HttpGone) }
184
+ }
185
+
186
+ Effect.MediaJoin -> bundle?.let(media::join)
187
+ Effect.MediaLeave -> media.leave()
188
+
189
+ Effect.CallSessionStart -> session.start(state.callId ?: "", state.media)
190
+ Effect.CallSessionStop -> {
191
+ session.stop()
192
+ bundle = null
193
+ ring = null
194
+ sessionToken = null
195
+ }
196
+ }
197
+ }
198
+
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)
207
+ }
208
+ dispatch(Event.HttpOk(created = result.bundle.created))
209
+ }
210
+ HttpResult.Gone -> dispatch(Event.HttpGone)
211
+ HttpResult.Timeout -> dispatch(Event.HttpTimeout)
212
+ is HttpResult.AuthFailed -> dispatch(Event.HttpGone)
213
+ }
214
+ }
215
+ }
216
+
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
226
+ }
227
+ }
228
+ }
229
+
230
+ // ── timers ──────────────────────────────────────────────────────────
231
+
232
+ 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
254
+ }
255
+ }
256
+ }
257
+ }
@@ -0,0 +1,292 @@
1
+ package dev.poolse.callcore
2
+
3
+ /**
4
+ * The call state machine — contract cv 1, CONTRACT.md §7.
5
+ *
6
+ * Pure: no I/O, no platform, no time. Events in, (state, effects) out.
7
+ * The adapters execute effects; the machine only decides. Its behavior
8
+ * is pinned by contracts/call-core/vectors.json, which the conformance
9
+ * test runs verbatim — change the vectors first, then this file.
10
+ *
11
+ * Lifecycle authority is the SERVER's (LiveKit-counted). This machine
12
+ * never counts participants to decide whether a call should end; it
13
+ * reacts to `RING_ENDED` and `CALL_FINISHED` and reports what the user
14
+ * did.
15
+ */
16
+
17
+ enum class Phase { IDLE, RINGING_IN, RINGING_OUT, CONNECTING, ACTIVE, RECONNECTING, ENDED }
18
+
19
+ enum class Direction { INCOMING, OUTGOING, JOINED }
20
+
21
+ enum class EndReason {
22
+ LEFT, FINISHED, DECLINED_ALL, BUSY_ALL, NO_ANSWER, CANCELLED, CONNECTION_LOST, FAILED;
23
+
24
+ val wire: String get() = name.lowercase()
25
+ }
26
+
27
+ /** Everything the machine knows. Immutable; transitions return a new one. */
28
+ data class MachineState(
29
+ val phase: Phase = Phase.IDLE,
30
+ val callId: String? = null,
31
+ val conversationId: String? = null,
32
+ val direction: Direction? = null,
33
+ val media: String = "audio",
34
+ val reason: EndReason? = null,
35
+ /** HTTP attempts made for the current connecting request (retry budget = 2). */
36
+ val httpAttempts: Int = 0,
37
+ ) {
38
+ companion object { val IDLE = MachineState() }
39
+ }
40
+
41
+ /** Inputs, named exactly as in the contract and vectors. */
42
+ sealed interface Event {
43
+ /** A ring from push or socket — the machine treats them identically. */
44
+ data class Ring(
45
+ val callId: String,
46
+ val conversationId: String? = null,
47
+ val media: String = "audio",
48
+ /** Deadline already passed, or registry already finished. Never surface. */
49
+ val stale: Boolean = false,
50
+ ) : Event
51
+
52
+ data class RingEnded(val callId: String, val reason: String) : Event
53
+ data class CallFinished(val callId: String) : Event
54
+
55
+ data object UserAnswer : Event
56
+ data object UserDecline : Event
57
+ data class UserPlace(val callId: String, val conversationId: String? = null, val media: String = "audio") : Event
58
+ data class UserJoin(val callId: String, val conversationId: String? = null) : Event
59
+ data object UserHangup : Event
60
+
61
+ /** Server accepted answer/join/place. `created=false` on place means an existing live call (it became a join). */
62
+ data class HttpOk(val created: Boolean = true) : Event
63
+ data object HttpGone : Event
64
+ data object HttpTimeout : Event
65
+
66
+ data object MediaConnected : Event
67
+ data object MediaFailed : Event
68
+ data object MediaLost : Event
69
+ data object MediaRecovered : Event
70
+ data object RemoteJoined : Event
71
+
72
+ /** Caller side: every ring resolved without anyone joining. */
73
+ data class AllRingsResolved(val outcome: String) : Event
74
+
75
+ data object RingDeadline : Event
76
+ data object ReconnectBudgetElapsed : Event
77
+ data object LingerElapsed : Event
78
+ }
79
+
80
+ /** Abstract effects — each adapter maps them to its platform. */
81
+ sealed interface Effect {
82
+ val wire: String
83
+
84
+ data object ShowRing : Effect { override val wire = "SHOW_RING" }
85
+ data object DismissRing : Effect { override val wire = "DISMISS_RING" }
86
+ data object RingtoneStart : Effect { override val wire = "RINGTONE_START" }
87
+ data object RingtoneStop : Effect { override val wire = "RINGTONE_STOP" }
88
+ data object RingbackStart : Effect { override val wire = "RINGBACK_START" }
89
+ data object RingbackStop : Effect { override val wire = "RINGBACK_STOP" }
90
+ data object HttpAnswer : Effect { override val wire = "HTTP_ANSWER" }
91
+ data object HttpJoin : Effect { override val wire = "HTTP_JOIN" }
92
+ data object HttpPlace : Effect { override val wire = "HTTP_PLACE" }
93
+ data object HttpDecline : Effect { override val wire = "HTTP_DECLINE" }
94
+ data class HttpDeclineBusy(val callId: String) : Effect { override val wire = "HTTP_DECLINE_BUSY:$callId" }
95
+ data object HttpHangup : Effect { override val wire = "HTTP_HANGUP" }
96
+ data object HttpReconnect : Effect { override val wire = "HTTP_RECONNECT" }
97
+ data object MediaJoin : Effect { override val wire = "MEDIA_JOIN" }
98
+ data object MediaLeave : Effect { override val wire = "MEDIA_LEAVE" }
99
+ data object CallSessionStart : Effect { override val wire = "CALL_SESSION_START" }
100
+ data object CallSessionStop : Effect { override val wire = "CALL_SESSION_STOP" }
101
+ }
102
+
103
+ data class Transition(val state: MachineState, val effects: List<Effect>)
104
+
105
+ object CallMachine {
106
+
107
+ fun transition(s: MachineState, e: Event): Transition = when (e) {
108
+
109
+ is Event.Ring -> ring(s, e)
110
+
111
+ is Event.RingEnded ->
112
+ if (s.phase == Phase.RINGING_IN && e.callId == s.callId) {
113
+ Transition(MachineState.IDLE, listOf(Effect.RingtoneStop, Effect.DismissRing))
114
+ } else noop(s)
115
+
116
+ is Event.CallFinished ->
117
+ if (e.callId == s.callId) when (s.phase) {
118
+ Phase.ACTIVE, Phase.RECONNECTING, Phase.RINGING_OUT -> Transition(
119
+ s.copy(phase = Phase.ENDED, reason = EndReason.FINISHED),
120
+ buildList {
121
+ if (s.phase == Phase.RINGING_OUT) add(Effect.RingbackStop)
122
+ add(Effect.MediaLeave); add(Effect.CallSessionStop)
123
+ },
124
+ )
125
+ Phase.CONNECTING -> Transition(
126
+ s.copy(phase = Phase.ENDED, reason = EndReason.FINISHED),
127
+ listOf(Effect.CallSessionStop),
128
+ )
129
+ else -> noop(s)
130
+ } else noop(s)
131
+
132
+ Event.UserAnswer ->
133
+ if (s.phase == Phase.RINGING_IN) Transition(
134
+ s.copy(phase = Phase.CONNECTING, direction = Direction.INCOMING, httpAttempts = 1),
135
+ listOf(Effect.RingtoneStop, Effect.DismissRing, Effect.HttpAnswer, Effect.CallSessionStart),
136
+ ) else noop(s)
137
+
138
+ Event.UserDecline ->
139
+ if (s.phase == Phase.RINGING_IN) Transition(
140
+ MachineState.IDLE,
141
+ listOf(Effect.RingtoneStop, Effect.DismissRing, Effect.HttpDecline),
142
+ ) else noop(s)
143
+
144
+ is Event.UserPlace ->
145
+ if (s.phase == Phase.IDLE || s.phase == Phase.ENDED) Transition(
146
+ MachineState(
147
+ phase = Phase.CONNECTING, callId = e.callId, conversationId = e.conversationId,
148
+ direction = Direction.OUTGOING, media = e.media, httpAttempts = 1,
149
+ ),
150
+ listOf(Effect.HttpPlace, Effect.CallSessionStart),
151
+ ) else noop(s)
152
+
153
+ is Event.UserJoin ->
154
+ if (s.phase == Phase.IDLE || s.phase == Phase.ENDED) Transition(
155
+ MachineState(
156
+ phase = Phase.CONNECTING, callId = e.callId, conversationId = e.conversationId,
157
+ direction = Direction.JOINED, httpAttempts = 1,
158
+ ),
159
+ listOf(Effect.HttpJoin, Effect.CallSessionStart),
160
+ ) else noop(s)
161
+
162
+ Event.UserHangup -> when (s.phase) {
163
+ Phase.RINGING_OUT -> Transition(
164
+ s.copy(phase = Phase.ENDED, reason = EndReason.CANCELLED),
165
+ listOf(Effect.RingbackStop, Effect.MediaLeave, Effect.HttpHangup, Effect.CallSessionStop),
166
+ )
167
+ Phase.ACTIVE, Phase.RECONNECTING, Phase.CONNECTING -> Transition(
168
+ s.copy(phase = Phase.ENDED, reason = EndReason.LEFT),
169
+ listOf(Effect.MediaLeave, Effect.HttpHangup, Effect.CallSessionStop),
170
+ )
171
+ else -> noop(s)
172
+ }
173
+
174
+ is Event.HttpOk -> when {
175
+ s.phase != Phase.CONNECTING -> noop(s)
176
+ s.direction == Direction.OUTGOING && e.created -> Transition(
177
+ s.copy(phase = Phase.RINGING_OUT),
178
+ listOf(Effect.MediaJoin, Effect.RingbackStart),
179
+ )
180
+ s.direction == Direction.OUTGOING && !e.created -> Transition(
181
+ // Somebody's call was already live: our place collapsed into a join.
182
+ s.copy(direction = Direction.JOINED),
183
+ listOf(Effect.MediaJoin),
184
+ )
185
+ else -> Transition(s, listOf(Effect.MediaJoin))
186
+ }
187
+
188
+ Event.HttpGone -> when (s.phase) {
189
+ Phase.CONNECTING -> Transition(
190
+ s.copy(phase = Phase.ENDED, reason = EndReason.FINISHED),
191
+ listOf(Effect.CallSessionStop),
192
+ )
193
+ Phase.RECONNECTING -> Transition(
194
+ // Finished — or this device was superseded by the same user
195
+ // joining elsewhere. Either way: we are no longer in it.
196
+ s.copy(phase = Phase.ENDED, reason = EndReason.LEFT),
197
+ listOf(Effect.MediaLeave, Effect.CallSessionStop),
198
+ )
199
+ else -> noop(s)
200
+ }
201
+
202
+ Event.HttpTimeout ->
203
+ if (s.phase == Phase.CONNECTING && s.httpAttempts < 2) Transition(
204
+ s.copy(httpAttempts = s.httpAttempts + 1),
205
+ listOf(
206
+ when (s.direction) {
207
+ Direction.OUTGOING -> Effect.HttpPlace
208
+ Direction.JOINED -> Effect.HttpJoin
209
+ else -> Effect.HttpAnswer
210
+ },
211
+ ),
212
+ ) else noop(s) // out of retries: media join (or its absence) decides
213
+
214
+ Event.MediaConnected -> when (s.phase) {
215
+ Phase.CONNECTING -> Transition(s.copy(phase = Phase.ACTIVE), emptyList())
216
+ else -> noop(s) // ringing_out keeps ringing until a remote joins
217
+ }
218
+
219
+ Event.MediaFailed ->
220
+ if (s.phase == Phase.CONNECTING) Transition(
221
+ s.copy(phase = Phase.ENDED, reason = EndReason.FAILED),
222
+ listOf(Effect.HttpHangup, Effect.CallSessionStop),
223
+ ) else noop(s)
224
+
225
+ Event.MediaLost ->
226
+ if (s.phase == Phase.ACTIVE) Transition(
227
+ s.copy(phase = Phase.RECONNECTING),
228
+ listOf(Effect.HttpReconnect),
229
+ ) else noop(s)
230
+
231
+ Event.MediaRecovered ->
232
+ if (s.phase == Phase.RECONNECTING) Transition(s.copy(phase = Phase.ACTIVE), emptyList())
233
+ else noop(s)
234
+
235
+ Event.RemoteJoined ->
236
+ if (s.phase == Phase.RINGING_OUT) Transition(
237
+ s.copy(phase = Phase.ACTIVE),
238
+ listOf(Effect.RingbackStop),
239
+ ) else noop(s)
240
+
241
+ is Event.AllRingsResolved ->
242
+ if (s.phase == Phase.RINGING_OUT) Transition(
243
+ s.copy(
244
+ phase = Phase.ENDED,
245
+ reason = when (e.outcome) {
246
+ "busy_all" -> EndReason.BUSY_ALL
247
+ "no_answer" -> EndReason.NO_ANSWER
248
+ else -> EndReason.DECLINED_ALL
249
+ },
250
+ ),
251
+ listOf(Effect.RingbackStop, Effect.MediaLeave, Effect.HttpHangup, Effect.CallSessionStop),
252
+ ) else noop(s)
253
+
254
+ Event.RingDeadline ->
255
+ if (s.phase == Phase.RINGING_IN) Transition(
256
+ MachineState.IDLE,
257
+ listOf(Effect.RingtoneStop, Effect.DismissRing),
258
+ ) else noop(s)
259
+
260
+ Event.ReconnectBudgetElapsed ->
261
+ if (s.phase == Phase.RECONNECTING) Transition(
262
+ s.copy(phase = Phase.ENDED, reason = EndReason.CONNECTION_LOST),
263
+ listOf(Effect.MediaLeave, Effect.HttpHangup, Effect.CallSessionStop),
264
+ ) else noop(s)
265
+
266
+ Event.LingerElapsed ->
267
+ if (s.phase == Phase.ENDED) Transition(MachineState.IDLE, emptyList()) else noop(s)
268
+ }
269
+
270
+ private fun ring(s: MachineState, e: Event.Ring): Transition = when {
271
+ e.stale -> noop(s)
272
+
273
+ // Already showing this very ring (push + socket race): one surface.
274
+ s.phase == Phase.RINGING_IN && e.callId == s.callId -> noop(s)
275
+
276
+ // A fresh ring lands on idle — or over a lingering ended screen,
277
+ // which the new call simply replaces.
278
+ s.phase == Phase.IDLE || s.phase == Phase.ENDED -> Transition(
279
+ MachineState(
280
+ phase = Phase.RINGING_IN, callId = e.callId,
281
+ conversationId = e.conversationId, direction = Direction.INCOMING, media = e.media,
282
+ ),
283
+ listOf(Effect.ShowRing, Effect.RingtoneStart),
284
+ )
285
+
286
+ // Busy with another call, in any phase: auto-decline the newcomer,
287
+ // whose banner still offers Join later. (Contract §7, scenario s9.)
288
+ else -> Transition(s, listOf(Effect.HttpDeclineBusy(e.callId)))
289
+ }
290
+
291
+ private fun noop(s: MachineState) = Transition(s, emptyList())
292
+ }