feat: wire WebRTC call signaling end-to-end
Connects all call infrastructure so calls flow through the system: - EventProcessor routes unwrapped call events (offer/answer/ICE/ hangup/reject) from gift wraps to CallManager - CallController orchestrates WebRTC session lifecycle: creates PeerConnection, generates SDP offers/answers, exchanges ICE candidates via gift-wrapped events, and manages foreground service - AccountViewModel initializes CallManager + CallController and wires answer/ICE callbacks between them - Account.publishCallSignaling() publishes gift-wrapped events - DM chat top bar gets a call button (1-on-1 rooms only) that initiates a voice call and navigates to ActiveCall screen - ActiveCall route registered in AppNavigation with CallScreen https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
This commit is contained in:
@@ -1686,6 +1686,11 @@ class Account(
|
||||
|
||||
suspend fun createStatus(newStatus: String) = sendMyPublicAndPrivateOutbox(UserStatusAction.create(newStatus, signer))
|
||||
|
||||
suspend fun publishCallSignaling(wrap: GiftWrapEvent) {
|
||||
val relayList = computeRelayListToBroadcast(wrap)
|
||||
client.publish(wrap, relayList)
|
||||
}
|
||||
|
||||
suspend fun updateStatus(
|
||||
oldStatus: AddressableNote,
|
||||
newStatus: String,
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.call
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.vitorpamplona.amethyst.commons.call.CallManager
|
||||
import com.vitorpamplona.amethyst.commons.call.CallState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip100WebRtcCalls.WebRtcCallFactory
|
||||
import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent
|
||||
import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.webrtc.IceCandidate
|
||||
import org.webrtc.MediaStream
|
||||
import org.webrtc.SessionDescription
|
||||
import java.util.UUID
|
||||
|
||||
class CallController(
|
||||
private val context: Context,
|
||||
private val callManager: CallManager,
|
||||
private val scope: CoroutineScope,
|
||||
private val publishWrap: suspend (GiftWrapEvent) -> Unit,
|
||||
private val signerProvider: suspend () -> com.vitorpamplona.quartz.nip01Core.signers.NostrSigner,
|
||||
) {
|
||||
private var webRtcSession: WebRtcCallSession? = null
|
||||
private val callFactory = WebRtcCallFactory()
|
||||
private var currentCallId: String? = null
|
||||
private var currentPeerPubKey: HexKey? = null
|
||||
|
||||
fun initiateCall(
|
||||
peerPubKey: HexKey,
|
||||
callType: CallType,
|
||||
) {
|
||||
val callId = UUID.randomUUID().toString()
|
||||
currentCallId = callId
|
||||
currentPeerPubKey = peerPubKey
|
||||
|
||||
createWebRtcSession()
|
||||
webRtcSession?.addAudioTrack()
|
||||
if (callType == CallType.VIDEO) {
|
||||
webRtcSession?.addVideoTrack()
|
||||
}
|
||||
|
||||
webRtcSession?.createOffer { sdp ->
|
||||
scope.launch {
|
||||
callManager.initiateCall(peerPubKey, callType, callId, sdp.description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun acceptIncomingCall(sdpOffer: String) {
|
||||
val state = callManager.state.value
|
||||
if (state !is CallState.IncomingCall) return
|
||||
|
||||
currentCallId = state.callId
|
||||
currentPeerPubKey = state.callerPubKey
|
||||
|
||||
createWebRtcSession()
|
||||
webRtcSession?.addAudioTrack()
|
||||
if (state.callType == CallType.VIDEO) {
|
||||
webRtcSession?.addVideoTrack()
|
||||
}
|
||||
|
||||
webRtcSession?.setRemoteDescription(
|
||||
SessionDescription(SessionDescription.Type.OFFER, sdpOffer),
|
||||
)
|
||||
|
||||
webRtcSession?.createAnswer { sdp ->
|
||||
scope.launch {
|
||||
callManager.acceptCall(sdp.description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onCallAnswerReceived(sdpAnswer: String) {
|
||||
webRtcSession?.setRemoteDescription(
|
||||
SessionDescription(SessionDescription.Type.ANSWER, sdpAnswer),
|
||||
)
|
||||
}
|
||||
|
||||
fun onIceCandidateReceived(event: CallIceCandidateEvent) {
|
||||
val json = event.candidateJson()
|
||||
try {
|
||||
val candidate = parseIceCandidate(json)
|
||||
webRtcSession?.addIceCandidate(candidate)
|
||||
} catch (_: Exception) {
|
||||
// Ignore malformed ICE candidates
|
||||
}
|
||||
}
|
||||
|
||||
fun hangup() {
|
||||
scope.launch { callManager.hangup() }
|
||||
cleanup()
|
||||
}
|
||||
|
||||
fun cleanup() {
|
||||
stopForegroundService()
|
||||
webRtcSession?.dispose()
|
||||
webRtcSession = null
|
||||
currentCallId = null
|
||||
currentPeerPubKey = null
|
||||
}
|
||||
|
||||
private fun createWebRtcSession() {
|
||||
val iceServers = IceServerConfig.buildIceServers()
|
||||
|
||||
webRtcSession =
|
||||
WebRtcCallSession(
|
||||
context = context,
|
||||
iceServers = iceServers,
|
||||
onIceCandidate = { candidate -> onLocalIceCandidate(candidate) },
|
||||
onPeerConnected = {
|
||||
callManager.onPeerConnected()
|
||||
startForegroundService()
|
||||
},
|
||||
onRemoteStream = { _: MediaStream -> },
|
||||
onDisconnected = {
|
||||
scope.launch { callManager.hangup() }
|
||||
cleanup()
|
||||
},
|
||||
)
|
||||
webRtcSession?.initialize()
|
||||
webRtcSession?.createPeerConnection()
|
||||
}
|
||||
|
||||
private fun onLocalIceCandidate(candidate: IceCandidate) {
|
||||
val callId = currentCallId ?: return
|
||||
val peerPubKey = currentPeerPubKey ?: return
|
||||
val candidateJson = serializeIceCandidate(candidate)
|
||||
|
||||
scope.launch {
|
||||
val signer = signerProvider()
|
||||
val result = callFactory.createIceCandidate(candidateJson, peerPubKey, callId, signer)
|
||||
publishWrap(result.wrap)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startForegroundService() {
|
||||
val intent =
|
||||
Intent(context, CallForegroundService::class.java).apply {
|
||||
action = CallForegroundService.ACTION_START
|
||||
putExtra(CallForegroundService.EXTRA_PEER_NAME, currentPeerPubKey ?: "")
|
||||
}
|
||||
context.startForegroundService(intent)
|
||||
}
|
||||
|
||||
private fun stopForegroundService() {
|
||||
val intent =
|
||||
Intent(context, CallForegroundService::class.java).apply {
|
||||
action = CallForegroundService.ACTION_STOP
|
||||
}
|
||||
context.startService(intent)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun serializeIceCandidate(candidate: IceCandidate): String = """{"candidate":"${candidate.sdp}","sdpMid":"${candidate.sdpMid}","sdpMLineIndex":${candidate.sdpMLineIndex}}"""
|
||||
|
||||
fun parseIceCandidate(json: String): IceCandidate {
|
||||
val candidateRegex = """"candidate"\s*:\s*"([^"]*)"""".toRegex()
|
||||
val sdpMidRegex = """"sdpMid"\s*:\s*"([^"]*)"""".toRegex()
|
||||
val sdpMLineIndexRegex = """"sdpMLineIndex"\s*:\s*(\d+)""".toRegex()
|
||||
|
||||
val sdp = candidateRegex.find(json)?.groupValues?.get(1) ?: ""
|
||||
val sdpMid = sdpMidRegex.find(json)?.groupValues?.get(1) ?: "0"
|
||||
val sdpMLineIndex =
|
||||
sdpMLineIndexRegex
|
||||
.find(json)
|
||||
?.groupValues
|
||||
?.get(1)
|
||||
?.toIntOrNull() ?: 0
|
||||
|
||||
return IceCandidate(sdpMid, sdpMLineIndex, sdp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.Disp
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen
|
||||
import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress
|
||||
import com.vitorpamplona.amethyst.ui.call.CallScreen
|
||||
import com.vitorpamplona.amethyst.ui.components.getActivity
|
||||
import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages
|
||||
import com.vitorpamplona.amethyst.ui.navigation.composableFromEnd
|
||||
@@ -168,6 +169,11 @@ fun BuildNavigation(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: Nav,
|
||||
) {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||
accountViewModel.initCallController(context)
|
||||
}
|
||||
|
||||
NavHost(
|
||||
navController = nav.controller,
|
||||
startDestination = Route.Home,
|
||||
@@ -254,6 +260,14 @@ fun BuildNavigation(
|
||||
composableFromEndArgs<Route.Room> { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.RoomByAuthor> { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) }
|
||||
|
||||
composableFromEndArgs<Route.ActiveCall> {
|
||||
CallScreen(
|
||||
callManager = accountViewModel.callManager,
|
||||
accountViewModel = accountViewModel,
|
||||
onCallEnded = { nav.popBack() },
|
||||
)
|
||||
}
|
||||
|
||||
composableFromEndArgs<Route.PublicChatChannel> {
|
||||
PublicChatChannelScreen(it.id, it.draftId, it.replyTo, accountViewModel, nav)
|
||||
}
|
||||
|
||||
+36
@@ -41,6 +41,7 @@ import com.vitorpamplona.amethyst.AccountInfo
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.call.CallManager
|
||||
import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache
|
||||
import com.vitorpamplona.amethyst.commons.compose.GenericBaseCacheAsync
|
||||
import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers
|
||||
@@ -65,6 +66,7 @@ import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilde
|
||||
import com.vitorpamplona.amethyst.service.OnlineChecker
|
||||
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
|
||||
import com.vitorpamplona.amethyst.service.broadcast.BroadcastTracker
|
||||
import com.vitorpamplona.amethyst.service.call.CallController
|
||||
import com.vitorpamplona.amethyst.service.cashu.CashuToken
|
||||
import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
@@ -187,6 +189,40 @@ class AccountViewModel(
|
||||
val broadcastTracker = BroadcastTracker()
|
||||
val feedStates = AccountFeedContentStates(account, viewModelScope)
|
||||
|
||||
val callManager =
|
||||
CallManager(
|
||||
signer = account.signer,
|
||||
scope = viewModelScope,
|
||||
isFollowing = { account.isFollowing(it) },
|
||||
publishEvent = { wrap ->
|
||||
viewModelScope.launch {
|
||||
account.publishCallSignaling(wrap)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
var callController: CallController? = null
|
||||
private set
|
||||
|
||||
fun initCallController(context: Context) {
|
||||
if (callController != null) return
|
||||
val controller =
|
||||
CallController(
|
||||
context = context.applicationContext,
|
||||
callManager = callManager,
|
||||
scope = viewModelScope,
|
||||
publishWrap = { wrap -> account.publishCallSignaling(wrap) },
|
||||
signerProvider = { account.signer },
|
||||
)
|
||||
callManager.onAnswerReceived = { event -> controller.onCallAnswerReceived(event.sdpAnswer()) }
|
||||
callManager.onIceCandidateReceived = { event -> controller.onIceCandidateReceived(event) }
|
||||
callController = controller
|
||||
}
|
||||
|
||||
init {
|
||||
account.newNotesPreProcessor.callManager = callManager
|
||||
}
|
||||
|
||||
val eventSync =
|
||||
EventSync(
|
||||
accountPubKey = account.signer.pubKey,
|
||||
|
||||
+20
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.call.CallManager
|
||||
import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
@@ -27,6 +28,12 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.IEvent
|
||||
import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent
|
||||
import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent
|
||||
import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent
|
||||
import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent
|
||||
import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent
|
||||
import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRenegotiateEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||
@@ -42,6 +49,7 @@ import kotlinx.coroutines.CancellationException
|
||||
class EventProcessor(
|
||||
private val account: Account,
|
||||
private val cache: LocalCache,
|
||||
var callManager: CallManager? = null,
|
||||
) {
|
||||
private val chatHandler = ChatHandler(account.chatroomList)
|
||||
private val draftHandler = DraftEventHandler(account, cache)
|
||||
@@ -68,10 +76,22 @@ class EventProcessor(
|
||||
publicNote: Note,
|
||||
) {
|
||||
when (event) {
|
||||
is CallOfferEvent,
|
||||
is CallAnswerEvent,
|
||||
is CallIceCandidateEvent,
|
||||
is CallHangupEvent,
|
||||
is CallRejectEvent,
|
||||
is CallRenegotiateEvent,
|
||||
-> callManager?.onSignalingEvent(event)
|
||||
|
||||
is ChatroomKeyable -> chatHandler.add(event, eventNote, publicNote)
|
||||
|
||||
is DraftWrapEvent -> draftHandler.add(event, eventNote, publicNote)
|
||||
|
||||
is GiftWrapEvent -> giftWrapHandler.add(event, eventNote, publicNote)
|
||||
|
||||
is SealedRumorEvent -> sealHandler.add(event, eventNote, publicNote)
|
||||
|
||||
is LnZapRequestEvent -> zapRequest.add(event, eventNote, publicNote)
|
||||
}
|
||||
}
|
||||
|
||||
+17
-1
@@ -46,7 +46,23 @@ fun ChatroomScreen(
|
||||
DisappearingScaffold(
|
||||
isInvertedLayout = true,
|
||||
topBar = {
|
||||
RenderRoomTopBar(roomId, accountViewModel, nav)
|
||||
RenderRoomTopBar(
|
||||
room = roomId,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onCallClick = { peerPubKey ->
|
||||
accountViewModel.callController?.initiateCall(
|
||||
peerPubKey,
|
||||
com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType.VOICE,
|
||||
)
|
||||
nav.nav(
|
||||
com.vitorpamplona.amethyst.ui.navigation.routes.Route.ActiveCall(
|
||||
callId = "",
|
||||
peerPubKey = peerPubKey,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
|
||||
+19
@@ -25,15 +25,19 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import androidx.compose.material.icons.filled.EditNote
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -69,6 +73,7 @@ fun RenderRoomTopBar(
|
||||
room: ChatroomKey,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onCallClick: ((String) -> Unit)? = null,
|
||||
) {
|
||||
if (room.users.size == 1) {
|
||||
TopBarExtensibleWithBackButton(
|
||||
@@ -84,6 +89,20 @@ fun RenderRoomTopBar(
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
|
||||
UsernameDisplay(baseUser, Modifier.weight(1f), fontWeight = FontWeight.Normal, accountViewModel = accountViewModel)
|
||||
|
||||
if (onCallClick != null) {
|
||||
IconButton(
|
||||
onClick = { onCallClick(baseUser.pubkeyHex) },
|
||||
modifier = Modifier.size(40.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Call,
|
||||
contentDescription = "Voice call",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+5
-2
@@ -51,6 +51,9 @@ class CallManager(
|
||||
private val _state = MutableStateFlow<CallState>(CallState.Idle)
|
||||
val state: StateFlow<CallState> = _state.asStateFlow()
|
||||
|
||||
var onAnswerReceived: ((CallAnswerEvent) -> Unit)? = null
|
||||
var onIceCandidateReceived: ((CallIceCandidateEvent) -> Unit)? = null
|
||||
|
||||
private var timeoutJob: Job? = null
|
||||
|
||||
companion object {
|
||||
@@ -115,6 +118,7 @@ class CallManager(
|
||||
|
||||
_state.value = CallState.Connecting(current.callId, current.peerPubKey, current.callType)
|
||||
cancelTimeout()
|
||||
onAnswerReceived?.invoke(event)
|
||||
}
|
||||
|
||||
fun onCallRejected(event: CallRejectEvent) {
|
||||
@@ -127,8 +131,7 @@ class CallManager(
|
||||
}
|
||||
|
||||
fun onIceCandidate(event: CallIceCandidateEvent) {
|
||||
// ICE candidates are handled by the WebRTC session directly.
|
||||
// This method exists for the call manager to validate the call-id.
|
||||
onIceCandidateReceived?.invoke(event)
|
||||
}
|
||||
|
||||
fun onPeerConnected() {
|
||||
|
||||
Reference in New Issue
Block a user