@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.
Files changed (30) hide show
  1. package/android/build.gradle +7 -0
  2. package/android/consumer-rules.pro +11 -0
  3. package/android/src/main/AndroidManifest.xml +2 -0
  4. package/android/src/main/java/dev/poolse/callcore/CallCoreRuntime.kt +245 -110
  5. package/android/src/main/java/dev/poolse/callcore/CallMachine.kt +113 -18
  6. package/android/src/main/java/dev/poolse/callcore/Ports.kt +17 -2
  7. package/android/src/main/java/dev/poolse/callcore/android/AndroidRingSurface.kt +51 -4
  8. package/android/src/main/java/dev/poolse/callcore/android/CallActionReceiver.kt +4 -3
  9. package/android/src/main/java/dev/poolse/callcore/android/CallCore.kt +269 -25
  10. package/android/src/main/java/dev/poolse/callcore/android/CallForegroundService.kt +228 -47
  11. package/android/src/main/java/dev/poolse/callcore/android/CallHttpClient.kt +97 -46
  12. package/android/src/main/java/dev/poolse/callcore/android/CallMessagingService.kt +59 -13
  13. package/android/src/main/java/dev/poolse/callcore/android/CallVideo.kt +36 -0
  14. package/android/src/main/java/dev/poolse/callcore/android/IncomingCallActivity.kt +100 -12
  15. package/android/src/main/java/dev/poolse/callcore/android/SnapshotJson.kt +15 -0
  16. package/android/src/main/java/expo/modules/poolsecalls/PoolseCallVideoView.kt +64 -0
  17. package/android/src/main/java/expo/modules/poolsecalls/PoolseCallsModule.kt +85 -17
  18. package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitMediaPort.kt +325 -23
  19. package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitVideoSurface.kt +107 -0
  20. package/android/src/test/java/dev/poolse/callcore/android/CallHttpClientTest.kt +61 -0
  21. package/dist/index.cjs +34 -0
  22. package/dist/index.cjs.map +1 -1
  23. package/dist/index.d.cts +52 -3
  24. package/dist/index.d.ts +52 -3
  25. package/dist/index.js +30 -2
  26. package/dist/index.js.map +1 -1
  27. package/package.json +2 -1
  28. package/src/expo-modules-core.d.ts +5 -0
  29. package/src/index.ts +91 -3
  30. package/src/react-shims.d.ts +22 -0
@@ -2,11 +2,17 @@ package dev.poolse.callcore.android
2
2
 
3
3
  import android.animation.ValueAnimator
4
4
  import android.app.Activity
5
+ import android.content.BroadcastReceiver
6
+ import android.content.Context
7
+ import android.content.Intent
8
+ import android.content.IntentFilter
5
9
  import android.graphics.Color
6
10
  import android.graphics.Typeface
7
11
  import android.graphics.drawable.GradientDrawable
8
12
  import android.os.Build
9
13
  import android.os.Bundle
14
+ import android.os.Handler
15
+ import android.os.Looper
10
16
  import android.view.Gravity
11
17
  import android.view.View
12
18
  import android.view.WindowManager
@@ -14,6 +20,10 @@ import android.view.animation.LinearInterpolator
14
20
  import android.widget.FrameLayout
15
21
  import android.widget.LinearLayout
16
22
  import android.widget.TextView
23
+ import androidx.core.content.ContextCompat
24
+ import dev.poolse.callcore.RingContext
25
+ import dev.poolse.callcore.android.AndroidRingSurface.Companion.ACTION_RING_CLOSED
26
+ import dev.poolse.callcore.android.AndroidRingSurface.Companion.EXTRA_CALL_ID
17
27
  import dev.poolse.callcore.android.AndroidRingSurface.Companion.EXTRA_RING
18
28
 
19
29
  /**
@@ -23,41 +33,118 @@ import dev.poolse.callcore.android.AndroidRingSurface.Companion.EXTRA_RING
23
33
  * ground, brand glow, a breathing ring) in plain views. The buttons
24
34
  * resolve into [CallCore], the same entry point every other surface
25
35
  * uses; answering brings the app forward over the keyguard.
36
+ *
37
+ * It closes itself when the ring ends anywhere else (caller cancelled,
38
+ * answered on another device, deadline) via the package-local
39
+ * [ACTION_RING_CLOSED] broadcast — before, a remotely-ended ring left
40
+ * this screen up with dead buttons.
26
41
  */
