@poolse/react-native-calls 0.1.2 → 0.2.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 +74 -12
- package/android/src/main/java/dev/poolse/callcore/android/CallActionReceiver.kt +4 -3
- package/android/src/main/java/dev/poolse/callcore/android/CallCore.kt +253 -26
- 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/IncomingCallActivity.kt +106 -8
- package/android/src/main/java/expo/modules/poolsecalls/PoolseCallsModule.kt +75 -17
- package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitMediaPort.kt +190 -16
- package/android/src/test/java/dev/poolse/callcore/android/CallHttpClientTest.kt +61 -0
- package/dist/index.cjs +10 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +29 -3
- package/dist/index.d.ts +29 -3
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/index.ts +39 -2
|
@@ -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,31 +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
|
+
}
|
|
77
|
+
|
|
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
|
+
}
|
|
36
84
|
|
|
37
|
-
|
|
38
|
-
|
|
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 }
|
|
90
|
+
|
|
91
|
+
// Answered from the notification: this activity exists only to be the
|
|
92
|
+
// foreground context that is ALLOWED to bring the app up (a
|
|
93
|
+
// background receiver is not).
|
|
94
|
+
if (intent.getBooleanExtra(AndroidRingSurface.EXTRA_AUTO_ANSWER, false)) {
|
|
95
|
+
ring = next
|
|
96
|
+
answerAndForward(core, next)
|
|
97
|
+
return
|
|
98
|
+
}
|
|
39
99
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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()
|
|
104
|
+
return
|
|
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
|
+
}
|
|
130
|
+
|
|
131
|
+
override fun onStop() {
|
|
132
|
+
super.onStop()
|
|
133
|
+
if (awaitingForward && !isFinishing) finish()
|
|
43
134
|
}
|
|
44
135
|
|
|
45
136
|
override fun onDestroy() {
|
|
137
|
+
main.removeCallbacksAndMessages(null)
|
|
138
|
+
runCatching { unregisterReceiver(ringClosed) }
|
|
46
139
|
pulse?.cancel()
|
|
47
140
|
pulse = null
|
|
48
141
|
super.onDestroy()
|
|
49
142
|
}
|
|
50
143
|
|
|
144
|
+
private companion object {
|
|
145
|
+
const val FORWARD_FALLBACK_MS = 4_000L
|
|
146
|
+
}
|
|
147
|
+
|
|
51
148
|
private fun showOverKeyguard() {
|
|
52
149
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
|
53
150
|
setShowWhenLocked(true)
|
|
@@ -62,7 +159,7 @@ class IncomingCallActivity : Activity() {
|
|
|
62
159
|
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
|
63
160
|
}
|
|
64
161
|
|
|
65
|
-
private fun buildScene(title: String, body: String, ring:
|
|
162
|
+
private fun buildScene(title: String, body: String, ring: RingContext): View {
|
|
66
163
|
val root = FrameLayout(this).apply { setBackgroundColor(Color.parseColor("#0B0B14")) }
|
|
67
164
|
|
|
68
165
|
root.addView(
|
|
@@ -137,10 +234,11 @@ class IncomingCallActivity : Activity() {
|
|
|
137
234
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, wrap())
|
|
138
235
|
}
|
|
139
236
|
val decline = actionButton("Decline", "✕", "#EF4444") {
|
|
140
|
-
CallCore.
|
|
237
|
+
CallCore.getOrNull(this)?.onUserDecline(ring); finish()
|
|
141
238
|
}
|
|
142
239
|
val accept = actionButton("Answer", "✓", "#22C55E") {
|
|
143
|
-
CallCore.
|
|
240
|
+
val core = CallCore.getOrNull(this)
|
|
241
|
+
if (core == null) finish() else answerAndForward(core, ring)
|
|
144
242
|
}
|
|
145
243
|
buttons.addView(decline, LinearLayout.LayoutParams(wrap(), wrap()).apply { rightMargin = px(52) })
|
|
146
244
|
buttons.addView(accept, LinearLayout.LayoutParams(wrap(), wrap()).apply { leftMargin = px(52) })
|
|
@@ -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
|
|
|
@@ -24,44 +33,93 @@ class PoolseCallsModule : Module() {
|
|
|
24
33
|
Events("onCallState")
|
|
25
34
|
|
|
26
35
|
// Snapshots → the single JS event. Null (idle) becomes an explicit
|
|
27
|
-
// cleared payload so the JS side always has a definite state.
|
|
36
|
+
// cleared payload so the JS side always has a definite state. The
|
|
37
|
+
// listener is process-level, so subscribing before configure works.
|
|
28
38
|
OnStartObserving {
|
|
29
|
-
CallCore.
|
|
30
|
-
sendEvent("onCallState", mapOf("snapshot" to state.toSnapshot()))
|
|
31
|
-
}
|
|
39
|
+
CallCore.listener = { snapshot -> sendEvent("onCallState", mapOf("snapshot" to snapshot)) }
|
|
32
40
|
}
|
|
33
41
|
OnStopObserving {
|
|
34
|
-
CallCore.
|
|
42
|
+
CallCore.listener = null
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
AsyncFunction("configure") { apiBaseUrl: String, options: Map<String, Any?>? ->
|
|
46
|
+
val ctx = context ?: return@AsyncFunction null
|
|
38
47
|
// Resource NAMES go to the core, which resolves + persists them —
|
|
39
48
|
// so a killed app woken by a ring can rebuild this without JS.
|
|
40
49
|
CallCore.configure(
|
|
41
|
-
|
|
50
|
+
ctx,
|
|
42
51
|
apiBaseUrl,
|
|
43
52
|
options?.get("smallIcon") as? String,
|
|
44
53
|
options?.get("ringback") as? String,
|
|
45
54
|
)
|
|
55
|
+
// Materialise the core now, so a call already in flight (cold
|
|
56
|
+
// answer) is visible to the first getCallState.
|
|
57
|
+
CallCore.getOrNull(ctx)
|
|
58
|
+
null
|
|
46
59
|
}
|
|
47
60
|
|
|
48
61
|
Function("getCallState") {
|
|
49
|
-
mapOf("snapshot" to CallCore.
|
|
62
|
+
mapOf("snapshot" to (CallCore.peek() ?: context?.let(CallCore::getOrNull))?.snapshot())
|
|
50
63
|
}
|
|
51
64
|
|
|
52
65
|
AsyncFunction("place") { conversationId: String, media: String, sessionToken: String ->
|
|
53
|
-
|
|
66
|
+
core()?.place(conversationId, media, sessionToken)
|
|
54
67
|
}
|
|
55
68
|
AsyncFunction("join") { callId: String, conversationId: String?, sessionToken: String ->
|
|
56
|
-
|
|
69
|
+
core()?.join(callId, conversationId, sessionToken)
|
|
70
|
+
}
|
|
71
|
+
Function("answer") { core()?.answer() }
|
|
72
|
+
Function("decline") { core()?.decline() }
|
|
73
|
+
Function("hangUp") { core()?.hangUp() }
|
|
74
|
+
Function("setMuted") { muted: Boolean -> core()?.setMuted(muted) }
|
|
75
|
+
Function("setCamera") { on: Boolean -> core()?.setCamera(on) }
|
|
76
|
+
Function("switchCamera") { core()?.switchCamera() }
|
|
77
|
+
Function("setSpeaker") { on: Boolean -> core()?.setSpeaker(on) }
|
|
78
|
+
|
|
79
|
+
// Socket forwarding (open app): idempotent with the FCM path.
|
|
80
|
+
Function("onRingEnded") { callId: String, reason: String -> core()?.onRingEnded(callId, reason) }
|
|
81
|
+
Function("onCallFinished") { callId: String, reason: String? ->
|
|
82
|
+
core()?.onCallFinished(callId, reason?.ifEmpty { null })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Android 14+: full-screen intents need a user grant for non-dialer apps.
|
|
86
|
+
Function("canUseFullScreenIntent") {
|
|
87
|
+
val ctx = context ?: return@Function true
|
|
88
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
89
|
+
ctx.getSystemService(NotificationManager::class.java).canUseFullScreenIntent()
|
|
90
|
+
} else {
|
|
91
|
+
true
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
Function("openFullScreenIntentSettings") {
|
|
95
|
+
val ctx = context ?: return@Function null
|
|
96
|
+
val pkg = Uri.parse("package:${ctx.packageName}")
|
|
97
|
+
val intents = buildList {
|
|
98
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
99
|
+
add(Intent(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT, pkg))
|
|
100
|
+
}
|
|
101
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
102
|
+
add(Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).putExtra(Settings.EXTRA_APP_PACKAGE, ctx.packageName))
|
|
103
|
+
}
|
|
104
|
+
add(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, pkg))
|
|
105
|
+
}
|
|
106
|
+
val opener = appContext.currentActivity
|
|
107
|
+
for (intent in intents) {
|
|
108
|
+
val ok = runCatching {
|
|
109
|
+
if (opener != null) opener.startActivity(intent)
|
|
110
|
+
else ctx.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
|
111
|
+
}.onFailure { Log.w(TAG, "settings intent ${intent.action} unavailable", it) }.isSuccess
|
|
112
|
+
if (ok) break
|
|
113
|
+
}
|
|
114
|
+
null
|
|
57
115
|
}
|
|
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
116
|
}
|
|
65
117
|
|
|
66
|
-
private val context get() =
|
|
118
|
+
private val context: Context? get() = appContext.reactContext
|
|
119
|
+
|
|
120
|
+
private fun core(): CallCore? = context?.let(CallCore::getOrNull)
|
|
121
|
+
|
|
122
|
+
private companion object {
|
|
123
|
+
const val TAG = "PoolseCalls"
|
|
124
|
+
}
|
|
67
125
|
}
|
|
@@ -1,20 +1,26 @@
|
|
|
1
1
|
package dev.poolse.callcore.livekit
|
|
2
2
|
|
|
3
3
|
import android.content.Context
|
|
4
|
+
import android.util.Log
|
|
5
|
+
import com.twilio.audioswitch.AudioDevice
|
|
4
6
|
import dev.poolse.callcore.CallSessionBundle
|
|
5
7
|
import dev.poolse.callcore.MediaPort
|
|
6
8
|
import dev.poolse.callcore.android.MediaEvents
|
|
7
9
|
import dev.poolse.callcore.android.MediaParticipant
|
|
8
10
|
import io.livekit.android.LiveKit
|
|
11
|
+
import io.livekit.android.audio.AudioSwitchHandler
|
|
12
|
+
import io.livekit.android.events.DisconnectReason
|
|
9
13
|
import io.livekit.android.events.RoomEvent
|
|
10
14
|
import io.livekit.android.events.collect
|
|
11
15
|
import io.livekit.android.room.Room
|
|
16
|
+
import io.livekit.android.room.participant.Participant
|
|
12
17
|
import kotlinx.coroutines.CoroutineScope
|
|
13
18
|
import kotlinx.coroutines.Dispatchers
|
|
14
19
|
import kotlinx.coroutines.Job
|
|
15
20
|
import kotlinx.coroutines.SupervisorJob
|
|
16
21
|
import kotlinx.coroutines.cancel
|
|
17
22
|
import kotlinx.coroutines.launch
|
|
23
|
+
import org.json.JSONObject
|
|
18
24
|
|
|
19
25
|
/**
|
|
20
26
|
* The shipped [MediaPort], over LiveKit's native Android SDK. Wire it
|
|
@@ -24,8 +30,14 @@ import kotlinx.coroutines.launch
|
|
|
24
30
|
* lives in its own source set (`src/main/livekit`) kept off the core
|
|
25
31
|
* library's classpath. Lifecycle and signaling are not its concern — it
|
|
26
32
|
* joins a room with the server-minted token, publishes the mic (and
|
|
27
|
-
* camera for video), and reports room transitions back
|
|
28
|
-
* [MediaEvents]. The runtime decides everything else.
|
|
33
|
+
* camera for video), routes audio, and reports room transitions back
|
|
34
|
+
* through [MediaEvents]. The runtime decides everything else.
|
|
35
|
+
*
|
|
36
|
+
* Threading: every method is called on the main thread (the runtime's
|
|
37
|
+
* thread) and every coroutine runs on Main.immediate, so [room] is only
|
|
38
|
+
* ever touched on main. Coroutines still re-check that the room they
|
|
39
|
+
* captured is the current one — a join/leave may have replaced it
|
|
40
|
+
* while they were suspended.
|
|
29
41
|
*/
|
|
30
42
|
class LiveKitMediaPort(
|
|
31
43
|
private val context: Context,
|
|
@@ -33,37 +45,96 @@ class LiveKitMediaPort(
|
|
|
33
45
|
) : MediaPort {
|
|
34
46
|
|
|
35
47
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
|
36
|
-
private var room: Room? = null
|
|
48
|
+
@Volatile private var room: Room? = null
|
|
37
49
|
private var eventJob: Job? = null
|
|
50
|
+
private var media: String = "audio"
|
|
51
|
+
|
|
52
|
+
/** Speaker forced by the user; null = the media-type default. */
|
|
53
|
+
private var speakerWanted: Boolean? = null
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The user's mute / camera choices for the CURRENT call. They survive
|
|
57
|
+
* a rejoin: /reconnect rebuilds the room, and blindly re-enabling the
|
|
58
|
+
* mic there went live while the UI still showed "muted". Reset only
|
|
59
|
+
* when the call ends ([leave]) or a different call joins.
|
|
60
|
+
*/
|
|
61
|
+
private var mutedWanted = false
|
|
62
|
+
private var cameraWanted: Boolean? = null
|
|
63
|
+
private var joinedCallId: String? = null
|
|
38
64
|
|
|
39
65
|
override fun join(bundle: CallSessionBundle) {
|
|
40
|
-
|
|
66
|
+
// Rejoin of the same call keeps the user's choices; so do choices
|
|
67
|
+
// made while this call was still connecting (joinedCallId null). A
|
|
68
|
+
// different call joining without a leave starts clean.
|
|
69
|
+
if (joinedCallId != null && bundle.callId != joinedCallId) resetWanted()
|
|
70
|
+
joinedCallId = bundle.callId
|
|
71
|
+
detach()
|
|
72
|
+
media = bundle.media
|
|
41
73
|
val wantsVideo = bundle.media == "video"
|
|
42
74
|
val newRoom = LiveKit.create(appContext = context.applicationContext)
|
|
75
|
+
configureAudio(newRoom, wantsVideo)
|
|
43
76
|
room = newRoom
|
|
44
77
|
eventJob = scope.launch { observe(newRoom) }
|
|
45
78
|
scope.launch {
|
|
46
79
|
runCatching {
|
|
47
80
|
newRoom.connect(bundle.livekitUrl, bundle.livekitToken)
|
|
48
|
-
newRoom
|
|
49
|
-
|
|
50
|
-
|
|
81
|
+
if (room !== newRoom) return@runCatching
|
|
82
|
+
newRoom.localParticipant.setMicrophoneEnabled(!mutedWanted)
|
|
83
|
+
if (cameraWanted ?: wantsVideo) newRoom.localParticipant.setCameraEnabled(true)
|
|
84
|
+
}.onFailure {
|
|
85
|
+
Log.w(TAG, "connect failed", it)
|
|
86
|
+
if (room === newRoom) {
|
|
87
|
+
// Don't leave a half-open room (and its audio mode) behind.
|
|
88
|
+
eventJob?.cancel()
|
|
89
|
+
eventJob = null
|
|
90
|
+
teardown(newRoom)
|
|
91
|
+
room = null
|
|
92
|
+
events.onFailed()
|
|
93
|
+
}
|
|
94
|
+
}
|
|
51
95
|
}
|
|
52
96
|
}
|
|
53
97
|
|
|
98
|
+
/** The call is over for this device: drop the room AND the call's choices. */
|
|
54
99
|
override fun leave() {
|
|
100
|
+
detach()
|
|
101
|
+
resetWanted()
|
|
102
|
+
joinedCallId = null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private fun resetWanted() {
|
|
106
|
+
mutedWanted = false
|
|
107
|
+
cameraWanted = null
|
|
108
|
+
speakerWanted = null
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Drop the current room only (a rejoin replaces it). */
|
|
112
|
+
private fun detach() {
|
|
55
113
|
eventJob?.cancel()
|
|
56
114
|
eventJob = null
|
|
57
|
-
room
|
|
115
|
+
val r = room ?: return
|
|
58
116
|
room = null
|
|
117
|
+
teardown(r)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private fun teardown(r: Room) {
|
|
121
|
+
runCatching { r.disconnect() }
|
|
122
|
+
// disconnect() keeps the room's engine, capturer and audio handler
|
|
123
|
+
// alive for a later reconnect; this port never reuses a room, so
|
|
124
|
+
// release it or every call leaks a PeerConnectionFactory.
|
|
125
|
+
runCatching { r.release() }
|
|
59
126
|
}
|
|
60
127
|
|
|
61
128
|
override fun setMuted(muted: Boolean) {
|
|
62
|
-
|
|
129
|
+
mutedWanted = muted
|
|
130
|
+
val r = room ?: return
|
|
131
|
+
scope.launch { runCatching { r.localParticipant.setMicrophoneEnabled(!muted) } }
|
|
63
132
|
}
|
|
64
133
|
|
|
65
134
|
override fun setCamera(on: Boolean) {
|
|
66
|
-
|
|
135
|
+
cameraWanted = on
|
|
136
|
+
val r = room ?: return
|
|
137
|
+
scope.launch { runCatching { r.localParticipant.setCameraEnabled(on) } }
|
|
67
138
|
}
|
|
68
139
|
|
|
69
140
|
override fun switchCamera() {
|
|
@@ -71,37 +142,140 @@ class LiveKitMediaPort(
|
|
|
71
142
|
// control here keeps the port surface complete.
|
|
72
143
|
}
|
|
73
144
|
|
|
145
|
+
override fun setSpeaker(on: Boolean) {
|
|
146
|
+
speakerWanted = on
|
|
147
|
+
val handler = room?.audioHandler as? AudioSwitchHandler ?: return
|
|
148
|
+
applyRoute(handler)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── audio routing ───────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* A voice call is held to the ear: earpiece by default (a connected
|
|
155
|
+
* headset or Bluetooth device first); video defaults to the speaker.
|
|
156
|
+
* Set before connect — the handler starts during connect.
|
|
157
|
+
*/
|
|
158
|
+
private fun configureAudio(r: Room, video: Boolean) {
|
|
159
|
+
val handler = r.audioHandler as? AudioSwitchHandler ?: return
|
|
160
|
+
handler.preferredDeviceList = if (video) {
|
|
161
|
+
listOf(
|
|
162
|
+
AudioDevice.BluetoothHeadset::class.java,
|
|
163
|
+
AudioDevice.WiredHeadset::class.java,
|
|
164
|
+
AudioDevice.Speakerphone::class.java,
|
|
165
|
+
AudioDevice.Earpiece::class.java,
|
|
166
|
+
)
|
|
167
|
+
} else {
|
|
168
|
+
listOf(
|
|
169
|
+
AudioDevice.BluetoothHeadset::class.java,
|
|
170
|
+
AudioDevice.WiredHeadset::class.java,
|
|
171
|
+
AudioDevice.Earpiece::class.java,
|
|
172
|
+
AudioDevice.Speakerphone::class.java,
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
handler.audioDeviceChangeListener = { devices, selected ->
|
|
176
|
+
// A user's speaker choice survives device-list changes, except
|
|
177
|
+
// that plugging in a headset always wins (as the dialer does).
|
|
178
|
+
val wanted = speakerWanted
|
|
179
|
+
if (wanted != null && selected != null && room?.audioHandler === handler) {
|
|
180
|
+
val external = selected is AudioDevice.BluetoothHeadset || selected is AudioDevice.WiredHeadset
|
|
181
|
+
val isSpeaker = selected is AudioDevice.Speakerphone
|
|
182
|
+
if (!external && isSpeaker != wanted) devices.pick(wanted)?.let(handler::selectDevice)
|
|
183
|
+
}
|
|
184
|
+
events.onAudioRoute(routeOf(handler.selectedAudioDevice ?: selected))
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private fun applyRoute(handler: AudioSwitchHandler) {
|
|
189
|
+
val want = speakerWanted ?: (media == "video")
|
|
190
|
+
handler.availableAudioDevices.pick(want)?.let(handler::selectDevice)
|
|
191
|
+
events.onAudioRoute(routeOf(handler.selectedAudioDevice))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Speaker, or the best private route: Bluetooth → wired → earpiece. */
|
|
195
|
+
private fun List<AudioDevice>.pick(speaker: Boolean): AudioDevice? =
|
|
196
|
+
if (speaker) firstOrNull { it is AudioDevice.Speakerphone }
|
|
197
|
+
else firstOrNull { it is AudioDevice.BluetoothHeadset }
|
|
198
|
+
?: firstOrNull { it is AudioDevice.WiredHeadset }
|
|
199
|
+
?: firstOrNull { it is AudioDevice.Earpiece }
|
|
200
|
+
|
|
201
|
+
private fun routeOf(d: AudioDevice?): String = when (d) {
|
|
202
|
+
is AudioDevice.Speakerphone -> "speaker"
|
|
203
|
+
is AudioDevice.BluetoothHeadset -> "bluetooth"
|
|
204
|
+
is AudioDevice.WiredHeadset -> "headset"
|
|
205
|
+
is AudioDevice.Earpiece -> "earpiece"
|
|
206
|
+
null -> if (media == "video") "speaker" else "earpiece"
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── room events ─────────────────────────────────────────────────────
|
|
210
|
+
|
|
74
211
|
private suspend fun observe(room: Room) {
|
|
75
212
|
room.events.collect { event ->
|
|
213
|
+
if (this.room !== room) return@collect
|
|
76
214
|
when (event) {
|
|
77
|
-
is RoomEvent.Connected ->
|
|
78
|
-
|
|
215
|
+
is RoomEvent.Connected -> {
|
|
216
|
+
(room.audioHandler as? AudioSwitchHandler)?.let { if (speakerWanted != null) applyRoute(it) }
|
|
217
|
+
events.onConnected()
|
|
218
|
+
// Somebody may already be in (a late join, or a callee who
|
|
219
|
+
// answered before our media came up): they will not "connect".
|
|
220
|
+
if (room.remoteParticipants.isNotEmpty()) events.onRemoteJoined()
|
|
221
|
+
emit(room)
|
|
222
|
+
}
|
|
223
|
+
// LiveKit is resuming the SAME session (ICE restart / signal
|
|
224
|
+
// resume). Only reflect it: tearing the room down here fought
|
|
225
|
+
// its own recovery and turned every network blip into a rejoin.
|
|
226
|
+
is RoomEvent.Reconnecting -> events.onReconnecting()
|
|
79
227
|
is RoomEvent.Reconnected -> events.onRecovered()
|
|
228
|
+
// LiveKit gave up (or the server removed us). Our own leave()
|
|
229
|
+
// never gets here — the collector is cancelled first.
|
|
230
|
+
is RoomEvent.Disconnected -> {
|
|
231
|
+
if (event.reason != DisconnectReason.CLIENT_INITIATED) events.onLost()
|
|
232
|
+
}
|
|
80
233
|
is RoomEvent.ParticipantConnected -> { events.onRemoteJoined(); emit(room) }
|
|
81
234
|
is RoomEvent.ParticipantDisconnected,
|
|
82
235
|
is RoomEvent.ActiveSpeakersChanged,
|
|
83
236
|
is RoomEvent.TrackMuted,
|
|
84
|
-
is RoomEvent.TrackUnmuted
|
|
237
|
+
is RoomEvent.TrackUnmuted,
|
|
238
|
+
is RoomEvent.TrackPublished,
|
|
239
|
+
is RoomEvent.TrackUnpublished,
|
|
240
|
+
is RoomEvent.ParticipantMetadataChanged,
|
|
241
|
+
is RoomEvent.ParticipantAttributesChanged -> emit(room)
|
|
85
242
|
else -> Unit
|
|
86
243
|
}
|
|
244
|
+
Unit
|
|
87
245
|
}
|
|
88
246
|
}
|
|
89
247
|
|
|
248
|
+
/** The REMOTE roster — the local participant is never included. */
|
|
90
249
|
private fun emit(room: Room) {
|
|
91
250
|
events.onParticipants(
|
|
92
251
|
room.remoteParticipants.values.map {
|
|
252
|
+
val identity = it.identity?.value ?: ""
|
|
93
253
|
MediaParticipant(
|
|
94
|
-
identity =
|
|
254
|
+
identity = identity,
|
|
95
255
|
speaking = it.isSpeaking,
|
|
96
|
-
muted = it.
|
|
256
|
+
muted = !it.isMicrophoneEnabled(),
|
|
257
|
+
cameraOn = it.isCameraEnabled(),
|
|
258
|
+
externalId = externalIdOf(it) ?: identity,
|
|
97
259
|
)
|
|
98
260
|
},
|
|
99
261
|
)
|
|
100
262
|
}
|
|
101
263
|
|
|
102
|
-
|
|
264
|
+
/** The host's own user id, if the server put it in attributes/metadata. */
|
|
265
|
+
private fun externalIdOf(p: Participant): String? =
|
|
266
|
+
runCatching { p.attributes["external_id"] }.getOrNull()?.ifEmpty { null }
|
|
267
|
+
?: p.metadata?.let { m ->
|
|
268
|
+
runCatching { JSONObject(m).optString("external_id", "") }.getOrNull()?.ifEmpty { null }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
fun dispose() {
|
|
272
|
+
leave()
|
|
273
|
+
scope.cancel()
|
|
274
|
+
}
|
|
103
275
|
|
|
104
276
|
companion object {
|
|
277
|
+
private const val TAG = "PoolseLiveKit"
|
|
278
|
+
|
|
105
279
|
/** Pass as `mediaFactory` in CallCoreConfig. */
|
|
106
280
|
val factory: (Context, MediaEvents) -> MediaPort = { ctx, ev -> LiveKitMediaPort(ctx, ev) }
|
|
107
281
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
package dev.poolse.callcore.android
|
|
2
|
+
|
|
3
|
+
import dev.poolse.callcore.CallSessionBundle
|
|
4
|
+
import dev.poolse.callcore.HttpResult
|
|
5
|
+
import org.junit.Assert.assertEquals
|
|
6
|
+
import org.junit.Assert.assertTrue
|
|
7
|
+
import org.junit.Test
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The HTTP classifier/parser, without a network: status + body →
|
|
11
|
+
* HttpResult exactly as CONTRACT.md §4 maps them.
|
|
12
|
+
*/
|
|
13
|
+
class CallHttpClientTest {
|
|
14
|
+
|
|
15
|
+
private val full = """
|
|
16
|
+
{"cv":1,"created":false,
|
|
17
|
+
"call":{"id":"c1","conversation_id":"v1","conversation_name":"Boiler","media":"video"},
|
|
18
|
+
"livekit":{"url":"wss://sfu","token":"lk-new"},
|
|
19
|
+
"control_token":"ctl-new"}
|
|
20
|
+
""".trimIndent()
|
|
21
|
+
|
|
22
|
+
private val current = CallSessionBundle(
|
|
23
|
+
callId = "c1", conversationId = "v1", conversationName = "Boiler", media = "audio",
|
|
24
|
+
created = true, livekitUrl = "wss://old", livekitToken = "lk-old", controlToken = "ctl-old",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
@Test fun `full bundle parses (answer, join, place and now reconnect)`() {
|
|
28
|
+
val r = CallHttpClient.classify(200, full, current) as HttpResult.Ok
|
|
29
|
+
assertEquals("c1", r.bundle.callId)
|
|
30
|
+
assertEquals("lk-new", r.bundle.livekitToken)
|
|
31
|
+
assertEquals("ctl-new", r.bundle.controlToken)
|
|
32
|
+
assertEquals("video", r.bundle.media)
|
|
33
|
+
assertEquals(false, r.bundle.created)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@Test fun `old reconnect shape is applied onto the current bundle`() {
|
|
37
|
+
val r = CallHttpClient.classify(200, """{"cv":1,"livekit":{"url":"wss://sfu2","token":"lk-2"}}""", current)
|
|
38
|
+
as HttpResult.Ok
|
|
39
|
+
assertEquals(current.copy(livekitUrl = "wss://sfu2", livekitToken = "lk-2"), r.bundle)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@Test fun `unparseable 2xx is a failure, not a retry`() {
|
|
43
|
+
assertTrue(CallHttpClient.classify(200, "<html>", null) is HttpResult.Failed)
|
|
44
|
+
assertTrue(CallHttpClient.classify(200, """{"livekit":{"token":"x"}}""", null) is HttpResult.Failed)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@Test fun `error envelope code is read`() {
|
|
48
|
+
val r = CallHttpClient.classify(401, """{"error":{"code":"grant_spent","message":"…"}}""")
|
|
49
|
+
assertEquals(HttpResult.AuthFailed("grant_spent"), r)
|
|
50
|
+
assertEquals(HttpResult.AuthFailed("http_403"), CallHttpClient.classify(403, null))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@Test fun `status mapping`() {
|
|
54
|
+
assertEquals(HttpResult.Gone, CallHttpClient.classify(410, null))
|
|
55
|
+
assertEquals(HttpResult.Gone, CallHttpClient.classify(404, """{"error":{"code":"no_call"}}"""))
|
|
56
|
+
assertEquals(HttpResult.Failed("http_503"), CallHttpClient.classify(503, null))
|
|
57
|
+
assertEquals(HttpResult.Timeout, CallHttpClient.classify(502, null))
|
|
58
|
+
assertEquals(HttpResult.Timeout, CallHttpClient.classify(429, null))
|
|
59
|
+
assertEquals(HttpResult.Failed("bad"), CallHttpClient.classify(422, """{"error":{"code":"bad"}}"""))
|
|
60
|
+
}
|
|
61
|
+
}
|