@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.
- package/android/build.gradle +7 -0
- package/android/consumer-rules.pro +11 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/dev/poolse/callcore/CallCoreRuntime.kt +245 -110
- package/android/src/main/java/dev/poolse/callcore/CallMachine.kt +113 -18
- package/android/src/main/java/dev/poolse/callcore/Ports.kt +17 -2
- package/android/src/main/java/dev/poolse/callcore/android/AndroidRingSurface.kt +51 -4
- package/android/src/main/java/dev/poolse/callcore/android/CallActionReceiver.kt +4 -3
- package/android/src/main/java/dev/poolse/callcore/android/CallCore.kt +269 -25
- package/android/src/main/java/dev/poolse/callcore/android/CallForegroundService.kt +228 -47
- package/android/src/main/java/dev/poolse/callcore/android/CallHttpClient.kt +97 -46
- package/android/src/main/java/dev/poolse/callcore/android/CallMessagingService.kt +59 -13
- package/android/src/main/java/dev/poolse/callcore/android/CallVideo.kt +36 -0
- package/android/src/main/java/dev/poolse/callcore/android/IncomingCallActivity.kt +100 -12
- package/android/src/main/java/dev/poolse/callcore/android/SnapshotJson.kt +15 -0
- package/android/src/main/java/expo/modules/poolsecalls/PoolseCallVideoView.kt +64 -0
- package/android/src/main/java/expo/modules/poolsecalls/PoolseCallsModule.kt +85 -17
- package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitMediaPort.kt +325 -23
- package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitVideoSurface.kt +107 -0
- package/android/src/test/java/dev/poolse/callcore/android/CallHttpClientTest.kt +61 -0
- package/dist/index.cjs +34 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +52 -3
- package/dist/index.d.ts +52 -3
- package/dist/index.js +30 -2
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/expo-modules-core.d.ts +5 -0
- package/src/index.ts +91 -3
- 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
|
-
|
|
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(
|
|
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 {
|
|
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
|
-
/**
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
81
|
-
|
|
177
|
+
/** The contract Snapshot (`null` = idle), safe from any thread. */
|
|
178
|
+
fun snapshot(): Map<String, Any?>? = lastSnapshot
|
|
82
179
|
|
|
83
|
-
|
|
84
|
-
|
|
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()
|
|
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
|
-
|
|
95
|
-
|
|
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(
|
|
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
|
-
|
|
104
|
-
|
|
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
|
)
|