@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
@@ -1,30 +1,183 @@
1
1
  package dev.poolse.callcore.android
2
2
 
3
+ import android.Manifest
3
4
  import android.app.Notification
5
+ import android.app.PendingIntent
4
6
  import android.app.Service
5
7
  import android.content.Context
6
8
  import android.content.Intent
9
+ import android.content.pm.PackageManager
7
10
  import android.content.pm.ServiceInfo
8
11
  import android.os.Build
12
+ import android.os.Handler
9
13
  import android.os.IBinder
14
+ import android.os.Looper
15
+ import android.util.Log
10
16
  import androidx.core.app.NotificationCompat
17
+ import androidx.core.content.ContextCompat
11
18
  import dev.poolse.callcore.CallSessionPort
12
19
 
13
20
  /**
14
- * Keeps a live call alive when the app is backgrounded or killed:
15
- * a `phoneCall|microphone` foreground service that owns the ongoing
16
- * notification for the call's whole lifetime. Android will not reclaim
17
- * the process while this runs, so media keeps flowing with the screen
18
- * off.
21
+ * Keeps the process alive while a call needs it:
19
22
  *
20
- * [CallSessionPort] is driven by the runtime (CALL_SESSION_START/STOP);
21
- * the service itself holds no call logic — it is the OS contract that
22
- * says "a call is happening here".
23
+ * - **ring** mode (`phoneCall` type only) for the length of an incoming
24
+ * ring, so an OEM freezer (Samsung, Android 14 cached-app freezer)
25
+ * does not kill the ringtone and the ring-deadline timer of a closed
26
+ * app;
27
+ * - **call** mode (`phoneCall|microphone`) for a live call's whole
28
+ * lifetime, so media keeps flowing with the screen off.
29
+ *
30
+ * [CallSessionPort] is driven by the runtime (CALL_SESSION_START/STOP),
31
+ * ring mode by [CallCore]'s state reconcile; the service itself holds no
32
+ * call logic — it is the OS contract that says "a call is happening".
33
+ *
34
+ * Robustness rules, each one a crash seen in the field:
35
+ * - every `startForegroundService` MUST be answered by `startForeground`,
36
+ * even if the call already ended before `onStartCommand` ran — so a
37
+ * stop never calls `stopService` on a not-yet-started service; the
38
+ * service starts foreground and then stops itself;
39
+ * - the microphone type is claimed only with RECORD_AUDIO granted
40
+ * (Android 14 throws otherwise);
41
+ * - `startForeground` failures are caught and the service stops, never
42
+ * taking the app down with it.
23
43
  */
