@poolse/react-native-calls 0.1.3 → 0.2.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.
@@ -2,9 +2,14 @@ package dev.poolse.callcore.android
2
2
 
3
3
  import android.content.Context
4
4
  import android.content.Intent
5
+ import android.os.Handler
6
+ import android.os.Looper
7
+ import android.os.PowerManager
8
+ import android.util.Log
5
9
  import dev.poolse.callcore.CallCoreRuntime
6
10
  import dev.poolse.callcore.MachineState
7
11
  import dev.poolse.callcore.MediaPort
12
+ import dev.poolse.callcore.Phase
8
13
  import dev.poolse.callcore.RingContext
9
14
  import dev.poolse.callcore.SnapshotSink
10
15
 
@@ -16,40 +21,98 @@ import dev.poolse.callcore.SnapshotSink
16
21
  * bridge — comes through here, so there is exactly one runtime and one
17
22
  * answer/decline path on the device. Process-singleton, created lazily
18
23
  * from the first caller's application context.
24
+ *
25
+ * It also owns what the pure machine deliberately does not carry but a
26
+ * truthful snapshot needs: the remote roster, the conversation name,
27
+ * self mute/camera, and the audio route. All of it lives on the
28
+ * runtime's single thread and is merged into every snapshot.
19
29
  */
20
30
  class CallCore private constructor(context: Context) {
21
31
 
22
32
  private val appContext = context.applicationContext
33
+ private val main = Handler(Looper.getMainLooper())
23
34
 
24
35
  // The SFU is injected, not baked in: the core stays media-engine
25
36
  // agnostic (CONTRACT.md — "LiveKit or any SFU"), and the library
26
37
  // compiles without a specific media SDK on the classpath. The host
27
38
  // supplies the factory; LiveKitMediaPort is the first implementation.
28
39
  private val media: MediaPort = requireConfig().mediaFactory(appContext, mediaEvents())
29
- private val surface = AndroidRingSurface(appContext, requireConfig().ringSurface)
40
+
41
+ // Surface + HTTP read the config on every use, so a later configure()
42
+ // (another API origin, icon, ringback) applies without a rebuild.
43
+ private val surface = AndroidRingSurface(appContext) {
44
+ peekConfig()?.ringSurface ?: RingSurfaceConfig(appContext.applicationInfo.icon, 0)
45
+ }
46
+
30
47
  private val runtime = CallCoreRuntime(
31
- http = CallHttpClient(requireConfig().apiBaseUrl),
48
+ http = CallHttpClient({ peekConfig()?.apiBaseUrl ?: "" }),
32
49
  media = media,
33
50
  surface = surface,
34
51
  session = CallForegroundService.also { it.attach(appContext) },
35
- timers = HandlerTimerPort(),
36
- sink = SnapshotSink { emitSnapshot(it) },
52
+ timers = HandlerTimerPort(main),
53
+ sink = SnapshotSink { onState(it) },
54
+ // One thread for every input: the main looper — where LiveKit,
55
+ // the timers, the notification and the service lifecycle already
56
+ // live. Already on it? Run now; the runtime's trampoline still
57
+ // queues anything produced mid-dispatch.
58
+ executor = { r -> if (Looper.myLooper() == Looper.getMainLooper()) r.run() else main.post(r) },
59
+ onError = { Log.e(TAG, "call runtime task failed", it) },
37
60
  )
38
61
 
62
+ // ── snapshot extras (runtime thread only) ───────────────────────────
63
+
64
+ private var extrasCallId: String? = null
65
+ private var extrasMedia: String? = null
66
+ private var roster: List<MediaParticipant> = emptyList()
67
+ private var conversationName: String? = null
68
+ private var mutedSelf = false
69
+ private var cameraOn = false
70
+ private var audioRoute: String? = null
71
+
72
+ /** Last snapshot emitted; read by the bridge's getCallState from any thread. */
73
+ @Volatile private var lastSnapshot: Map<String, Any?>? = null
74
+
75
+ private val proximity: PowerManager.WakeLock? = runCatching {
76
+ val pm = appContext.getSystemService(PowerManager::class.java)
77
+ if (pm.isWakeLockLevelSupported(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK)) {
78
+ pm.newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, "poolse:call-proximity")
79
+ .apply { setReferenceCounted(false) }
80
+ } else null
81
+ }.getOrNull()
82
+
39
83
  // ── native entry points ─────────────────────────────────────────────
