@poolse/react-native-calls 0.1.0 → 0.1.2
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.
|
@@ -106,27 +106,97 @@ class CallCore private constructor(context: Context) {
|
|
|
106
106
|
|
|
107
107
|
companion object {
|
|
108
108
|
const val EXTRA_LAUNCH_CALL = "poolse_launch_call"
|
|
109
|
+
private const val PREFS = "dev.poolse.callcore"
|
|
109
110
|
|
|
110
111
|
@Volatile private var instance: CallCore? = null
|
|
111
112
|
@Volatile private var config: CallCoreConfig? = null
|
|
112
113
|
|
|
113
114
|
/**
|
|
114
|
-
*
|
|
115
|
-
* app
|
|
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.
|
|
116
122
|
*/
|
|
117
|
-
fun configure(
|
|
118
|
-
|
|
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)
|
|
119
131
|
}
|
|
120
132
|
|
|
121
133
|
fun get(context: Context): CallCore =
|
|
122
134
|
instance ?: synchronized(this) {
|
|
123
|
-
instance ?:
|
|
135
|
+
instance ?: run {
|
|
136
|
+
if (config == null) restoreConfig(context.applicationContext)
|
|
137
|
+
CallCore(context).also { instance = it }
|
|
138
|
+
}
|
|
124
139
|
}
|
|
125
140
|
|
|
126
141
|
internal fun peekConfig(): CallCoreConfig? = config
|
|
127
142
|
|
|
128
143
|
internal fun requireConfig(): CallCoreConfig =
|
|
129
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
|
+
}
|
|
130
200
|
}
|
|
131
201
|
}
|
|
132
202
|
|
|
@@ -25,6 +25,23 @@ class CallForegroundService : Service() {
|
|
|
25
25
|
|
|
26
26
|
override fun onBind(intent: Intent?): IBinder? = null
|
|
27
27
|
|
|
28
|
+
// The ongoing-call notification has its own channel, created here so it
|
|
29
|
+
// exists on an OUTGOING call too — the incoming ring surface's channel
|
|
30
|
+
// is only made when a ring shows, so a placed call would otherwise post
|
|
31
|
+
// to a missing channel and startForeground would crash.
|
|
32
|
+
private fun ensureChannel() {
|
|
33
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
|
34
|
+
val manager = getSystemService(android.app.NotificationManager::class.java)
|
|
35
|
+
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
|
36
|
+
manager.createNotificationChannel(
|
|
37
|
+
android.app.NotificationChannel(
|
|
38
|
+
CHANNEL_ID,
|
|
39
|
+
"Ongoing calls",
|
|
40
|
+
android.app.NotificationManager.IMPORTANCE_LOW,
|
|
41
|
+
).apply { setSound(null, null) },
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
28
45
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
29
46
|
val media = intent?.getStringExtra(EXTRA_MEDIA) ?: "audio"
|
|
30
47
|
startInForeground(media)
|
|
@@ -34,8 +51,9 @@ class CallForegroundService : Service() {
|
|
|
34
51
|
}
|
|
35
52
|
|
|
36
53
|
private fun startInForeground(media: String) {
|
|
54
|
+
ensureChannel()
|
|
37
55
|
val config = CallCore.peekConfig()
|
|
38
|
-
val notification: Notification = NotificationCompat.Builder(this,
|
|
56
|
+
val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
|
39
57
|
.setSmallIcon(config?.smallIcon ?: android.R.drawable.sym_call_outgoing)
|
|
40
58
|
.setContentTitle("Ongoing call")
|
|
41
59
|
.setContentText(if (media == "video") "Video call" else "Voice call")
|
|
@@ -58,6 +76,7 @@ class CallForegroundService : Service() {
|
|
|
58
76
|
companion object : CallSessionPort {
|
|
59
77
|
private const val NOTIFICATION_ID = 0x9C0FE
|
|
60
78
|
private const val EXTRA_MEDIA = "media"
|
|
79
|
+
private const val CHANNEL_ID = "poolse_ongoing_call"
|
|
61
80
|
|
|
62
81
|
private lateinit var appContext: Context
|
|
63
82
|
|
|
@@ -35,7 +35,14 @@ class PoolseCallsModule : Module() {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
AsyncFunction("configure") { apiBaseUrl: String, options: Map<String, Any?>? ->
|
|
38
|
-
|
|
38
|
+
// Resource NAMES go to the core, which resolves + persists them —
|
|
39
|
+
// so a killed app woken by a ring can rebuild this without JS.
|
|
40
|
+
CallCore.configure(
|
|
41
|
+
context,
|
|
42
|
+
apiBaseUrl,
|
|
43
|
+
options?.get("smallIcon") as? String,
|
|
44
|
+
options?.get("ringback") as? String,
|
|
45
|
+
)
|
|
39
46
|
}
|
|
40
47
|
|
|
41
48
|
Function("getCallState") {
|
|
@@ -57,53 +64,4 @@ class PoolseCallsModule : Module() {
|
|
|
57
64
|
}
|
|
58
65
|
|
|
59
66
|
private val context get() = requireNotNull(appContext.reactContext) { "no react context" }
|
|
60
|
-
|
|
61
|
-
private fun buildConfig(apiBaseUrl: String, options: Map<String, Any?>?) =
|
|
62
|
-
dev.poolse.callcore.android.CallCoreConfig(
|
|
63
|
-
apiBaseUrl = apiBaseUrl.trimEnd('/'),
|
|
64
|
-
smallIcon = resolveDrawable(options?.get("smallIcon") as? String),
|
|
65
|
-
ringbackRawRes = resolveRaw(options?.get("ringback") as? String),
|
|
66
|
-
mediaFactory = resolveMediaFactory(),
|
|
67
|
-
)
|
|
68
|
-
|
|
69
|
-
private fun resolveDrawable(name: String?): Int {
|
|
70
|
-
val ctx = context
|
|
71
|
-
val id = name?.let { ctx.resources.getIdentifier(it, "drawable", ctx.packageName) } ?: 0
|
|
72
|
-
// Fall back to the app's launcher icon rather than crash on a typo.
|
|
73
|
-
return if (id != 0) id else ctx.applicationInfo.icon
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
private fun resolveRaw(name: String?): Int {
|
|
77
|
-
val ctx = context
|
|
78
|
-
return name?.let { ctx.resources.getIdentifier(it, "raw", ctx.packageName) } ?: 0
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* The shipped LiveKit adapter is loaded reflectively so the core
|
|
83
|
-
* library does not statically depend on the SFU source set. Absent
|
|
84
|
-
* (LiveKit not built in), calls still ring and signal; there is just
|
|
85
|
-
* no media — a supported degraded mode, not a crash.
|
|
86
|
-
*/
|
|
87
|
-
@Suppress("UNCHECKED_CAST")
|
|
88
|
-
private fun resolveMediaFactory(): (android.content.Context, dev.poolse.callcore.android.MediaEvents) -> dev.poolse.callcore.MediaPort {
|
|
89
|
-
return runCatching {
|
|
90
|
-
val companion = Class.forName("dev.poolse.callcore.livekit.LiveKitMediaPort\$Companion")
|
|
91
|
-
val instance = Class.forName("dev.poolse.callcore.livekit.LiveKitMediaPort")
|
|
92
|
-
.getField("Companion").get(null)
|
|
93
|
-
val getter = companion.getMethod("getFactory")
|
|
94
|
-
getter.invoke(instance) as (android.content.Context, dev.poolse.callcore.android.MediaEvents) -> dev.poolse.callcore.MediaPort
|
|
95
|
-
}.getOrElse {
|
|
96
|
-
{ _, _ -> NoMediaPort }
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** Signalling-only fallback: the call rings and the state machine
|
|
101
|
-
* runs, there is simply no audio/video transport. */
|
|
102
|
-
private object NoMediaPort : dev.poolse.callcore.MediaPort {
|
|
103
|
-
override fun join(bundle: dev.poolse.callcore.CallSessionBundle) {}
|
|
104
|
-
override fun leave() {}
|
|
105
|
-
override fun setMuted(muted: Boolean) {}
|
|
106
|
-
override fun setCamera(on: Boolean) {}
|
|
107
|
-
override fun switchCamera() {}
|
|
108
|
-
}
|
|
109
67
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poolse/react-native-calls",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Native call core for poolse — Kotlin/Swift calling engine with a thin React Native bridge. Ring, answer and carry audio with the app killed and the phone locked.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|