27
42
  class IncomingCallActivity : Activity() {
28
43
 
29
44
  private var pulse: ValueAnimator? = null
30
45
  private val density by lazy { resources.displayMetrics.density }
31
46
  private fun px(v: Int): Int = (v * density).toInt()
47
+ private val main = Handler(Looper.getMainLooper())
48
+
49
+ /** The ring on screen. */
50
+ private var ring: RingContext? = null
51
+
52
+ /**
53
+ * Answered: we are only waiting for the app to come forward over us.
54
+ * Our own DISMISS_RING broadcast must not close us before it does —
55
+ * finishing first left the app behind the keyguard.
56
+ */
57
+ private var awaitingForward = false
58
+
59
+ private val ringClosed = object : BroadcastReceiver() {
60
+ override fun onReceive(context: Context, intent: Intent) {
61
+ val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: return
62
+ if (callId == ring?.callId && !awaitingForward) finish()
63
+ }
64
+ }
32
65
 
33
66
  override fun onCreate(savedInstanceState: Bundle?) {
34
67
  super.onCreate(savedInstanceState)
35
68
  showOverKeyguard()
69
+ ContextCompat.registerReceiver(
70
+ this,
71
+ ringClosed,
72
+ IntentFilter(ACTION_RING_CLOSED),
73
+ ContextCompat.RECEIVER_NOT_EXPORTED,
74
+ )
75
+ handle(intent)
76
+ }
36
77
 
37
- val ring = intent.getBundleExtra(EXTRA_RING)?.toRingContext()
38
- if (ring == null) { finish(); return }
78
+ /** singleInstance: a new ring (or the answer action) arrives here, not in onCreate. */
79
+ override fun onNewIntent(intent: Intent) {
80
+ super.onNewIntent(intent)
81
+ setIntent(intent)
82
+ handle(intent)
83
+ }
84
+
85
+ private fun handle(intent: Intent) {
86
+ val next = intent.getBundleExtra(EXTRA_RING)?.toRingContext()
87
+ if (next == null) { if (ring == null) finish(); return }
88
+ val core = CallCore.getOrNull(this)
89
+ if (core == null) { finish(); return }
39
90
 
40
91
  // Answered from the notification: this activity exists only to be the
41
92
  // foreground context that is ALLOWED to bring the app up (a
42
- // background receiver is not). Answer and get out before drawing —
43
- // onUserAnswer launches the app over the keyguard.
93
+ // background receiver is not).
44
94
  if (intent.getBooleanExtra(AndroidRingSurface.EXTRA_AUTO_ANSWER, false)) {
45
- CallCore.get(this).onUserAnswer(ring)
46
- finish()
95
+ ring = next
96
+ answerAndForward(core, next)
97
+ return
98
+ }
99
+
100
+ // A stale tap (the ring resolved, or the process restarted after it
101
+ // ended): there is nothing to answer.
102
+ if (!core.isRingLive(next)) {
103
+ if (ring == null || ring?.callId == next.callId) finish()
47
104
  return
48
105
  }
106
+ ring = next
107
+ awaitingForward = false
108
+ pulse?.cancel()
109
+ val title = next.conversationName ?: next.callerName ?: "Incoming call"
110
+ val body = if (next.media == "video") "Incoming video call" else "Incoming call"
111
+ setContentView(buildScene(title, body, next))
112
+ }
113
+
114
+ /**
115
+ * Answer, then bring the app forward FROM THIS ACTIVITY, and stay
116
+ * (as a plain dark window) until the app is in front — finishing at
117
+ * once dropped the start on some OEMs and left the call audible
118
+ * behind the lock screen. onStop (we got covered) finishes us; a
119
+ * timer is the fallback.
120
+ */
121
+ private fun answerAndForward(core: CallCore, ring: RingContext) {
122
+ awaitingForward = true
123
+ pulse?.cancel()
124
+ pulse = null
125
+ setContentView(View(this).apply { setBackgroundColor(Color.parseColor("#0B0B14")) })
126
+ val launched = core.onUserAnswer(ring, this)
127
+ if (!launched) { finish(); return }
128
+ main.postDelayed({ if (!isFinishing) finish() }, FORWARD_FALLBACK_MS)
129
+ }
49
130
 
50
- val title = ring.conversationName ?: ring.callerName ?: "Incoming call"
51
- val body = if (ring.media == "video") "Incoming video call" else "Incoming call"
52
- setContentView(buildScene(title, body, ring))
131
+ override fun onStop() {
132
+ super.onStop()
133
+ if (awaitingForward && !isFinishing) finish()
53
134
  }
54
135
 
55
136
  override fun onDestroy() {
137
+ main.removeCallbacksAndMessages(null)
138
+ runCatching { unregisterReceiver(ringClosed) }
56
139
  pulse?.cancel()
57
140
  pulse = null
58
141
  super.onDestroy()
59
142
  }
60
143
 
144
+ private companion object {
145
+ const val FORWARD_FALLBACK_MS = 4_000L
146
+ }
147
+
61
148
  private fun showOverKeyguard() {
62
149
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
63
150
  setShowWhenLocked(true)
@@ -72,7 +159,7 @@ class IncomingCallActivity : Activity() {
72
159
  window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
73
160
  }
74
161
 
75
- private fun buildScene(title: String, body: String, ring: dev.poolse.callcore.RingContext): View {
162
+ private fun buildScene(title: String, body: String, ring: RingContext): View {
76
163
  val root = FrameLayout(this).apply { setBackgroundColor(Color.parseColor("#0B0B14")) }
77
164
 
78
165
  root.addView(
@@ -147,10 +234,11 @@ class IncomingCallActivity : Activity() {
147
234
  layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, wrap())
148
235
  }
149
236
  val decline = actionButton("Decline", "✕", "#EF4444") {
150
- CallCore.get(this).onUserDecline(ring); finish()
237
+ CallCore.getOrNull(this)?.onUserDecline(ring); finish()
151
238
  }
152
239
  val accept = actionButton("Answer", "✓", "#22C55E") {
153
- CallCore.get(this).onUserAnswer(ring); finish()
240
+ val core = CallCore.getOrNull(this)
241
+ if (core == null) finish() else answerAndForward(core, ring)
154
242
  }
155
243
  buttons.addView(decline, LinearLayout.LayoutParams(wrap(), wrap()).apply { rightMargin = px(52) })
156
244
  buttons.addView(accept, LinearLayout.LayoutParams(wrap(), wrap()).apply { leftMargin = px(52) })
@@ -20,8 +20,23 @@ internal fun MachineState.toSnapshot(
20
20
  audioRoute: String = "earpiece",
21
21
  mutedSelf: Boolean = false,
22
22
  cameraOn: Boolean = false,
23
+ cameraFacing: String? = null,
23
24
  ): Map<String, Any?>? {
24
25
  if (phase == Phase.IDLE) return null
26
+ return buildMap {
27
+ putAll(snapshotCore(conversationName, participants, audioRoute, mutedSelf, cameraOn))
28
+ // Optional in the contract: present only while a camera is published.
29
+ if (cameraFacing != null) put("cameraFacing", cameraFacing)
30
+ }
31
+ }
32
+
33
+ private fun MachineState.snapshotCore(
34
+ conversationName: String?,
35
+ participants: List<Map<String, Any>>,
36
+ audioRoute: String,
37
+ mutedSelf: Boolean,
38
+ cameraOn: Boolean,
39
+ ): Map<String, Any?> {
25
40
  return mapOf(
26
41
  "cv" to 1,
27
42
  "callId" to callId,
@@ -0,0 +1,64 @@
1
+ package expo.modules.poolsecalls
2
+
3
+ import android.content.Context
4
+ import android.view.ViewGroup
5
+ import dev.poolse.callcore.android.CallCore
6
+ import dev.poolse.callcore.android.CallVideoSurface
7
+ import expo.modules.kotlin.AppContext
8
+ import expo.modules.kotlin.views.ExpoView
9
+
10
+ /**
11
+ * `<CallVideoView>` on Android: a thin host for the core's
12
+ * [CallVideoSurface]. All binding logic (which track, rebinds on
13
+ * subscribe/mute/rejoin) lives in the media adapter; this view only
14
+ * forwards props and ties the surface's lifetime to the window.
15
+ */
16
+ class PoolseCallVideoView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
17
+
18
+ // The renderer is a native child React does not lay out: let Android
19
+ // measure it (and re-measure when the video's size changes).
20
+ override val shouldUseAndroidLayout = true
21
+
22
+ private var surface: CallVideoSurface? = null
23
+
24
+ var participantId: String? = null
25
+ set(v) { field = v; apply() }
26
+ var local: Boolean = false
27
+ set(v) { field = v; apply() }
28
+ var mirror: Boolean? = null
29
+ set(v) { field = v; apply() }
30
+ var fit: String = "cover"
31
+ set(v) { field = v; apply() }
32
+
33
+ override fun onAttachedToWindow() {
34
+ super.onAttachedToWindow()
35
+ attachSurface()
36
+ }
37
+
38
+ override fun onDetachedFromWindow() {
39
+ detachSurface()
40
+ super.onDetachedFromWindow()
41
+ }
42
+
43
+ private fun attachSurface() {
44
+ if (surface != null) return
45
+ // No core yet (never configured) or no video-capable SFU: stay empty.
46
+ val s = CallCore.peek()?.createVideoSurface(context) ?: return
47
+ surface = s
48
+ addView(s.view, LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
49
+ apply()
50
+ }
51
+
52
+ internal fun detachSurface() {
53
+ val s = surface ?: return
54
+ surface = null
55
+ s.release()
56
+ removeView(s.view)
57
+ }
58
+
59
+ private fun apply() {
60
+ // Mounted before the core existed (configure still pending): try again.
61
+ val s = surface ?: run { if (isAttachedToWindow) attachSurface(); return }
62
+ s.configure(participantId, local, mirror, if (fit == "contain") "contain" else "cover")
63
+ }
64
+ }
@@ -1,7 +1,13 @@
1
1
  package expo.modules.poolsecalls
2
2
 
3
+ import android.app.NotificationManager
4
+ import android.content.Context
5
+ import android.content.Intent
6
+ import android.net.Uri
7
+ import android.os.Build
8
+ import android.provider.Settings
9
+ import android.util.Log
3
10
  import dev.poolse.callcore.android.CallCore
4
- import dev.poolse.callcore.android.toSnapshot
5
11
  import expo.modules.kotlin.modules.Module
6
12
  import expo.modules.kotlin.modules.ModuleDefinition
7
13
 
@@ -15,6 +21,9 @@ import expo.modules.kotlin.modules.ModuleDefinition
15
21
  * the JS side needs no R references. The SFU factory defaults to the
16
22
  * shipped LiveKit adapter when present, else a no-op (audio-less), so a
17
23
  * build without the LiveKit source set still rings and signals.
24
+ *
25
+ * Nothing here throws before `configure`: reads return idle, commands
26
+ * no-op (a fresh install renders its UI before JS has configured).
18
27
  */
19
28
  class PoolseCallsModule : Module() {
20
29
 
@@ -23,45 +32,104 @@ class PoolseCallsModule : Module() {
23
32
 
24
33
  Events("onCallState")
25
34
 
35
+ // <CallVideoView>: renders a participant's (or our own) camera.
36
+ View(PoolseCallVideoView::class) {
37
+ Name("PoolseCallVideo")
38
+ Prop("participantId") { view: PoolseCallVideoView, id: String? -> view.participantId = id }
39
+ Prop("local") { view: PoolseCallVideoView, local: Boolean? -> view.local = local ?: false }
40
+ Prop("mirror") { view: PoolseCallVideoView, mirror: Boolean? -> view.mirror = mirror }
41
+ Prop("fit") { view: PoolseCallVideoView, fit: String? -> view.fit = fit ?: "cover" }
42
+ OnViewDestroys { view: PoolseCallVideoView -> view.detachSurface() }
43
+ }
44
+
26
45
  // Snapshots → the single JS event. Null (idle) becomes an explicit
27
- // cleared payload so the JS side always has a definite state.
46
+ // cleared payload so the JS side always has a definite state. The
47
+ // listener is process-level, so subscribing before configure works.
28
48
  OnStartObserving {
29
- CallCore.get(context).snapshotListener = { state ->
30
- sendEvent("onCallState", mapOf("snapshot" to state.toSnapshot()))
31
- }
49
+ CallCore.listener = { snapshot -> sendEvent("onCallState", mapOf("snapshot" to snapshot)) }
32
50
  }
33
51
  OnStopObserving {
34
- CallCore.get(context).snapshotListener = null
52
+ CallCore.listener = null
35
53
  }
36
54
 
37
55
  AsyncFunction("configure") { apiBaseUrl: String, options: Map<String, Any?>? ->
56
+ val ctx = context ?: return@AsyncFunction null
38
57
  // Resource NAMES go to the core, which resolves + persists them —
39
58
  // so a killed app woken by a ring can rebuild this without JS.
40
59
  CallCore.configure(
41
- context,
60
+ ctx,
42
61
  apiBaseUrl,
43
62
  options?.get("smallIcon") as? String,
44
63
  options?.get("ringback") as? String,
45
64
  )
65
+ // Materialise the core now, so a call already in flight (cold
66
+ // answer) is visible to the first getCallState.
67
+ CallCore.getOrNull(ctx)
68
+ null
46
69
  }
47
70
 
48
71
  Function("getCallState") {
49
- mapOf("snapshot" to CallCore.get(context).snapshot().toSnapshot())
72
+ mapOf("snapshot" to (CallCore.peek() ?: context?.let(CallCore::getOrNull))?.snapshot())
50
73
  }
51
74
 
52
75
  AsyncFunction("place") { conversationId: String, media: String, sessionToken: String ->
53
- CallCore.get(context).place(conversationId, media, sessionToken)
76
+ core()?.place(conversationId, media, sessionToken)
54
77
  }
55
78
  AsyncFunction("join") { callId: String, conversationId: String?, sessionToken: String ->
56
- CallCore.get(context).join(callId, conversationId, sessionToken)
79
+ core()?.join(callId, conversationId, sessionToken)
80
+ }
81
+ Function("answer") { core()?.answer() }
82
+ Function("decline") { core()?.decline() }
83
+ Function("hangUp") { core()?.hangUp() }
84
+ Function("setMuted") { muted: Boolean -> core()?.setMuted(muted) }
85
+ Function("setCamera") { on: Boolean -> core()?.setCamera(on) }
86
+ Function("switchCamera") { core()?.switchCamera() }
87
+ Function("setSpeaker") { on: Boolean -> core()?.setSpeaker(on) }
88
+
89
+ // Socket forwarding (open app): idempotent with the FCM path.
90
+ Function("onRingEnded") { callId: String, reason: String -> core()?.onRingEnded(callId, reason) }
91
+ Function("onCallFinished") { callId: String, reason: String? ->
92
+ core()?.onCallFinished(callId, reason?.ifEmpty { null })
93
+ }
94
+
95
+ // Android 14+: full-screen intents need a user grant for non-dialer apps.
96
+ Function("canUseFullScreenIntent") {
97
+ val ctx = context ?: return@Function true
98
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
99
+ ctx.getSystemService(NotificationManager::class.java).canUseFullScreenIntent()
100
+ } else {
101
+ true
102
+ }
103
+ }
104
+ Function("openFullScreenIntentSettings") {
105
+ val ctx = context ?: return@Function null
106
+ val pkg = Uri.parse("package:${ctx.packageName}")
107
+ val intents = buildList {
108
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
109
+ add(Intent(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT, pkg))
110
+ }
111
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
112
+ add(Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).putExtra(Settings.EXTRA_APP_PACKAGE, ctx.packageName))
113
+ }
114
+ add(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, pkg))
115
+ }
116
+ val opener = appContext.currentActivity
117
+ for (intent in intents) {
118
+ val ok = runCatching {
119
+ if (opener != null) opener.startActivity(intent)
120
+ else ctx.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
121
+ }.onFailure { Log.w(TAG, "settings intent ${intent.action} unavailable", it) }.isSuccess
122
+ if (ok) break
123
+ }
124
+ null
57
125
  }
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
126
  }
65
127
 
66
- private val context get() = requireNotNull(appContext.reactContext) { "no react context" }
128
+ private val context: Context? get() = appContext.reactContext
129
+
130
+ private fun core(): CallCore? = context?.let(CallCore::getOrNull)
131
+
132
+ private companion object {
133
+ const val TAG = "PoolseCalls"
134
+ }
67
135
  }