24
44
  class CallForegroundService : Service() {
25
45
 
26
46
  override fun onBind(intent: Intent?): IBinder? = null
27
47
 
48
+ override fun onCreate() {
49
+ super.onCreate()
50
+ instance = this
51
+ }
52
+
53
+ override fun onDestroy() {
54
+ if (instance === this) instance = null
55
+ super.onDestroy()
56
+ }
57
+
58
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
59
+ pendingStarts = (pendingStarts - 1).coerceAtLeast(0)
60
+ reconcile(desired)
61
+ // Sticky-less: if the system kills us, the call is already gone with
62
+ // the process; a relaunched empty service would be a ghost.
63
+ return START_NOT_STICKY
64
+ }
65
+
66
+ /** Bring the service in line with [want]; null = stop (after honouring the start). */
67
+ internal fun reconcile(want: Desired?) {
68
+ // Always answer the start with startForeground — a placeholder ring
69
+ // notification if the call is already gone.
70
+ val ok = tryStartForeground(want ?: Desired(Mode.RING, "", "audio"))
71
+ if (want == null || !ok) {
72
+ if (pendingStarts == 0) stopNow()
73
+ }
74
+ }
75
+
76
+ internal fun stopNow() {
77
+ runCatching { stopForeground(STOP_FOREGROUND_REMOVE) }
78
+ shownId = null
79
+ stopSelf()
80
+ }
81
+
82
+ /** The notification id we are currently foreground with. */
83
+ private var shownId: Int? = null
84
+
85
+ /**
86
+ * Start (or switch) foreground. If the full type is refused — the
87
+ * microphone type can be, on Android 14, when the app is not in a
88
+ * while-in-use state — fall back to phoneCall only rather than stop:
89
+ * stopping dropped the ring-mode service that was already working.
90
+ * Stop only when even that fails.
91
+ */
92
+ private fun tryStartForeground(want: Desired): Boolean {
93
+ val (id, notification) = notificationFor(want)
94
+ val full = typeFor(want.mode)
95
+ val attempts = if (full != ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL) {
96
+ listOf(full, ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL)
97
+ } else listOf(full)
98
+ for (type in attempts) {
99
+ try {
100
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) startForeground(id, notification, type)
101
+ else startForeground(id, notification)
102
+ // Switching id (ring → call) leaves the old one as a plain
103
+ // notification; the ring is over, so remove it.
104
+ shownId?.takeIf { it != id }?.let { old ->
105
+ runCatching { getSystemService(android.app.NotificationManager::class.java).cancel(old) }
106
+ }
107
+ shownId = id
108
+ return true
109
+ } catch (t: Throwable) {
110
+ // ForegroundServiceStartNotAllowedException, SecurityException
111
+ // (missing type permission), InvalidForegroundServiceTypeException…
112
+ Log.e(TAG, "startForeground(${want.mode}, type=$type) failed", t)
113
+ }
114
+ }
115
+ return false
116
+ }
117
+
118
+ /**
119
+ * Ring mode wears the CallStyle ring notification itself (same id),
120
+ * so a ringing phone shows ONE notification, not the ring plus an
121
+ * "Incoming call" service notice. Falls back to our own if the ring
122
+ * surface has not posted yet.
123
+ */
124
+ private fun notificationFor(want: Desired): Pair<Int, Notification> {
125
+ if (want.mode == Mode.RING) {
126
+ AndroidRingSurface.shownRing?.let { (callId, n) ->
127
+ if (callId == want.callId) {
128
+ return with(AndroidRingSurface) { callId.notificationId() } to n
129
+ }
130
+ }
131
+ }
132
+ return NOTIFICATION_ID to buildNotification(want)
133
+ }
134
+
135
+ private fun typeFor(mode: Mode): Int {
136
+ var type = ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL
137
+ if (mode == Mode.CALL && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && micGranted()) {
138
+ type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
139
+ }
140
+ return type
141
+ }
142
+
143
+ private fun micGranted() =
144
+ ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
145
+ PackageManager.PERMISSION_GRANTED
146
+
147
+ private fun buildNotification(want: Desired): Notification {
148
+ ensureChannel()
149
+ val config = CallCore.peekConfig()
150
+ val title: String
151
+ val text: String
152
+ if (want.mode == Mode.RING) {
153
+ title = "Incoming call"; text = "Ringing…"
154
+ } else {
155
+ title = "Ongoing call"; text = if (want.media == "video") "Video call" else "Voice call"
156
+ }
157
+ val builder = NotificationCompat.Builder(this, CHANNEL_ID)
158
+ .setSmallIcon(config?.smallIcon ?: android.R.drawable.sym_call_outgoing)
159
+ .setContentTitle(title)
160
+ .setContentText(text)
161
+ .setOngoing(true)
162
+ .setCategory(NotificationCompat.CATEGORY_CALL)
163
+ .setPriority(NotificationCompat.PRIORITY_LOW)
164
+ // Tapping the ongoing-call notification returns to the call.
165
+ if (want.mode == Mode.CALL) launchIntent(want.callId)?.let(builder::setContentIntent)
166
+ return builder.build()
167
+ }
168
+
169
+ private fun launchIntent(callId: String): PendingIntent? {
170
+ val launch = packageManager.getLaunchIntentForPackage(packageName) ?: return null
171
+ launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
172
+ launch.putExtra(CallCore.EXTRA_LAUNCH_CALL, callId)
173
+ return PendingIntent.getActivity(
174
+ this,
175
+ NOTIFICATION_ID,
176
+ launch,
177
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
178
+ )
179
+ }
180
+
28
181
  // The ongoing-call notification has its own channel, created here so it