40
84
 
41
85
  /** A ring arrived — from FCM (closed app) or the socket (open app). */
42
86
  fun onRing(ring: RingContext) = runtime.onRing(ring)
43
87
 
44
- /** The caller withdrew, or the ring resolved elsewhere. */
45
- fun onRingEnded(callId: String, reason: String) = runtime.onRingEnded(callId, reason)
88
+ /**
89
+ * The caller withdrew, or the ring resolved elsewhere. Beyond the
90
+ * machine (which only knows the ring it is showing), ALWAYS tear down
91
+ * any surface for this call: after a process restart the notification
92
+ * and full-screen activity outlive the in-memory state that would
93
+ * otherwise dismiss them.
94
+ */
95
+ fun onRingEnded(callId: String, reason: String) {
96
+ runtime.onRingEnded(callId, reason)
97
+ runtime.post { tearDownRing(callId) }
98
+ }
46
99
 
47
- fun onCallFinished(callId: String) = runtime.onCallFinished(callId)
100
+ /** The server finished the call (push `finished` or socket `call:finished`). */
101
+ fun onCallFinished(callId: String, reason: String? = null) {
102
+ runtime.onCallFinished(callId, reason)
103
+ runtime.post { tearDownRing(callId) }
104
+ }
48
105
 
49
- fun onUserAnswer(ring: RingContext) {
106
+ /**
107
+ * Answer from a native surface. [from] is the activity the tap
108
+ * landed in: the app must be brought forward from THAT context — a
109
+ * background start from the application context is blocked on
110
+ * Android 10+ and left audio connected behind the lock screen.
111
+ */
112
+ fun onUserAnswer(ring: RingContext, from: Context? = null): Boolean {
50
113
  runtime.onRing(ring) // idempotent: dedupes if already showing
51
114
  runtime.answer()
52
- bringAppToFront(ring)
115
+ return bringAppToFront(ring.callId, from)
53
116
  }
54
117
 
55
118
  fun onUserDecline(ring: RingContext) {
@@ -57,11 +120,16 @@ class CallCore private constructor(context: Context) {
57
120
  runtime.decline()
58
121
  }
59
122
 
60
- fun onUserOpen(ring: RingContext) {
123
+ fun onUserOpen(ring: RingContext, from: Context? = null) {
61
124
  runtime.onRing(ring)
62
- bringAppToFront(ring)
125
+ bringAppToFront(ring.callId, from)
63
126
  }
64
127
 
128
+ /** False once this ring is resolved here or past its deadline (+ clock slack). */
129
+ fun isRingLive(ring: RingContext): Boolean =
130
+ !runtime.isResolved(ring.callId) &&
131
+ ring.ringDeadlineEpochMs + CallCoreRuntime.CLOCK_SLACK_MS > System.currentTimeMillis()
132
+
65
133
  // ── bridge-driven actions ───────────────────────────────────────────
66
134
 
67
135
  fun answer() = runtime.answer()
@@ -71,46 +139,182 @@ class CallCore private constructor(context: Context) {
71
139
  runtime.place(conversationId, media, sessionToken)
72
140
  fun join(callId: String, conversationId: String?, sessionToken: String) =
73
141
  runtime.join(callId, conversationId, sessionToken)
74
- fun setMuted(muted: Boolean) = runtime.setMuted(muted)
75
- fun setCamera(on: Boolean) = runtime.setCamera(on)
142
+
143
+ fun setMuted(muted: Boolean) {
144
+ runtime.post { mutedSelf = muted }
145
+ runtime.setMuted(muted)
146
+ runtime.reemit()
147
+ }
148
+
149
+ fun setCamera(on: Boolean) {
150
+ runtime.post { cameraOn = on }
151
+ runtime.setCamera(on)
152
+ runtime.reemit()
153
+ }
154
+
76
155
  fun switchCamera() = runtime.switchCamera()
77
156
 
78
- fun snapshot(): MachineState = runtime.currentState()
157
+ fun setSpeaker(on: Boolean) {
158
+ // Optimistic; the media adapter reports the route it actually got.
159
+ runtime.post { audioRoute = if (on) "speaker" else "earpiece" }
160
+ runtime.setSpeaker(on)
161
+ runtime.reemit()
162
+ }
163
+
164
+ fun allRingsResolved(outcome: String) = runtime.allRingsResolved(outcome)
79
165
 
80
- /** The bridge subscribes here; replaced each mount, cleared on unmount. */
81
- @Volatile var snapshotListener: ((MachineState) -> Unit)? = null
166
+ fun state(): MachineState = runtime.currentState()
82
167
 
83
- private fun emitSnapshot(state: MachineState) {
84
- snapshotListener?.invoke(state)
168
+ /** The contract Snapshot (`null` = idle), safe from any thread. */
169
+ fun snapshot(): Map<String, Any?>? = lastSnapshot
170
+
171
+ // ── snapshot assembly (runtime thread) ──────────────────────────────
172
+
173
+ private fun onState(state: MachineState) {
174
+ if (state.phase == Phase.IDLE) {
175
+ if (extrasCallId != null) {
176
+ // Idle: no call owns media any more. Clears the port's per-call
177
+ // mute/camera/speaker choices even when the call ended on a path
178
+ // with no MEDIA_LEAVE (e.g. refused while connecting).
179
+ media.leave()
180
+ }
181
+ resetExtras(null, null)
182
+ } else if (state.callId != extrasCallId) {
183
+ // `pending:<conv>` → the server-minted id is the same call.
184
+ if (extrasCallId?.startsWith("pending:") == true) extrasCallId = state.callId
185
+ else resetExtras(state.callId, state.media)
186
+ }
187
+ if (state.phase != Phase.IDLE && state.media != extrasMedia) {
188
+ // The server corrected the media (a join defaults to audio until
189
+ // its bundle says otherwise): re-derive what depends on it.
190
+ if (extrasMedia != null) {
191
+ cameraOn = state.media == "video"
192
+ audioRoute = null
193
+ CallForegroundService.updateCall(state.callId ?: "", state.media)
194
+ }
195
+ extrasMedia = state.media
196
+ }
197
+ runtime.conversationName?.let { conversationName = it }
198
+
199
+ val snap = state.toSnapshot(
200
+ conversationName = conversationName,
201
+ participants = roster.map {
202
+ mapOf(
203
+ "userId" to it.identity,
204
+ "externalId" to it.externalId,
205
+ "speaking" to it.speaking,
206
+ "muted" to it.muted,
207
+ "cameraOn" to it.cameraOn,
208
+ )
209
+ },
210
+ audioRoute = audioRoute ?: if (state.media == "video") "speaker" else "earpiece",
211
+ mutedSelf = mutedSelf,
212
+ cameraOn = cameraOn,
213
+ )
214
+ lastSnapshot = snap
215
+ reconcileAndroid(state)
216
+ runCatching { listener?.invoke(snap) }.onFailure { Log.w(TAG, "snapshot listener failed", it) }
217
+ }
218
+
219
+ private fun resetExtras(callId: String?, media: String?) {
220
+ extrasCallId = callId
221
+ extrasMedia = media
222
+ roster = emptyList()
223
+ conversationName = null
224
+ mutedSelf = false
225
+ // Video calls publish the camera on join (LiveKitMediaPort).
226
+ cameraOn = media == "video"
227
+ audioRoute = null
228
+ }
229
+
230
+ /**
231
+ * OS-level consequences of the state, applied after the FINAL state
232
+ * of each dispatch — so an answer's DISMISS_RING → CALL_SESSION_START
233
+ * hands the foreground service from ring to call without a gap.
234
+ */
235
+ private fun reconcileAndroid(state: MachineState) {
236
+ // Keep the process alive while ringing (OEM freezers otherwise kill
237
+ // the ringtone and the deadline timer of a closed app).
238
+ if (state.phase == Phase.RINGING_IN) {
239
+ CallForegroundService.startRing(state.callId ?: "")
240
+ } else {
241
+ CallForegroundService.stopRing()
242
+ }
243
+
244
+ if (state.phase == Phase.IDLE || state.phase == Phase.ENDED) surface.setCommunicationReady(false)
245
+
246
+ // Screen off at the ear: only for an audio call on the earpiece.
247
+ val inCall = state.phase == Phase.ACTIVE || state.phase == Phase.RECONNECTING ||
248
+ state.phase == Phase.RINGING_OUT || state.phase == Phase.CONNECTING
249
+ val earpiece = (audioRoute ?: if (state.media == "video") "speaker" else "earpiece") == "earpiece"
250
+ setProximity(inCall && state.media == "audio" && earpiece)
251
+ }
252
+
253
+ private fun setProximity(on: Boolean) {
254
+ val lock = proximity ?: return
255
+ runCatching {
256
+ if (on && !lock.isHeld) lock.acquire(4 * 60 * 60 * 1000L)
257
+ else if (!on && lock.isHeld) lock.release(PowerManager.RELEASE_FLAG_WAIT_FOR_NO_PROXIMITY)
258
+ }.onFailure { Log.w(TAG, "proximity wake lock", it) }
259
+ }
260
+
261
+ private fun tearDownRing(callId: String) {
262
+ val s = runtime.currentState()
263
+ surface.dismiss(callId)
264
+ // Never silence a DIFFERENT call that is ringing right now.
265
+ if (!(s.phase == Phase.RINGING_IN && s.callId != callId)) surface.ringtoneStop()
266
+ if (s.phase != Phase.RINGING_IN) CallForegroundService.stopRing()
85
267
  }
86
268
 
87
269
  private fun mediaEvents() = object : MediaEvents {
88
- override fun onConnected() = runtime.onMediaConnected()
270
+ override fun onConnected() {
271
+ // The communication audio mode is set by now: ringback may play
272
+ // through the call route (earpiece), not the loudspeaker.
273
+ runtime.post { surface.setCommunicationReady(true) }
274
+ runtime.onMediaConnected()
275
+ }
89
276
  override fun onFailed() = runtime.onMediaFailed()
90
277
  override fun onLost() = runtime.onMediaLost()
278
+ override fun onReconnecting() = runtime.onMediaReconnecting()
91
279
  override fun onRecovered() = runtime.onMediaRecovered()
92
280
  override fun onRemoteJoined() = runtime.onRemoteJoined()
93
281
  override fun onParticipants(participants: List<MediaParticipant>) {
94
- // Participant roster flows to the bridge via the snapshot; the
95
- // runtime does not need the detail to drive the machine.
282
+ runtime.post { roster = participants }
283
+ runtime.reemit()
284
+ }
285
+ override fun onAudioRoute(route: String) {
286
+ runtime.post { audioRoute = route }
287
+ runtime.reemit()
96
288
  }
97
289
  }
98
290
 
99
- private fun bringAppToFront(ring: RingContext) {
291
+ private fun bringAppToFront(callId: String, from: Context?): Boolean {
100
292
  val launch = appContext.packageManager.getLaunchIntentForPackage(appContext.packageName)
101
- ?: return
293
+ ?: return false
102
294
  launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
103
- launch.putExtra(EXTRA_LAUNCH_CALL, ring.callId)
104
- runCatching { appContext.startActivity(launch) }
295
+ // The host's MainActivity reads this to show over the keyguard and
296
+ // dismiss it, and to route straight to the call screen.
297
+ launch.putExtra(EXTRA_LAUNCH_CALL, callId)
298
+ return runCatching { (from ?: appContext).startActivity(launch) }
299
+ .onFailure { Log.w(TAG, "could not bring the app forward", it) }
300
+ .isSuccess
105
301
  }
106
302
 
107
303
  companion object {
108
304
  const val EXTRA_LAUNCH_CALL = "poolse_launch_call"
305
+ private const val TAG = "PoolseCallCore"
109
306
  private const val PREFS = "dev.poolse.callcore"
110
307
 
111
308
  @Volatile private var instance: CallCore? = null
112
309
  @Volatile private var config: CallCoreConfig? = null
113
310
 
311
+ /**
312
+ * The bridge's snapshot subscriber. Process-level (not per instance)
313
+ * so the JS side may subscribe before configure() — the core picks
314
+ * it up when it is created.
315
+ */
316
+ @Volatile var listener: ((Map<String, Any?>?) -> Unit)? = null
317
+
114
318
  /**
115
319
  * Configure the core and PERSIST the inputs. A ring can wake a killed
116
320
  * app: the OS spawns the process cold to run [CallMessagingService],
@@ -119,6 +323,10 @@ class CallCore private constructor(context: Context) {
119
323
  * that cold start, so closed-app ringing works. The bridge calls this
120
324
  * each time the app opens; the resource names (not the resolved ids)
121
325
  * are stored, since ids are not stable across installs.
326
+ *
327
+ * Re-configuring a live core swaps the config in place: HTTP and the
328
+ * ring surface read it per use, so a new API origin takes effect on
329
+ * the next request.
122
330
  */
123
331
  fun configure(context: Context, apiBaseUrl: String, smallIconName: String?, ringbackName: String?) {
124
332
  val ctx = context.applicationContext
@@ -130,14 +338,22 @@ class CallCore private constructor(context: Context) {
130
338
  config = build(ctx, apiBaseUrl, smallIconName, ringbackName)
131
339
  }
132
340
 
341
+ /** The core; throws if never configured (not even persisted). Prefer [getOrNull]. */
133
342
  fun get(context: Context): CallCore =
343
+ getOrNull(context) ?: error("CallCore.configure() must be called before use")
344
+
345
+ /** The core, or null if this install was never configured — callers no-op. */
346
+ fun getOrNull(context: Context): CallCore? =
134
347
  instance ?: synchronized(this) {
135
348
  instance ?: run {
136
349
  if (config == null) restoreConfig(context.applicationContext)
137
- CallCore(context).also { instance = it }
350
+ if (config == null) null else CallCore(context).also { instance = it }
138
351
  }
139
352
  }
140
353
 
354
+ /** The live core if one exists — never creates (cheap reads, e.g. getCallState). */
355
+ fun peek(): CallCore? = instance
356
+
141
357
  internal fun peekConfig(): CallCoreConfig? = config
142
358
 
143
359
  internal fun requireConfig(): CallCoreConfig =
@@ -196,6 +412,7 @@ class CallCore private constructor(context: Context) {
196
412
  override fun setMuted(muted: Boolean) {}
197
413
  override fun setCamera(on: Boolean) {}
198
414
  override fun switchCamera() {}
415
+ override fun setSpeaker(on: Boolean) {}
199
416
  }
200
417
  }
201
418
  }
@@ -223,14 +440,24 @@ data class CallCoreConfig(
223
440
  interface MediaEvents {
224
441
  fun onConnected()
225
442
  fun onFailed()
443
+ /** Unexpected disconnect — the SFU gave up; the core asks the server and rejoins. */
226
444
  fun onLost()
445
+ /** The SFU is resuming the session itself; only reflected in the UI. */
446
+ fun onReconnecting()
227
447
  fun onRecovered()
228
448
  fun onRemoteJoined()
449
+ /** The REMOTE roster (the local participant excluded). */
229
450
  fun onParticipants(participants: List<MediaParticipant>)
451
+ /** `earpiece` | `speaker` | `bluetooth` | `headset`. */
452
+ fun onAudioRoute(route: String)
230
453
  }
231
454
 
232
455
  data class MediaParticipant(
456
+ /** The SFU identity — the poolse user id. */
233
457
  val identity: String,
234
458
  val speaking: Boolean,
235
459
  val muted: Boolean,
460
+ val cameraOn: Boolean = false,
461
+ /** The host app's own id for the user, when the SFU carries it; else the identity. */
462
+ val externalId: String = identity,
236
463
  )