@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
|
@@ -1,30 +1,183 @@
|
|
|
1
1
|
package dev.poolse.callcore.android
|
|
2
2
|
|
|
3
|
+
import android.Manifest
|
|
3
4
|
import android.app.Notification
|
|
5
|
+
import android.app.PendingIntent
|
|
4
6
|
import android.app.Service
|
|
5
7
|
import android.content.Context
|
|
6
8
|
import android.content.Intent
|
|
9
|
+
import android.content.pm.PackageManager
|
|
7
10
|
import android.content.pm.ServiceInfo
|
|
8
11
|
import android.os.Build
|
|
12
|
+
import android.os.Handler
|
|
9
13
|
import android.os.IBinder
|
|
14
|
+
import android.os.Looper
|
|
15
|
+
import android.util.Log
|
|
10
16
|
import androidx.core.app.NotificationCompat
|
|
17
|
+
import androidx.core.content.ContextCompat
|
|
11
18
|
import dev.poolse.callcore.CallSessionPort
|
|
12
19
|
|
|
13
20
|
/**
|
|
14
|
-
* Keeps
|
|
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.
|
|
21
|
+
* Keeps the process alive while a call needs it:
|
|
19
22
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
+
* - **ring** mode (`phoneCall` type only) for the length of an incoming
|
|
24
|
+
* ring, so an OEM freezer (Samsung, Android 14 cached-app freezer)
|
|
25
|
+
* does not kill the ringtone and the ring-deadline timer of a closed
|
|
26
|
+
* app;
|
|
27
|
+
* - **call** mode (`phoneCall|microphone`) for a live call's whole
|
|
28
|
+
* lifetime, so media keeps flowing with the screen off.
|
|
29
|
+
*
|
|
30
|
+
* [CallSessionPort] is driven by the runtime (CALL_SESSION_START/STOP),
|
|
31
|
+
* ring mode by [CallCore]'s state reconcile; the service itself holds no
|
|
32
|
+
* call logic — it is the OS contract that says "a call is happening".
|
|
33
|
+
*
|
|
34
|
+
* Robustness rules, each one a crash seen in the field:
|
|
35
|
+
* - every `startForegroundService` MUST be answered by `startForeground`,
|
|
36
|
+
* even if the call already ended before `onStartCommand` ran — so a
|
|
37
|
+
* stop never calls `stopService` on a not-yet-started service; the
|
|
38
|
+
* service starts foreground and then stops itself;
|
|
39
|
+
* - the microphone type is claimed only with RECORD_AUDIO granted
|
|
40
|
+
* (Android 14 throws otherwise);
|
|
41
|
+
* - `startForeground` failures are caught and the service stops, never
|
|
42
|
+
* taking the app down with it.
|
|
23
43
|
*/
|
|
24
44
|
class CallForegroundService : Service() {
|
|
25
45
|
|
|
26
46
|
override fun onBind(intent: Intent?): IBinder? = null
|
|
27
47
|
|
|
48
|
+
override fun onCreate() {
|
|
49
|
+
super.onCreate()
|
|
50
|
+
instance = this
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
override fun onDestroy() {
|
|
54
|
+
if (instance === this) instance = null
|
|
55
|
+
super.onDestroy()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
59
|
+
pendingStarts = (pendingStarts - 1).coerceAtLeast(0)
|
|
60
|
+
reconcile(desired)
|
|
61
|
+
// Sticky-less: if the system kills us, the call is already gone with
|
|
62
|
+
// the process; a relaunched empty service would be a ghost.
|
|
63
|
+
return START_NOT_STICKY
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Bring the service in line with [want]; null = stop (after honouring the start). */
|
|
67
|
+
internal fun reconcile(want: Desired?) {
|
|
68
|
+
// Always answer the start with startForeground — a placeholder ring
|
|
69
|
+
// notification if the call is already gone.
|
|
70
|
+
val ok = tryStartForeground(want ?: Desired(Mode.RING, "", "audio"))
|
|
71
|
+
if (want == null || !ok) {
|
|
72
|
+
if (pendingStarts == 0) stopNow()
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
internal fun stopNow() {
|
|
77
|
+
runCatching { stopForeground(STOP_FOREGROUND_REMOVE) }
|
|
78
|
+
shownId = null
|
|
79
|
+
stopSelf()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The notification id we are currently foreground with. */
|
|
83
|
+
private var shownId: Int? = null
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Start (or switch) foreground. If the full type is refused — the
|
|
87
|
+
* microphone type can be, on Android 14, when the app is not in a
|
|
88
|
+
* while-in-use state — fall back to phoneCall only rather than stop:
|
|
89
|
+
* stopping dropped the ring-mode service that was already working.
|
|
90
|
+
* Stop only when even that fails.
|
|
91
|
+
*/
|
|
92
|
+
private fun tryStartForeground(want: Desired): Boolean {
|
|
93
|
+
val (id, notification) = notificationFor(want)
|
|
94
|
+
val full = typeFor(want.mode)
|
|
95
|
+
val attempts = if (full != ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL) {
|
|
96
|
+
listOf(full, ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL)
|
|
97
|
+
} else listOf(full)
|
|
98
|
+
for (type in attempts) {
|
|
99
|
+
try {
|
|
100
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) startForeground(id, notification, type)
|
|
101
|
+
else startForeground(id, notification)
|
|
102
|
+
// Switching id (ring → call) leaves the old one as a plain
|
|
103
|
+
// notification; the ring is over, so remove it.
|
|
104
|
+
shownId?.takeIf { it != id }?.let { old ->
|
|
105
|
+
runCatching { getSystemService(android.app.NotificationManager::class.java).cancel(old) }
|
|
106
|
+
}
|
|
107
|
+
shownId = id
|
|
108
|
+
return true
|
|
109
|
+
} catch (t: Throwable) {
|
|
110
|
+
// ForegroundServiceStartNotAllowedException, SecurityException
|
|
111
|
+
// (missing type permission), InvalidForegroundServiceTypeException…
|
|
112
|
+
Log.e(TAG, "startForeground(${want.mode}, type=$type) failed", t)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return false
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Ring mode wears the CallStyle ring notification itself (same id),
|
|
120
|
+
* so a ringing phone shows ONE notification, not the ring plus an
|
|
121
|
+
* "Incoming call" service notice. Falls back to our own if the ring
|
|
122
|
+
* surface has not posted yet.
|
|
123
|
+
*/
|
|
124
|
+
private fun notificationFor(want: Desired): Pair<Int, Notification> {
|
|
125
|
+
if (want.mode == Mode.RING) {
|
|
126
|
+
AndroidRingSurface.shownRing?.let { (callId, n) ->
|
|
127
|
+
if (callId == want.callId) {
|
|
128
|
+
return with(AndroidRingSurface) { callId.notificationId() } to n
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return NOTIFICATION_ID to buildNotification(want)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private fun typeFor(mode: Mode): Int {
|
|
136
|
+
var type = ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL
|
|
137
|
+
if (mode == Mode.CALL && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && micGranted()) {
|
|
138
|
+
type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
|
|
139
|
+
}
|
|
140
|
+
return type
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private fun micGranted() =
|
|
144
|
+
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
|
|
145
|
+
PackageManager.PERMISSION_GRANTED
|
|
146
|
+
|
|
147
|
+
private fun buildNotification(want: Desired): Notification {
|
|
148
|
+
ensureChannel()
|
|
149
|
+
val config = CallCore.peekConfig()
|
|
150
|
+
val title: String
|
|
151
|
+
val text: String
|
|
152
|
+
if (want.mode == Mode.RING) {
|
|
153
|
+
title = "Incoming call"; text = "Ringing…"
|
|
154
|
+
} else {
|
|
155
|
+
title = "Ongoing call"; text = if (want.media == "video") "Video call" else "Voice call"
|
|
156
|
+
}
|
|
157
|
+
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
|
|
158
|
+
.setSmallIcon(config?.smallIcon ?: android.R.drawable.sym_call_outgoing)
|
|
159
|
+
.setContentTitle(title)
|
|
160
|
+
.setContentText(text)
|
|
161
|
+
.setOngoing(true)
|
|
162
|
+
.setCategory(NotificationCompat.CATEGORY_CALL)
|
|
163
|
+
.setPriority(NotificationCompat.PRIORITY_LOW)
|
|
164
|
+
// Tapping the ongoing-call notification returns to the call.
|
|
165
|
+
if (want.mode == Mode.CALL) launchIntent(want.callId)?.let(builder::setContentIntent)
|
|
166
|
+
return builder.build()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private fun launchIntent(callId: String): PendingIntent? {
|
|
170
|
+
val launch = packageManager.getLaunchIntentForPackage(packageName) ?: return null
|
|
171
|
+
launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
|
172
|
+
launch.putExtra(CallCore.EXTRA_LAUNCH_CALL, callId)
|
|
173
|
+
return PendingIntent.getActivity(
|
|
174
|
+
this,
|
|
175
|
+
NOTIFICATION_ID,
|
|
176
|
+
launch,
|
|
177
|
+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
28
181
|
// The ongoing-call notification has its own channel, created here so it
|
|
29
182
|
// exists on an OUTGOING call too — the incoming ring surface's channel
|
|
30
183
|
// is only made when a ring shows, so a placed call would otherwise post
|
|
@@ -42,58 +195,86 @@ class CallForegroundService : Service() {
|
|
|
42
195
|
)
|
|
43
196
|
}
|
|
44
197
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
startInForeground(media)
|
|
48
|
-
// Sticky-less: if the system kills us, the call is already gone with
|
|
49
|
-
// the process; a relaunched empty service would be a ghost.
|
|
50
|
-
return START_NOT_STICKY
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
private fun startInForeground(media: String) {
|
|
54
|
-
ensureChannel()
|
|
55
|
-
val config = CallCore.peekConfig()
|
|
56
|
-
val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
|
57
|
-
.setSmallIcon(config?.smallIcon ?: android.R.drawable.sym_call_outgoing)
|
|
58
|
-
.setContentTitle("Ongoing call")
|
|
59
|
-
.setContentText(if (media == "video") "Video call" else "Voice call")
|
|
60
|
-
.setOngoing(true)
|
|
61
|
-
.setCategory(NotificationCompat.CATEGORY_CALL)
|
|
62
|
-
.setPriority(NotificationCompat.PRIORITY_LOW)
|
|
63
|
-
.build()
|
|
64
|
-
|
|
65
|
-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
66
|
-
var type = ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
|
|
67
|
-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
68
|
-
type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL
|
|
69
|
-
}
|
|
70
|
-
startForeground(NOTIFICATION_ID, notification, type)
|
|
71
|
-
} else {
|
|
72
|
-
startForeground(NOTIFICATION_ID, notification)
|
|
73
|
-
}
|
|
74
|
-
}
|
|
198
|
+
internal enum class Mode { RING, CALL }
|
|
199
|
+
internal data class Desired(val mode: Mode, val callId: String, val media: String)
|
|
75
200
|
|
|
201
|
+
/**
|
|
202
|
+
* All companion state is touched on the main thread only (the
|
|
203
|
+
* runtime's thread and the service lifecycle's), so no locking.
|
|
204
|
+
*/
|
|
76
205
|
companion object : CallSessionPort {
|
|
206
|
+
private const val TAG = "PoolseCallFgs"
|
|
77
207
|
private const val NOTIFICATION_ID = 0x9C0FE
|
|
78
|
-
private const val EXTRA_MEDIA = "media"
|
|
79
208
|
private const val CHANNEL_ID = "poolse_ongoing_call"
|
|
80
209
|
|
|
81
210
|
private lateinit var appContext: Context
|
|
211
|
+
private val main by lazy { Handler(Looper.getMainLooper()) }
|
|
212
|
+
|
|
213
|
+
private var desired: Desired? = null
|
|
214
|
+
private var instance: CallForegroundService? = null
|
|
215
|
+
/** startForegroundService calls not yet delivered to onStartCommand. */
|
|
216
|
+
private var pendingStarts = 0
|
|
82
217
|
|
|
83
218
|
/** Wired once by [CallCore] so the port can start/stop the service. */
|
|
84
219
|
fun attach(context: Context) { appContext = context.applicationContext }
|
|
85
220
|
|
|
86
|
-
override fun start(callId: String, media: String) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
221
|
+
override fun start(callId: String, media: String) = onMain {
|
|
222
|
+
desired = Desired(Mode.CALL, callId, media)
|
|
223
|
+
launch()
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
override fun stop() = onMain {
|
|
227
|
+
desired = null
|
|
228
|
+
// Only a started instance with nothing pending may stop now; a
|
|
229
|
+
// pending start stops itself after startForeground.
|
|
230
|
+
if (pendingStarts == 0) instance?.stopNow()
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Ring mode for [callId]; a no-op while a call holds the service. */
|
|
234
|
+
fun startRing(callId: String) = onMain {
|
|
235
|
+
val d = desired
|
|
236
|
+
if (d?.mode == Mode.CALL) return@onMain
|
|
237
|
+
if (d?.mode == Mode.RING && d.callId == callId) return@onMain
|
|
238
|
+
desired = Desired(Mode.RING, callId, "audio")
|
|
239
|
+
launch()
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The call's media became known/changed (a join's bundle said video):
|
|
244
|
+
* refresh the notification and type in place — no new start needed.
|
|
245
|
+
*/
|
|
246
|
+
fun updateCall(callId: String, media: String) = onMain {
|
|
247
|
+
val d = desired
|
|
248
|
+
if (d?.mode != Mode.CALL || (d.callId == callId && d.media == media)) return@onMain
|
|
249
|
+
desired = d.copy(callId = callId, media = media)
|
|
250
|
+
if (pendingStarts == 0) instance?.reconcile(desired)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Leave ring mode — never touches a call that took the service over. */
|
|
254
|
+
fun stopRing() = onMain {
|
|
255
|
+
if (desired?.mode == Mode.RING) stop()
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private fun launch() {
|
|
259
|
+
if (!::appContext.isInitialized) return
|
|
260
|
+
val intent = Intent(appContext, CallForegroundService::class.java)
|
|
261
|
+
try {
|
|
262
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
263
|
+
appContext.startForegroundService(intent)
|
|
264
|
+
} else {
|
|
265
|
+
appContext.startService(intent)
|
|
266
|
+
}
|
|
267
|
+
pendingStarts++
|
|
268
|
+
} catch (t: Throwable) {
|
|
269
|
+
// Background-start restriction (no FCM/user exemption at hand).
|
|
270
|
+
// A running instance can still switch type in place.
|
|
271
|
+
Log.w(TAG, "could not start the call service", t)
|
|
272
|
+
instance?.reconcile(desired)
|
|
92
273
|
}
|
|
93
274
|
}
|
|
94
275
|
|
|
95
|
-
|
|
96
|
-
|
|
276
|
+
private inline fun onMain(crossinline block: () -> Unit) {
|
|
277
|
+
if (Looper.myLooper() == Looper.getMainLooper()) block() else main.post { block() }
|
|
97
278
|
}
|
|
98
279
|
}
|
|
99
280
|
}
|
|
@@ -25,7 +25,12 @@ import org.json.JSONObject
|
|
|
25
25
|
* those (one retry, then media decides).
|
|
26
26
|
*/
|
|
27
27
|
internal class CallHttpClient(
|
|
28
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Read per request, not captured: a later `configure()` with another
|
|
30
|
+
* API origin (staging ↔ prod switch) takes effect on the next call
|
|
31
|
+
* without rebuilding the process-wide runtime.
|
|
32
|
+
*/
|
|
33
|
+
private val baseUrl: () -> String,
|
|
29
34
|
private val client: OkHttpClient = defaultClient(),
|
|
30
35
|
) : HttpPort {
|
|
31
36
|
|
|
@@ -47,8 +52,8 @@ internal class CallHttpClient(
|
|
|
47
52
|
cb = cb,
|
|
48
53
|
)
|
|
49
54
|
|
|
50
|
-
override fun reconnect(
|
|
51
|
-
post("calls/$callId/reconnect", controlToken, body = null, cb = cb)
|
|
55
|
+
override fun reconnect(current: CallSessionBundle, cb: (HttpResult) -> Unit) =
|
|
56
|
+
post("calls/${current.callId}/reconnect", current.controlToken, body = null, current = current, cb = cb)
|
|
52
57
|
|
|
53
58
|
override fun decline(callId: String, auth: String, busy: Boolean) =
|
|
54
59
|
postBestEffort(
|
|
@@ -62,24 +67,26 @@ internal class CallHttpClient(
|
|
|
62
67
|
|
|
63
68
|
// ── request/response ────────────────────────────────────────────────
|
|
64
69
|
|
|
65
|
-
private fun post(
|
|
66
|
-
|
|
70
|
+
private fun post(
|
|
71
|
+
path: String,
|
|
72
|
+
token: String,
|
|
73
|
+
body: JSONObject?,
|
|
74
|
+
current: CallSessionBundle? = null,
|
|
75
|
+
cb: (HttpResult) -> Unit,
|
|
76
|
+
) {
|
|
77
|
+
val req = runCatching { request(path, token, body) }.getOrElse {
|
|
78
|
+
// A malformed base URL is a configuration error, not a timeout:
|
|
79
|
+
// retrying cannot fix it.
|
|
80
|
+
cb(HttpResult.Failed("bad_url")); return
|
|
81
|
+
}
|
|
82
|
+
client.newCall(req).enqueue(object : Callback {
|
|
67
83
|
override fun onFailure(call: Call, e: IOException) = cb(HttpResult.Timeout)
|
|
68
84
|
|
|
69
85
|
override fun onResponse(call: Call, response: Response) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
in 200..299 -> runCatching { HttpResult.Ok(parseBundle(payload)) }
|
|
75
|
-
.getOrElse { HttpResult.Timeout }
|
|
76
|
-
410 -> HttpResult.Gone
|
|
77
|
-
401, 403 -> HttpResult.AuthFailed(reasonOf(payload, it.code))
|
|
78
|
-
408, 429, in 500..599 -> HttpResult.Timeout
|
|
79
|
-
else -> HttpResult.AuthFailed("http_${it.code}")
|
|
80
|
-
},
|
|
81
|
-
)
|
|
82
|
-
}
|
|
86
|
+
val result = runCatching {
|
|
87
|
+
response.use { classify(it.code, it.body?.string(), current) }
|
|
88
|
+
}.getOrElse { HttpResult.Timeout } // body read failed mid-stream: transient
|
|
89
|
+
cb(result)
|
|
83
90
|
}
|
|
84
91
|
})
|
|
85
92
|
}
|
|
@@ -87,7 +94,8 @@ internal class CallHttpClient(
|
|
|
87
94
|
// ── fire-and-forget with bounded retry ──────────────────────────────
|
|
88
95
|
|
|
89
96
|
private fun postBestEffort(path: String, token: String, body: JSONObject?, attempt: Int = 0) {
|
|
90
|
-
|
|
97
|
+
val req = runCatching { request(path, token, body) }.getOrNull() ?: return
|
|
98
|
+
client.newCall(req).enqueue(object : Callback {
|
|
91
99
|
override fun onFailure(call: Call, e: IOException) {
|
|
92
100
|
if (attempt < MAX_BEST_EFFORT_RETRIES) postBestEffort(path, token, body, attempt + 1)
|
|
93
101
|
}
|
|
@@ -108,44 +116,87 @@ internal class CallHttpClient(
|
|
|
108
116
|
|
|
109
117
|
private fun request(path: String, token: String, body: JSONObject?): Request =
|
|
110
118
|
Request.Builder()
|
|
111
|
-
.url("$baseUrl/v1/$path")
|
|
119
|
+
.url("${baseUrl().trimEnd('/')}/v1/$path")
|
|
112
120
|
.header("Authorization", "Bearer $token")
|
|
113
121
|
.header("Accept", "application/json")
|
|
114
122
|
.post((body?.toString() ?: "{}").toRequestBody(JSON))
|
|
115
123
|
.build()
|
|
116
124
|
|
|
117
|
-
|
|
118
|
-
val root = JSONObject(requireNotNull(payload) { "empty success body" })
|
|
119
|
-
val call = root.getJSONObject("call")
|
|
120
|
-
val livekit = root.getJSONObject("livekit")
|
|
121
|
-
return CallSessionBundle(
|
|
122
|
-
callId = call.getString("id"),
|
|
123
|
-
conversationId = call.getString("conversation_id"),
|
|
124
|
-
conversationName = call.optStringOrNull("conversation_name"),
|
|
125
|
-
media = call.optString("media", "audio"),
|
|
126
|
-
created = root.optBoolean("created", true),
|
|
127
|
-
livekitUrl = livekit.getString("url"),
|
|
128
|
-
livekitToken = livekit.getString("token"),
|
|
129
|
-
controlToken = root.getString("control_token"),
|
|
130
|
-
)
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
private fun reasonOf(payload: String?, code: Int): String =
|
|
134
|
-
payload?.let { runCatching { JSONObject(it).optStringOrNull("error") }.getOrNull() }
|
|
135
|
-
?: "http_$code"
|
|
136
|
-
|
|
137
|
-
private fun JSONObject.optStringOrNull(key: String): String? =
|
|
138
|
-
if (isNull(key)) null else optString(key, "").ifEmpty { null }
|
|
139
|
-
|
|
140
|
-
private companion object {
|
|
125
|
+
internal companion object {
|
|
141
126
|
const val MAX_BEST_EFFORT_RETRIES = 3
|
|
142
127
|
val JSON = "application/json; charset=utf-8".toMediaType()
|
|
143
128
|
|
|
144
129
|
fun defaultClient(): OkHttpClient = OkHttpClient.Builder()
|
|
145
130
|
// A call action must not hang the ring surface. Short, deliberate
|
|
146
|
-
// ceilings; the runtime retries answer/place once on top of these
|
|
147
|
-
.
|
|
131
|
+
// ceilings; the runtime retries answer/place once on top of these,
|
|
132
|
+
// and both attempts fit inside its 20 s CONNECTING watchdog.
|
|
133
|
+
.callTimeout(java.time.Duration.ofSeconds(9))
|
|
148
134
|
.connectTimeout(java.time.Duration.ofSeconds(6))
|
|
149
135
|
.build()
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Status + body → [HttpResult] (CONTRACT.md §4). Pure, so it is unit
|
|
139
|
+
* tested without a network.
|
|
140
|
+
*
|
|
141
|
+
* - 2xx that will not parse is [HttpResult.Failed], NOT a timeout:
|
|
142
|
+
* retrying an identical request returns the identical body, and
|
|
143
|
+
* treating it as transient left the UI stuck in connecting.
|
|
144
|
+
* - 404/410: the call (or this device's place in it) is gone.
|
|
145
|
+
* - 401/403: auth refused — `failed`, never `finished`.
|
|
146
|
+
* - 503: no SFU capacity / maintenance — terminal for this attempt.
|
|
147
|
+
*/
|
|
148
|
+
fun classify(code: Int, payload: String?, current: CallSessionBundle? = null): HttpResult =
|
|
149
|
+
when (code) {
|
|
150
|
+
in 200..299 -> runCatching { HttpResult.Ok(parseBundle(payload, current)) }
|
|
151
|
+
.getOrElse { HttpResult.Failed("bad_body") }
|
|
152
|
+
404, 410 -> HttpResult.Gone
|
|
153
|
+
401, 403 -> HttpResult.AuthFailed(errorCode(payload) ?: "http_$code")
|
|
154
|
+
503 -> HttpResult.Failed(errorCode(payload) ?: "http_503")
|
|
155
|
+
408, 429, in 500..599 -> HttpResult.Timeout
|
|
156
|
+
else -> HttpResult.Failed(errorCode(payload) ?: "http_$code")
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* One parser for answer, join, place AND reconnect: the server now
|
|
161
|
+
* returns the full bundle on reconnect too. An older server's
|
|
162
|
+
* reconnect answered only `{livekit:{url,token}}` (maybe a fresh
|
|
163
|
+
* `control_token`); with [current] given, that is applied onto the
|
|
164
|
+
* existing bundle instead of failing.
|
|
165
|
+
*/
|
|
166
|
+
fun parseBundle(payload: String?, current: CallSessionBundle? = null): CallSessionBundle {
|
|
167
|
+
val root = JSONObject(requireNotNull(payload) { "empty success body" })
|
|
168
|
+
val livekit = root.getJSONObject("livekit")
|
|
169
|
+
val call = root.optJSONObject("call")
|
|
170
|
+
if (call == null) {
|
|
171
|
+
requireNotNull(current) { "bundle without call" }
|
|
172
|
+
return current.copy(
|
|
173
|
+
livekitUrl = livekit.optStringOrNull("url") ?: current.livekitUrl,
|
|
174
|
+
livekitToken = livekit.getString("token"),
|
|
175
|
+
controlToken = root.optStringOrNull("control_token") ?: current.controlToken,
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
return CallSessionBundle(
|
|
179
|
+
callId = call.getString("id"),
|
|
180
|
+
conversationId = call.getString("conversation_id"),
|
|
181
|
+
conversationName = call.optStringOrNull("conversation_name") ?: current?.conversationName,
|
|
182
|
+
media = call.optStringOrNull("media") ?: current?.media ?: "audio",
|
|
183
|
+
created = root.optBoolean("created", true),
|
|
184
|
+
livekitUrl = livekit.getString("url"),
|
|
185
|
+
livekitToken = livekit.getString("token"),
|
|
186
|
+
controlToken = root.optStringOrNull("control_token")
|
|
187
|
+
?: requireNotNull(current?.controlToken) { "bundle without control_token" },
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The server's envelope is `{"error":{"code":"…"}}`; tolerate a bare string too. */
|
|
192
|
+
fun errorCode(payload: String?): String? = payload?.let {
|
|
193
|
+
runCatching {
|
|
194
|
+
val root = JSONObject(it)
|
|
195
|
+
root.optJSONObject("error")?.optStringOrNull("code") ?: root.optStringOrNull("error")
|
|
196
|
+
}.getOrNull()
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private fun JSONObject.optStringOrNull(key: String): String? =
|
|
200
|
+
if (!has(key) || isNull(key)) null else (opt(key) as? String)?.ifEmpty { null }
|
|
150
201
|
}
|
|
151
202
|
}
|
|
@@ -12,7 +12,9 @@ import dev.poolse.callcore.RingContext
|
|
|
12
12
|
* messaging service (declared via metadata), so chat notifications and
|
|
13
13
|
* everything else keep working unchanged.
|
|
14
14
|
*
|
|
15
|
-
* Contract: data-only messages with `type: "call"
|
|
15
|
+
* Contract: data-only messages — rings with `type: "call"`, control
|
|
16
|
+
* pushes (`ring_ended`, `finished`) with `type: "call_event"`
|
|
17
|
+
* (CONTRACT.md §5).
|
|
16
18
|
*/
|
|
17
19
|
class CallMessagingService : FirebaseMessagingService() {
|
|
18
20
|
|
|
@@ -22,18 +24,42 @@ class CallMessagingService : FirebaseMessagingService() {
|
|
|
22
24
|
forwardToHost { it.onNewToken(token) }
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
override fun onDeletedMessages() {
|
|
28
|
+
// FCM dropped queued messages; the host's own sync logic needs to know.
|
|
29
|
+
forwardToHost { it.onDeletedMessages() }
|
|
30
|
+
}
|
|
31
|
+
|
|
25
32
|
override fun onMessageReceived(message: RemoteMessage) {
|
|
26
33
|
val data = message.data
|
|
27
|
-
|
|
34
|
+
// Rings ride type=call; control pushes (ring_ended, finished) ride
|
|
35
|
+
// type=call_event so old JS Android builds — which ring on ANY
|
|
36
|
+
// type=call — ignore them. Everything else belongs to the host.
|
|
37
|
+
val type = data["type"]
|
|
38
|
+
if (type != "call" && type != "call_event") {
|
|
28
39
|
forwardToHost { it.onMessageReceived(message) }
|
|
29
40
|
return
|
|
30
41
|
}
|
|
31
42
|
|
|
32
|
-
|
|
33
|
-
|
|
43
|
+
// Never configured on this install: nothing can answer a call, and
|
|
44
|
+
// throwing here would crash the messaging process.
|
|
45
|
+
val core = CallCore.getOrNull(this) ?: return
|
|
46
|
+
// A type=call push without an event is a legacy-shaped ring.
|
|
47
|
+
val event = data["event"]?.ifEmpty { null } ?: if (type == "call") "ring" else return
|
|
48
|
+
when (event) {
|
|
49
|
+
// ring_ended reasons: cancelled | answered_elsewhere | declined |
|
|
50
|
+
// timeout | finished. The machine only lets them end a RINGING_IN,
|
|
51
|
+
// so an answered_elsewhere echo of THIS device's own answer
|
|
52
|
+
// (optional device_id) can never end a call we already answered.
|
|
53
|
+
"ring" -> data.toRingContext()?.let(core::onRing)
|
|
34
54
|
"ring_ended" -> {
|
|
35
55
|
val callId = data["call_id"] ?: return
|
|
36
|
-
|
|
56
|
+
core.onRingEnded(callId, data["reason"] ?: "finished")
|
|
57
|
+
}
|
|
58
|
+
// The call is over for everyone (CONTRACT.md §5.3). Ends an active
|
|
59
|
+
// or ringing-out call, and tears down any ring surface still up.
|
|
60
|
+
"finished" -> {
|
|
61
|
+
val callId = data["call_id"] ?: return
|
|
62
|
+
core.onCallFinished(callId, data["reason"]?.ifEmpty { null })
|
|
37
63
|
}
|
|
38
64
|
}
|
|
39
65
|
}
|
|
@@ -57,15 +83,17 @@ class CallMessagingService : FirebaseMessagingService() {
|
|
|
57
83
|
* Forward to the host app's declared FirebaseMessagingService so its
|
|
58
84
|
* own (non-call) pushes still arrive. The host names its class in the
|
|
59
85
|
* manifest: `<meta-data android:name="dev.poolse.callcore.delegate"
|
|
60
|
-
* android:value="com.app.MyMessagingService"
|
|
86
|
+
* android:value="com.app.MyMessagingService"/>` — on `<application>`
|
|
87
|
+
* (where config plugins put it), or on this service.
|
|
88
|
+
*
|
|
89
|
+
* Reading only the SERVICE's meta-data missed the application-level
|
|
90
|
+
* entry, so every chat push was silently dropped.
|
|
61
91
|
*/
|
|
62
92
|
private fun forwardToHost(block: (FirebaseMessagingService) -> Unit) {
|
|
63
|
-
val name =
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
).metaData?.getString(DELEGATE_META)
|
|
68
|
-
}.getOrNull() ?: return
|
|
93
|
+
val name = delegateName() ?: run {
|
|
94
|
+
android.util.Log.w(TAG, "no $DELEGATE_META meta-data: non-call push dropped")
|
|
95
|
+
return
|
|
96
|
+
}
|
|
69
97
|
|
|
70
98
|
runCatching {
|
|
71
99
|
val delegate = Class.forName(name).getDeclaredConstructor().newInstance()
|
|
@@ -76,11 +104,29 @@ class CallMessagingService : FirebaseMessagingService() {
|
|
|
76
104
|
// never crash the call path.
|
|
77
105
|
delegate.attachForDelegation(this)
|
|
78
106
|
block(delegate)
|
|
107
|
+
} else {
|
|
108
|
+
android.util.Log.w(TAG, "$name is not a FirebaseMessagingService")
|
|
79
109
|
}
|
|
80
|
-
}
|
|
110
|
+
}.onFailure { android.util.Log.e(TAG, "delegate $name failed", it) }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private fun delegateName(): String? {
|
|
114
|
+
val pm = packageManager
|
|
115
|
+
val fromApp = runCatching {
|
|
116
|
+
pm.getApplicationInfo(packageName, android.content.pm.PackageManager.GET_META_DATA)
|
|
117
|
+
.metaData?.getString(DELEGATE_META)
|
|
118
|
+
}.getOrNull()
|
|
119
|
+
if (!fromApp.isNullOrEmpty()) return fromApp
|
|
120
|
+
return runCatching {
|
|
121
|
+
pm.getServiceInfo(
|
|
122
|
+
android.content.ComponentName(this, CallMessagingService::class.java),
|
|
123
|
+
android.content.pm.PackageManager.GET_META_DATA,
|
|
124
|
+
).metaData?.getString(DELEGATE_META)
|
|
125
|
+
}.getOrNull()?.ifEmpty { null }
|
|
81
126
|
}
|
|
82
127
|
|
|
83
128
|
private companion object {
|
|
129
|
+
const val TAG = "PoolseCallFcm"
|
|
84
130
|
const val DELEGATE_META = "dev.poolse.callcore.delegate"
|
|
85
131
|
}
|
|
86
132
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
package dev.poolse.callcore.android
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.view.View
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Video rendering, kept SFU-agnostic: a [dev.poolse.callcore.MediaPort]
|
|
8
|
+
* that can render video ALSO implements this; the bridge's view asks
|
|
9
|
+
* [CallCore.createVideoSurface] and never sees the SFU's types. A port
|
|
10
|
+
* without video (the signalling-only fallback) simply doesn't implement
|
|
11
|
+
* it, and the view stays empty.
|
|
12
|
+
*
|
|
13
|
+
* Main thread only — surfaces live in the view hierarchy, and the media
|
|
14
|
+
* adapter keeps its room on main.
|
|
15
|
+
*/
|
|
16
|
+
interface CallVideoSource {
|
|
17
|
+
fun createVideoSurface(context: Context): CallVideoSurface
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** One on-screen video slot, bound to a participant's camera. */
|
|
21
|
+
interface CallVideoSurface {
|
|
22
|
+
/** The Android view to host; it fills its parent. */
|
|
23
|
+
val view: View
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What to show. [participantId] is the remote's userId (SFU
|
|
27
|
+
* identity); [local] shows the self view instead. [mirror] null =
|
|
28
|
+
* the default (mirrored for the local front camera only). [fit] is
|
|
29
|
+
* `cover` or `contain`. Rebinds immediately and again whenever the
|
|
30
|
+
* underlying track appears, disappears, mutes or the room is replaced.
|
|
31
|
+
*/
|
|
32
|
+
fun configure(participantId: String?, local: Boolean, mirror: Boolean?, fit: String)
|
|
33
|
+
|
|
34
|
+
/** Detach from any track and free the renderer. Idempotent. */
|
|
35
|
+
fun release()
|
|
36
|
+
}
|