@poolse/react-native-calls 0.0.0-stage → 0.1.1
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/README.md +33 -2
- package/android/build.gradle +51 -0
- package/android/src/main/AndroidManifest.xml +40 -0
- package/android/src/main/java/dev/poolse/callcore/CallCoreRuntime.kt +257 -0
- package/android/src/main/java/dev/poolse/callcore/CallMachine.kt +292 -0
- package/android/src/main/java/dev/poolse/callcore/Ports.kt +94 -0
- package/android/src/main/java/dev/poolse/callcore/android/AndroidRingSurface.kt +183 -0
- package/android/src/main/java/dev/poolse/callcore/android/CallActionReceiver.kt +28 -0
- package/android/src/main/java/dev/poolse/callcore/android/CallCore.kt +236 -0
- package/android/src/main/java/dev/poolse/callcore/android/CallForegroundService.kt +80 -0
- package/android/src/main/java/dev/poolse/callcore/android/CallHttpClient.kt +151 -0
- package/android/src/main/java/dev/poolse/callcore/android/CallMessagingService.kt +99 -0
- package/android/src/main/java/dev/poolse/callcore/android/HandlerTimerPort.kt +42 -0
- package/android/src/main/java/dev/poolse/callcore/android/IncomingCallActivity.kt +209 -0
- package/android/src/main/java/dev/poolse/callcore/android/RingBundle.kt +42 -0
- package/android/src/main/java/dev/poolse/callcore/android/SnapshotJson.kt +57 -0
- package/android/src/main/java/expo/modules/poolsecalls/PoolseCallsModule.kt +67 -0
- package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitMediaPort.kt +108 -0
- package/dist/index.cjs +45 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +51 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/expo-module.config.json +6 -0
- package/package.json +39 -4
- package/src/expo-modules-core.d.ts +11 -0
- package/src/index.ts +111 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
package dev.poolse.callcore
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The seams between the pure machine and the platform. Each adapter
|
|
5
|
+
* lives in the bridge/Android layer; the core knows only these shapes.
|
|
6
|
+
* Every port call corresponds 1:1 to an Effect — the runtime below is
|
|
7
|
+
* the only place effects are executed.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** CONTRACT.md §4 — the three doors. Tokens are opaque strings. */
|
|
11
|
+
interface HttpPort {
|
|
12
|
+
/** POST /calls/:id/answer with the ring grant. */
|
|
13
|
+
fun answer(callId: String, grant: String, cb: (HttpResult) -> Unit)
|
|
14
|
+
/** POST /calls/:id/join with a session token. */
|
|
15
|
+
fun join(callId: String, sessionToken: String, cb: (HttpResult) -> Unit)
|
|
16
|
+
/** POST /conversations/:id/calls with a session token. */
|
|
17
|
+
fun place(conversationId: String, media: String, sessionToken: String, cb: (HttpResult) -> Unit)
|
|
18
|
+
/** POST /calls/:id/decline — grant or session; fire-and-forget with retry. */
|
|
19
|
+
fun decline(callId: String, auth: String, busy: Boolean)
|
|
20
|
+
/** POST /calls/:id/hangup with the call-control token; fire-and-forget with retry. */
|
|
21
|
+
fun hangup(callId: String, controlToken: String)
|
|
22
|
+
/** POST /calls/:id/reconnect with the call-control token. */
|
|
23
|
+
fun reconnect(callId: String, controlToken: String, cb: (HttpResult) -> Unit)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
sealed interface HttpResult {
|
|
27
|
+
/** 2xx: the CallSessionBundle (or reconnect's token refresh). */
|
|
28
|
+
data class Ok(val bundle: CallSessionBundle) : HttpResult
|
|
29
|
+
/** 410 — the call is finished (or this device was superseded). */
|
|
30
|
+
data object Gone : HttpResult
|
|
31
|
+
/** Timeout / transient network failure. */
|
|
32
|
+
data object Timeout : HttpResult
|
|
33
|
+
/** 401/403 — grant spent, expired, or wrong device. Terminal. */
|
|
34
|
+
data class AuthFailed(val code: String) : HttpResult
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
data class CallSessionBundle(
|
|
38
|
+
val callId: String,
|
|
39
|
+
val conversationId: String,
|
|
40
|
+
val conversationName: String?,
|
|
41
|
+
val media: String,
|
|
42
|
+
val created: Boolean,
|
|
43
|
+
val livekitUrl: String,
|
|
44
|
+
val livekitToken: String,
|
|
45
|
+
val controlToken: String,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
/** LiveKit (or any SFU) — media only, no lifecycle opinions. */
|
|
49
|
+
interface MediaPort {
|
|
50
|
+
fun join(bundle: CallSessionBundle)
|
|
51
|
+
fun leave()
|
|
52
|
+
fun setMuted(muted: Boolean)
|
|
53
|
+
fun setCamera(on: Boolean)
|
|
54
|
+
fun switchCamera()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The ring UX: CallStyle notification + full-screen activity + tones. */
|
|
58
|
+
interface RingSurfacePort {
|
|
59
|
+
fun show(ring: RingContext)
|
|
60
|
+
fun dismiss(callId: String)
|
|
61
|
+
fun ringtoneStart()
|
|
62
|
+
fun ringtoneStop()
|
|
63
|
+
fun ringbackStart()
|
|
64
|
+
fun ringbackStop()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
data class RingContext(
|
|
68
|
+
val callId: String,
|
|
69
|
+
val conversationId: String,
|
|
70
|
+
val conversationName: String?,
|
|
71
|
+
val callerName: String?,
|
|
72
|
+
val media: String,
|
|
73
|
+
val grant: String,
|
|
74
|
+
val ringDeadlineEpochMs: Long,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
/** Android: the phoneCall foreground service. iOS: the CallKit session. */
|
|
78
|
+
interface CallSessionPort {
|
|
79
|
+
fun start(callId: String, media: String)
|
|
80
|
+
fun stop()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Snapshots out — the bridge turns these into JS events. */
|
|
84
|
+
fun interface SnapshotSink {
|
|
85
|
+
fun emit(state: MachineState)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Time, injectable: real adapters use a Handler/looper; tests a fake. */
|
|
89
|
+
interface TimerPort {
|
|
90
|
+
fun nowMs(): Long
|
|
91
|
+
/** Scheduling an existing key replaces it. */
|
|
92
|
+
fun schedule(key: String, delayMs: Long, fire: () -> Unit)
|
|
93
|
+
fun cancel(key: String)
|
|
94
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
package dev.poolse.callcore.android
|
|
2
|
+
|
|
3
|
+
import android.app.NotificationChannel
|
|
4
|
+
import android.app.NotificationManager
|
|
5
|
+
import android.app.PendingIntent
|
|
6
|
+
import android.content.Context
|
|
7
|
+
import android.content.Intent
|
|
8
|
+
import android.media.AudioAttributes
|
|
9
|
+
import android.media.MediaPlayer
|
|
10
|
+
import android.media.Ringtone
|
|
11
|
+
import android.media.RingtoneManager
|
|
12
|
+
import android.os.Build
|
|
13
|
+
import androidx.core.app.NotificationCompat
|
|
14
|
+
import androidx.core.app.NotificationManagerCompat
|
|
15
|
+
import androidx.core.app.Person
|
|
16
|
+
import dev.poolse.callcore.RingContext
|
|
17
|
+
import dev.poolse.callcore.RingSurfacePort
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The incoming-call UX: a CallStyle notification with a full-screen
|
|
21
|
+
* intent, so a locked phone lights up with a call screen the way the
|
|
22
|
+
* dialer does, plus the ringtone and the caller's ringback.
|
|
23
|
+
*
|
|
24
|
+
* Tones are owned here, not delegated to react-native-incall-manager,
|
|
25
|
+
* whose stream handling without its full lifecycle played the ringback
|
|
26
|
+
* on the loudspeaker and the ringtone in the earpiece. Both players use
|
|
27
|
+
* explicit [AudioAttributes]: ringtone on the ringtone stream (loud,
|
|
28
|
+
* speaker), ringback on voice-communication (quiet, follows the call
|
|
29
|
+
* route).
|
|
30
|
+
*
|
|
31
|
+
* Tap actions resolve to broadcasts handled by [CallActionReceiver],
|
|
32
|
+
* which forwards into the single native entry point so that notification
|
|
33
|
+
* button, full-screen button and app-closed cold-launch all funnel to
|
|
34
|
+
* one place.
|
|
35
|
+
*/
|
|
36
|
+
internal class AndroidRingSurface(
|
|
37
|
+
private val context: Context,
|
|
38
|
+
private val config: RingSurfaceConfig,
|
|
39
|
+
) : RingSurfacePort {
|
|
40
|
+
|
|
41
|
+
private var ringtone: Ringtone? = null
|
|
42
|
+
private var ringback: MediaPlayer? = null
|
|
43
|
+
|
|
44
|
+
override fun show(ring: RingContext) {
|
|
45
|
+
ensureChannel()
|
|
46
|
+
val extras = ring.toBundle()
|
|
47
|
+
|
|
48
|
+
fun actionPending(action: String, requestOffset: Int): PendingIntent {
|
|
49
|
+
val intent = Intent(context, CallActionReceiver::class.java)
|
|
50
|
+
.setAction("$ACTION_PREFIX.$action")
|
|
51
|
+
.putExtra(EXTRA_RING, extras)
|
|
52
|
+
return PendingIntent.getBroadcast(
|
|
53
|
+
context,
|
|
54
|
+
ring.callId.hashCode() + requestOffset,
|
|
55
|
+
intent,
|
|
56
|
+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
val fullScreen = PendingIntent.getActivity(
|
|
61
|
+
context,
|
|
62
|
+
ring.callId.hashCode(),
|
|
63
|
+
Intent(context, IncomingCallActivity::class.java)
|
|
64
|
+
.putExtra(EXTRA_RING, extras)
|
|
65
|
+
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP),
|
|
66
|
+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
val caller = Person.Builder()
|
|
70
|
+
.setName(ring.conversationName ?: ring.callerName ?: "Incoming call")
|
|
71
|
+
.setImportant(true)
|
|
72
|
+
.build()
|
|
73
|
+
|
|
74
|
+
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
|
75
|
+
.setSmallIcon(config.smallIcon)
|
|
76
|
+
.setCategory(NotificationCompat.CATEGORY_CALL)
|
|
77
|
+
.setPriority(NotificationCompat.PRIORITY_MAX)
|
|
78
|
+
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
|
79
|
+
.setOngoing(true)
|
|
80
|
+
.setAutoCancel(false)
|
|
81
|
+
.setFullScreenIntent(fullScreen, true)
|
|
82
|
+
.setStyle(
|
|
83
|
+
NotificationCompat.CallStyle.forIncomingCall(
|
|
84
|
+
caller,
|
|
85
|
+
actionPending(ACTION_DECLINE, 1),
|
|
86
|
+
actionPending(ACTION_ANSWER, 2),
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
.build()
|
|
90
|
+
|
|
91
|
+
runCatching {
|
|
92
|
+
NotificationManagerCompat.from(context).notify(ring.callId.notificationId(), notification)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
override fun dismiss(callId: String) {
|
|
97
|
+
NotificationManagerCompat.from(context).cancel(callId.notificationId())
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
override fun ringtoneStart() {
|
|
101
|
+
ringtoneStop()
|
|
102
|
+
runCatching {
|
|
103
|
+
val uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE) ?: return
|
|
104
|
+
ringtone = RingtoneManager.getRingtone(context, uri)?.apply {
|
|
105
|
+
audioAttributes = AudioAttributes.Builder()
|
|
106
|
+
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
|
|
107
|
+
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
|
108
|
+
.build()
|
|
109
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) isLooping = true
|
|
110
|
+
play()
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
override fun ringtoneStop() {
|
|
116
|
+
runCatching { ringtone?.stop() }
|
|
117
|
+
ringtone = null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
override fun ringbackStart() {
|
|
121
|
+
ringbackStop()
|
|
122
|
+
val resId = config.ringbackRawRes
|
|
123
|
+
if (resId == 0) return
|
|
124
|
+
runCatching {
|
|
125
|
+
ringback = MediaPlayer.create(
|
|
126
|
+
context,
|
|
127
|
+
resId,
|
|
128
|
+
AudioAttributes.Builder()
|
|
129
|
+
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
|
|
130
|
+
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
|
131
|
+
.build(),
|
|
132
|
+
/* audioSessionId = */ 0,
|
|
133
|
+
)?.apply {
|
|
134
|
+
isLooping = true
|
|
135
|
+
start()
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
override fun ringbackStop() {
|
|
141
|
+
runCatching {
|
|
142
|
+
ringback?.stop()
|
|
143
|
+
ringback?.release()
|
|
144
|
+
}
|
|
145
|
+
ringback = null
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private fun ensureChannel() {
|
|
149
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
|
150
|
+
val manager = context.getSystemService(NotificationManager::class.java)
|
|
151
|
+
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
|
152
|
+
// Silent channel: the ringtone is ours (looping, controllable). A
|
|
153
|
+
// channel sound on top would double-ring and could not be stopped
|
|
154
|
+
// the instant the ring resolves.
|
|
155
|
+
manager.createNotificationChannel(
|
|
156
|
+
NotificationChannel(CHANNEL_ID, "Incoming calls", NotificationManager.IMPORTANCE_HIGH).apply {
|
|
157
|
+
setSound(null, null)
|
|
158
|
+
enableVibration(true)
|
|
159
|
+
vibrationPattern = longArrayOf(0, 400, 300, 400)
|
|
160
|
+
lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
|
|
161
|
+
},
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
companion object {
|
|
166
|
+
const val CHANNEL_ID = "poolse_incoming_calls"
|
|
167
|
+
const val ACTION_PREFIX = "dev.poolse.callcore.action"
|
|
168
|
+
const val ACTION_ANSWER = "ANSWER"
|
|
169
|
+
const val ACTION_DECLINE = "DECLINE"
|
|
170
|
+
const val ACTION_OPEN = "OPEN"
|
|
171
|
+
const val EXTRA_RING = "poolse_ring"
|
|
172
|
+
|
|
173
|
+
/** Stable per-call notification id, so teardown targets the right one. */
|
|
174
|
+
fun String.notificationId(): Int = hashCode()
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Host-supplied, platform-specific bits the core cannot know. */
|
|
179
|
+
data class RingSurfaceConfig(
|
|
180
|
+
val smallIcon: Int,
|
|
181
|
+
/** `R.raw.incallmanager_ringback`, or 0 to fall back to silence. */
|
|
182
|
+
val ringbackRawRes: Int,
|
|
183
|
+
)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
package dev.poolse.callcore.android
|
|
2
|
+
|
|
3
|
+
import android.content.BroadcastReceiver
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.content.Intent
|
|
6
|
+
import dev.poolse.callcore.android.AndroidRingSurface.Companion.ACTION_ANSWER
|
|
7
|
+
import dev.poolse.callcore.android.AndroidRingSurface.Companion.ACTION_DECLINE
|
|
8
|
+
import dev.poolse.callcore.android.AndroidRingSurface.Companion.ACTION_OPEN
|
|
9
|
+
import dev.poolse.callcore.android.AndroidRingSurface.Companion.ACTION_PREFIX
|
|
10
|
+
import dev.poolse.callcore.android.AndroidRingSurface.Companion.EXTRA_RING
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Answer / Decline taps on the CallStyle notification land here, as do
|
|
14
|
+
* programmatic opens. Everything funnels into [CallCore], the one native
|
|
15
|
+
* entry point — the same place the full-screen activity and a cold
|
|
16
|
+
* launch resolve to, so there is a single answer/decline path regardless
|
|
17
|
+
* of how the tap arrived.
|
|
18
|
+
*/
|
|
19
|
+
class CallActionReceiver : BroadcastReceiver() {
|
|
20
|
+
override fun onReceive(context: Context, intent: Intent) {
|
|
21
|
+
val ring = intent.getBundleExtra(EXTRA_RING)?.toRingContext() ?: return
|
|
22
|
+
when (intent.action) {
|
|
23
|
+
"$ACTION_PREFIX.$ACTION_ANSWER" -> CallCore.get(context).onUserAnswer(ring)
|
|
24
|
+
"$ACTION_PREFIX.$ACTION_DECLINE" -> CallCore.get(context).onUserDecline(ring)
|
|
25
|
+
"$ACTION_PREFIX.$ACTION_OPEN" -> CallCore.get(context).onUserOpen(ring)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
package dev.poolse.callcore.android
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.content.Intent
|
|
5
|
+
import dev.poolse.callcore.CallCoreRuntime
|
|
6
|
+
import dev.poolse.callcore.MachineState
|
|
7
|
+
import dev.poolse.callcore.MediaPort
|
|
8
|
+
import dev.poolse.callcore.RingContext
|
|
9
|
+
import dev.poolse.callcore.SnapshotSink
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The composition root and the single native entry point.
|
|
13
|
+
*
|
|
14
|
+
* Everything that can originate a call action — the FCM service, the
|
|
15
|
+
* notification receiver, the full-screen activity, the React Native
|
|
16
|
+
* bridge — comes through here, so there is exactly one runtime and one
|
|
17
|
+
* answer/decline path on the device. Process-singleton, created lazily
|
|
18
|
+
* from the first caller's application context.
|
|
19
|
+
*/
|
|
20
|
+
class CallCore private constructor(context: Context) {
|
|
21
|
+
|
|
22
|
+
private val appContext = context.applicationContext
|
|
23
|
+
|
|
24
|
+
// The SFU is injected, not baked in: the core stays media-engine
|
|
25
|
+
// agnostic (CONTRACT.md — "LiveKit or any SFU"), and the library
|
|
26
|
+
// compiles without a specific media SDK on the classpath. The host
|
|
27
|
+
// supplies the factory; LiveKitMediaPort is the first implementation.
|
|
28
|
+
private val media: MediaPort = requireConfig().mediaFactory(appContext, mediaEvents())
|
|
29
|
+
private val surface = AndroidRingSurface(appContext, requireConfig().ringSurface)
|
|
30
|
+
private val runtime = CallCoreRuntime(
|
|
31
|
+
http = CallHttpClient(requireConfig().apiBaseUrl),
|
|
32
|
+
media = media,
|
|
33
|
+
surface = surface,
|
|
34
|
+
session = CallForegroundService.also { it.attach(appContext) },
|
|
35
|
+
timers = HandlerTimerPort(),
|
|
36
|
+
sink = SnapshotSink { emitSnapshot(it) },
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
// ── native entry points ─────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/** A ring arrived — from FCM (closed app) or the socket (open app). */
|
|
42
|
+
fun onRing(ring: RingContext) = runtime.onRing(ring)
|
|
43
|
+
|
|
44
|
+
/** The caller withdrew, or the ring resolved elsewhere. */
|
|
45
|
+
fun onRingEnded(callId: String, reason: String) = runtime.onRingEnded(callId, reason)
|
|
46
|
+
|
|
47
|
+
fun onCallFinished(callId: String) = runtime.onCallFinished(callId)
|
|
48
|
+
|
|
49
|
+
fun onUserAnswer(ring: RingContext) {
|
|
50
|
+
runtime.onRing(ring) // idempotent: dedupes if already showing
|
|
51
|
+
runtime.answer()
|
|
52
|
+
bringAppToFront(ring)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
fun onUserDecline(ring: RingContext) {
|
|
56
|
+
runtime.onRing(ring)
|
|
57
|
+
runtime.decline()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
fun onUserOpen(ring: RingContext) {
|
|
61
|
+
runtime.onRing(ring)
|
|
62
|
+
bringAppToFront(ring)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── bridge-driven actions ───────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
fun answer() = runtime.answer()
|
|
68
|
+
fun decline() = runtime.decline()
|
|
69
|
+
fun hangUp() = runtime.hangUp()
|
|
70
|
+
fun place(conversationId: String, media: String, sessionToken: String) =
|
|
71
|
+
runtime.place(conversationId, media, sessionToken)
|
|
72
|
+
fun join(callId: String, conversationId: String?, sessionToken: String) =
|
|
73
|
+
runtime.join(callId, conversationId, sessionToken)
|
|
74
|
+
fun setMuted(muted: Boolean) = runtime.setMuted(muted)
|
|
75
|
+
fun setCamera(on: Boolean) = runtime.setCamera(on)
|
|
76
|
+
fun switchCamera() = runtime.switchCamera()
|
|
77
|
+
|
|
78
|
+
fun snapshot(): MachineState = runtime.currentState()
|
|
79
|
+
|
|
80
|
+
/** The bridge subscribes here; replaced each mount, cleared on unmount. */
|
|
81
|
+
@Volatile var snapshotListener: ((MachineState) -> Unit)? = null
|
|
82
|
+
|
|
83
|
+
private fun emitSnapshot(state: MachineState) {
|
|
84
|
+
snapshotListener?.invoke(state)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private fun mediaEvents() = object : MediaEvents {
|
|
88
|
+
override fun onConnected() = runtime.onMediaConnected()
|
|
89
|
+
override fun onFailed() = runtime.onMediaFailed()
|
|
90
|
+
override fun onLost() = runtime.onMediaLost()
|
|
91
|
+
override fun onRecovered() = runtime.onMediaRecovered()
|
|
92
|
+
override fun onRemoteJoined() = runtime.onRemoteJoined()
|
|
93
|
+
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.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private fun bringAppToFront(ring: RingContext) {
|
|
100
|
+
val launch = appContext.packageManager.getLaunchIntentForPackage(appContext.packageName)
|
|
101
|
+
?: return
|
|
102
|
+
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) }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
companion object {
|
|
108
|
+
const val EXTRA_LAUNCH_CALL = "poolse_launch_call"
|
|
109
|
+
private const val PREFS = "dev.poolse.callcore"
|
|
110
|
+
|
|
111
|
+
@Volatile private var instance: CallCore? = null
|
|
112
|
+
@Volatile private var config: CallCoreConfig? = null
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Configure the core and PERSIST the inputs. A ring can wake a killed
|
|
116
|
+
* app: the OS spawns the process cold to run [CallMessagingService],
|
|
117
|
+
* with no React context and no chance for the JS bridge to call this.
|
|
118
|
+
* Persisting lets [restoreConfig] rebuild the same config from disk on
|
|
119
|
+
* that cold start, so closed-app ringing works. The bridge calls this
|
|
120
|
+
* each time the app opens; the resource names (not the resolved ids)
|
|
121
|
+
* are stored, since ids are not stable across installs.
|
|
122
|
+
*/
|
|
123
|
+
fun configure(context: Context, apiBaseUrl: String, smallIconName: String?, ringbackName: String?) {
|
|
124
|
+
val ctx = context.applicationContext
|
|
125
|
+
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
|
126
|
+
.putString("apiBaseUrl", apiBaseUrl)
|
|
127
|
+
.putString("smallIcon", smallIconName)
|
|
128
|
+
.putString("ringback", ringbackName)
|
|
129
|
+
.apply()
|
|
130
|
+
config = build(ctx, apiBaseUrl, smallIconName, ringbackName)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
fun get(context: Context): CallCore =
|
|
134
|
+
instance ?: synchronized(this) {
|
|
135
|
+
instance ?: run {
|
|
136
|
+
if (config == null) restoreConfig(context.applicationContext)
|
|
137
|
+
CallCore(context).also { instance = it }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
internal fun peekConfig(): CallCoreConfig? = config
|
|
142
|
+
|
|
143
|
+
internal fun requireConfig(): CallCoreConfig =
|
|
144
|
+
config ?: error("CallCore.configure() must be called before use")
|
|
145
|
+
|
|
146
|
+
// Rebuild config from the persisted inputs on a cold start. Leaves
|
|
147
|
+
// config null if the app was never configured — impossible in
|
|
148
|
+
// practice, since registering for pushes requires opening the app.
|
|
149
|
+
private fun restoreConfig(context: Context) {
|
|
150
|
+
val p = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
|
151
|
+
val url = p.getString("apiBaseUrl", null) ?: return
|
|
152
|
+
config = build(context, url, p.getString("smallIcon", null), p.getString("ringback", null))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private fun build(
|
|
156
|
+
context: Context,
|
|
157
|
+
apiBaseUrl: String,
|
|
158
|
+
smallIconName: String?,
|
|
159
|
+
ringbackName: String?,
|
|
160
|
+
): CallCoreConfig {
|
|
161
|
+
val iconId = smallIconName
|
|
162
|
+
?.let { context.resources.getIdentifier(it, "drawable", context.packageName) } ?: 0
|
|
163
|
+
val ringbackId = ringbackName
|
|
164
|
+
?.let { context.resources.getIdentifier(it, "raw", context.packageName) } ?: 0
|
|
165
|
+
return CallCoreConfig(
|
|
166
|
+
apiBaseUrl = apiBaseUrl.trimEnd('/'),
|
|
167
|
+
// Fall back to the app's launcher icon rather than crash on a typo.
|
|
168
|
+
smallIcon = if (iconId != 0) iconId else context.applicationInfo.icon,
|
|
169
|
+
ringbackRawRes = ringbackId,
|
|
170
|
+
mediaFactory = resolveMediaFactory(),
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The shipped LiveKit adapter is loaded reflectively so the core
|
|
176
|
+
* library carries no static dependency on the SFU source set. Absent
|
|
177
|
+
* (LiveKit not built in), calls still ring and signal; there is just
|
|
178
|
+
* no media — a supported degraded mode, not a crash.
|
|
179
|
+
*/
|
|
180
|
+
@Suppress("UNCHECKED_CAST")
|
|
181
|
+
private fun resolveMediaFactory(): (Context, MediaEvents) -> MediaPort =
|
|
182
|
+
runCatching {
|
|
183
|
+
val companion = Class.forName("dev.poolse.callcore.livekit.LiveKitMediaPort\$Companion")
|
|
184
|
+
val livekit = Class.forName("dev.poolse.callcore.livekit.LiveKitMediaPort")
|
|
185
|
+
.getField("Companion").get(null)
|
|
186
|
+
companion.getMethod("getFactory").invoke(livekit) as (Context, MediaEvents) -> MediaPort
|
|
187
|
+
}.getOrElse {
|
|
188
|
+
{ _, _ -> NoMediaPort }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Signalling-only fallback: the call rings and the state machine
|
|
192
|
+
* runs, there is simply no audio/video transport. */
|
|
193
|
+
private object NoMediaPort : MediaPort {
|
|
194
|
+
override fun join(bundle: dev.poolse.callcore.CallSessionBundle) {}
|
|
195
|
+
override fun leave() {}
|
|
196
|
+
override fun setMuted(muted: Boolean) {}
|
|
197
|
+
override fun setCamera(on: Boolean) {}
|
|
198
|
+
override fun switchCamera() {}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The host-supplied configuration: the API base URL the HTTP doors
|
|
205
|
+
* target, and the drawable/raw resources the surfaces need. Everything
|
|
206
|
+
* here is knowable only by the app, never by the library.
|
|
207
|
+
*/
|
|
208
|
+
data class CallCoreConfig(
|
|
209
|
+
val apiBaseUrl: String,
|
|
210
|
+
val smallIcon: Int,
|
|
211
|
+
val ringbackRawRes: Int,
|
|
212
|
+
/**
|
|
213
|
+
* Builds the media engine for a call. Injected so the core carries no
|
|
214
|
+
* SFU dependency; pass `::LiveKitMediaPortFactory` (the shipped
|
|
215
|
+
* implementation) or your own.
|
|
216
|
+
*/
|
|
217
|
+
val mediaFactory: (Context, MediaEvents) -> MediaPort,
|
|
218
|
+
) {
|
|
219
|
+
internal val ringSurface get() = RingSurfaceConfig(smallIcon, ringbackRawRes)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Media-level signals the SFU adapter reports back to the core. */
|
|
223
|
+
interface MediaEvents {
|
|
224
|
+
fun onConnected()
|
|
225
|
+
fun onFailed()
|
|
226
|
+
fun onLost()
|
|
227
|
+
fun onRecovered()
|
|
228
|
+
fun onRemoteJoined()
|
|
229
|
+
fun onParticipants(participants: List<MediaParticipant>)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
data class MediaParticipant(
|
|
233
|
+
val identity: String,
|
|
234
|
+
val speaking: Boolean,
|
|
235
|
+
val muted: Boolean,
|
|
236
|
+
)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
package dev.poolse.callcore.android
|
|
2
|
+
|
|
3
|
+
import android.app.Notification
|
|
4
|
+
import android.app.Service
|
|
5
|
+
import android.content.Context
|
|
6
|
+
import android.content.Intent
|
|
7
|
+
import android.content.pm.ServiceInfo
|
|
8
|
+
import android.os.Build
|
|
9
|
+
import android.os.IBinder
|
|
10
|
+
import androidx.core.app.NotificationCompat
|
|
11
|
+
import dev.poolse.callcore.CallSessionPort
|
|
12
|
+
|
|
13
|
+
/**
|
|
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.
|
|
19
|
+
*
|
|
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
|
+
*/
|
|
24
|
+
class CallForegroundService : Service() {
|
|
25
|
+
|
|
26
|
+
override fun onBind(intent: Intent?): IBinder? = null
|
|
27
|
+
|
|
28
|
+
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
29
|
+
val media = intent?.getStringExtra(EXTRA_MEDIA) ?: "audio"
|
|
30
|
+
startInForeground(media)
|
|
31
|
+
// Sticky-less: if the system kills us, the call is already gone with
|
|
32
|
+
// the process; a relaunched empty service would be a ghost.
|
|
33
|
+
return START_NOT_STICKY
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private fun startInForeground(media: String) {
|
|
37
|
+
val config = CallCore.peekConfig()
|
|
38
|
+
val notification: Notification = NotificationCompat.Builder(this, AndroidRingSurface.CHANNEL_ID)
|
|
39
|
+
.setSmallIcon(config?.smallIcon ?: android.R.drawable.sym_call_outgoing)
|
|
40
|
+
.setContentTitle("Ongoing call")
|
|
41
|
+
.setContentText(if (media == "video") "Video call" else "Voice call")
|
|
42
|
+
.setOngoing(true)
|
|
43
|
+
.setCategory(NotificationCompat.CATEGORY_CALL)
|
|
44
|
+
.setPriority(NotificationCompat.PRIORITY_LOW)
|
|
45
|
+
.build()
|
|
46
|
+
|
|
47
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
48
|
+
var type = ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
|
|
49
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
50
|
+
type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL
|
|
51
|
+
}
|
|
52
|
+
startForeground(NOTIFICATION_ID, notification, type)
|
|
53
|
+
} else {
|
|
54
|
+
startForeground(NOTIFICATION_ID, notification)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
companion object : CallSessionPort {
|
|
59
|
+
private const val NOTIFICATION_ID = 0x9C0FE
|
|
60
|
+
private const val EXTRA_MEDIA = "media"
|
|
61
|
+
|
|
62
|
+
private lateinit var appContext: Context
|
|
63
|
+
|
|
64
|
+
/** Wired once by [CallCore] so the port can start/stop the service. */
|
|
65
|
+
fun attach(context: Context) { appContext = context.applicationContext }
|
|
66
|
+
|
|
67
|
+
override fun start(callId: String, media: String) {
|
|
68
|
+
val intent = Intent(appContext, CallForegroundService::class.java).putExtra(EXTRA_MEDIA, media)
|
|
69
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
70
|
+
appContext.startForegroundService(intent)
|
|
71
|
+
} else {
|
|
72
|
+
appContext.startService(intent)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
override fun stop() {
|
|
77
|
+
appContext.stopService(Intent(appContext, CallForegroundService::class.java))
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|