29
182
  // exists on an OUTGOING call too — the incoming ring surface's channel
30
183
  // is only made when a ring shows, so a placed call would otherwise post
@@ -42,58 +195,86 @@ class CallForegroundService : Service() {
42
195
  )
43
196
  }
44
197
 
45
- override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
46
- val media = intent?.getStringExtra(EXTRA_MEDIA) ?: "audio"
47
- startInForeground(media)
48
- // Sticky-less: if the system kills us, the call is already gone with
49
- // the process; a relaunched empty service would be a ghost.
50
- return START_NOT_STICKY
51
- }
52
-
53
- private fun startInForeground(media: String) {
54
- ensureChannel()
55
- val config = CallCore.peekConfig()
56
- val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
57
- .setSmallIcon(config?.smallIcon ?: android.R.drawable.sym_call_outgoing)
58
- .setContentTitle("Ongoing call")
59
- .setContentText(if (media == "video") "Video call" else "Voice call")
60
- .setOngoing(true)
61
- .setCategory(NotificationCompat.CATEGORY_CALL)
62
- .setPriority(NotificationCompat.PRIORITY_LOW)
63
- .build()
64
-
65
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
66
- var type = ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
67
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
68
- type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL
69
- }
70
- startForeground(NOTIFICATION_ID, notification, type)
71
- } else {
72
- startForeground(NOTIFICATION_ID, notification)
73
- }
74
- }
198
+ internal enum class Mode { RING, CALL }
199
+ internal data class Desired(val mode: Mode, val callId: String, val media: String)
75
200
 
201
+ /**
202
+ * All companion state is touched on the main thread only (the
203
+ * runtime's thread and the service lifecycle's), so no locking.
204
+ */
76
205
  companion object : CallSessionPort {
206
+ private const val TAG = "PoolseCallFgs"
77
207
  private const val NOTIFICATION_ID = 0x9C0FE
78
- private const val EXTRA_MEDIA = "media"
79
208
  private const val CHANNEL_ID = "poolse_ongoing_call"
80
209
 
81
210
  private lateinit var appContext: Context
211
+ private val main by lazy { Handler(Looper.getMainLooper()) }
212
+
213
+ private var desired: Desired? = null
214
+ private var instance: CallForegroundService? = null
215
+ /** startForegroundService calls not yet delivered to onStartCommand. */
216
+ private var pendingStarts = 0
82
217
 
83
218
  /** Wired once by [CallCore] so the port can start/stop the service. */
84
219
  fun attach(context: Context) { appContext = context.applicationContext }
85
220
 
86
- override fun start(callId: String, media: String) {
87
- val intent = Intent(appContext, CallForegroundService::class.java).putExtra(EXTRA_MEDIA, media)
88
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
89
- appContext.startForegroundService(intent)
90
- } else {
91
- appContext.startService(intent)
221
+ override fun start(callId: String, media: String) = onMain {
222
+ desired = Desired(Mode.CALL, callId, media)
223
+ launch()
224
+ }
225
+
226
+ override fun stop() = onMain {
227
+ desired = null
228
+ // Only a started instance with nothing pending may stop now; a
229
+ // pending start stops itself after startForeground.
230
+ if (pendingStarts == 0) instance?.stopNow()
231
+ }
232
+
233
+ /** Ring mode for [callId]; a no-op while a call holds the service. */
234
+ fun startRing(callId: String) = onMain {
235
+ val d = desired
236
+ if (d?.mode == Mode.CALL) return@onMain
237
+ if (d?.mode == Mode.RING && d.callId == callId) return@onMain
238
+ desired = Desired(Mode.RING, callId, "audio")
239
+ launch()
240
+ }
241
+
242
+ /**
243
+ * The call's media became known/changed (a join's bundle said video):
244
+ * refresh the notification and type in place — no new start needed.
245
+ */
246
+ fun updateCall(callId: String, media: String) = onMain {
247
+ val d = desired
248
+ if (d?.mode != Mode.CALL || (d.callId == callId && d.media == media)) return@onMain
249
+ desired = d.copy(callId = callId, media = media)
250
+ if (pendingStarts == 0) instance?.reconcile(desired)
251
+ }
252
+
253
+ /** Leave ring mode — never touches a call that took the service over. */
254
+ fun stopRing() = onMain {
255
+ if (desired?.mode == Mode.RING) stop()
256
+ }
257
+
258
+ private fun launch() {
259
+ if (!::appContext.isInitialized) return
260
+ val intent = Intent(appContext, CallForegroundService::class.java)
261
+ try {
262
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
263
+ appContext.startForegroundService(intent)
264
+ } else {
265
+ appContext.startService(intent)
266
+ }
267
+ pendingStarts++
268
+ } catch (t: Throwable) {
269
+ // Background-start restriction (no FCM/user exemption at hand).
270
+ // A running instance can still switch type in place.
271
+ Log.w(TAG, "could not start the call service", t)
272
+ instance?.reconcile(desired)
92
273
  }
93
274
  }
94
275
 
95
- override fun stop() {
96
- appContext.stopService(Intent(appContext, CallForegroundService::class.java))
276
+ private inline fun onMain(crossinline block: () -> Unit) {
277
+ if (Looper.myLooper() == Looper.getMainLooper()) block() else main.post { block() }
97
278
  }
98
279
  }
99
280
  }
