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 createStatus(newStatus: String) = sendMyPublicAndPrivateOutbox(UserStatusAction.create(newStatus, signer))
suspend fun publishCallSignaling(wrap: GiftWrapEvent) {
val relayList = computeRelayListToBroadcast(wrap)
client.publish(wrap, relayList)
}
suspend fun updateStatus( suspend fun updateStatus(
oldStatus: AddressableNote, oldStatus: AddressableNote,
newStatus: String, 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.NewUserMetadataScreen
import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen
import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress 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.getActivity
import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages
import com.vitorpamplona.amethyst.ui.navigation.composableFromEnd import com.vitorpamplona.amethyst.ui.navigation.composableFromEnd
@@ -168,6 +169,11 @@ fun BuildNavigation(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: Nav, nav: Nav,
) { ) {
val context = androidx.compose.ui.platform.LocalContext.current
androidx.compose.runtime.LaunchedEffect(Unit) {
accountViewModel.initCallController(context)
}
NavHost( NavHost(
navController = nav.controller, navController = nav.controller,
startDestination = Route.Home, 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.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.RoomByAuthor> { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) }
composableFromEndArgs<Route.ActiveCall> {
CallScreen(
callManager = accountViewModel.callManager,
accountViewModel = accountViewModel,
onCallEnded = { nav.popBack() },
)
}
composableFromEndArgs<Route.PublicChatChannel> { composableFromEndArgs<Route.PublicChatChannel> {
PublicChatChannelScreen(it.id, it.draftId, it.replyTo, accountViewModel, nav) 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.Amethyst
import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R 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.GenericBaseCache
import com.vitorpamplona.amethyst.commons.compose.GenericBaseCacheAsync import com.vitorpamplona.amethyst.commons.compose.GenericBaseCacheAsync
import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers 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.OnlineChecker
import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.service.broadcast.BroadcastTracker 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.CashuToken
import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
@@ -187,6 +189,40 @@ class AccountViewModel(
val broadcastTracker = BroadcastTracker() val broadcastTracker = BroadcastTracker()
val feedStates = AccountFeedContentStates(account, viewModelScope) 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 = val eventSync =
EventSync( EventSync(
accountPubKey = account.signer.pubKey, accountPubKey = account.signer.pubKey,
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn 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.commons.model.privateChats.ChatroomList
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache 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.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.IEvent 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.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
@@ -42,6 +49,7 @@ import kotlinx.coroutines.CancellationException
class EventProcessor( class EventProcessor(
private val account: Account, private val account: Account,
private val cache: LocalCache, private val cache: LocalCache,
var callManager: CallManager? = null,
) { ) {
private val chatHandler = ChatHandler(account.chatroomList) private val chatHandler = ChatHandler(account.chatroomList)
private val draftHandler = DraftEventHandler(account, cache) private val draftHandler = DraftEventHandler(account, cache)
@@ -68,10 +76,22 @@ class EventProcessor(
publicNote: Note, publicNote: Note,
) { ) {
when (event) { 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 ChatroomKeyable -> chatHandler.add(event, eventNote, publicNote)
is DraftWrapEvent -> draftHandler.add(event, eventNote, publicNote) is DraftWrapEvent -> draftHandler.add(event, eventNote, publicNote)
is GiftWrapEvent -> giftWrapHandler.add(event, eventNote, publicNote) is GiftWrapEvent -> giftWrapHandler.add(event, eventNote, publicNote)
is SealedRumorEvent -> sealHandler.add(event, eventNote, publicNote) is SealedRumorEvent -> sealHandler.add(event, eventNote, publicNote)
is LnZapRequestEvent -> zapRequest.add(event, eventNote, publicNote) is LnZapRequestEvent -> zapRequest.add(event, eventNote, publicNote)
} }
} }
@@ -46,7 +46,23 @@ fun ChatroomScreen(
DisappearingScaffold( DisappearingScaffold(
isInvertedLayout = true, isInvertedLayout = true,
topBar = { 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, accountViewModel = accountViewModel,
) { ) {
@@ -25,15 +25,19 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.EditNote import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -69,6 +73,7 @@ fun RenderRoomTopBar(
room: ChatroomKey, room: ChatroomKey,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
onCallClick: ((String) -> Unit)? = null,
) { ) {
if (room.users.size == 1) { if (room.users.size == 1) {
TopBarExtensibleWithBackButton( TopBarExtensibleWithBackButton(
@@ -84,6 +89,20 @@ fun RenderRoomTopBar(
Spacer(modifier = DoubleHorzSpacer) Spacer(modifier = DoubleHorzSpacer)
UsernameDisplay(baseUser, Modifier.weight(1f), fontWeight = FontWeight.Normal, accountViewModel = accountViewModel) 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) private val _state = MutableStateFlow<CallState>(CallState.Idle)
val state: StateFlow<CallState> = _state.asStateFlow() val state: StateFlow<CallState> = _state.asStateFlow()
var onAnswerReceived: ((CallAnswerEvent) -> Unit)? = null
var onIceCandidateReceived: ((CallIceCandidateEvent) -> Unit)? = null
private var timeoutJob: Job? = null private var timeoutJob: Job? = null
companion object { companion object {
@@ -115,6 +118,7 @@ class CallManager(
_state.value = CallState.Connecting(current.callId, current.peerPubKey, current.callType) _state.value = CallState.Connecting(current.callId, current.peerPubKey, current.callType)
cancelTimeout() cancelTimeout()
onAnswerReceived?.invoke(event)
} }
fun onCallRejected(event: CallRejectEvent) { fun onCallRejected(event: CallRejectEvent) {
@@ -127,8 +131,7 @@ class CallManager(
} }
fun onIceCandidate(event: CallIceCandidateEvent) { fun onIceCandidate(event: CallIceCandidateEvent) {
// ICE candidates are handled by the WebRTC session directly. onIceCandidateReceived?.invoke(event)
// This method exists for the call manager to validate the call-id.
} }
fun onPeerConnected() { fun onPeerConnected() {