feat: add WebRTC voice/video call infrastructure

Implements P2P calling over Nostr relays using WebRTC for media
transport and NIP-59 Gift Wraps for encrypted signaling. No custom
server required — only public STUN servers for NAT traversal.

Protocol layer (quartz/nip100WebRtcCalls):
- 6 new event kinds (25050-25055): offer, answer, ICE candidate,
  hangup, reject, renegotiate
- WebRtcCallFactory for creating and gift-wrapping signaling events
- CallIdTag and CallTypeTag for event metadata
- Events registered in EventFactory

Call state machine (commons/call):
- CallState sealed interface with full lifecycle states
- CallManager orchestrating signaling and state transitions
- Follow-gate spam prevention: only followed users can ring,
  non-follows are silently ignored

Android WebRTC integration (amethyst/service/call):
- WebRtcCallSession wrapping Google WebRTC PeerConnection
- CallForegroundService for keeping calls alive in background
- IceServerConfig with default public STUN servers
- User-configurable TURN server support

Android UI (amethyst/ui/call):
- CallScreen with offering, connecting, connected, and ended states
- IncomingCallUI with accept/reject buttons
- ConnectedCallUI with mute, video toggle, speaker, and timer
- Call button added to 1-on-1 DM chat header
- ActiveCall route added to navigation

https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
This commit is contained in:
Claude
2026-04-01 21:09:12 +00:00
parent 0f7bcdba38
commit 2ee48da064
22 changed files with 1802 additions and 1 deletions
+3
View File
@@ -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
+7
View File
@@ -39,6 +39,7 @@
<!-- Audio/Video Playback -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
<!-- Keeps screen on while playing videos -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
@@ -221,6 +222,12 @@
</intent-filter>
</service>
<service
android:name=".service.call.CallForegroundService"
android:foregroundServiceType="phoneCall"
android:stopWithTask="true"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
@@ -0,0 +1,101 @@
/*
* 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.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import com.vitorpamplona.amethyst.R
class CallForegroundService : Service() {
companion object {
const val CHANNEL_ID = "amethyst_call_channel"
const val NOTIFICATION_ID = 9001
const val ACTION_START = "com.vitorpamplona.amethyst.CALL_START"
const val ACTION_STOP = "com.vitorpamplona.amethyst.CALL_STOP"
const val EXTRA_PEER_NAME = "peer_name"
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int,
): Int {
when (intent?.action) {
ACTION_START -> {
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()
}
@@ -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<TurnServerConfig> = emptyList()): List<PeerConnection.IceServer> {
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,
)
@@ -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<PeerConnection.IceServer>,
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<out IceCandidate>?) {}
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<out MediaStream>?,
) {}
},
)
}
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")
}
}
}
@@ -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)
}
@@ -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()
@@ -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),
)
}
}
}
}
}
@@ -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>(CallState.Idle)
val state: StateFlow<CallState> = _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
}
}
@@ -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,
}
+2
View File
@@ -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" }
@@ -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)
}
}
@@ -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<Array<String>>,
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<CallAnswerEvent>.() -> Unit = {},
) = eventTemplate(KIND, sdpAnswer, createdAt) {
alt(ALT_DESCRIPTION)
pTag(callerPubKey)
callId(callId)
expiration(createdAt + EXPIRATION_SECONDS)
initializer()
}
}
}
@@ -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<Array<String>>,
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<CallHangupEvent>.() -> Unit = {},
) = eventTemplate(KIND, reason, createdAt) {
alt(ALT_DESCRIPTION)
pTag(peerPubKey)
callId(callId)
expiration(createdAt + EXPIRATION_SECONDS)
initializer()
}
}
}
@@ -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<Array<String>>,
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<CallIceCandidateEvent>.() -> Unit = {},
) = eventTemplate(KIND, candidateJson, createdAt) {
alt(ALT_DESCRIPTION)
pTag(peerPubKey)
callId(callId)
expiration(createdAt + EXPIRATION_SECONDS)
initializer()
}
}
}
@@ -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<Array<String>>,
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<CallOfferEvent>.() -> Unit = {},
) = eventTemplate(KIND, sdpOffer, createdAt) {
alt(ALT_DESCRIPTION)
pTag(calleePubKey)
callId(callId)
callType(type)
expiration(createdAt + EXPIRATION_SECONDS)
initializer()
}
}
}
@@ -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<Array<String>>,
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<CallRejectEvent>.() -> Unit = {},
) = eventTemplate(KIND, reason, createdAt) {
alt(ALT_DESCRIPTION)
pTag(callerPubKey)
callId(callId)
expiration(createdAt + EXPIRATION_SECONDS)
initializer()
}
}
}
@@ -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<Array<String>>,
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<CallRenegotiateEvent>.() -> Unit = {},
) = eventTemplate(KIND, sdpOffer, createdAt) {
alt(ALT_DESCRIPTION)
pTag(peerPubKey)
callId(callId)
expiration(createdAt + EXPIRATION_SECONDS)
initializer()
}
}
}
@@ -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>): 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)
}
}
@@ -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<String>): 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)
}
}
@@ -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 <T : Event> TagArrayBuilder<T>.callId(callId: String) = addUnique(CallIdTag.assemble(callId))
fun <T : Event> TagArrayBuilder<T>.callType(callType: CallType) = addUnique(CallTypeTag.assemble(callType))
@@ -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)