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)