@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
@@ -0,0 +1,151 @@
1
+ package dev.poolse.callcore.android
2
+
3
+ import dev.poolse.callcore.CallSessionBundle
4
+ import dev.poolse.callcore.HttpPort
5
+ import dev.poolse.callcore.HttpResult
6
+ import java.io.IOException
7
+ import okhttp3.Call
8
+ import okhttp3.Callback
9
+ import okhttp3.MediaType.Companion.toMediaType
10
+ import okhttp3.OkHttpClient
11
+ import okhttp3.Request
12
+ import okhttp3.RequestBody.Companion.toRequestBody
13
+ import okhttp3.Response
14
+ import org.json.JSONObject
15
+
16
+ /**
17
+ * The HTTP adapter for the contract's three token doors
18
+ * (CONTRACT.md §4). Every call is a bare REST request with a bearer
19
+ * token — no session state, no SDK coupling — so the core can answer a
20
+ * call on a cold process with nothing but the grant from the push.
21
+ *
22
+ * Fire-and-forget methods (`decline`, `hangup`) retry transient
23
+ * failures a few times on OkHttp's dispatcher; request/response methods
24
+ * surface `HttpResult` to the runtime, which owns the retry policy for
25
+ * those (one retry, then media decides).
26
+ */
27
+ internal class CallHttpClient(
28
+ private val baseUrl: String,
29
+ private val client: OkHttpClient = defaultClient(),
30
+ ) : HttpPort {
31
+
32
+ override fun answer(callId: String, grant: String, cb: (HttpResult) -> Unit) =
33
+ post("calls/$callId/answer", grant, body = null, cb = cb)
34
+
35
+ override fun join(callId: String, sessionToken: String, cb: (HttpResult) -> Unit) =
36
+ post("calls/$callId/join", sessionToken, body = null, cb = cb)
37
+
38
+ override fun place(
39
+ conversationId: String,
40
+ media: String,
41
+ sessionToken: String,
42
+ cb: (HttpResult) -> Unit,
43
+ ) = post(
44
+ "conversations/$conversationId/calls",
45
+ sessionToken,
46
+ body = JSONObject().put("media", media),
47
+ cb = cb,
48
+ )
49
+
50
+ override fun reconnect(callId: String, controlToken: String, cb: (HttpResult) -> Unit) =
51
+ post("calls/$callId/reconnect", controlToken, body = null, cb = cb)
52
+
53
+ override fun decline(callId: String, auth: String, busy: Boolean) =
54
+ postBestEffort(
55
+ "calls/$callId/decline",
56
+ auth,
57
+ JSONObject().put("reason", if (busy) "busy" else "declined"),
58
+ )
59
+
60
+ override fun hangup(callId: String, controlToken: String) =
61
+ postBestEffort("calls/$callId/hangup", controlToken, body = null)
62
+
63
+ // ── request/response ────────────────────────────────────────────────
64
+
65
+ private fun post(path: String, token: String, body: JSONObject?, cb: (HttpResult) -> Unit) {
66
+ client.newCall(request(path, token, body)).enqueue(object : Callback {
67
+ override fun onFailure(call: Call, e: IOException) = cb(HttpResult.Timeout)
68
+
69
+ override fun onResponse(call: Call, response: Response) {
70
+ response.use {
71
+ val payload = it.body?.string()
72
+ cb(
73
+ when (it.code) {
74
+ in 200..299 -> runCatching { HttpResult.Ok(parseBundle(payload)) }
75
+ .getOrElse { HttpResult.Timeout }
76
+ 410 -> HttpResult.Gone
77
+ 401, 403 -> HttpResult.AuthFailed(reasonOf(payload, it.code))
78
+ 408, 429, in 500..599 -> HttpResult.Timeout
79
+ else -> HttpResult.AuthFailed("http_${it.code}")
80
+ },
81
+ )
82
+ }
83
+ }
84
+ })
85
+ }
86
+
87
+ // ── fire-and-forget with bounded retry ──────────────────────────────
88
+
89
+ private fun postBestEffort(path: String, token: String, body: JSONObject?, attempt: Int = 0) {
90
+ client.newCall(request(path, token, body)).enqueue(object : Callback {
91
+ override fun onFailure(call: Call, e: IOException) {
92
+ if (attempt < MAX_BEST_EFFORT_RETRIES) postBestEffort(path, token, body, attempt + 1)
93
+ }
94
+
95
+ override fun onResponse(call: Call, response: Response) {
96
+ response.use {
97
+ // 2xx and 4xx are both terminal here — the server either acted
98
+ // or will never act. Only transient 5xx/timeout retries.
99
+ if ((it.code == 408 || it.code == 429 || it.code in 500..599) &&
100
+ attempt < MAX_BEST_EFFORT_RETRIES
101
+ ) {
102
+ postBestEffort(path, token, body, attempt + 1)
103
+ }
104
+ }
105
+ }
106
+ })
107
+ }
108
+
109
+ private fun request(path: String, token: String, body: JSONObject?): Request =
110
+ Request.Builder()
111
+ .url("$baseUrl/v1/$path")
112
+ .header("Authorization", "Bearer $token")
113
+ .header("Accept", "application/json")
114
+ .post((body?.toString() ?: "{}").toRequestBody(JSON))
115
+ .build()
116
+
117
+ private fun parseBundle(payload: String?): CallSessionBundle {
118
+ val root = JSONObject(requireNotNull(payload) { "empty success body" })
119
+ val call = root.getJSONObject("call")
120
+ val livekit = root.getJSONObject("livekit")
121
+ return CallSessionBundle(
122
+ callId = call.getString("id"),
123
+ conversationId = call.getString("conversation_id"),
124
+ conversationName = call.optStringOrNull("conversation_name"),
125
+ media = call.optString("media", "audio"),
126
+ created = root.optBoolean("created", true),
127
+ livekitUrl = livekit.getString("url"),
128
+ livekitToken = livekit.getString("token"),
129
+ controlToken = root.getString("control_token"),
130
+ )
131
+ }
132
+
133
+ private fun reasonOf(payload: String?, code: Int): String =
134
+ payload?.let { runCatching { JSONObject(it).optStringOrNull("error") }.getOrNull() }
135
+ ?: "http_$code"
136
+
137
+ private fun JSONObject.optStringOrNull(key: String): String? =
138
+ if (isNull(key)) null else optString(key, "").ifEmpty { null }
139
+
140
+ private companion object {
141
+ const val MAX_BEST_EFFORT_RETRIES = 3
142
+ val JSON = "application/json; charset=utf-8".toMediaType()
143
+
144
+ fun defaultClient(): OkHttpClient = OkHttpClient.Builder()
145
+ // A call action must not hang the ring surface. Short, deliberate
146
+ // ceilings; the runtime retries answer/place once on top of these.
147
+ .callTimeout(java.time.Duration.ofSeconds(12))
148
+ .connectTimeout(java.time.Duration.ofSeconds(6))
149
+ .build()
150
+ }
151
+ }
@@ -0,0 +1,99 @@
1
+ package dev.poolse.callcore.android
2
+
3
+ import com.google.firebase.messaging.FirebaseMessagingService
4
+ import com.google.firebase.messaging.RemoteMessage
5
+ import dev.poolse.callcore.RingContext
6
+
7
+ /**
8
+ * Native FCM intake — the point of the whole architecture. A call data
9
+ * message is handled here, in native code, the instant it arrives: no
10
+ * headless JS, no boot race, no millisecond budget spent waiting for
11
+ * Hermes. Non-call messages are forwarded to the host app's own
12
+ * messaging service (declared via metadata), so chat notifications and
13
+ * everything else keep working unchanged.
14
+ *
15
+ * Contract: data-only messages with `type: "call"` (CONTRACT.md §5).
16
+ */
17
+ class CallMessagingService : FirebaseMessagingService() {
18
+
19
+ override fun onNewToken(token: String) {
20
+ // Registration is the app's concern (it knows the session); it reads
21
+ // the fresh token from the bridge's token API. Nothing to do here.
22
+ forwardToHost { it.onNewToken(token) }
23
+ }
24
+
25
+ override fun onMessageReceived(message: RemoteMessage) {
26
+ val data = message.data
27
+ if (data["type"] != "call") {
28
+ forwardToHost { it.onMessageReceived(message) }
29
+ return
30
+ }
31
+
32
+ when (data["event"]) {
33
+ "ring" -> data.toRingContext()?.let { CallCore.get(this).onRing(it) }
34
+ "ring_ended" -> {
35
+ val callId = data["call_id"] ?: return
36
+ CallCore.get(this).onRingEnded(callId, data["reason"] ?: "finished")
37
+ }
38
+ }
39
+ }
40
+
41
+ private fun Map<String, String>.toRingContext(): RingContext? {
42
+ val callId = this["call_id"] ?: return null
43
+ val conversationId = this["conversation_id"] ?: return null
44
+ val grant = this["grant"] ?: return null
45
+ return RingContext(
46
+ callId = callId,
47
+ conversationId = conversationId,
48
+ conversationName = this["conversation_name"]?.ifEmpty { null },
49
+ callerName = this["caller_name"]?.ifEmpty { null },
50
+ media = this["media"] ?: "audio",
51
+ grant = grant,
52
+ ringDeadlineEpochMs = this["ring_deadline"]?.toLongOrNull() ?: 0L,
53
+ )
54
+ }
55
+
56
+ /**
57
+ * Forward to the host app's declared FirebaseMessagingService so its
58
+ * own (non-call) pushes still arrive. The host names its class in the
59
+ * manifest: `<meta-data android:name="dev.poolse.callcore.delegate"
60
+ * android:value="com.app.MyMessagingService"/>`.
61
+ */
62
+ private fun forwardToHost(block: (FirebaseMessagingService) -> Unit) {
63
+ val name = runCatching {
64
+ packageManager.getServiceInfo(
65
+ android.content.ComponentName(this, CallMessagingService::class.java),
66
+ android.content.pm.PackageManager.GET_META_DATA,
67
+ ).metaData?.getString(DELEGATE_META)
68
+ }.getOrNull() ?: return
69
+
70
+ runCatching {
71
+ val delegate = Class.forName(name).getDeclaredConstructor().newInstance()
72
+ if (delegate is FirebaseMessagingService) {
73
+ // The delegate needs our context to do anything useful; FCM's
74
+ // base class resolves it from the running service, so we attach
75
+ // ours. Kept behind runCatching: a misconfigured delegate must
76
+ // never crash the call path.
77
+ delegate.attachForDelegation(this)
78
+ block(delegate)
79
+ }
80
+ }
81
+ }
82
+
83
+ private companion object {
84
+ const val DELEGATE_META = "dev.poolse.callcore.delegate"
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Minimal context attach so a delegated [FirebaseMessagingService] can
90
+ * run outside its own lifecycle. Uses the base ContextWrapper hook.
91
+ */
92
+ private fun FirebaseMessagingService.attachForDelegation(host: FirebaseMessagingService) {
93
+ runCatching {
94
+ val m = android.content.ContextWrapper::class.java
95
+ .getDeclaredMethod("attachBaseContext", android.content.Context::class.java)
96
+ m.isAccessible = true
97
+ m.invoke(this, host.applicationContext)
98
+ }
99
+ }
@@ -0,0 +1,42 @@
1
+ package dev.poolse.callcore.android
2
+
3
+ import android.os.Handler
4
+ import android.os.Looper
5
+ import dev.poolse.callcore.TimerPort
6
+
7
+ /**
8
+ * The real [TimerPort]: a single main-looper Handler keyed by token, so
9
+ * the runtime's timers (ring deadline, ended-linger, reconnect budget)
10
+ * fire on one predictable thread and rescheduling a key cleanly
11
+ * replaces its pending callback.
12
+ *
13
+ * [nowMs] is wall-clock epoch, because the one value it is compared
14
+ * against — the push's `ring_deadline` — is epoch, minted by the
15
+ * server (CONTRACT.md §5.1). The actual scheduling delay is handed to
16
+ * [Handler.postDelayed], which counts down monotonically regardless of
17
+ * clock changes, so a mid-ring NTP correction shifts the deadline by at
18
+ * most the drift, never breaks it.
19
+ */
20
+ internal class HandlerTimerPort(
21
+ private val handler: Handler = Handler(Looper.getMainLooper()),
22
+ ) : TimerPort {
23
+
24
+ private val pending = mutableMapOf<String, Runnable>()
25
+
26
+ override fun nowMs(): Long = System.currentTimeMillis()
27
+
28
+ override fun schedule(key: String, delayMs: Long, fire: () -> Unit) {
29
+ cancel(key)
30
+ val runnable = Runnable {
31
+ synchronized(pending) { pending.remove(key) }
32
+ fire()
33
+ }
34
+ synchronized(pending) { pending[key] = runnable }
35
+ handler.postDelayed(runnable, delayMs.coerceAtLeast(0))
36
+ }
37
+
38
+ override fun cancel(key: String) {
39
+ val runnable = synchronized(pending) { pending.remove(key) } ?: return
40
+ handler.removeCallbacks(runnable)
41
+ }
42
+ }
@@ -0,0 +1,209 @@
1
+ package dev.poolse.callcore.android
2
+
3
+ import android.animation.ValueAnimator
4
+ import android.app.Activity
5
+ import android.graphics.Color
6
+ import android.graphics.Typeface
7
+ import android.graphics.drawable.GradientDrawable
8
+ import android.os.Build
9
+ import android.os.Bundle
10
+ import android.view.Gravity
11
+ import android.view.View
12
+ import android.view.WindowManager
13
+ import android.view.animation.LinearInterpolator
14
+ import android.widget.FrameLayout
15
+ import android.widget.LinearLayout
16
+ import android.widget.TextView
17
+ import dev.poolse.callcore.android.AndroidRingSurface.Companion.EXTRA_RING
18
+
19
+ /**
20
+ * The full-screen ring a locked phone shows. Native and dependency-free
21
+ * so it is on screen within the OS's full-screen-intent budget, long
22
+ * before any JS could load — carrying the call scene's look (dark
23
+ * ground, brand glow, a breathing ring) in plain views. The buttons
24
+ * resolve into [CallCore], the same entry point every other surface
25
+ * uses; answering brings the app forward over the keyguard.
26
+ */
27
+ class IncomingCallActivity : Activity() {
28
+
29
+ private var pulse: ValueAnimator? = null
30
+ private val density by lazy { resources.displayMetrics.density }
31
+ private fun px(v: Int): Int = (v * density).toInt()
32
+
33
+ override fun onCreate(savedInstanceState: Bundle?) {
34
+ super.onCreate(savedInstanceState)
35
+ showOverKeyguard()
36
+
37
+ val ring = intent.getBundleExtra(EXTRA_RING)?.toRingContext()
38
+ if (ring == null) { finish(); return }
39
+
40
+ val title = ring.conversationName ?: ring.callerName ?: "Incoming call"
41
+ val body = if (ring.media == "video") "Incoming video call" else "Incoming call"
42
+ setContentView(buildScene(title, body, ring))
43
+ }
44
+
45
+ override fun onDestroy() {
46
+ pulse?.cancel()
47
+ pulse = null
48
+ super.onDestroy()
49
+ }
50
+
51
+ private fun showOverKeyguard() {
52
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
53
+ setShowWhenLocked(true)
54
+ setTurnScreenOn(true)
55
+ } else {
56
+ @Suppress("DEPRECATION")
57
+ window.addFlags(
58
+ WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
59
+ WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON,
60
+ )
61
+ }
62
+ window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
63
+ }
64
+
65
+ private fun buildScene(title: String, body: String, ring: dev.poolse.callcore.RingContext): View {
66
+ val root = FrameLayout(this).apply { setBackgroundColor(Color.parseColor("#0B0B14")) }
67
+
68
+ root.addView(
69
+ View(this).apply {
70
+ background = GradientDrawable(
71
+ GradientDrawable.Orientation.TOP_BOTTOM,
72
+ intArrayOf(Color.parseColor("#875B54E8"), Color.parseColor("#000B0B14")),
73
+ ).apply {
74
+ gradientType = GradientDrawable.RADIAL_GRADIENT
75
+ gradientRadius = 560 * density
76
+ setGradientCenter(0.5f, 0.3f)
77
+ }
78
+ },
79
+ FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT,
80
+ )
81
+
82
+ val column = LinearLayout(this).apply {
83
+ orientation = LinearLayout.VERTICAL
84
+ gravity = Gravity.CENTER_HORIZONTAL
85
+ setPadding(px(32), px(110), px(32), px(72))
86
+ }
87
+
88
+ val stage = FrameLayout(this).apply {
89
+ layoutParams = LinearLayout.LayoutParams(px(176), px(176)).apply { bottomMargin = px(30) }
90
+ }
91
+ val ringView = View(this).apply {
92
+ background = GradientDrawable().apply {
93
+ shape = GradientDrawable.OVAL
94
+ setColor(Color.TRANSPARENT)
95
+ setStroke(px(2), Color.parseColor("#805B54E8"))
96
+ }
97
+ }
98
+ stage.addView(ringView, FrameLayout.LayoutParams(px(176), px(176), Gravity.CENTER))
99
+ stage.addView(
100
+ TextView(this).apply {
101
+ text = title.trim().take(1).uppercase()
102
+ textSize = 48f
103
+ setTextColor(Color.WHITE)
104
+ typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
105
+ gravity = Gravity.CENTER
106
+ background = GradientDrawable(
107
+ GradientDrawable.Orientation.TL_BR,
108
+ intArrayOf(Color.parseColor("#7D76FF"), Color.parseColor("#5B54E8")),
109
+ ).apply { shape = GradientDrawable.OVAL }
110
+ },
111
+ FrameLayout.LayoutParams(px(124), px(124), Gravity.CENTER),
112
+ )
113
+
114
+ val name = TextView(this).apply {
115
+ text = title
116
+ textSize = 32f
117
+ setTextColor(Color.WHITE)
118
+ typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
119
+ gravity = Gravity.CENTER
120
+ }
121
+ val sub = TextView(this).apply {
122
+ text = body
123
+ textSize = 15f
124
+ setTextColor(Color.parseColor("#B8B8C6"))
125
+ gravity = Gravity.CENTER
126
+ background = GradientDrawable().apply {
127
+ cornerRadius = 999f
128
+ setColor(Color.parseColor("#14FFFFFF"))
129
+ }
130
+ setPadding(px(16), px(8), px(16), px(8))
131
+ layoutParams = LinearLayout.LayoutParams(wrap(), wrap()).apply { topMargin = px(12) }
132
+ }
133
+
134
+ val buttons = LinearLayout(this).apply {
135
+ orientation = LinearLayout.HORIZONTAL
136
+ gravity = Gravity.CENTER
137
+ layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, wrap())
138
+ }
139
+ val decline = actionButton("Decline", "✕", "#EF4444") {
140
+ CallCore.get(this).onUserDecline(ring); finish()
141
+ }
142
+ val accept = actionButton("Answer", "✓", "#22C55E") {
143
+ CallCore.get(this).onUserAnswer(ring); finish()
144
+ }
145
+ buttons.addView(decline, LinearLayout.LayoutParams(wrap(), wrap()).apply { rightMargin = px(52) })
146
+ buttons.addView(accept, LinearLayout.LayoutParams(wrap(), wrap()).apply { leftMargin = px(52) })
147
+
148
+ column.addView(stage)
149
+ column.addView(name)
150
+ column.addView(sub)
151
+ column.addView(View(this).apply { layoutParams = LinearLayout.LayoutParams(0, 0, 1f) })
152
+ column.addView(buttons)
153
+ root.addView(
154
+ column,
155
+ FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT),
156
+ )
157
+
158
+ pulse = ValueAnimator.ofFloat(0f, 1f).apply {
159
+ duration = 1800
160
+ repeatCount = ValueAnimator.INFINITE
161
+ interpolator = LinearInterpolator()
162
+ addUpdateListener {
163
+ val t = it.animatedValue as Float
164
+ ringView.scaleX = 1f + 0.28f * t
165
+ ringView.scaleY = 1f + 0.28f * t
166
+ ringView.alpha = 1f - t
167
+ }
168
+ start()
169
+ }
170
+ return root
171
+ }
172
+
173
+ private fun actionButton(
174
+ label: String,
175
+ glyph: String,
176
+ color: String,
177
+ onClick: () -> Unit,
178
+ ): LinearLayout = LinearLayout(this).apply {
179
+ orientation = LinearLayout.VERTICAL
180
+ gravity = Gravity.CENTER_HORIZONTAL
181
+ addView(
182
+ TextView(this@IncomingCallActivity).apply {
183
+ text = glyph
184
+ textSize = 30f
185
+ setTextColor(Color.WHITE)
186
+ gravity = Gravity.CENTER
187
+ background = GradientDrawable().apply {
188
+ shape = GradientDrawable.OVAL
189
+ setColor(Color.parseColor(color))
190
+ }
191
+ layoutParams = LinearLayout.LayoutParams(px(82), px(82))
192
+ isClickable = true
193
+ isFocusable = true
194
+ setOnClickListener { onClick() }
195
+ },
196
+ )
197
+ addView(
198
+ TextView(this@IncomingCallActivity).apply {
199
+ text = label
200
+ textSize = 14f
201
+ setTextColor(Color.parseColor("#B8B8C6"))
202
+ gravity = Gravity.CENTER
203
+ layoutParams = LinearLayout.LayoutParams(wrap(), wrap()).apply { topMargin = px(10) }
204
+ },
205
+ )
206
+ }
207
+
208
+ private fun wrap() = LinearLayout.LayoutParams.WRAP_CONTENT
209
+ }
@@ -0,0 +1,42 @@
1
+ package dev.poolse.callcore.android
2
+
3
+ import android.os.Bundle
4
+ import dev.poolse.callcore.RingContext
5
+
6
+ /**
7
+ * [RingContext] ⇄ [Bundle]. A ring crosses process boundaries as
8
+ * PendingIntent extras (the notification lives on even when our process
9
+ * is dead), so it must round-trip through a Bundle losslessly.
10
+ */
11
+ internal fun RingContext.toBundle(): Bundle = Bundle().apply {
12
+ putString(K_CALL_ID, callId)
13
+ putString(K_CONVERSATION_ID, conversationId)
14
+ putString(K_CONVERSATION_NAME, conversationName)
15
+ putString(K_CALLER_NAME, callerName)
16
+ putString(K_MEDIA, media)
17
+ putString(K_GRANT, grant)
18
+ putLong(K_DEADLINE, ringDeadlineEpochMs)
19
+ }
20
+
21
+ internal fun Bundle.toRingContext(): RingContext? {
22
+ val callId = getString(K_CALL_ID) ?: return null
23
+ val conversationId = getString(K_CONVERSATION_ID) ?: return null
24
+ val grant = getString(K_GRANT) ?: return null
25
+ return RingContext(
26
+ callId = callId,
27
+ conversationId = conversationId,
28
+ conversationName = getString(K_CONVERSATION_NAME),
29
+ callerName = getString(K_CALLER_NAME),
30
+ media = getString(K_MEDIA) ?: "audio",
31
+ grant = grant,
32
+ ringDeadlineEpochMs = getLong(K_DEADLINE),
33
+ )
34
+ }
35
+
36
+ private const val K_CALL_ID = "callId"
37
+ private const val K_CONVERSATION_ID = "conversationId"
38
+ private const val K_CONVERSATION_NAME = "conversationName"
39
+ private const val K_CALLER_NAME = "callerName"
40
+ private const val K_MEDIA = "media"
41
+ private const val K_GRANT = "grant"
42
+ private const val K_DEADLINE = "ringDeadline"
@@ -0,0 +1,57 @@
1
+ package dev.poolse.callcore.android
2
+
3
+ import dev.poolse.callcore.Direction
4
+ import dev.poolse.callcore.MachineState
5
+ import dev.poolse.callcore.Phase
6
+
7
+ /**
8
+ * The one place a [MachineState] becomes the contract's `Snapshot`
9
+ * (CONTRACT.md §8). Both the Android bridge and — mirrored — the JS
10
+ * machine produce this exact shape, so a UI reads identically wherever
11
+ * it runs. Returns `null` for idle: the bridge maps that to a cleared
12
+ * call state.
13
+ *
14
+ * The participant roster is merged in by the bridge from the media
15
+ * adapter's reports; the machine itself does not carry it.
16
+ */
17
+ internal fun MachineState.toSnapshot(
18
+ conversationName: String? = null,
19
+ participants: List<Map<String, Any>> = emptyList(),
20
+ audioRoute: String = "earpiece",
21
+ mutedSelf: Boolean = false,
22
+ cameraOn: Boolean = false,
23
+ ): Map<String, Any?>? {
24
+ if (phase == Phase.IDLE) return null
25
+ return mapOf(
26
+ "cv" to 1,
27
+ "callId" to callId,
28
+ "conversationId" to conversationId,
29
+ "conversationName" to conversationName,
30
+ "direction" to (direction?.wire ?: Direction.INCOMING.wire),
31
+ "media" to media,
32
+ "phase" to phase.wire,
33
+ "reason" to reason?.wire,
34
+ "participants" to participants,
35
+ "audioRoute" to audioRoute,
36
+ "mutedSelf" to mutedSelf,
37
+ "cameraOn" to cameraOn,
38
+ )
39
+ }
40
+
41
+ private val Phase.wire: String
42
+ get() = when (this) {
43
+ Phase.IDLE -> "idle"
44
+ Phase.RINGING_IN -> "ringing_in"
45
+ Phase.RINGING_OUT -> "ringing_out"
46
+ Phase.CONNECTING -> "connecting"
47
+ Phase.ACTIVE -> "active"
48
+ Phase.RECONNECTING -> "reconnecting"
49
+ Phase.ENDED -> "ended"
50
+ }
51
+
52
+ private val Direction.wire: String
53
+ get() = when (this) {
54
+ Direction.INCOMING -> "incoming"
55
+ Direction.OUTGOING -> "outgoing"
56
+ Direction.JOINED -> "joined"
57
+ }