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:
Claude
2026-04-01 22:40:13 +00:00
parent 8ae65df96b
commit 6128e35765
8 changed files with 312 additions and 3 deletions
@@ -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)
}
@@ -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,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)
}
}
@@ -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,
) {
@@ -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),
)
}
}
}
}
},
@@ -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() {