@@ -25,7 +25,12 @@ import org.json.JSONObject
25
25
  * those (one retry, then media decides).
26
26
  */
27
27
  internal class CallHttpClient(
28
- private val baseUrl: String,
28
+ /**
29
+ * Read per request, not captured: a later `configure()` with another
30
+ * API origin (staging ↔ prod switch) takes effect on the next call
31
+ * without rebuilding the process-wide runtime.
32
+ */
33
+ private val baseUrl: () -> String,
29
34
  private val client: OkHttpClient = defaultClient(),
30
35
  ) : HttpPort {
31
36
 
@@ -47,8 +52,8 @@ internal class CallHttpClient(
47
52
  cb = cb,
48
53
  )
49
54
 
50
- override fun reconnect(callId: String, controlToken: String, cb: (HttpResult) -> Unit) =
51
- post("calls/$callId/reconnect", controlToken, body = null, cb = cb)
55
+ override fun reconnect(current: CallSessionBundle, cb: (HttpResult) -> Unit) =
56
+ post("calls/${current.callId}/reconnect", current.controlToken, body = null, current = current, cb = cb)
52
57
 
53
58
  override fun decline(callId: String, auth: String, busy: Boolean) =
54
59
  postBestEffort(
@@ -62,24 +67,26 @@ internal class CallHttpClient(
62
67
 
63
68
  // ── request/response ────────────────────────────────────────────────
64
69
 
65
- private fun post(path: String, token: String, body: JSONObject?, cb: (HttpResult) -> Unit) {
66
- client.newCall(request(path, token, body)).enqueue(object : Callback {
70
+ private fun post(
71
+ path: String,
72
+ token: String,
73
+ body: JSONObject?,
74
+ current: CallSessionBundle? = null,
75
+ cb: (HttpResult) -> Unit,
76
+ ) {
77
+ val req = runCatching { request(path, token, body) }.getOrElse {
78
+ // A malformed base URL is a configuration error, not a timeout:
79
+ // retrying cannot fix it.
80
+ cb(HttpResult.Failed("bad_url")); return
81
+ }
82
+ client.newCall(req).enqueue(object : Callback {
67
83
  override fun onFailure(call: Call, e: IOException) = cb(HttpResult.Timeout)
68
84
 
69
85
  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
- }
86
+ val result = runCatching {
87
+ response.use { classify(it.code, it.body?.string(), current) }
88
+ }.getOrElse { HttpResult.Timeout } // body read failed mid-stream: transient
89
+ cb(result)
83
90
  }
84
91
  })
85
92
  }
@@ -87,7 +94,8 @@ internal class CallHttpClient(
87
94
  // ── fire-and-forget with bounded retry ──────────────────────────────
88
95
 
89
96
  private fun postBestEffort(path: String, token: String, body: JSONObject?, attempt: Int = 0) {
90
- client.newCall(request(path, token, body)).enqueue(object : Callback {
97
+ val req = runCatching { request(path, token, body) }.getOrNull() ?: return
98
+ client.newCall(req).enqueue(object : Callback {
91
99
  override fun onFailure(call: Call, e: IOException) {
92
100
  if (attempt < MAX_BEST_EFFORT_RETRIES) postBestEffort(path, token, body, attempt + 1)
93
101
  }
@@ -108,44 +116,87 @@ internal class CallHttpClient(
108
116
 
109
117
  private fun request(path: String, token: String, body: JSONObject?): Request =
110
118
  Request.Builder()
111
- .url("$baseUrl/v1/$path")
119
+ .url("${baseUrl().trimEnd('/')}/v1/$path")
112
120
  .header("Authorization", "Bearer $token")
113
121
  .header("Accept", "application/json")
114
122
  .post((body?.toString() ?: "{}").toRequestBody(JSON))
115
123
  .build()
116
124
 
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 {
125
+ internal companion object {
141
126
  const val MAX_BEST_EFFORT_RETRIES = 3
142
127
  val JSON = "application/json; charset=utf-8".toMediaType()
143
128
 
144
129
  fun defaultClient(): OkHttpClient = OkHttpClient.Builder()
145
130
  // 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))
131
+ // ceilings; the runtime retries answer/place once on top of these,
132
+ // and both attempts fit inside its 20 s CONNECTING watchdog.
133
+ .callTimeout(java.time.Duration.ofSeconds(9))
148
134
  .connectTimeout(java.time.Duration.ofSeconds(6))
149
135
  .build()
136
+
137
+ /**
138
+ * Status + body → [HttpResult] (CONTRACT.md §4). Pure, so it is unit
139
+ * tested without a network.
140
+ *
141
+ * - 2xx that will not parse is [HttpResult.Failed], NOT a timeout:
142
+ * retrying an identical request returns the identical body, and
143
+ * treating it as transient left the UI stuck in connecting.
144
+ * - 404/410: the call (or this device's place in it) is gone.
145
+ * - 401/403: auth refused — `failed`, never `finished`.
146
+ * - 503: no SFU capacity / maintenance — terminal for this attempt.
147
+ */
148
+ fun classify(code: Int, payload: String?, current: CallSessionBundle? = null): HttpResult =
149
+ when (code) {
150
+ in 200..299 -> runCatching { HttpResult.Ok(parseBundle(payload, current)) }
151
+ .getOrElse { HttpResult.Failed("bad_body") }
152
+ 404, 410 -> HttpResult.Gone
153
+ 401, 403 -> HttpResult.AuthFailed(errorCode(payload) ?: "http_$code")
154
+ 503 -> HttpResult.Failed(errorCode(payload) ?: "http_503")
155
+ 408, 429, in 500..599 -> HttpResult.Timeout
156
+ else -> HttpResult.Failed(errorCode(payload) ?: "http_$code")
157
+ }
158
+
159
+ /**
160
+ * One parser for answer, join, place AND reconnect: the server now
161
+ * returns the full bundle on reconnect too. An older server's
162
+ * reconnect answered only `{livekit:{url,token}}` (maybe a fresh
163
+ * `control_token`); with [current] given, that is applied onto the
164
+ * existing bundle instead of failing.
165
+ */
166
+ fun parseBundle(payload: String?, current: CallSessionBundle? = null): CallSessionBundle {
167
+ val root = JSONObject(requireNotNull(payload) { "empty success body" })
168
+ val livekit = root.getJSONObject("livekit")
169
+ val call = root.optJSONObject("call")
170
+ if (call == null) {
171
+ requireNotNull(current) { "bundle without call" }
172
+ return current.copy(
173
+ livekitUrl = livekit.optStringOrNull("url") ?: current.livekitUrl,
174
+ livekitToken = livekit.getString("token"),
175
+ controlToken = root.optStringOrNull("control_token") ?: current.controlToken,
176
+ )
177
+ }
178
+ return CallSessionBundle(
179
+ callId = call.getString("id"),
180
+ conversationId = call.getString("conversation_id"),
181
+ conversationName = call.optStringOrNull("conversation_name") ?: current?.conversationName,
182
+ media = call.optStringOrNull("media") ?: current?.media ?: "audio",
183
+ created = root.optBoolean("created", true),
184
+ livekitUrl = livekit.getString("url"),
185
+ livekitToken = livekit.getString("token"),
186
+ controlToken = root.optStringOrNull("control_token")
187
+ ?: requireNotNull(current?.controlToken) { "bundle without control_token" },
188
+ )
189
+ }
190
+
191
+ /** The server's envelope is `{"error":{"code":"…"}}`; tolerate a bare string too. */
192
+ fun errorCode(payload: String?): String? = payload?.let {
193
+ runCatching {
194
+ val root = JSONObject(it)
195
+ root.optJSONObject("error")?.optStringOrNull("code") ?: root.optStringOrNull("error")
196
+ }.getOrNull()
197
+ }
198
+
199
+ private fun JSONObject.optStringOrNull(key: String): String? =
200
+ if (!has(key) || isNull(key)) null else (opt(key) as? String)?.ifEmpty { null }
150
201
  }
151
202
  }
@@ -12,7 +12,9 @@ import dev.poolse.callcore.RingContext
12
12
  * messaging service (declared via metadata), so chat notifications and
13
13
  * everything else keep working unchanged.
14
14
  *
15
- * Contract: data-only messages with `type: "call"` (CONTRACT.md §5).
15
+ * Contract: data-only messages — rings with `type: "call"`, control
16
+ * pushes (`ring_ended`, `finished`) with `type: "call_event"`
17
+ * (CONTRACT.md §5).
16
18
  */
17
19
  class CallMessagingService : FirebaseMessagingService() {
18
20
 
@@ -22,18 +24,42 @@ class CallMessagingService : FirebaseMessagingService() {
22
24
  forwardToHost { it.onNewToken(token) }
23
25
  }
24
26
 
27
+ override fun onDeletedMessages() {
28
+ // FCM dropped queued messages; the host's own sync logic needs to know.
29
+ forwardToHost { it.onDeletedMessages() }
30
+ }
31
+
25
32
  override fun onMessageReceived(message: RemoteMessage) {
26
33
  val data = message.data
27
- if (data["type"] != "call") {
34
+ // Rings ride type=call; control pushes (ring_ended, finished) ride
35
+ // type=call_event so old JS Android builds — which ring on ANY
36
+ // type=call — ignore them. Everything else belongs to the host.
37
+ val type = data["type"]
38
+ if (type != "call" && type != "call_event") {
28
39
  forwardToHost { it.onMessageReceived(message) }
29
40
  return
30
41
  }
31
42
 
32
- when (data["event"]) {
33
- "ring" -> data.toRingContext()?.let { CallCore.get(this).onRing(it) }
43
+ // Never configured on this install: nothing can answer a call, and
44
+ // throwing here would crash the messaging process.
45
+ val core = CallCore.getOrNull(this) ?: return
46
+ // A type=call push without an event is a legacy-shaped ring.
47
+ val event = data["event"]?.ifEmpty { null } ?: if (type == "call") "ring" else return
48
+ when (event) {
49
+ // ring_ended reasons: cancelled | answered_elsewhere | declined |
50
+ // timeout | finished. The machine only lets them end a RINGING_IN,
51
+ // so an answered_elsewhere echo of THIS device's own answer
52
+ // (optional device_id) can never end a call we already answered.
53
+ "ring" -> data.toRingContext()?.let(core::onRing)
34
54
  "ring_ended" -> {
35
55
  val callId = data["call_id"] ?: return
36
- CallCore.get(this).onRingEnded(callId, data["reason"] ?: "finished")
56
+ core.onRingEnded(callId, data["reason"] ?: "finished")
57
+ }
58
+ // The call is over for everyone (CONTRACT.md §5.3). Ends an active
59
+ // or ringing-out call, and tears down any ring surface still up.
60
+ "finished" -> {
61
+ val callId = data["call_id"] ?: return
62
+ core.onCallFinished(callId, data["reason"]?.ifEmpty { null })
37
63
  }
38
64
  }
39
65
  }
@@ -57,15 +83,17 @@ class CallMessagingService : FirebaseMessagingService() {
57
83
  * Forward to the host app's declared FirebaseMessagingService so its
58
84
  * own (non-call) pushes still arrive. The host names its class in the
59
85
  * manifest: `<meta-data android:name="dev.poolse.callcore.delegate"
60
- * android:value="com.app.MyMessagingService"/>`.
86
+ * android:value="com.app.MyMessagingService"/>` — on `<application>`
87
+ * (where config plugins put it), or on this service.
88
+ *
89
+ * Reading only the SERVICE's meta-data missed the application-level
90
+ * entry, so every chat push was silently dropped.
61
91
  */
62
92
  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
93
+ val name = delegateName() ?: run {
94
+ android.util.Log.w(TAG, "no $DELEGATE_META meta-data: non-call push dropped")
95
+ return
96
+ }
69
97
 
70
98
  runCatching {
71
99
  val delegate = Class.forName(name).getDeclaredConstructor().newInstance()
@@ -76,11 +104,29 @@ class CallMessagingService : FirebaseMessagingService() {
76
104
  // never crash the call path.
77
105
  delegate.attachForDelegation(this)
78
106
  block(delegate)
107
+ } else {
108
+ android.util.Log.w(TAG, "$name is not a FirebaseMessagingService")
79
109
  }
80
- }
110
+ }.onFailure { android.util.Log.e(TAG, "delegate $name failed", it) }
111
+ }
112
+
113
+ private fun delegateName(): String? {
114
+ val pm = packageManager
115
+ val fromApp = runCatching {
116
+ pm.getApplicationInfo(packageName, android.content.pm.PackageManager.GET_META_DATA)
117
+ .metaData?.getString(DELEGATE_META)
118
+ }.getOrNull()
119
+ if (!fromApp.isNullOrEmpty()) return fromApp
120
+ return runCatching {
121
+ pm.getServiceInfo(
122
+ android.content.ComponentName(this, CallMessagingService::class.java),
123
+ android.content.pm.PackageManager.GET_META_DATA,
124
+ ).metaData?.getString(DELEGATE_META)
125
+ }.getOrNull()?.ifEmpty { null }
81
126
  }
82
127
 
83
128
  private companion object {
129
+ const val TAG = "PoolseCallFcm"
84
130
  const val DELEGATE_META = "dev.poolse.callcore.delegate"
85
131
  }
86
132
  }
@@ -0,0 +1,36 @@
1
+ package dev.poolse.callcore.android
2
+
3
+ import android.content.Context
4
+ import android.view.View
5
+
6
+ /**
7
+ * Video rendering, kept SFU-agnostic: a [dev.poolse.callcore.MediaPort]
8
+ * that can render video ALSO implements this; the bridge's view asks
9
+ * [CallCore.createVideoSurface] and never sees the SFU's types. A port
10
+ * without video (the signalling-only fallback) simply doesn't implement
11
+ * it, and the view stays empty.
12
+ *
13
+ * Main thread only — surfaces live in the view hierarchy, and the media
14
+ * adapter keeps its room on main.
15
+ */
16
+ interface CallVideoSource {
17
+ fun createVideoSurface(context: Context): CallVideoSurface
18
+ }
19
+
20
+ /** One on-screen video slot, bound to a participant's camera. */
21
+ interface CallVideoSurface {
22
+ /** The Android view to host; it fills its parent. */
23
+ val view: View
24
+
25
+ /**
26
+ * What to show. [participantId] is the remote's userId (SFU
27
+ * identity); [local] shows the self view instead. [mirror] null =
28
+ * the default (mirrored for the local front camera only). [fit] is
29
+ * `cover` or `contain`. Rebinds immediately and again whenever the
30
+ * underlying track appears, disappears, mutes or the room is replaced.
31
+ */
32
+ fun configure(participantId: String?, local: Boolean, mirror: Boolean?, fit: String)
33
+
34
+ /** Detach from any track and free the renderer. Idempotent. */
35
+ fun release()
36
+ }