diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index e7e4eb3c6..0f30f4259 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt new file mode 100644 index 000000000..57c37ab78 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -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) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index fc83584fb..5d888706d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) } composableFromEndArgs { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) } + composableFromEndArgs { + CallScreen( + callManager = accountViewModel.callManager, + accountViewModel = accountViewModel, + onCallEnded = { nav.popBack() }, + ) + } + composableFromEndArgs { PublicChatChannelScreen(it.id, it.draftId, it.replyTo, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 029e535c2..900744d8a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index a569b5ac9..88a628fe9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -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) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt index c1d324abd..5580dc9f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt @@ -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, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt index 33513c931..8ec60cc3f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt @@ -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), + ) + } + } } } }, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt index 97af4dcd4..e312e608f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt @@ -51,6 +51,9 @@ class CallManager( private val _state = MutableStateFlow(CallState.Idle) val state: StateFlow = _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() {