@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.
- package/README.md +33 -2
- package/android/build.gradle +51 -0
- package/android/src/main/AndroidManifest.xml +40 -0
- package/android/src/main/java/dev/poolse/callcore/CallCoreRuntime.kt +257 -0
- package/android/src/main/java/dev/poolse/callcore/CallMachine.kt +292 -0
- package/android/src/main/java/dev/poolse/callcore/Ports.kt +94 -0
- package/android/src/main/java/dev/poolse/callcore/android/AndroidRingSurface.kt +183 -0
- package/android/src/main/java/dev/poolse/callcore/android/CallActionReceiver.kt +28 -0
- package/android/src/main/java/dev/poolse/callcore/android/CallCore.kt +166 -0
- package/android/src/main/java/dev/poolse/callcore/android/CallForegroundService.kt +80 -0
- package/android/src/main/java/dev/poolse/callcore/android/CallHttpClient.kt +151 -0
- package/android/src/main/java/dev/poolse/callcore/android/CallMessagingService.kt +99 -0
- package/android/src/main/java/dev/poolse/callcore/android/HandlerTimerPort.kt +42 -0
- package/android/src/main/java/dev/poolse/callcore/android/IncomingCallActivity.kt +209 -0
- package/android/src/main/java/dev/poolse/callcore/android/RingBundle.kt +42 -0
- package/android/src/main/java/dev/poolse/callcore/android/SnapshotJson.kt +57 -0
- package/android/src/main/java/expo/modules/poolsecalls/PoolseCallsModule.kt +109 -0
- package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitMediaPort.kt +108 -0
- package/dist/index.cjs +45 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +51 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/expo-module.config.json +6 -0
- package/package.json +39 -4
- package/src/expo-modules-core.d.ts +11 -0
- package/src/index.ts +111 -0
package/README.md
CHANGED
|
@@ -1,3 +1,34 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @poolse/react-native-calls
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
}
|