diff --git a/amethyst/build.gradle b/amethyst/build.gradle index 5b4516875..57630fc71 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -363,6 +363,9 @@ dependencies { // Voice anonymization DSP implementation libs.tarsosdsp + // WebRTC for voice/video calls + implementation libs.stream.webrtc.android + // Cbor for cashuB format implementation libs.kotlinx.serialization.cbor diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 4aae58459..9df886f66 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -39,6 +39,7 @@ + @@ -221,6 +222,12 @@ + + { + val peerName = intent.getStringExtra(EXTRA_PEER_NAME) ?: "Unknown" + val notification = buildNotification(peerName) + ServiceCompat.startForeground( + this, + NOTIFICATION_ID, + notification, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL + } else { + 0 + }, + ) + } + + ACTION_STOP -> { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + } + return START_NOT_STICKY + } + + private fun createNotificationChannel() { + val channel = + NotificationChannel( + CHANNEL_ID, + "Calls", + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = "Ongoing call notification" + } + val notificationManager = getSystemService(NotificationManager::class.java) + notificationManager.createNotificationChannel(channel) + } + + private fun buildNotification(peerName: String): Notification = + NotificationCompat + .Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.app_name)) + .setContentText("Call with $peerName") + .setSmallIcon(R.drawable.amethyst) + .setOngoing(true) + .build() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/IceServerConfig.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/IceServerConfig.kt new file mode 100644 index 000000000..b83598961 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/IceServerConfig.kt @@ -0,0 +1,52 @@ +/* + * 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 org.webrtc.PeerConnection + +object IceServerConfig { + val defaultStunServers = + listOf( + PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer(), + PeerConnection.IceServer.builder("stun:stun1.l.google.com:19302").createIceServer(), + PeerConnection.IceServer.builder("stun:stun.cloudflare.com:3478").createIceServer(), + ) + + fun buildIceServers(userTurnServers: List = emptyList()): List { + val servers = defaultStunServers.toMutableList() + userTurnServers.forEach { turn -> + servers.add( + PeerConnection.IceServer + .builder(turn.url) + .setUsername(turn.username) + .setPassword(turn.credential) + .createIceServer(), + ) + } + return servers + } +} + +data class TurnServerConfig( + val url: String, + val username: String, + val credential: String, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt new file mode 100644 index 000000000..db832d665 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt @@ -0,0 +1,260 @@ +/* + * 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 com.vitorpamplona.quartz.utils.Log +import org.webrtc.AudioSource +import org.webrtc.AudioTrack +import org.webrtc.DataChannel +import org.webrtc.DefaultVideoDecoderFactory +import org.webrtc.DefaultVideoEncoderFactory +import org.webrtc.EglBase +import org.webrtc.IceCandidate +import org.webrtc.MediaConstraints +import org.webrtc.MediaStream +import org.webrtc.PeerConnection +import org.webrtc.PeerConnectionFactory +import org.webrtc.RtpReceiver +import org.webrtc.SdpObserver +import org.webrtc.SessionDescription +import org.webrtc.VideoSource +import org.webrtc.VideoTrack + +private const val TAG = "WebRtcCallSession" + +class WebRtcCallSession( + private val context: Context, + private val iceServers: List, + private val onIceCandidate: (IceCandidate) -> Unit, + private val onPeerConnected: () -> Unit, + private val onRemoteStream: (MediaStream) -> Unit, + private val onDisconnected: () -> Unit, +) { + private var peerConnectionFactory: PeerConnectionFactory? = null + private var peerConnection: PeerConnection? = null + private var localAudioTrack: AudioTrack? = null + private var localVideoTrack: VideoTrack? = null + private var audioSource: AudioSource? = null + private var videoSource: VideoSource? = null + + val eglBase: EglBase = EglBase.create() + + fun initialize() { + PeerConnectionFactory.initialize( + PeerConnectionFactory + .InitializationOptions + .builder(context) + .createInitializationOptions(), + ) + + peerConnectionFactory = + PeerConnectionFactory + .builder() + .setVideoDecoderFactory(DefaultVideoDecoderFactory(eglBase.eglBaseContext)) + .setVideoEncoderFactory(DefaultVideoEncoderFactory(eglBase.eglBaseContext, true, true)) + .createPeerConnectionFactory() + } + + fun createPeerConnection() { + val rtcConfig = + PeerConnection.RTCConfiguration(iceServers).apply { + sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN + continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY + } + + peerConnection = + peerConnectionFactory?.createPeerConnection( + rtcConfig, + object : PeerConnection.Observer { + override fun onIceCandidate(candidate: IceCandidate?) { + candidate?.let { onIceCandidate(it) } + } + + override fun onIceCandidatesRemoved(candidates: Array?) {} + + override fun onSignalingChange(state: PeerConnection.SignalingState?) {} + + override fun onIceConnectionChange(state: PeerConnection.IceConnectionState?) { + Log.d(TAG) { "ICE connection state: $state" } + when (state) { + PeerConnection.IceConnectionState.CONNECTED -> { + onPeerConnected() + } + + PeerConnection.IceConnectionState.DISCONNECTED, + PeerConnection.IceConnectionState.FAILED, + -> { + onDisconnected() + } + + else -> {} + } + } + + override fun onIceConnectionReceivingChange(receiving: Boolean) {} + + override fun onIceGatheringChange(state: PeerConnection.IceGatheringState?) {} + + override fun onAddStream(stream: MediaStream?) { + stream?.let { onRemoteStream(it) } + } + + override fun onRemoveStream(stream: MediaStream?) {} + + override fun onDataChannel(channel: DataChannel?) {} + + override fun onRenegotiationNeeded() {} + + override fun onAddTrack( + receiver: RtpReceiver?, + streams: Array?, + ) {} + }, + ) + } + + fun addAudioTrack() { + val constraints = MediaConstraints() + audioSource = peerConnectionFactory?.createAudioSource(constraints) + localAudioTrack = + peerConnectionFactory?.createAudioTrack("audio0", audioSource).also { + peerConnection?.addTrack(it) + } + } + + fun addVideoTrack() { + videoSource = peerConnectionFactory?.createVideoSource(false) + localVideoTrack = + peerConnectionFactory?.createVideoTrack("video0", videoSource).also { + peerConnection?.addTrack(it) + } + } + + fun getLocalVideoSource(): VideoSource? = videoSource + + fun getLocalVideoTrack(): VideoTrack? = localVideoTrack + + fun createOffer(onSdpCreated: (SessionDescription) -> Unit) { + val constraints = + MediaConstraints().apply { + mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true")) + mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", "true")) + } + + peerConnection?.createOffer( + object : SdpObserver { + override fun onCreateSuccess(sdp: SessionDescription?) { + sdp?.let { + peerConnection?.setLocalDescription(noOpSdpObserver(), it) + onSdpCreated(it) + } + } + + override fun onCreateFailure(error: String?) { + Log.e(TAG, "Create offer failed: $error") + } + + override fun onSetSuccess() {} + + override fun onSetFailure(error: String?) {} + }, + constraints, + ) + } + + fun createAnswer(onSdpCreated: (SessionDescription) -> Unit) { + val constraints = + MediaConstraints().apply { + mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true")) + mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", "true")) + } + + peerConnection?.createAnswer( + object : SdpObserver { + override fun onCreateSuccess(sdp: SessionDescription?) { + sdp?.let { + peerConnection?.setLocalDescription(noOpSdpObserver(), it) + onSdpCreated(it) + } + } + + override fun onCreateFailure(error: String?) { + Log.e(TAG, "Create answer failed: $error") + } + + override fun onSetSuccess() {} + + override fun onSetFailure(error: String?) {} + }, + constraints, + ) + } + + fun setRemoteDescription(sdp: SessionDescription) { + peerConnection?.setRemoteDescription(noOpSdpObserver(), sdp) + } + + fun addIceCandidate(candidate: IceCandidate) { + peerConnection?.addIceCandidate(candidate) + } + + fun setAudioEnabled(enabled: Boolean) { + localAudioTrack?.setEnabled(enabled) + } + + fun setVideoEnabled(enabled: Boolean) { + localVideoTrack?.setEnabled(enabled) + } + + fun dispose() { + localAudioTrack?.dispose() + localVideoTrack?.dispose() + audioSource?.dispose() + videoSource?.dispose() + peerConnection?.close() + peerConnection?.dispose() + peerConnectionFactory?.dispose() + eglBase.release() + + localAudioTrack = null + localVideoTrack = null + audioSource = null + videoSource = null + peerConnection = null + peerConnectionFactory = null + } + + private fun noOpSdpObserver() = + object : SdpObserver { + override fun onCreateSuccess(sdp: SessionDescription?) {} + + override fun onCreateFailure(error: String?) { + Log.e(TAG, "SDP operation failed: $error") + } + + override fun onSetSuccess() {} + + override fun onSetFailure(error: String?) { + Log.e(TAG, "SDP set failed: $error") + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt new file mode 100644 index 000000000..3aa9c8d77 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt @@ -0,0 +1,354 @@ +/* + * 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.ui.call + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Call +import androidx.compose.material.icons.filled.CallEnd +import androidx.compose.material3.FloatingActionButton +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.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.commons.call.CallManager +import com.vitorpamplona.amethyst.commons.call.CallState +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun CallScreen( + callManager: CallManager, + accountViewModel: AccountViewModel, + onCallEnded: () -> Unit, +) { + val callState by callManager.state.collectAsState() + val scope = rememberCoroutineScope() + + when (val state = callState) { + is CallState.Idle -> { + LaunchedEffect(Unit) { onCallEnded() } + } + + is CallState.Offering -> { + CallInProgressUI( + peerPubKey = state.peerPubKey, + statusText = "Calling...", + accountViewModel = accountViewModel, + onHangup = { scope.launch { callManager.hangup() } }, + ) + } + + is CallState.IncomingCall -> { + IncomingCallUI( + callerPubKey = state.callerPubKey, + callType = state.callType, + accountViewModel = accountViewModel, + onAccept = { /* handled by caller */ }, + onReject = { scope.launch { callManager.rejectCall() } }, + ) + } + + is CallState.Connecting -> { + CallInProgressUI( + peerPubKey = state.peerPubKey, + statusText = "Connecting...", + accountViewModel = accountViewModel, + onHangup = { scope.launch { callManager.hangup() } }, + ) + } + + is CallState.Connected -> { + ConnectedCallUI( + state = state, + accountViewModel = accountViewModel, + onHangup = { scope.launch { callManager.hangup() } }, + onToggleMute = { callManager.toggleAudioMute() }, + onToggleVideo = { callManager.toggleVideo() }, + onToggleSpeaker = { callManager.toggleSpeaker() }, + ) + } + + is CallState.Ended -> { + LaunchedEffect(Unit) { + delay(2000) + callManager.reset() + onCallEnded() + } + CallInProgressUI( + peerPubKey = state.peerPubKey, + statusText = "Call ended", + accountViewModel = accountViewModel, + onHangup = { onCallEnded() }, + ) + } + } +} + +@Composable +private fun CallInProgressUI( + peerPubKey: String, + statusText: String, + accountViewModel: AccountViewModel, + onHangup: () -> Unit, +) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + LoadUser(baseUserHex = peerPubKey, accountViewModel = accountViewModel) { user -> + if (user != null) { + ClickableUserPicture( + baseUser = user, + size = 120.dp, + accountViewModel = accountViewModel, + ) + Spacer(modifier = Modifier.height(16.dp)) + UsernameDisplay( + baseUser = user, + accountViewModel = accountViewModel, + fontWeight = FontWeight.Bold, + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = statusText, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 16.sp, + ) + Spacer(modifier = Modifier.height(48.dp)) + FloatingActionButton( + onClick = onHangup, + containerColor = Color.Red, + shape = CircleShape, + modifier = Modifier.size(64.dp), + ) { + Icon( + Icons.Default.CallEnd, + contentDescription = "Hang up", + tint = Color.White, + modifier = Modifier.size(32.dp), + ) + } + } + } +} + +@Composable +private fun IncomingCallUI( + callerPubKey: String, + callType: com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType, + accountViewModel: AccountViewModel, + onAccept: () -> Unit, + onReject: () -> Unit, +) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + LoadUser(baseUserHex = callerPubKey, accountViewModel = accountViewModel) { user -> + if (user != null) { + ClickableUserPicture( + baseUser = user, + size = 120.dp, + accountViewModel = accountViewModel, + ) + Spacer(modifier = Modifier.height(16.dp)) + UsernameDisplay( + baseUser = user, + accountViewModel = accountViewModel, + fontWeight = FontWeight.Bold, + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Incoming ${callType.value} call...", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 16.sp, + ) + Spacer(modifier = Modifier.height(48.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(48.dp), + ) { + FloatingActionButton( + onClick = onReject, + containerColor = Color.Red, + shape = CircleShape, + modifier = Modifier.size(64.dp), + ) { + Icon( + Icons.Default.CallEnd, + contentDescription = "Reject", + tint = Color.White, + modifier = Modifier.size(32.dp), + ) + } + FloatingActionButton( + onClick = onAccept, + containerColor = Color(0xFF4CAF50), + shape = CircleShape, + modifier = Modifier.size(64.dp), + ) { + Icon( + Icons.Default.Call, + contentDescription = "Accept", + tint = Color.White, + modifier = Modifier.size(32.dp), + ) + } + } + } + } +} + +@Composable +private fun ConnectedCallUI( + state: CallState.Connected, + accountViewModel: AccountViewModel, + onHangup: () -> Unit, + onToggleMute: () -> Unit, + onToggleVideo: () -> Unit, + onToggleSpeaker: () -> Unit, +) { + var elapsed by remember { mutableLongStateOf(0L) } + + LaunchedEffect(state.startedAtEpoch) { + while (true) { + elapsed = TimeUtils.now() - state.startedAtEpoch + delay(1000) + } + } + + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + LoadUser(baseUserHex = state.peerPubKey, accountViewModel = accountViewModel) { user -> + if (user != null) { + ClickableUserPicture( + baseUser = user, + size = 120.dp, + accountViewModel = accountViewModel, + ) + Spacer(modifier = Modifier.height(16.dp)) + UsernameDisplay( + baseUser = user, + accountViewModel = accountViewModel, + fontWeight = FontWeight.Bold, + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = formatDuration(elapsed), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 16.sp, + ) + Spacer(modifier = Modifier.height(48.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + IconButton(onClick = onToggleMute) { + Text(if (state.isAudioMuted) "Unmute" else "Mute") + } + IconButton(onClick = onToggleVideo) { + Text(if (state.isVideoEnabled) "Cam Off" else "Cam On") + } + IconButton(onClick = onToggleSpeaker) { + Text(if (state.isSpeakerOn) "Earpiece" else "Speaker") + } + } + Spacer(modifier = Modifier.height(24.dp)) + FloatingActionButton( + onClick = onHangup, + containerColor = Color.Red, + shape = CircleShape, + modifier = Modifier.size(64.dp), + ) { + Icon( + Icons.Default.CallEnd, + contentDescription = "Hang up", + tint = Color.White, + modifier = Modifier.size(32.dp), + ) + } + } + } +} + +private fun formatDuration(seconds: Long): String { + val mins = seconds / 60 + val secs = seconds % 60 + return "%02d:%02d".format(mins, secs) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index b6dcdbcc6..8f69aaaf5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -302,6 +302,11 @@ sealed class Route { val id: String, ) : Route() + @Serializable data class ActiveCall( + val callId: String, + val peerPubKey: HexKey, + ) : Route() + @Serializable data class EventRedirect( val id: String, ) : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/ChatroomHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/ChatroomHeader.kt index ed24d5ccc..09085a2d0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/ChatroomHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/ChatroomHeader.kt @@ -26,6 +26,12 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Call +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -46,6 +52,7 @@ fun ChatroomHeader( room: ChatroomKey, modifier: Modifier = StdPadding, accountViewModel: AccountViewModel, + onCallClick: ((String) -> Unit)? = null, onClick: () -> Unit, ) { if (room.users.size == 1) { @@ -56,6 +63,7 @@ fun ChatroomHeader( modifier = modifier, accountViewModel = accountViewModel, onClick = onClick, + onCallClick = onCallClick?.let { callback -> { callback(baseUser.pubkeyHex) } }, ) } } @@ -75,6 +83,7 @@ fun UserChatroomHeader( modifier: Modifier = StdPadding, accountViewModel: AccountViewModel, onClick: () -> Unit, + onCallClick: (() -> Unit)? = null, ) { Column( Modifier @@ -91,9 +100,28 @@ fun UserChatroomHeader( size = Size34dp, ) - Column(modifier = Modifier.padding(start = 10.dp)) { + Column( + modifier = + Modifier + .padding(start = 10.dp) + .weight(1f), + ) { UsernameDisplay(baseUser, accountViewModel = accountViewModel) } + + if (onCallClick != null) { + IconButton( + onClick = onCallClick, + 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 new file mode 100644 index 000000000..97af4dcd4 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt @@ -0,0 +1,259 @@ +/* + * 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.commons.call + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip100WebRtcCalls.WebRtcCallFactory +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.tags.CallType +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class CallManager( + private val signer: NostrSigner, + private val scope: CoroutineScope, + private val isFollowing: (HexKey) -> Boolean, + private val publishEvent: (GiftWrapEvent) -> Unit, +) { + private val factory = WebRtcCallFactory() + + private val _state = MutableStateFlow(CallState.Idle) + val state: StateFlow = _state.asStateFlow() + + private var timeoutJob: Job? = null + + companion object { + const val CALL_TIMEOUT_MS = 60_000L // 60 seconds ringing timeout + } + + suspend fun initiateCall( + calleePubKey: HexKey, + callType: CallType, + callId: String, + sdpOffer: String, + ) { + val result = factory.createCallOffer(sdpOffer, calleePubKey, callId, callType, signer) + _state.value = CallState.Offering(callId, calleePubKey, callType) + publishEvent(result.wrap) + startTimeout(callId) + } + + fun onIncomingCallEvent(event: CallOfferEvent) { + val callerPubKey = event.pubKey + val callId = event.callId() ?: return + val callType = event.callType() ?: CallType.VOICE + + if (!isFollowing(callerPubKey)) return + + if (_state.value !is CallState.Idle) return + + _state.value = + CallState.IncomingCall( + callId = callId, + callerPubKey = callerPubKey, + callType = callType, + sdpOffer = event.sdpOffer(), + ) + startTimeout(callId) + } + + suspend fun acceptCall(sdpAnswer: String) { + val current = _state.value + if (current !is CallState.IncomingCall) return + + val result = factory.createCallAnswer(sdpAnswer, current.callerPubKey, current.callId, signer) + _state.value = CallState.Connecting(current.callId, current.callerPubKey, current.callType) + cancelTimeout() + publishEvent(result.wrap) + } + + suspend fun rejectCall() { + val current = _state.value + if (current !is CallState.IncomingCall) return + + val result = factory.createReject(current.callerPubKey, current.callId, signer = signer) + _state.value = CallState.Ended(current.callId, current.callerPubKey, EndReason.REJECTED) + cancelTimeout() + publishEvent(result.wrap) + } + + fun onCallAnswered(event: CallAnswerEvent) { + val current = _state.value + if (current !is CallState.Offering) return + if (event.callId() != current.callId) return + + _state.value = CallState.Connecting(current.callId, current.peerPubKey, current.callType) + cancelTimeout() + } + + fun onCallRejected(event: CallRejectEvent) { + val current = _state.value + if (current !is CallState.Offering) return + if (event.callId() != current.callId) return + + _state.value = CallState.Ended(current.callId, current.peerPubKey, EndReason.PEER_REJECTED) + cancelTimeout() + } + + 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. + } + + fun onPeerConnected() { + val current = _state.value + if (current !is CallState.Connecting) return + + _state.value = + CallState.Connected( + callId = current.callId, + peerPubKey = current.peerPubKey, + callType = current.callType, + startedAtEpoch = TimeUtils.now(), + ) + } + + suspend fun hangup() { + val peerPubKey: HexKey + val callId: String + when (val current = _state.value) { + is CallState.Offering -> { + peerPubKey = current.peerPubKey + callId = current.callId + } + + is CallState.Connecting -> { + peerPubKey = current.peerPubKey + callId = current.callId + } + + is CallState.Connected -> { + peerPubKey = current.peerPubKey + callId = current.callId + } + + else -> { + return + } + } + + val result = factory.createHangup(peerPubKey, callId, signer = signer) + _state.value = CallState.Ended(callId, peerPubKey, EndReason.HANGUP) + cancelTimeout() + publishEvent(result.wrap) + } + + fun onPeerHangup(event: CallHangupEvent) { + val current = _state.value + val callId = event.callId() ?: return + val currentCallId = + when (current) { + is CallState.Offering -> current.callId + is CallState.Connecting -> current.callId + is CallState.Connected -> current.callId + is CallState.IncomingCall -> current.callId + else -> return + } + if (callId != currentCallId) return + + val peerPubKey = event.pubKey + _state.value = CallState.Ended(callId, peerPubKey, EndReason.PEER_HANGUP) + cancelTimeout() + } + + fun onSignalingEvent(event: Event) { + when (event) { + is CallOfferEvent -> onIncomingCallEvent(event) + is CallAnswerEvent -> onCallAnswered(event) + is CallRejectEvent -> onCallRejected(event) + is CallHangupEvent -> onPeerHangup(event) + is CallIceCandidateEvent -> onIceCandidate(event) + } + } + + fun toggleAudioMute() { + val current = _state.value + if (current is CallState.Connected) { + _state.value = current.copy(isAudioMuted = !current.isAudioMuted) + } + } + + fun toggleVideo() { + val current = _state.value + if (current is CallState.Connected) { + _state.value = current.copy(isVideoEnabled = !current.isVideoEnabled) + } + } + + fun toggleSpeaker() { + val current = _state.value + if (current is CallState.Connected) { + _state.value = current.copy(isSpeakerOn = !current.isSpeakerOn) + } + } + + fun reset() { + _state.value = CallState.Idle + cancelTimeout() + } + + private fun startTimeout(callId: String) { + cancelTimeout() + timeoutJob = + scope.launch { + delay(CALL_TIMEOUT_MS) + val current = _state.value + val currentCallId = + when (current) { + is CallState.Offering -> current.callId + is CallState.IncomingCall -> current.callId + else -> null + } + if (currentCallId == callId) { + val peerPubKey = + when (current) { + is CallState.Offering -> current.peerPubKey + is CallState.IncomingCall -> current.callerPubKey + else -> return@launch + } + _state.value = CallState.Ended(callId, peerPubKey, EndReason.TIMEOUT) + } + } + } + + private fun cancelTimeout() { + timeoutJob?.cancel() + timeoutJob = null + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt new file mode 100644 index 000000000..4a7e90630 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt @@ -0,0 +1,74 @@ +/* + * 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.commons.call + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType + +@Immutable +sealed interface CallState { + data object Idle : CallState + + data class Offering( + val callId: String, + val peerPubKey: HexKey, + val callType: CallType, + ) : CallState + + data class IncomingCall( + val callId: String, + val callerPubKey: HexKey, + val callType: CallType, + val sdpOffer: String, + ) : CallState + + data class Connecting( + val callId: String, + val peerPubKey: HexKey, + val callType: CallType, + ) : CallState + + data class Connected( + val callId: String, + val peerPubKey: HexKey, + val callType: CallType, + val startedAtEpoch: Long, + val isAudioMuted: Boolean = false, + val isVideoEnabled: Boolean = true, + val isSpeakerOn: Boolean = false, + ) : CallState + + data class Ended( + val callId: String, + val peerPubKey: HexKey, + val reason: EndReason, + ) : CallState +} + +enum class EndReason { + HANGUP, + REJECTED, + TIMEOUT, + ERROR, + PEER_HANGUP, + PEER_REJECTED, +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cc6e06459..60485652c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -48,6 +48,7 @@ secp256k1KmpJniAndroid = "0.23.0" securityCryptoKtx = "1.1.0" slf4j = "2.0.17" spotless = "8.4.0" +streamWebrtcAndroid = "1.3.8" tarsosdsp = "2.5" translate = "17.0.3" jetbrainsCompose = "1.10.3" @@ -157,6 +158,7 @@ okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", secp256k1-kmp-common = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp", version.ref = "secp256k1KmpJniAndroid" } secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" } secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" } +stream-webrtc-android = { group = "io.getstream", name = "stream-webrtc-android", version.ref = "streamWebrtcAndroid" } tarsosdsp = { group = "be.tarsos.dsp", name = "core", version.ref = "tarsosdsp" } unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" } vico-charts-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico-charts-compose" } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/WebRtcCallFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/WebRtcCallFactory.kt new file mode 100644 index 000000000..858c5b591 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/WebRtcCallFactory.kt @@ -0,0 +1,113 @@ +/* + * 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.quartz.nip100WebRtcCalls + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +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.nip100WebRtcCalls.tags.CallType +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent + +class WebRtcCallFactory { + data class Result( + val msg: Event, + val wrap: GiftWrapEvent, + ) + + suspend fun createCallOffer( + sdpOffer: String, + calleePubKey: HexKey, + callId: String, + callType: CallType, + signer: NostrSigner, + ): Result { + val template = CallOfferEvent.build(sdpOffer, calleePubKey, callId, callType) + val signed = signer.sign(template) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = calleePubKey) + return Result(signed, wrap) + } + + suspend fun createCallAnswer( + sdpAnswer: String, + callerPubKey: HexKey, + callId: String, + signer: NostrSigner, + ): Result { + val template = CallAnswerEvent.build(sdpAnswer, callerPubKey, callId) + val signed = signer.sign(template) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = callerPubKey) + return Result(signed, wrap) + } + + suspend fun createIceCandidate( + candidateJson: String, + peerPubKey: HexKey, + callId: String, + signer: NostrSigner, + ): Result { + val template = CallIceCandidateEvent.build(candidateJson, peerPubKey, callId) + val signed = signer.sign(template) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey) + return Result(signed, wrap) + } + + suspend fun createHangup( + peerPubKey: HexKey, + callId: String, + reason: String = "", + signer: NostrSigner, + ): Result { + val template = CallHangupEvent.build(peerPubKey, callId, reason) + val signed = signer.sign(template) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey) + return Result(signed, wrap) + } + + suspend fun createReject( + callerPubKey: HexKey, + callId: String, + reason: String = "", + signer: NostrSigner, + ): Result { + val template = CallRejectEvent.build(callerPubKey, callId, reason) + val signed = signer.sign(template) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = callerPubKey) + return Result(signed, wrap) + } + + suspend fun createRenegotiate( + sdpOffer: String, + peerPubKey: HexKey, + callId: String, + signer: NostrSigner, + ): Result { + val template = CallRenegotiateEvent.build(sdpOffer, peerPubKey, callId) + val signed = signer.sign(template) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey) + return Result(signed, wrap) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallAnswerEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallAnswerEvent.kt new file mode 100644 index 000000000..b09c7da97 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallAnswerEvent.kt @@ -0,0 +1,67 @@ +/* + * 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.quartz.nip100WebRtcCalls.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class CallAnswerEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse) + + fun sdpAnswer() = content + + companion object { + const val KIND = 25051 + const val ALT_DESCRIPTION = "WebRTC call answer" + const val EXPIRATION_SECONDS = 300L + + fun build( + sdpAnswer: String, + callerPubKey: HexKey, + callId: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, sdpAnswer, createdAt) { + alt(ALT_DESCRIPTION) + pTag(callerPubKey) + callId(callId) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallHangupEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallHangupEvent.kt new file mode 100644 index 000000000..648cb6489 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallHangupEvent.kt @@ -0,0 +1,67 @@ +/* + * 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.quartz.nip100WebRtcCalls.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class CallHangupEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse) + + fun reason() = content.ifEmpty { null } + + companion object { + const val KIND = 25053 + const val ALT_DESCRIPTION = "WebRTC call hangup" + const val EXPIRATION_SECONDS = 300L + + fun build( + peerPubKey: HexKey, + callId: String, + reason: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, reason, createdAt) { + alt(ALT_DESCRIPTION) + pTag(peerPubKey) + callId(callId) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallIceCandidateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallIceCandidateEvent.kt new file mode 100644 index 000000000..8a3d13087 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallIceCandidateEvent.kt @@ -0,0 +1,67 @@ +/* + * 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.quartz.nip100WebRtcCalls.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class CallIceCandidateEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse) + + fun candidateJson() = content + + companion object { + const val KIND = 25052 + const val ALT_DESCRIPTION = "WebRTC ICE candidate" + const val EXPIRATION_SECONDS = 300L + + fun build( + candidateJson: String, + peerPubKey: HexKey, + callId: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, candidateJson, createdAt) { + alt(ALT_DESCRIPTION) + pTag(peerPubKey) + callId(callId) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallOfferEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallOfferEvent.kt new file mode 100644 index 000000000..e4cd9be14 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallOfferEvent.kt @@ -0,0 +1,74 @@ +/* + * 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.quartz.nip100WebRtcCalls.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallTypeTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callType +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class CallOfferEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse) + + fun callType() = tags.firstNotNullOfOrNull(CallTypeTag::parse) + + fun sdpOffer() = content + + companion object { + const val KIND = 25050 + const val ALT_DESCRIPTION = "WebRTC call offer" + const val EXPIRATION_SECONDS = 300L // 5 minutes + + fun build( + sdpOffer: String, + calleePubKey: HexKey, + callId: String, + type: CallType, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, sdpOffer, createdAt) { + alt(ALT_DESCRIPTION) + pTag(calleePubKey) + callId(callId) + callType(type) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRejectEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRejectEvent.kt new file mode 100644 index 000000000..84be6ad0f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRejectEvent.kt @@ -0,0 +1,67 @@ +/* + * 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.quartz.nip100WebRtcCalls.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class CallRejectEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse) + + fun reason() = content.ifEmpty { null } + + companion object { + const val KIND = 25054 + const val ALT_DESCRIPTION = "WebRTC call rejection" + const val EXPIRATION_SECONDS = 300L + + fun build( + callerPubKey: HexKey, + callId: String, + reason: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, reason, createdAt) { + alt(ALT_DESCRIPTION) + pTag(callerPubKey) + callId(callId) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRenegotiateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRenegotiateEvent.kt new file mode 100644 index 000000000..f38898f70 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRenegotiateEvent.kt @@ -0,0 +1,67 @@ +/* + * 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.quartz.nip100WebRtcCalls.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class CallRenegotiateEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse) + + fun sdpOffer() = content + + companion object { + const val KIND = 25055 + const val ALT_DESCRIPTION = "WebRTC call renegotiation" + const val EXPIRATION_SECONDS = 300L + + fun build( + sdpOffer: String, + peerPubKey: HexKey, + callId: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, sdpOffer, createdAt) { + alt(ALT_DESCRIPTION) + pTag(peerPubKey) + callId(callId) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallIdTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallIdTag.kt new file mode 100644 index 000000000..d70865550 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallIdTag.kt @@ -0,0 +1,39 @@ +/* + * 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.quartz.nip100WebRtcCalls.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class CallIdTag { + companion object { + const val TAG_NAME = "call-id" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(callId: String) = arrayOf(TAG_NAME, callId) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallTypeTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallTypeTag.kt new file mode 100644 index 000000000..f0b055af9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallTypeTag.kt @@ -0,0 +1,55 @@ +/* + * 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.quartz.nip100WebRtcCalls.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +enum class CallType( + val value: String, +) { + VOICE("voice"), + VIDEO("video"), + ; + + companion object { + fun fromString(value: String): CallType? = + when (value) { + "voice" -> VOICE + "video" -> VIDEO + else -> null + } + } +} + +class CallTypeTag { + companion object { + const val TAG_NAME = "call-type" + + fun parse(tag: Array): CallType? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return CallType.fromString(tag[1]) + } + + fun assemble(callType: CallType) = arrayOf(TAG_NAME, callType.value) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/TagArrayBuilderExt.kt new file mode 100644 index 000000000..17631ee78 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/TagArrayBuilderExt.kt @@ -0,0 +1,28 @@ +/* + * 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.quartz.nip100WebRtcCalls.tags + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.callId(callId: String) = addUnique(CallIdTag.assemble(callId)) + +fun TagArrayBuilder.callType(callType: CallType) = addUnique(CallTypeTag.assemble(callType)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 06b51ad2e..446615569 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -50,6 +50,12 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +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.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip15Marketplace.auction.AuctionEvent import com.vitorpamplona.quartz.nip15Marketplace.bid.BidEvent @@ -295,6 +301,12 @@ class EventFactory { CalendarEvent.KIND -> CalendarEvent(id, pubKey, createdAt, tags, content, sig) CalendarTimeSlotEvent.KIND -> CalendarTimeSlotEvent(id, pubKey, createdAt, tags, content, sig) CalendarRSVPEvent.KIND -> CalendarRSVPEvent(id, pubKey, createdAt, tags, content, sig) + CallAnswerEvent.KIND -> CallAnswerEvent(id, pubKey, createdAt, tags, content, sig) + CallHangupEvent.KIND -> CallHangupEvent(id, pubKey, createdAt, tags, content, sig) + CallIceCandidateEvent.KIND -> CallIceCandidateEvent(id, pubKey, createdAt, tags, content, sig) + CallOfferEvent.KIND -> CallOfferEvent(id, pubKey, createdAt, tags, content, sig) + CallRejectEvent.KIND -> CallRejectEvent(id, pubKey, createdAt, tags, content, sig) + CallRenegotiateEvent.KIND -> CallRenegotiateEvent(id, pubKey, createdAt, tags, content, sig) CashuMintEvent.KIND -> CashuMintEvent(id, pubKey, createdAt, tags, content, sig) CashuMintQuoteEvent.KIND -> CashuMintQuoteEvent(id, pubKey, createdAt, tags, content, sig) CashuTokenEvent.KIND -> CashuTokenEvent(id, pubKey, createdAt, tags, content, sig)