@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
@@ -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,99 @@ 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 cameraFacing: String? = null
71
+ private var audioRoute: String? = null
72
+
73
+ /** Last snapshot emitted; read by the bridge's getCallState from any thread. */
74
+ @Volatile private var lastSnapshot: Map<String, Any?>? = null
75
+
76
+ private val proximity: PowerManager.WakeLock? = runCatching {
77
+ val pm = appContext.getSystemService(PowerManager::class.java)
78
+ if (pm.isWakeLockLevelSupported(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK)) {
79
+ pm.newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, "poolse:call-proximity")
80
+ .apply { setReferenceCounted(false) }
81
+ } else null
82
+ }.getOrNull()
83
+
39
84
  // ── native entry points ─────────────────────────────────────────────
40
85
 
41
86
  /** A ring arrived — from FCM (closed app) or the socket (open app). */
42
87
  fun onRing(ring: RingContext) = runtime.onRing(ring)
43
88
 
44
- /** The caller withdrew, or the ring resolved elsewhere. */
45
- fun onRingEnded(callId: String, reason: String) = runtime.onRingEnded(callId, reason)
89
+ /**
90
+ * The caller withdrew, or the ring resolved elsewhere. Beyond the
91
+ * machine (which only knows the ring it is showing), ALWAYS tear down
92
+ * any surface for this call: after a process restart the notification
93
+ * and full-screen activity outlive the in-memory state that would
94
+ * otherwise dismiss them.
95
+ */
96
+ fun onRingEnded(callId: String, reason: String) {
97
+ runtime.onRingEnded(callId, reason)
98
+ runtime.post { tearDownRing(callId) }
99
+ }
46
100
 
47
- fun onCallFinished(callId: String) = runtime.onCallFinished(callId)
101
+ /** The server finished the call (push `finished` or socket `call:finished`). */
102
+ fun onCallFinished(callId: String, reason: String? = null) {
103
+ runtime.onCallFinished(callId, reason)
104
+ runtime.post { tearDownRing(callId) }
105
+ }
48
106
 
