@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,151 @@
|
|
|
1
|
+
package dev.poolse.callcore.android
|
|
2
|
+
|
|
3
|
+
import dev.poolse.callcore.CallSessionBundle
|
|
4
|
+
import dev.poolse.callcore.HttpPort
|
|
5
|
+
import dev.poolse.callcore.HttpResult
|
|
6
|
+
import java.io.IOException
|
|
7
|
+
import okhttp3.Call
|
|
8
|
+
import okhttp3.Callback
|
|
9
|
+
import okhttp3.MediaType.Companion.toMediaType
|
|
10
|
+
import okhttp3.OkHttpClient
|
|
11
|
+
import okhttp3.Request
|
|
12
|
+
import okhttp3.RequestBody.Companion.toRequestBody
|
|
13
|
+
import okhttp3.Response
|
|
14
|
+
import org.json.JSONObject
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The HTTP adapter for the contract's three token doors
|
|
18
|
+
* (CONTRACT.md §4). Every call is a bare REST request with a bearer
|
|
19
|
+
* token — no session state, no SDK coupling — so the core can answer a
|
|
20
|
+
* call on a cold process with nothing but the grant from the push.
|
|
21
|
+
*
|
|
22
|
+
* Fire-and-forget methods (`decline`, `hangup`) retry transient
|
|
23
|
+
* failures a few times on OkHttp's dispatcher; request/response methods
|
|
24
|
+
* surface `HttpResult` to the runtime, which owns the retry policy for
|
|
25
|
+
* those (one retry, then media decides).
|
|
26
|
+
*/
|
|
27
|
+
internal class CallHttpClient(
|
|
28
|
+
private val baseUrl: String,
|
|
29
|
+
private val client: OkHttpClient = defaultClient(),
|
|
30
|
+
) : HttpPort {
|
|
31
|
+
|
|
32
|
+
override fun answer(callId: String, grant: String, cb: (HttpResult) -> Unit) =
|
|
33
|
+
post("calls/$callId/answer", grant, body = null, cb = cb)
|
|
34
|
+
|
|
35
|
+
override fun join(callId: String, sessionToken: String, cb: (HttpResult) -> Unit) =
|
|
36
|
+
post("calls/$callId/join", sessionToken, body = null, cb = cb)
|
|
37
|
+
|
|
38
|
+
override fun place(
|
|
39
|
+
conversationId: String,
|
|
40
|
+
media: String,
|
|
41
|
+
sessionToken: String,
|
|
42
|
+
cb: (HttpResult) -> Unit,
|
|
43
|
+
) = post(
|
|
44
|
+
"conversations/$conversationId/calls",
|
|
45
|
+
sessionToken,
|
|
46
|
+
body = JSONObject().put("media", media),
|
|
47
|
+
cb = cb,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
override fun reconnect(callId: String, controlToken: String, cb: (HttpResult) -> Unit) =
|
|
51
|
+
post("calls/$callId/reconnect", controlToken, body = null, cb = cb)
|
|
52
|
+
|
|
53
|
+
override fun decline(callId: String, auth: String, busy: Boolean) =
|
|
54
|
+
postBestEffort(
|
|
55
|
+
"calls/$callId/decline",
|
|
56
|
+
auth,
|
|
57
|
+
JSONObject().put("reason", if (busy) "busy" else "declined"),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
override fun hangup(callId: String, controlToken: String) =
|
|
61
|
+
postBestEffort("calls/$callId/hangup", controlToken, body = null)
|
|
62
|
+
|
|
63
|
+
// ── request/response ────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
private fun post(path: String, token: String, body: JSONObject?, cb: (HttpResult) -> Unit) {
|
|
66
|
+
client.newCall(request(path, token, body)).enqueue(object : Callback {
|
|
67
|
+
override fun onFailure(call: Call, e: IOException) = cb(HttpResult.Timeout)
|
|
68
|
+
|
|
69
|
+
override fun onResponse(call: Call, response: Response) {
|
|
70
|
+
response.use {
|
|
71
|
+
val payload = it.body?.string()
|
|
72
|
+
cb(
|
|
73
|
+
when (it.code) {
|
|
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
|
+
}
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── fire-and-forget with bounded retry ──────────────────────────────
|
|
88
|
+
|
|
89
|
+
private fun postBestEffort(path: String, token: String, body: JSONObject?, attempt: Int = 0) {
|
|
90
|
+
client.newCall(request(path, token, body)).enqueue(object : Callback {
|
|
91
|
+
override fun onFailure(call: Call, e: IOException) {
|
|
92
|
+
if (attempt < MAX_BEST_EFFORT_RETRIES) postBestEffort(path, token, body, attempt + 1)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
override fun onResponse(call: Call, response: Response) {
|
|
96
|
+
response.use {
|
|
97
|
+
// 2xx and 4xx are both terminal here — the server either acted
|
|
98
|
+
// or will never act. Only transient 5xx/timeout retries.
|
|
99
|
+
if ((it.code == 408 || it.code == 429 || it.code in 500..599) &&
|
|
100
|
+
attempt < MAX_BEST_EFFORT_RETRIES
|
|
101
|
+
) {
|
|
102
|
+
postBestEffort(path, token, body, attempt + 1)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private fun request(path: String, token: String, body: JSONObject?): Request =
|
|
110
|
+
Request.Builder()
|
|
111
|
+
.url("$baseUrl/v1/$path")
|
|
112
|
+
.header("Authorization", "Bearer $token")
|
|
113
|
+
.header("Accept", "application/json")
|
|
114
|
+
.post((body?.toString() ?: "{}").toRequestBody(JSON))
|
|
115
|
+
.build()
|
|
116
|
+
|
|
117
|
+
private fun parseBundle(payload: String?): CallSessionBundle {
|
|
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 {
|
|
141
|
+
const val MAX_BEST_EFFORT_RETRIES = 3
|
|
142
|
+
val JSON = "application/json; charset=utf-8".toMediaType()
|
|
143
|
+
|
|
144
|
+
fun defaultClient(): OkHttpClient = OkHttpClient.Builder()
|
|
145
|
+
// A call action must not hang the ring surface. Short, deliberate
|
|
146
|
+
// ceilings; the runtime retries answer/place once on top of these.
|
|
147
|
+
.callTimeout(java.time.Duration.ofSeconds(12))
|
|
148
|
+
.connectTimeout(java.time.Duration.ofSeconds(6))
|
|
149
|
+
.build()
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
package dev.poolse.callcore.android
|
|
2
|
+
|
|
3
|
+
import com.google.firebase.messaging.FirebaseMessagingService
|
|
4
|
+
import com.google.firebase.messaging.RemoteMessage
|
|
5
|
+
import dev.poolse.callcore.RingContext
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Native FCM intake — the point of the whole architecture. A call data
|
|
9
|
+
* message is handled here, in native code, the instant it arrives: no
|
|
10
|
+
* headless JS, no boot race, no millisecond budget spent waiting for
|
|
11
|
+
* Hermes. Non-call messages are forwarded to the host app's own
|
|
12
|
+
* messaging service (declared via metadata), so chat notifications and
|
|
13
|
+
* everything else keep working unchanged.
|
|
14
|
+
*
|
|
15
|
+
* Contract: data-only messages with `type: "call"` (CONTRACT.md §5).
|
|
16
|
+
*/
|
|
17
|
+
class CallMessagingService : FirebaseMessagingService() {
|
|
18
|
+
|
|
19
|
+
override fun onNewToken(token: String) {
|
|
20
|
+
// Registration is the app's concern (it knows the session); it reads
|
|
21
|
+
// the fresh token from the bridge's token API. Nothing to do here.
|
|
22
|
+
forwardToHost { it.onNewToken(token) }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
override fun onMessageReceived(message: RemoteMessage) {
|
|
26
|
+
val data = message.data
|
|
27
|
+
if (data["type"] != "call") {
|
|
28
|
+
forwardToHost { it.onMessageReceived(message) }
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
when (data["event"]) {
|
|
33
|
+
"ring" -> data.toRingContext()?.let { CallCore.get(this).onRing(it) }
|
|
34
|
+
"ring_ended" -> {
|
|
35
|
+
val callId = data["call_id"] ?: return
|
|
36
|
+
CallCore.get(this).onRingEnded(callId, data["reason"] ?: "finished")
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private fun Map<String, String>.toRingContext(): RingContext? {
|
|
42
|
+
val callId = this["call_id"] ?: return null
|
|
43
|
+
val conversationId = this["conversation_id"] ?: return null
|
|
44
|
+
val grant = this["grant"] ?: return null
|
|
45
|
+
return RingContext(
|
|
46
|
+
callId = callId,
|
|
47
|
+
conversationId = conversationId,
|
|
48
|
+
conversationName = this["conversation_name"]?.ifEmpty { null },
|
|
49
|
+
callerName = this["caller_name"]?.ifEmpty { null },
|
|
50
|
+
media = this["media"] ?: "audio",
|
|
51
|
+
grant = grant,
|
|
52
|
+
ringDeadlineEpochMs = this["ring_deadline"]?.toLongOrNull() ?: 0L,
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Forward to the host app's declared FirebaseMessagingService so its
|
|
58
|
+
* own (non-call) pushes still arrive. The host names its class in the
|
|
59
|
+
* manifest: `<meta-data android:name="dev.poolse.callcore.delegate"
|
|
60
|
+
* android:value="com.app.MyMessagingService"/>`.
|
|
61
|
+
*/
|
|
62
|
+
private fun forwardToHost(block: (FirebaseMessagingService) -> Unit) {
|
|
63
|
+
val name = runCatching {
|
|
64
|
+
packageManager.getServiceInfo(
|
|
65
|
+
android.content.ComponentName(this, CallMessagingService::class.java),
|
|
66
|
+
android.content.pm.PackageManager.GET_META_DATA,
|
|
67
|
+
).metaData?.getString(DELEGATE_META)
|
|
68
|
+
}.getOrNull() ?: return
|
|
69
|
+
|
|
70
|
+
runCatching {
|
|
71
|
+
val delegate = Class.forName(name).getDeclaredConstructor().newInstance()
|
|
72
|
+
if (delegate is FirebaseMessagingService) {
|
|
73
|
+
// The delegate needs our context to do anything useful; FCM's
|
|
74
|
+
// base class resolves it from the running service, so we attach
|
|
75
|
+
// ours. Kept behind runCatching: a misconfigured delegate must
|
|
76
|
+
// never crash the call path.
|
|
77
|
+
delegate.attachForDelegation(this)
|
|
78
|
+
block(delegate)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private companion object {
|
|
84
|
+
const val DELEGATE_META = "dev.poolse.callcore.delegate"
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Minimal context attach so a delegated [FirebaseMessagingService] can
|
|
90
|
+
* run outside its own lifecycle. Uses the base ContextWrapper hook.
|
|
91
|
+
*/
|
|
92
|
+
private fun FirebaseMessagingService.attachForDelegation(host: FirebaseMessagingService) {
|
|
93
|
+
runCatching {
|
|
94
|
+
val m = android.content.ContextWrapper::class.java
|
|
95
|
+
.getDeclaredMethod("attachBaseContext", android.content.Context::class.java)
|
|
96
|
+
m.isAccessible = true
|
|
97
|
+
m.invoke(this, host.applicationContext)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
package dev.poolse.callcore.android
|
|
2
|
+
|
|
3
|
+
import android.os.Handler
|
|
4
|
+
import android.os.Looper
|
|
5
|
+
import dev.poolse.callcore.TimerPort
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The real [TimerPort]: a single main-looper Handler keyed by token, so
|
|
9
|
+
* the runtime's timers (ring deadline, ended-linger, reconnect budget)
|
|
10
|
+
* fire on one predictable thread and rescheduling a key cleanly
|
|
11
|
+
* replaces its pending callback.
|
|
12
|
+
*
|
|
13
|
+
* [nowMs] is wall-clock epoch, because the one value it is compared
|
|
14
|
+
* against — the push's `ring_deadline` — is epoch, minted by the
|
|
15
|
+
* server (CONTRACT.md §5.1). The actual scheduling delay is handed to
|
|
16
|
+
* [Handler.postDelayed], which counts down monotonically regardless of
|
|
17
|
+
* clock changes, so a mid-ring NTP correction shifts the deadline by at
|
|
18
|
+
* most the drift, never breaks it.
|
|
19
|
+
*/
|
|
20
|
+
internal class HandlerTimerPort(
|
|
21
|
+
private val handler: Handler = Handler(Looper.getMainLooper()),
|
|
22
|
+
) : TimerPort {
|
|
23
|
+
|
|
24
|
+
private val pending = mutableMapOf<String, Runnable>()
|
|
25
|
+
|
|
26
|
+
override fun nowMs(): Long = System.currentTimeMillis()
|
|
27
|
+
|
|
28
|
+
override fun schedule(key: String, delayMs: Long, fire: () -> Unit) {
|
|
29
|
+
cancel(key)
|
|
30
|
+
val runnable = Runnable {
|
|
31
|
+
synchronized(pending) { pending.remove(key) }
|
|
32
|
+
fire()
|
|
33
|
+
}
|
|
34
|
+
synchronized(pending) { pending[key] = runnable }
|
|
35
|
+
handler.postDelayed(runnable, delayMs.coerceAtLeast(0))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
override fun cancel(key: String) {
|
|
39
|
+
val runnable = synchronized(pending) { pending.remove(key) } ?: return
|
|
40
|
+
handler.removeCallbacks(runnable)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
package dev.poolse.callcore.android
|
|
2
|
+
|
|
3
|
+
import android.animation.ValueAnimator
|
|
4
|
+
import android.app.Activity
|
|
5
|
+
import android.graphics.Color
|
|
6
|
+
import android.graphics.Typeface
|
|
7
|
+
import android.graphics.drawable.GradientDrawable
|
|
8
|
+
import android.os.Build
|
|
9
|
+
import android.os.Bundle
|
|
10
|
+
import android.view.Gravity
|
|
11
|
+
import android.view.View
|
|
12
|
+
import android.view.WindowManager
|
|
13
|
+
import android.view.animation.LinearInterpolator
|
|
14
|
+
import android.widget.FrameLayout
|
|
15
|
+
import android.widget.LinearLayout
|
|
16
|
+
import android.widget.TextView
|
|
17
|
+
import dev.poolse.callcore.android.AndroidRingSurface.Companion.EXTRA_RING
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The full-screen ring a locked phone shows. Native and dependency-free
|
|
21
|
+
* so it is on screen within the OS's full-screen-intent budget, long
|
|
22
|
+
* before any JS could load — carrying the call scene's look (dark
|
|
23
|
+
* ground, brand glow, a breathing ring) in plain views. The buttons
|
|
24
|
+
* resolve into [CallCore], the same entry point every other surface
|
|
25
|
+
* uses; answering brings the app forward over the keyguard.
|
|
26
|
+
*/
|
|
27
|
+
class IncomingCallActivity : Activity() {
|
|
28
|
+
|
|
29
|
+
private var pulse: ValueAnimator? = null
|
|
30
|
+
private val density by lazy { resources.displayMetrics.density }
|
|
31
|
+
private fun px(v: Int): Int = (v * density).toInt()
|
|
32
|
+
|
|
33
|
+
override fun onCreate(savedInstanceState: Bundle?) {
|
|
34
|
+
super.onCreate(savedInstanceState)
|
|
35
|
+
showOverKeyguard()
|
|
36
|
+
|
|
37
|
+
val ring = intent.getBundleExtra(EXTRA_RING)?.toRingContext()
|
|
38
|
+
if (ring == null) { finish(); return }
|
|
39
|
+
|
|
40
|
+
val title = ring.conversationName ?: ring.callerName ?: "Incoming call"
|
|
41
|
+
val body = if (ring.media == "video") "Incoming video call" else "Incoming call"
|
|
42
|
+
setContentView(buildScene(title, body, ring))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
override fun onDestroy() {
|
|
46
|
+
pulse?.cancel()
|
|
47
|
+
pulse = null
|
|
48
|
+
super.onDestroy()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private fun showOverKeyguard() {
|
|
52
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
|
53
|
+
setShowWhenLocked(true)
|
|
54
|
+
setTurnScreenOn(true)
|
|
55
|
+
} else {
|
|
56
|
+
@Suppress("DEPRECATION")
|
|
57
|
+
window.addFlags(
|
|
58
|
+
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
|
|
59
|
+
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON,
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private fun buildScene(title: String, body: String, ring: dev.poolse.callcore.RingContext): View {
|
|
66
|
+
val root = FrameLayout(this).apply { setBackgroundColor(Color.parseColor("#0B0B14")) }
|
|
67
|
+
|
|
68
|
+
root.addView(
|
|
69
|
+
View(this).apply {
|
|
70
|
+
background = GradientDrawable(
|
|
71
|
+
GradientDrawable.Orientation.TOP_BOTTOM,
|
|
72
|
+
intArrayOf(Color.parseColor("#875B54E8"), Color.parseColor("#000B0B14")),
|
|
73
|
+
).apply {
|
|
74
|
+
gradientType = GradientDrawable.RADIAL_GRADIENT
|
|
75
|
+
gradientRadius = 560 * density
|
|
76
|
+
setGradientCenter(0.5f, 0.3f)
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
val column = LinearLayout(this).apply {
|
|
83
|
+
orientation = LinearLayout.VERTICAL
|
|
84
|
+
gravity = Gravity.CENTER_HORIZONTAL
|
|
85
|
+
setPadding(px(32), px(110), px(32), px(72))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
val stage = FrameLayout(this).apply {
|
|
89
|
+
layoutParams = LinearLayout.LayoutParams(px(176), px(176)).apply { bottomMargin = px(30) }
|
|
90
|
+
}
|
|
91
|
+
val ringView = View(this).apply {
|
|
92
|
+
background = GradientDrawable().apply {
|
|
93
|
+
shape = GradientDrawable.OVAL
|
|
94
|
+
setColor(Color.TRANSPARENT)
|
|
95
|
+
setStroke(px(2), Color.parseColor("#805B54E8"))
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
stage.addView(ringView, FrameLayout.LayoutParams(px(176), px(176), Gravity.CENTER))
|
|
99
|
+
stage.addView(
|
|
100
|
+
TextView(this).apply {
|
|
101
|
+
text = title.trim().take(1).uppercase()
|
|
102
|
+
textSize = 48f
|
|
103
|
+
setTextColor(Color.WHITE)
|
|
104
|
+
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
|
105
|
+
gravity = Gravity.CENTER
|
|
106
|
+
background = GradientDrawable(
|
|
107
|
+
GradientDrawable.Orientation.TL_BR,
|
|
108
|
+
intArrayOf(Color.parseColor("#7D76FF"), Color.parseColor("#5B54E8")),
|
|
109
|
+
).apply { shape = GradientDrawable.OVAL }
|
|
110
|
+
},
|
|
111
|
+
FrameLayout.LayoutParams(px(124), px(124), Gravity.CENTER),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
val name = TextView(this).apply {
|
|
115
|
+
text = title
|
|
116
|
+
textSize = 32f
|
|
117
|
+
setTextColor(Color.WHITE)
|
|
118
|
+
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
|
119
|
+
gravity = Gravity.CENTER
|
|
120
|
+
}
|
|
121
|
+
val sub = TextView(this).apply {
|
|
122
|
+
text = body
|
|
123
|
+
textSize = 15f
|
|
124
|
+
setTextColor(Color.parseColor("#B8B8C6"))
|
|
125
|
+
gravity = Gravity.CENTER
|
|
126
|
+
background = GradientDrawable().apply {
|
|
127
|
+
cornerRadius = 999f
|
|
128
|
+
setColor(Color.parseColor("#14FFFFFF"))
|
|
129
|
+
}
|
|
130
|
+
setPadding(px(16), px(8), px(16), px(8))
|
|
131
|
+
layoutParams = LinearLayout.LayoutParams(wrap(), wrap()).apply { topMargin = px(12) }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
val buttons = LinearLayout(this).apply {
|
|
135
|
+
orientation = LinearLayout.HORIZONTAL
|
|
136
|
+
gravity = Gravity.CENTER
|
|
137
|
+
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, wrap())
|
|
138
|
+
}
|
|
139
|
+
val decline = actionButton("Decline", "✕", "#EF4444") {
|
|
140
|
+
CallCore.get(this).onUserDecline(ring); finish()
|
|
141
|
+
}
|
|
142
|
+
val accept = actionButton("Answer", "✓", "#22C55E") {
|
|
143
|
+
CallCore.get(this).onUserAnswer(ring); finish()
|
|
144
|
+
}
|
|
145
|
+
buttons.addView(decline, LinearLayout.LayoutParams(wrap(), wrap()).apply { rightMargin = px(52) })
|
|
146
|
+
buttons.addView(accept, LinearLayout.LayoutParams(wrap(), wrap()).apply { leftMargin = px(52) })
|
|
147
|
+
|
|
148
|
+
column.addView(stage)
|
|
149
|
+
column.addView(name)
|
|
150
|
+
column.addView(sub)
|
|
151
|
+
column.addView(View(this).apply { layoutParams = LinearLayout.LayoutParams(0, 0, 1f) })
|
|
152
|
+
column.addView(buttons)
|
|
153
|
+
root.addView(
|
|
154
|
+
column,
|
|
155
|
+
FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
pulse = ValueAnimator.ofFloat(0f, 1f).apply {
|
|
159
|
+
duration = 1800
|
|
160
|
+
repeatCount = ValueAnimator.INFINITE
|
|
161
|
+
interpolator = LinearInterpolator()
|
|
162
|
+
addUpdateListener {
|
|
163
|
+
val t = it.animatedValue as Float
|
|
164
|
+
ringView.scaleX = 1f + 0.28f * t
|
|
165
|
+
ringView.scaleY = 1f + 0.28f * t
|
|
166
|
+
ringView.alpha = 1f - t
|
|
167
|
+
}
|
|
168
|
+
start()
|
|
169
|
+
}
|
|
170
|
+
return root
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private fun actionButton(
|
|
174
|
+
label: String,
|
|
175
|
+
glyph: String,
|
|
176
|
+
color: String,
|
|
177
|
+
onClick: () -> Unit,
|
|
178
|
+
): LinearLayout = LinearLayout(this).apply {
|
|
179
|
+
orientation = LinearLayout.VERTICAL
|
|
180
|
+
gravity = Gravity.CENTER_HORIZONTAL
|
|
181
|
+
addView(
|
|
182
|
+
TextView(this@IncomingCallActivity).apply {
|
|
183
|
+
text = glyph
|
|
184
|
+
textSize = 30f
|
|
185
|
+
setTextColor(Color.WHITE)
|
|
186
|
+
gravity = Gravity.CENTER
|
|
187
|
+
background = GradientDrawable().apply {
|
|
188
|
+
shape = GradientDrawable.OVAL
|
|
189
|
+
setColor(Color.parseColor(color))
|
|
190
|
+
}
|
|
191
|
+
layoutParams = LinearLayout.LayoutParams(px(82), px(82))
|
|
192
|
+
isClickable = true
|
|
193
|
+
isFocusable = true
|
|
194
|
+
setOnClickListener { onClick() }
|
|
195
|
+
},
|
|
196
|
+
)
|
|
197
|
+
addView(
|
|
198
|
+
TextView(this@IncomingCallActivity).apply {
|
|
199
|
+
text = label
|
|
200
|
+
textSize = 14f
|
|
201
|
+
setTextColor(Color.parseColor("#B8B8C6"))
|
|
202
|
+
gravity = Gravity.CENTER
|
|
203
|
+
layoutParams = LinearLayout.LayoutParams(wrap(), wrap()).apply { topMargin = px(10) }
|
|
204
|
+
},
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private fun wrap() = LinearLayout.LayoutParams.WRAP_CONTENT
|
|
209
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
package dev.poolse.callcore.android
|
|
2
|
+
|
|
3
|
+
import android.os.Bundle
|
|
4
|
+
import dev.poolse.callcore.RingContext
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* [RingContext] ⇄ [Bundle]. A ring crosses process boundaries as
|
|
8
|
+
* PendingIntent extras (the notification lives on even when our process
|
|
9
|
+
* is dead), so it must round-trip through a Bundle losslessly.
|
|
10
|
+
*/
|
|
11
|
+
internal fun RingContext.toBundle(): Bundle = Bundle().apply {
|
|
12
|
+
putString(K_CALL_ID, callId)
|
|
13
|
+
putString(K_CONVERSATION_ID, conversationId)
|
|
14
|
+
putString(K_CONVERSATION_NAME, conversationName)
|
|
15
|
+
putString(K_CALLER_NAME, callerName)
|
|
16
|
+
putString(K_MEDIA, media)
|
|
17
|
+
putString(K_GRANT, grant)
|
|
18
|
+
putLong(K_DEADLINE, ringDeadlineEpochMs)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
internal fun Bundle.toRingContext(): RingContext? {
|
|
22
|
+
val callId = getString(K_CALL_ID) ?: return null
|
|
23
|
+
val conversationId = getString(K_CONVERSATION_ID) ?: return null
|
|
24
|
+
val grant = getString(K_GRANT) ?: return null
|
|
25
|
+
return RingContext(
|
|
26
|
+
callId = callId,
|
|
27
|
+
conversationId = conversationId,
|
|
28
|
+
conversationName = getString(K_CONVERSATION_NAME),
|
|
29
|
+
callerName = getString(K_CALLER_NAME),
|
|
30
|
+
media = getString(K_MEDIA) ?: "audio",
|
|
31
|
+
grant = grant,
|
|
32
|
+
ringDeadlineEpochMs = getLong(K_DEADLINE),
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private const val K_CALL_ID = "callId"
|
|
37
|
+
private const val K_CONVERSATION_ID = "conversationId"
|
|
38
|
+
private const val K_CONVERSATION_NAME = "conversationName"
|
|
39
|
+
private const val K_CALLER_NAME = "callerName"
|
|
40
|
+
private const val K_MEDIA = "media"
|
|
41
|
+
private const val K_GRANT = "grant"
|
|
42
|
+
private const val K_DEADLINE = "ringDeadline"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
package dev.poolse.callcore.android
|
|
2
|
+
|
|
3
|
+
import dev.poolse.callcore.Direction
|
|
4
|
+
import dev.poolse.callcore.MachineState
|
|
5
|
+
import dev.poolse.callcore.Phase
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The one place a [MachineState] becomes the contract's `Snapshot`
|
|
9
|
+
* (CONTRACT.md §8). Both the Android bridge and — mirrored — the JS
|
|
10
|
+
* machine produce this exact shape, so a UI reads identically wherever
|
|
11
|
+
* it runs. Returns `null` for idle: the bridge maps that to a cleared
|
|
12
|
+
* call state.
|
|
13
|
+
*
|
|
14
|
+
* The participant roster is merged in by the bridge from the media
|
|
15
|
+
* adapter's reports; the machine itself does not carry it.
|
|
16
|
+
*/
|
|
17
|
+
internal fun MachineState.toSnapshot(
|
|
18
|
+
conversationName: String? = null,
|
|
19
|
+
participants: List<Map<String, Any>> = emptyList(),
|
|
20
|
+
audioRoute: String = "earpiece",
|
|
21
|
+
mutedSelf: Boolean = false,
|
|
22
|
+
cameraOn: Boolean = false,
|
|
23
|
+
): Map<String, Any?>? {
|
|
24
|
+
if (phase == Phase.IDLE) return null
|
|
25
|
+
return mapOf(
|
|
26
|
+
"cv" to 1,
|
|
27
|
+
"callId" to callId,
|
|
28
|
+
"conversationId" to conversationId,
|
|
29
|
+
"conversationName" to conversationName,
|
|
30
|
+
"direction" to (direction?.wire ?: Direction.INCOMING.wire),
|
|
31
|
+
"media" to media,
|
|
32
|
+
"phase" to phase.wire,
|
|
33
|
+
"reason" to reason?.wire,
|
|
34
|
+
"participants" to participants,
|
|
35
|
+
"audioRoute" to audioRoute,
|
|
36
|
+
"mutedSelf" to mutedSelf,
|
|
37
|
+
"cameraOn" to cameraOn,
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private val Phase.wire: String
|
|
42
|
+
get() = when (this) {
|
|
43
|
+
Phase.IDLE -> "idle"
|
|
44
|
+
Phase.RINGING_IN -> "ringing_in"
|
|
45
|
+
Phase.RINGING_OUT -> "ringing_out"
|
|
46
|
+
Phase.CONNECTING -> "connecting"
|
|
47
|
+
Phase.ACTIVE -> "active"
|
|
48
|
+
Phase.RECONNECTING -> "reconnecting"
|
|
49
|
+
Phase.ENDED -> "ended"
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private val Direction.wire: String
|
|
53
|
+
get() = when (this) {
|
|
54
|
+
Direction.INCOMING -> "incoming"
|
|
55
|
+
Direction.OUTGOING -> "outgoing"
|
|
56
|
+
Direction.JOINED -> "joined"
|
|
57
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
package expo.modules.poolsecalls
|
|
2
|
+
|
|
3
|
+
import dev.poolse.callcore.android.CallCore
|
|
4
|
+
import dev.poolse.callcore.android.toSnapshot
|
|
5
|
+
import expo.modules.kotlin.modules.Module
|
|
6
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The React Native face of the native call core. Deliberately thin: it
|
|
10
|
+
* forwards commands to [CallCore] and streams snapshots out. All call
|
|
11
|
+
* logic lives in the core; this class only crosses the JS boundary.
|
|
12
|
+
*
|
|
13
|
+
* `configure` is the single required setup call — the host passes the
|
|
14
|
+
* API base URL; the icon and ringback resources are resolved by name so
|
|
15
|
+
* the JS side needs no R references. The SFU factory defaults to the
|
|
16
|
+
* shipped LiveKit adapter when present, else a no-op (audio-less), so a
|
|
17
|
+
* build without the LiveKit source set still rings and signals.
|
|
18
|
+
*/
|
|
19
|
+
class PoolseCallsModule : Module() {
|
|
20
|
+
|
|
21
|
+
override fun definition() = ModuleDefinition {
|
|
22
|
+
Name("PoolseCalls")
|
|
23
|
+
|
|
24
|
+
Events("onCallState")
|
|
25
|
+
|
|
26
|
+
// Snapshots → the single JS event. Null (idle) becomes an explicit
|
|
27
|
+
// cleared payload so the JS side always has a definite state.
|
|
28
|
+
OnStartObserving {
|
|
29
|
+
CallCore.get(context).snapshotListener = { state ->
|
|
30
|
+
sendEvent("onCallState", mapOf("snapshot" to state.toSnapshot()))
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
OnStopObserving {
|
|
34
|
+
CallCore.get(context).snapshotListener = null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
AsyncFunction("configure") { apiBaseUrl: String, options: Map<String, Any?>? ->
|
|
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
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
Function("getCallState") {
|
|
49
|
+
mapOf("snapshot" to CallCore.get(context).snapshot().toSnapshot())
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
AsyncFunction("place") { conversationId: String, media: String, sessionToken: String ->
|
|
53
|
+
CallCore.get(context).place(conversationId, media, sessionToken)
|
|
54
|
+
}
|
|
55
|
+
AsyncFunction("join") { callId: String, conversationId: String?, sessionToken: String ->
|
|
56
|
+
CallCore.get(context).join(callId, conversationId, sessionToken)
|
|
57
|
+
}
|
|
58
|
+
Function("answer") { CallCore.get(context).answer() }
|
|
59
|
+
Function("decline") { CallCore.get(context).decline() }
|
|
60
|
+
Function("hangUp") { CallCore.get(context).hangUp() }
|
|
61
|
+
Function("setMuted") { muted: Boolean -> CallCore.get(context).setMuted(muted) }
|
|
62
|
+
Function("setCamera") { on: Boolean -> CallCore.get(context).setCamera(on) }
|
|
63
|
+
Function("switchCamera") { CallCore.get(context).switchCamera() }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private val context get() = requireNotNull(appContext.reactContext) { "no react context" }
|
|
67
|
+
}
|