49
- fun onUserAnswer(ring: RingContext) {
107
+ /**
108
+ * Answer from a native surface. [from] is the activity the tap
109
+ * landed in: the app must be brought forward from THAT context — a
110
+ * background start from the application context is blocked on
111
+ * Android 10+ and left audio connected behind the lock screen.
112
+ */
113
+ fun onUserAnswer(ring: RingContext, from: Context? = null): Boolean {
50
114
  runtime.onRing(ring) // idempotent: dedupes if already showing
51
115
  runtime.answer()
52
- bringAppToFront(ring)
116
+ return bringAppToFront(ring.callId, from)
53
117
  }
54
118
 
55
119
  fun onUserDecline(ring: RingContext) {
@@ -57,11 +121,16 @@ class CallCore private constructor(context: Context) {
57
121
  runtime.decline()
58
122
  }
59
123
 
60
- fun onUserOpen(ring: RingContext) {
124
+ fun onUserOpen(ring: RingContext, from: Context? = null) {
61
125
  runtime.onRing(ring)
62
- bringAppToFront(ring)
126
+ bringAppToFront(ring.callId, from)
63
127
  }
64
128
 
129
+ /** False once this ring is resolved here or past its deadline (+ clock slack). */
130
+ fun isRingLive(ring: RingContext): Boolean =
131
+ !runtime.isResolved(ring.callId) &&
132
+ ring.ringDeadlineEpochMs + CallCoreRuntime.CLOCK_SLACK_MS > System.currentTimeMillis()
133
+
65
134
  // ── bridge-driven actions ───────────────────────────────────────────
66
135
 
67
136
  fun answer() = runtime.answer()
@@ -71,46 +140,195 @@ class CallCore private constructor(context: Context) {
71
140
  runtime.place(conversationId, media, sessionToken)
72
141
  fun join(callId: String, conversationId: String?, sessionToken: String) =
73
142
  runtime.join(callId, conversationId, sessionToken)
74
- fun setMuted(muted: Boolean) = runtime.setMuted(muted)
143
+
144
+ fun setMuted(muted: Boolean) {
145
+ runtime.post { mutedSelf = muted }
146
+ runtime.setMuted(muted)
147
+ runtime.reemit()
148
+ }
149
+
150
+ /**
151
+ * Not optimistic: `cameraOn` in the snapshot flips only when the
152
+ * media adapter reports the local camera track actually published
153
+ * (it may be refused — e.g. no CAMERA permission).
154
+ */
75
155
  fun setCamera(on: Boolean) = runtime.setCamera(on)
156
+
76
157
  fun switchCamera() = runtime.switchCamera()
77
158
 
78
- fun snapshot(): MachineState = runtime.currentState()
159
+ /**
160
+ * A video slot for `<CallVideoView>`, or null when the media engine
161
+ * renders no video (signalling-only build). Main thread.
162
+ */
163
+ fun createVideoSurface(context: Context): CallVideoSurface? =
164
+ (media as? CallVideoSource)?.createVideoSurface(context)
165
+
166
+ fun setSpeaker(on: Boolean) {
167
+ // Optimistic; the media adapter reports the route it actually got.
168
+ runtime.post { audioRoute = if (on) "speaker" else "earpiece" }
169
+ runtime.setSpeaker(on)
170
+ runtime.reemit()
171
+ }
172
+
173
+ fun allRingsResolved(outcome: String) = runtime.allRingsResolved(outcome)
174
+
175
+ fun state(): MachineState = runtime.currentState()
79
176
 
80
- /** The bridge subscribes here; replaced each mount, cleared on unmount. */
81
- @Volatile var snapshotListener: ((MachineState) -> Unit)? = null
177
+ /** The contract Snapshot (`null` = idle), safe from any thread. */
178
+ fun snapshot(): Map<String, Any?>? = lastSnapshot
82
179
 
83
- private fun emitSnapshot(state: MachineState) {
84
- snapshotListener?.invoke(state)
180
+ // ── snapshot assembly (runtime thread) ──────────────────────────────
181
+
182
+ private fun onState(state: MachineState) {
183
+ if (state.phase == Phase.IDLE) {
184
+ if (extrasCallId != null) {
185
+ // Idle: no call owns media any more. Clears the port's per-call
186
+ // mute/camera/speaker choices even when the call ended on a path
187
+ // with no MEDIA_LEAVE (e.g. refused while connecting).
188
+ media.leave()
189
+ }
190
+ resetExtras(null, null)
191
+ } else if (state.callId != extrasCallId) {
192
+ // `pending:<conv>` → the server-minted id is the same call.
193
+ if (extrasCallId?.startsWith("pending:") == true) extrasCallId = state.callId
194
+ else resetExtras(state.callId, state.media)
195
+ }
196
+ if (state.phase != Phase.IDLE && state.media != extrasMedia) {
197
+ // The server corrected the media (a join defaults to audio until
198
+ // its bundle says otherwise): re-derive what depends on it.
199
+ if (extrasMedia != null) {
200
+ audioRoute = null
201
+ CallForegroundService.updateCall(state.callId ?: "", state.media)
202
+ }
203
+ extrasMedia = state.media
204
+ }
205
+ runtime.conversationName?.let { conversationName = it }
206
+
207
+ val snap = state.toSnapshot(
208
+ conversationName = conversationName,
209
+ participants = roster.map {
210
+ mapOf(
211
+ "userId" to it.identity,
212
+ "externalId" to it.externalId,
213
+ "speaking" to it.speaking,
214
+ "muted" to it.muted,
215
+ "cameraOn" to it.cameraOn,
216
+ )
217
+ },
218
+ audioRoute = audioRoute ?: if (state.media == "video") "speaker" else "earpiece",
219
+ mutedSelf = mutedSelf,
220
+ cameraOn = cameraOn,
221
+ cameraFacing = cameraFacing,
222
+ )
223
+ lastSnapshot = snap
224
+ reconcileAndroid(state)
225
+ runCatching { listener?.invoke(snap) }.onFailure { Log.w(TAG, "snapshot listener failed", it) }
226
+ }
227
+
228
+ private fun resetExtras(callId: String?, media: String?) {
229
+ extrasCallId = callId
230
+ extrasMedia = media
231
+ roster = emptyList()
232
+ conversationName = null
233
+ mutedSelf = false
234
+ // Reported by the media adapter once the camera is really published.
235
+ cameraOn = false
236
+ cameraFacing = null
237
+ audioRoute = null
238
+ }
239
+
240
+ /**
241
+ * OS-level consequences of the state, applied after the FINAL state
242
+ * of each dispatch — so an answer's DISMISS_RING → CALL_SESSION_START
243
+ * hands the foreground service from ring to call without a gap.
244
+ */
245
+ private fun reconcileAndroid(state: MachineState) {
246
+ // Keep the process alive while ringing (OEM freezers otherwise kill
247
+ // the ringtone and the deadline timer of a closed app).
248
+ if (state.phase == Phase.RINGING_IN) {
249
+ CallForegroundService.startRing(state.callId ?: "")
250
+ } else {
251
+ CallForegroundService.stopRing()
252
+ }
253
+
254
+ if (state.phase == Phase.IDLE || state.phase == Phase.ENDED) surface.setCommunicationReady(false)
255
+
256
+ // Screen off at the ear: only for an audio call on the earpiece.
257
+ val inCall = state.phase == Phase.ACTIVE || state.phase == Phase.RECONNECTING ||
258
+ state.phase == Phase.RINGING_OUT || state.phase == Phase.CONNECTING
259
+ val earpiece = (audioRoute ?: if (state.media == "video") "speaker" else "earpiece") == "earpiece"
260
+ setProximity(inCall && state.media == "audio" && earpiece)
261
+ }
262
+
263
+ private fun setProximity(on: Boolean) {
264
+ val lock = proximity ?: return
265
+ runCatching {
266
+ if (on && !lock.isHeld) lock.acquire(4 * 60 * 60 * 1000L)
267
+ else if (!on && lock.isHeld) lock.release(PowerManager.RELEASE_FLAG_WAIT_FOR_NO_PROXIMITY)
268
+ }.onFailure { Log.w(TAG, "proximity wake lock", it) }
269
+ }
270
+
271
+ private fun tearDownRing(callId: String) {
272
+ val s = runtime.currentState()
273
+ surface.dismiss(callId)
274
+ // Never silence a DIFFERENT call that is ringing right now.
275
+ if (!(s.phase == Phase.RINGING_IN && s.callId != callId)) surface.ringtoneStop()
276
+ if (s.phase != Phase.RINGING_IN) CallForegroundService.stopRing()
85
277
  }
86
278
 
87
279
  private fun mediaEvents() = object : MediaEvents {
88
- override fun onConnected() = runtime.onMediaConnected()
280
+ override fun onConnected() {
281
+ // The communication audio mode is set by now: ringback may play
282
+ // through the call route (earpiece), not the loudspeaker.
283
+ runtime.post { surface.setCommunicationReady(true) }
284
+ runtime.onMediaConnected()
285
+ }
89
286
  override fun onFailed() = runtime.onMediaFailed()
90
287
  override fun onLost() = runtime.onMediaLost()
288
+ override fun onReconnecting() = runtime.onMediaReconnecting()
91
289
  override fun onRecovered() = runtime.onMediaRecovered()
92
290
  override fun onRemoteJoined() = runtime.onRemoteJoined()
93
291
  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.
292
+ runtime.post { roster = participants }
293
+ runtime.reemit()
294
+ }
295
+ override fun onAudioRoute(route: String) {
296
+ runtime.post { audioRoute = route }
297
+ runtime.reemit()
298
+ }
299
+ override fun onLocalCamera(on: Boolean, facing: String?) {
300
+ runtime.post { cameraOn = on; cameraFacing = facing }
301
+ runtime.reemit()
96
302
  }
97
303
  }
98
304
 
99
- private fun bringAppToFront(ring: RingContext) {
305
+ private fun bringAppToFront(callId: String, from: Context?): Boolean {
100
306
  val launch = appContext.packageManager.getLaunchIntentForPackage(appContext.packageName)
101
- ?: return
307
+ ?: return false
102
308
  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) }
309
+ // The host's MainActivity reads this to show over the keyguard and
310
+ // dismiss it, and to route straight to the call screen.
311
+ launch.putExtra(EXTRA_LAUNCH_CALL, callId)
312
+ return runCatching { (from ?: appContext).startActivity(launch) }
313
+ .onFailure { Log.w(TAG, "could not bring the app forward", it) }
314
+ .isSuccess
105
315
  }
106
316
 
107
317
  companion object {
108
318
  const val EXTRA_LAUNCH_CALL = "poolse_launch_call"
319
+ private const val TAG = "PoolseCallCore"
109
320
  private const val PREFS = "dev.poolse.callcore"
110
321
 
111
322
  @Volatile private var instance: CallCore? = null
112
323
  @Volatile private var config: CallCoreConfig? = null
113
324
 
325
+ /**
326
+ * The bridge's snapshot subscriber. Process-level (not per instance)
327
+ * so the JS side may subscribe before configure() — the core picks
328
+ * it up when it is created.
329
+ */
330
+ @Volatile var listener: ((Map<String, Any?>?) -> Unit)? = null
331
+
114
332
  /**
115
333
  * Configure the core and PERSIST the inputs. A ring can wake a killed
116
334
  * app: the OS spawns the process cold to run [CallMessagingService],
@@ -119,6 +337,10 @@ class CallCore private constructor(context: Context) {
119
337
  * that cold start, so closed-app ringing works. The bridge calls this
120
338
  * each time the app opens; the resource names (not the resolved ids)
121
339
  * are stored, since ids are not stable across installs.
340
+ *
341
+ * Re-configuring a live core swaps the config in place: HTTP and the
342
+ * ring surface read it per use, so a new API origin takes effect on
343
+ * the next request.
122
344
  */
123
345
  fun configure(context: Context, apiBaseUrl: String, smallIconName: String?, ringbackName: String?) {
124
346
  val ctx = context.applicationContext
@@ -130,14 +352,22 @@ class CallCore private constructor(context: Context) {
130
352
  config = build(ctx, apiBaseUrl, smallIconName, ringbackName)
131
353
  }
132
354
 
355
+ /** The core; throws if never configured (not even persisted). Prefer [getOrNull]. */
133
356
  fun get(context: Context): CallCore =
357
+ getOrNull(context) ?: error("CallCore.configure() must be called before use")
358
+
359
+ /** The core, or null if this install was never configured — callers no-op. */
360
+ fun getOrNull(context: Context): CallCore? =
134
361
  instance ?: synchronized(this) {
135
362
  instance ?: run {
136
363
  if (config == null) restoreConfig(context.applicationContext)
137
- CallCore(context).also { instance = it }
364
+ if (config == null) null else CallCore(context).also { instance = it }
138
365
  }
139
366
  }
140
367
 
368
+ /** The live core if one exists — never creates (cheap reads, e.g. getCallState). */
369
+ fun peek(): CallCore? = instance
370
+
141
371
  internal fun peekConfig(): CallCoreConfig? = config
142
372
 
143
373
  internal fun requireConfig(): CallCoreConfig =
@@ -196,6 +426,7 @@ class CallCore private constructor(context: Context) {
196
426
  override fun setMuted(muted: Boolean) {}
197
427
  override fun setCamera(on: Boolean) {}
198
428
  override fun switchCamera() {}
429
+ override fun setSpeaker(on: Boolean) {}
199
430
  }
200
431
  }
201
432
  }
@@ -223,14 +454,27 @@ data class CallCoreConfig(
223
454
  interface MediaEvents {
224
455
  fun onConnected()
225
456
  fun onFailed()
457
+ /** Unexpected disconnect — the SFU gave up; the core asks the server and rejoins. */
226
458
  fun onLost()
459
+ /** The SFU is resuming the session itself; only reflected in the UI. */
460
+ fun onReconnecting()
227
461
  fun onRecovered()
228
462
  fun onRemoteJoined()
463
+ /** The REMOTE roster (the local participant excluded). */
229
464
  fun onParticipants(participants: List<MediaParticipant>)
465
+ /** `earpiece` | `speaker` | `bluetooth` | `headset`. */
466
+ fun onAudioRoute(route: String)
467
+ /** The local camera track is (not) published + enabled; [facing] `front` | `back`. */
468
+ fun onLocalCamera(on: Boolean, facing: String?) {}
230
469
  }
231
470
 
232
471
  data class MediaParticipant(
472
+ /** The SFU identity — the poolse user id. */
233
473
  val identity: String,
234
474
  val speaking: Boolean,
235
475
  val muted: Boolean,
476
+ /** True only with a published, subscribed, unmuted camera track — a frame to draw. */
477
+ val cameraOn: Boolean = false,
478
+ /** The host app's own id for the user, when the SFU carries it; else the identity. */
479
+ val externalId: String = identity,
236
480
  )