Merge pull request #2143 from vitorpamplona/claude/webrtc-nip-ac-tests-f1l1g

Add comprehensive test suite for NIP-AC call state machine
This commit is contained in:
Vitor Pamplona
2026-04-05 12:29:30 -04:00
committed by GitHub
12 changed files with 3359 additions and 168 deletions
@@ -26,8 +26,14 @@ import coil3.ImageLoader
import coil3.asDrawable
import coil3.request.ImageRequest
import coil3.request.allowHardware
import com.vitorpamplona.amethyst.commons.call.AnswerRouteAction
import com.vitorpamplona.amethyst.commons.call.CallManager
import com.vitorpamplona.amethyst.commons.call.CallState
import com.vitorpamplona.amethyst.commons.call.IceCandidateData
import com.vitorpamplona.amethyst.commons.call.PeerSession
import com.vitorpamplona.amethyst.commons.call.PeerSessionManager
import com.vitorpamplona.amethyst.commons.call.SdpType
import com.vitorpamplona.amethyst.commons.call.SignalingState
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -56,9 +62,7 @@ import org.webrtc.DefaultVideoEncoderFactory
import org.webrtc.EglBase
import org.webrtc.IceCandidate
import org.webrtc.MediaConstraints
import org.webrtc.PeerConnection
import org.webrtc.PeerConnectionFactory
import org.webrtc.SessionDescription
import org.webrtc.SurfaceTextureHelper
import org.webrtc.VideoFrame
import org.webrtc.VideoSink
@@ -66,8 +70,6 @@ import org.webrtc.VideoSource
import org.webrtc.VideoTrack
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
private const val TAG = "CallController"
@@ -79,19 +81,14 @@ class CallController(
private val scope: CoroutineScope,
private val publishWrap: suspend (EphemeralGiftWrapEvent) -> Unit,
private val signerProvider: suspend () -> com.vitorpamplona.quartz.nip01Core.signers.NostrSigner,
localPubKey: HexKey,
) {
// ---- Per-peer session state ----
// ---- Per-peer session state (delegated to PeerSessionManager) ----
private class PeerSessionState(
val session: WebRtcCallSession,
val remoteDescriptionSet: AtomicBoolean = AtomicBoolean(false),
val pendingIceCandidates: CopyOnWriteArrayList<IceCandidate> = CopyOnWriteArrayList(),
)
private var peerSessionMgr = PeerSessionManager(localPubKey)
private val peerSessions = ConcurrentHashMap<HexKey, PeerSessionState>()
// Candidates received before a PeerSession exists for the sender
private val globalPendingIce = ConcurrentHashMap<HexKey, CopyOnWriteArrayList<IceCandidate>>()
/** Retrieves the underlying WebRtcCallSession for a peer (for WebRTC-specific APIs like addTrack). */
private fun webRtcSession(peerPubKey: HexKey): WebRtcCallSession? = (peerSessionMgr.getSession(peerPubKey)?.session as? WebRtcPeerSessionAdapter)?.webRtcSession
// ---- Shared WebRTC resources ----
@@ -275,9 +272,11 @@ class CallController(
// Create a PeerConnection + offer for each callee
for (peerPubKey in peerPubKeys) {
try {
val ps = withContext(Dispatchers.IO) { createPeerSession(peerPubKey) }
val webRtcSession = withContext(Dispatchers.IO) { createWebRtcSession(peerPubKey) }
val adapter = WebRtcPeerSessionAdapter(webRtcSession)
peerSessionMgr.registerSession(peerPubKey, adapter)
Log.d(TAG) { "initiateCall: PeerConnection created for ${peerPubKey.take(8)}" }
ps.session.createOffer { sdp ->
webRtcSession.createOffer { sdp ->
Log.d(TAG) { "initiateCall: offer created for ${peerPubKey.take(8)}, sdpLength=${sdp.description.length}" }
scope.launch {
callManager.publishOfferToPeer(peerPubKey, peerPubKeys, callType, callId, sdp.description)
@@ -315,28 +314,25 @@ class CallController(
return@launch
}
// Retrieve any ICE candidates buffered while ringing (before session existed)
val globalPending = getGlobalPendingCandidates(callerPubKey)
val ps =
val webRtcSession =
try {
withContext(Dispatchers.IO) { createPeerSession(callerPubKey) }
withContext(Dispatchers.IO) { createWebRtcSession(callerPubKey) }
} catch (e: Exception) {
Log.e(TAG, "Failed to create PeerConnection", e)
_errorMessage.value = "Failed to accept call: ${e.message}"
return@launch
}
// Inject any globally-buffered ICE candidates for the caller
globalPending.forEach { ps.pendingIceCandidates.add(it) }
val adapter = WebRtcPeerSessionAdapter(webRtcSession)
val entry = peerSessionMgr.registerSession(callerPubKey, adapter)
Log.d(TAG) { "acceptIncomingCall: setting remote description (OFFER)..." }
ps.session.setRemoteDescription(SessionDescription(SessionDescription.Type.OFFER, sdpOffer))
Log.d(TAG) { "acceptIncomingCall: flushing ${ps.pendingIceCandidates.size} pending ICE candidates..." }
flushPendingIceCandidates(callerPubKey, ps)
adapter.setRemoteDescription(SdpType.OFFER, sdpOffer)
Log.d(TAG) { "acceptIncomingCall: flushing ${entry.pendingIceCandidates.size} pending ICE candidates..." }
peerSessionMgr.flushPendingIceCandidates(callerPubKey)
Log.d(TAG) { "acceptIncomingCall: creating answer..." }
ps.session.createAnswer { sdp ->
webRtcSession.createAnswer { sdp ->
Log.d(TAG) { "acceptIncomingCall: answer created, sdpLength=${sdp.description.length}, publishing..." }
scope.launch {
callManager.acceptCall(sdp.description)
@@ -358,29 +354,23 @@ class CallController(
peerPubKey: HexKey,
sdpAnswer: String,
) {
Log.d(TAG) { "onCallAnswerReceived: from=${peerPubKey.take(8)}, knownSessions=${peerSessions.keys.map { it.take(8) }}" }
val ps = peerSessions[peerPubKey]
if (ps != null) {
// We have a PeerConnection for this peer — set remote description
val signalingState = ps.session.getSignalingState()
Log.d(TAG) {
"Answer received from ${peerPubKey.take(8)}, sdpLength=${sdpAnswer.length}, " +
"signalingState=$signalingState, remoteDescSet=${ps.remoteDescriptionSet.get()}"
Log.d(TAG) { "onCallAnswerReceived: from=${peerPubKey.take(8)}, knownSessions=${peerSessionMgr.allSessionKeys().map { it.take(8) }}" }
val action = peerSessionMgr.routeAnswer(peerPubKey, sdpAnswer)
Log.d(TAG) { "onCallAnswerReceived: action=$action" }
when (action) {
AnswerRouteAction.APPLIED -> {
Log.d(TAG) { "Answer applied for ${peerPubKey.take(8)}" }
}
if (signalingState != PeerConnection.SignalingState.HAVE_LOCAL_OFFER) {
Log.d(TAG) { "Ignoring answer from ${peerPubKey.take(8)} in $signalingState (no pending local offer)" }
return
AnswerRouteAction.NO_SESSION -> {
Log.d(TAG) { "Answer from unknown peer ${peerPubKey.take(8)} — triggering callee-to-callee connection" }
onNewPeerInGroupCall(peerPubKey)
}
Log.d(TAG) { "Setting remote description (ANSWER) for ${peerPubKey.take(8)}..." }
ps.session.setRemoteDescription(SessionDescription(SessionDescription.Type.ANSWER, sdpAnswer))
flushPendingIceCandidates(peerPubKey, ps)
} else {
// No session for this peer — they are another callee who joined.
// Initiate a callee-to-callee connection (with tie-breaking).
Log.d(TAG) { "Answer from unknown peer ${peerPubKey.take(8)} — triggering callee-to-callee connection" }
onNewPeerInGroupCall(peerPubKey)
AnswerRouteAction.IGNORED_WRONG_STATE -> {
Log.d(TAG) { "Ignoring answer from ${peerPubKey.take(8)} (no pending local offer)" }
}
}
}
@@ -393,37 +383,14 @@ class CallController(
fun onIceCandidateReceived(event: CallIceCandidateEvent) {
try {
val senderPubKey = event.pubKey
val candidate = IceCandidate(event.sdpMid(), event.sdpMLineIndex(), event.candidateSdp())
val ps = peerSessions[senderPubKey]
if (ps != null && ps.remoteDescriptionSet.get()) {
Log.d(TAG) { "Adding ICE candidate from ${senderPubKey.take(8)} directly" }
ps.session.addIceCandidate(candidate)
} else if (ps != null) {
Log.d(TAG) { "Buffering ICE candidate from ${senderPubKey.take(8)} (remoteDesc not set)" }
ps.pendingIceCandidates.add(candidate)
} else {
// No session yet — buffer globally (keyed by sender)
Log.d(TAG) { "Buffering ICE candidate from ${senderPubKey.take(8)} (no session yet)" }
globalPendingIce.getOrPut(senderPubKey) { CopyOnWriteArrayList() }.add(candidate)
}
val candidate = IceCandidateData(event.candidateSdp(), event.sdpMid(), event.sdpMLineIndex())
val action = peerSessionMgr.routeIceCandidate(senderPubKey, candidate)
Log.d(TAG) { "ICE candidate from ${senderPubKey.take(8)}: $action" }
} catch (e: Exception) {
Log.e(TAG, "Failed to parse ICE candidate", e)
}
}
private fun getGlobalPendingCandidates(peerPubKey: HexKey): List<IceCandidate> = globalPendingIce.remove(peerPubKey)?.toList() ?: emptyList()
private fun flushPendingIceCandidates(
peerPubKey: HexKey,
ps: PeerSessionState,
) {
ps.remoteDescriptionSet.set(true)
val candidates = ps.pendingIceCandidates.toList()
Log.d(TAG) { "Flushing ${candidates.size} buffered ICE candidates for ${peerPubKey.take(8)}" }
ps.pendingIceCandidates.clear()
candidates.forEach { ps.session.addIceCandidate(it) }
}
private fun onLocalIceCandidate(
peerPubKey: HexKey,
candidate: IceCandidate,
@@ -447,15 +414,14 @@ class CallController(
* peer with the lexicographically lower pubkey initiates.
*/
fun onNewPeerInGroupCall(peerPubKey: HexKey) {
if (peerSessions.containsKey(peerPubKey)) {
if (peerSessionMgr.hasSession(peerPubKey)) {
Log.d(TAG) { "onNewPeerInGroupCall: session already exists for ${peerPubKey.take(8)} — skipping" }
return
}
scope.launch {
val myPubKey = signerProvider().pubKey
Log.d(TAG) { "onNewPeerInGroupCall: peer=${peerPubKey.take(8)}, myPubKey=${myPubKey.take(8)}, iAmLower=${myPubKey < peerPubKey}" }
if (myPubKey < peerPubKey) {
Log.d(TAG) { "onNewPeerInGroupCall: peer=${peerPubKey.take(8)}, shouldInitiate=${peerSessionMgr.shouldInitiateOffer(peerPubKey)}" }
if (peerSessionMgr.shouldInitiateOffer(peerPubKey)) {
Log.d(TAG) { "Initiating callee-to-callee connection to ${peerPubKey.take(8)} (I have lower pubkey)" }
createAndOfferToPeer(peerPubKey)
} else {
@@ -466,19 +432,19 @@ class CallController(
private suspend fun createAndOfferToPeer(peerPubKey: HexKey) {
if (peerConnectionFactory == null) return
val globalPending = getGlobalPendingCandidates(peerPubKey)
val ps =
val webRtcSession =
try {
withContext(Dispatchers.IO) { createPeerSession(peerPubKey) }
withContext(Dispatchers.IO) { createWebRtcSession(peerPubKey) }
} catch (e: Exception) {
Log.e(TAG, "Failed to create PeerConnection for ${peerPubKey.take(8)}", e)
return
}
globalPending.forEach { ps.pendingIceCandidates.add(it) }
val adapter = WebRtcPeerSessionAdapter(webRtcSession)
peerSessionMgr.registerSession(peerPubKey, adapter)
ps.session.createOffer { sdp ->
webRtcSession.createOffer { sdp ->
Log.d(TAG) { "Callee-to-callee offer created for ${peerPubKey.take(8)}, sdpLength=${sdp.description.length}" }
scope.launch {
callManager.publishOfferToPeer(peerPubKey, sdp.description)
@@ -494,7 +460,7 @@ class CallController(
peerPubKey: HexKey,
sdpOffer: String,
) {
if (peerSessions.containsKey(peerPubKey)) {
if (peerSessionMgr.hasSession(peerPubKey)) {
Log.d(TAG) { "Mid-call offer from ${peerPubKey.take(8)} but session already exists — ignoring" }
return
}
@@ -506,22 +472,20 @@ class CallController(
return@launch
}
val globalPending = getGlobalPendingCandidates(peerPubKey)
val ps =
val webRtcSession =
try {
withContext(Dispatchers.IO) { createPeerSession(peerPubKey) }
withContext(Dispatchers.IO) { createWebRtcSession(peerPubKey) }
} catch (e: Exception) {
Log.e(TAG, "Failed to create PeerConnection for mid-call offer from ${peerPubKey.take(8)}", e)
return@launch
}
globalPending.forEach { ps.pendingIceCandidates.add(it) }
val adapter = WebRtcPeerSessionAdapter(webRtcSession)
peerSessionMgr.registerSession(peerPubKey, adapter)
adapter.setRemoteDescription(SdpType.OFFER, sdpOffer)
peerSessionMgr.flushPendingIceCandidates(peerPubKey)
ps.session.setRemoteDescription(SessionDescription(SessionDescription.Type.OFFER, sdpOffer))
flushPendingIceCandidates(peerPubKey, ps)
ps.session.createAnswer { sdp ->
webRtcSession.createAnswer { sdp ->
Log.d(TAG) { "Callee-to-callee answer for ${peerPubKey.take(8)}, sdpLength=${sdp.description.length}" }
scope.launch {
callManager.publishAnswerToPeer(peerPubKey, sdp.description)
@@ -534,53 +498,38 @@ class CallController(
private fun onRenegotiationOfferReceived(event: CallRenegotiateEvent) {
val peerPubKey = event.pubKey
val ps = peerSessions[peerPubKey] ?: return
val sdpOffer = event.sdpOffer()
Log.d(TAG) { "Renegotiation offer from ${peerPubKey.take(8)}, sdpLength=${sdpOffer.length}" }
scope.launch {
val signalingState = ps.session.getSignalingState()
if (signalingState == PeerConnection.SignalingState.HAVE_LOCAL_OFFER) {
// Glare: both sides sent offers simultaneously.
// Use pubkey comparison as tiebreaker — higher pubkey wins.
val myPubKey = signerProvider().pubKey
if (myPubKey > peerPubKey) {
Log.d(TAG) { "Glare with ${peerPubKey.take(8)}: I win (my offer takes priority), ignoring remote offer" }
return@launch
val resolution =
peerSessionMgr.resolveRenegotiationGlare(peerPubKey, sdpOffer) { entry ->
applyRenegotiationOffer(entry.session, peerPubKey, sdpOffer)
}
Log.d(TAG) { "Glare with ${peerPubKey.take(8)}: I lose, rolling back my offer" }
ps.session.rollback {
scope.launch {
applyRenegotiationOffer(ps, peerPubKey, sdpOffer)
}
}
} else {
applyRenegotiationOffer(ps, peerPubKey, sdpOffer)
}
Log.d(TAG) { "Renegotiation glare resolution with ${peerPubKey.take(8)}: $resolution" }
}
}
private fun applyRenegotiationOffer(
ps: PeerSessionState,
session: PeerSession,
peerPubKey: HexKey,
sdpOffer: String,
) {
ps.session.setRemoteDescription(SessionDescription(SessionDescription.Type.OFFER, sdpOffer))
ps.session.createAnswer { sdp ->
session.setRemoteDescription(SdpType.OFFER, sdpOffer)
session.createAnswer { sdpAnswer ->
scope.launch {
callManager.sendRenegotiationAnswer(sdp.description, peerPubKey)
callManager.sendRenegotiationAnswer(sdpAnswer, peerPubKey)
}
}
}
private fun performRenegotiation(peerPubKey: HexKey) {
val ps = peerSessions[peerPubKey] ?: return
val webRtcSession = webRtcSession(peerPubKey) ?: return
val state = callManager.state.value
if (state !is CallState.Connected && state !is CallState.Connecting) return
Log.d(TAG) { "Starting renegotiation with ${peerPubKey.take(8)}" }
ps.session.createOffer { sdp ->
webRtcSession.createOffer { sdp ->
scope.launch {
callManager.sendRenegotiation(sdp.description, peerPubKey)
}
@@ -602,8 +551,10 @@ class CallController(
if (localVideoTrackInternal == null) {
// Voice → video upgrade: create video source/track and add to all sessions
createVideoResources()
peerSessions.values.forEach { ps ->
localVideoTrackInternal?.let { ps.session.addTrack(it, VIDEO_MAX_BITRATE_BPS) }
peerSessionMgr.allSessionKeys().forEach { key ->
webRtcSession(key)?.let { session ->
localVideoTrackInternal?.let { track -> session.addTrack(track, VIDEO_MAX_BITRATE_BPS) }
}
}
} else {
localVideoTrackInternal?.setEnabled(true)
@@ -710,8 +661,8 @@ class CallController(
// ---- Per-peer PeerConnection creation ----
private fun createPeerSession(peerPubKey: HexKey): PeerSessionState {
Log.d(TAG) { "createPeerSession: ${peerPubKey.take(8)}, existing sessions=${peerSessions.keys.map { it.take(8) }}" }
private fun createWebRtcSession(peerPubKey: HexKey): WebRtcCallSession {
Log.d(TAG) { "createWebRtcSession: ${peerPubKey.take(8)}, existing sessions=${peerSessionMgr.allSessionKeys().map { it.take(8) }}" }
val factory = peerConnectionFactory ?: throw IllegalStateException("PeerConnectionFactory not initialized")
val session =
@@ -744,9 +695,7 @@ class CallController(
localAudioTrackInternal?.let { session.addTrack(it) }
localVideoTrackInternal?.let { session.addTrack(it, VIDEO_MAX_BITRATE_BPS) }
val ps = PeerSessionState(session)
peerSessions[peerPubKey] = ps
return ps
return session
}
private fun onRemoteVideoTrack(
@@ -814,17 +763,11 @@ class CallController(
private fun onPeerDisconnected(peerPubKey: HexKey) {
Log.d(TAG) { "Peer ${peerPubKey.take(8)} disconnected (ICE FAILED)" }
// If all peers disconnected, hang up.
// A session counts as "not active" if it is the one that just failed,
// if it was already closed, or if it never received a remote description
// (e.g. the peer rejected before answering).
val sessionStates = peerSessions.map { (key, ps) -> "${key.take(8)}=${ps.session.getSignalingState()},remoteSet=${ps.remoteDescriptionSet.get()}" }
Log.d(TAG) { "onPeerDisconnected: checking remaining sessions: $sessionStates" }
val allDisconnected =
peerSessions.keys.all { key ->
peerSessionMgr.allSessionKeys().all { key ->
key == peerPubKey ||
peerSessions[key]?.session?.getSignalingState() == PeerConnection.SignalingState.CLOSED ||
peerSessions[key]?.remoteDescriptionSet?.get() != true
peerSessionMgr.getSession(key)?.session?.getSignalingState() == SignalingState.CLOSED ||
peerSessionMgr.getSession(key)?.remoteDescriptionSet != true
}
if (allDisconnected) {
Log.d(TAG) { "onPeerDisconnected: all peers disconnected, hanging up" }
@@ -841,11 +784,11 @@ class CallController(
* but the call continues with remaining peers.
*/
fun disposePeerSession(peerPubKey: HexKey) {
val ps = peerSessions.remove(peerPubKey)
if (ps != null) {
Log.d(TAG) { "disposePeerSession: closing session for ${peerPubKey.take(8)}, remaining sessions=${peerSessions.keys.map { it.take(8) }}" }
val entry = peerSessionMgr.removeSession(peerPubKey)
if (entry != null) {
Log.d(TAG) { "disposePeerSession: closing session for ${peerPubKey.take(8)}" }
try {
ps.session.dispose()
entry?.session?.dispose()
} catch (e: Exception) {
Log.e(TAG, "disposePeerSession: dispose() failed for ${peerPubKey.take(8)}", e)
}
@@ -868,14 +811,12 @@ class CallController(
} else {
Log.d(TAG) { "disposePeerSession: no session for ${peerPubKey.take(8)} (already disposed or never created)" }
}
// Clean up any buffered ICE candidates for this peer
globalPendingIce.remove(peerPubKey)
}
// ---- Cleanup ----
fun cleanup() {
Log.d(TAG) { "cleanup: disposing ${peerSessions.size} peer sessions, state=${callManager.state.value::class.simpleName}" }
Log.d(TAG) { "cleanup: disposing ${peerSessionMgr.allSessionKeys().size} peer sessions, state=${callManager.state.value::class.simpleName}" }
// Each block is wrapped individually so that a failure in one
// (e.g. a WebRTC native crash) does not prevent the rest from
// running. Without this, a single exception could leave the
@@ -900,14 +841,12 @@ class CallController(
}
// Dispose all peer sessions
for (ps in peerSessions.values) {
try {
ps.session.dispose()
} catch (e: Exception) {
Log.e(TAG, "cleanup: PeerSession.dispose() failed", e)
}
try {
peerSessionMgr.disposeAll()
} catch (e: Exception) {
Log.e(TAG, "cleanup: sessionManager.disposeAll() failed", e)
}
peerSessions.clear()
peerSessionMgr = PeerSessionManager(peerSessionMgr.localPubKey)
// Dispose shared resources — each in its own try-catch so one
// failure does not prevent the others from being released.
@@ -954,8 +893,6 @@ class CallController(
peerConnectionFactory = null
sharedEglBase = null
globalPendingIce.clear()
_remoteVideoTrack.value = null
_remoteVideoTracks.value = emptyMap()
_localVideoTrack.value = null
@@ -0,0 +1,83 @@
/*
* 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 com.vitorpamplona.amethyst.commons.call.IceCandidateData
import com.vitorpamplona.amethyst.commons.call.PeerSession
import com.vitorpamplona.amethyst.commons.call.SdpType
import com.vitorpamplona.amethyst.commons.call.SignalingState
import org.webrtc.IceCandidate
import org.webrtc.PeerConnection
import org.webrtc.SessionDescription
/**
* Adapts a [WebRtcCallSession] to the platform-independent [PeerSession] interface,
* allowing [com.vitorpamplona.amethyst.commons.call.PeerSessionManager] to manage
* ICE buffering, answer routing, and renegotiation glare without depending on org.webrtc.
*/
class WebRtcPeerSessionAdapter(
val webRtcSession: WebRtcCallSession,
) : PeerSession {
override fun getSignalingState(): SignalingState? =
when (webRtcSession.getSignalingState()) {
PeerConnection.SignalingState.STABLE -> SignalingState.STABLE
PeerConnection.SignalingState.HAVE_LOCAL_OFFER -> SignalingState.HAVE_LOCAL_OFFER
PeerConnection.SignalingState.HAVE_REMOTE_OFFER -> SignalingState.HAVE_REMOTE_OFFER
PeerConnection.SignalingState.HAVE_LOCAL_PRANSWER -> SignalingState.HAVE_LOCAL_PRANSWER
PeerConnection.SignalingState.HAVE_REMOTE_PRANSWER -> SignalingState.HAVE_REMOTE_PRANSWER
PeerConnection.SignalingState.CLOSED -> SignalingState.CLOSED
null -> null
}
override fun setRemoteDescription(
type: SdpType,
sdp: String,
) {
val sdType =
when (type) {
SdpType.OFFER -> SessionDescription.Type.OFFER
SdpType.ANSWER -> SessionDescription.Type.ANSWER
}
webRtcSession.setRemoteDescription(SessionDescription(sdType, sdp))
}
override fun addIceCandidate(candidate: IceCandidateData) {
webRtcSession.addIceCandidate(
IceCandidate(candidate.sdpMid, candidate.sdpMLineIndex, candidate.sdp),
)
}
override fun createOffer(onSdpCreated: (String) -> Unit) {
webRtcSession.createOffer { sdp -> onSdpCreated(sdp.description) }
}
override fun createAnswer(onSdpCreated: (String) -> Unit) {
webRtcSession.createAnswer { sdp -> onSdpCreated(sdp.description) }
}
override fun rollback(onDone: () -> Unit) {
webRtcSession.rollback(onDone)
}
override fun dispose() {
webRtcSession.dispose()
}
}
@@ -218,6 +218,7 @@ class AccountViewModel(
scope = viewModelScope,
publishWrap = { wrap -> account.publishCallSignaling(wrap) },
signerProvider = { account.signer },
localPubKey = account.signer.pubKey,
)
// Set callbacks before exposing controller to avoid timing races
+1
View File
@@ -83,6 +83,7 @@ kotlin {
commonTest {
dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
}
}
@@ -0,0 +1,72 @@
/*
* 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
/**
* Represents a single ICE candidate received from a peer.
* Platform-neutral — does not depend on org.webrtc.
*/
data class IceCandidateData(
val sdp: String,
val sdpMid: String,
val sdpMLineIndex: Int,
)
/**
* Represents the signaling state of a peer connection.
* Maps 1:1 with WebRTC's PeerConnection.SignalingState.
*/
enum class SignalingState {
STABLE,
HAVE_LOCAL_OFFER,
HAVE_REMOTE_OFFER,
HAVE_LOCAL_PRANSWER,
HAVE_REMOTE_PRANSWER,
CLOSED,
}
enum class SdpType {
OFFER,
ANSWER,
}
/**
* Abstraction over a single peer connection's signaling operations.
* Implemented by the platform-specific WebRTC wrapper (e.g. WebRtcPeerSessionAdapter).
*/
interface PeerSession {
fun getSignalingState(): SignalingState?
fun setRemoteDescription(
type: SdpType,
sdp: String,
)
fun addIceCandidate(candidate: IceCandidateData)
fun createOffer(onSdpCreated: (String) -> Unit)
fun createAnswer(onSdpCreated: (String) -> Unit)
fun rollback(onDone: () -> Unit)
fun dispose()
}
@@ -0,0 +1,215 @@
/*
* 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.HexKey
/**
* Manages per-peer session state and ICE candidate buffering.
*
* This class encapsulates the NIP-AC spec requirements for:
* - **Two-layer ICE buffering**: global buffer (before session exists) and
* per-session buffer (before remote description is set)
* - **Renegotiation glare handling**: pubkey comparison tiebreaker
* - **Callee-to-callee mesh**: lower pubkey initiates
*
* It is platform-independent and testable without real WebRTC.
*/
class PeerSessionManager(
val localPubKey: HexKey,
) {
data class SessionEntry(
val session: PeerSession,
var remoteDescriptionSet: Boolean = false,
val pendingIceCandidates: MutableList<IceCandidateData> = mutableListOf(),
)
private val sessions = mutableMapOf<HexKey, SessionEntry>()
/** Candidates received before a session exists for the sender. */
private val globalPendingIce = mutableMapOf<HexKey, MutableList<IceCandidateData>>()
// ---- Session management ----
fun registerSession(
peerPubKey: HexKey,
session: PeerSession,
): SessionEntry {
val globalPending = globalPendingIce.remove(peerPubKey) ?: emptyList()
val entry = SessionEntry(session)
entry.pendingIceCandidates.addAll(globalPending)
sessions[peerPubKey] = entry
return entry
}
fun getSession(peerPubKey: HexKey): SessionEntry? = sessions[peerPubKey]
fun hasSession(peerPubKey: HexKey): Boolean = sessions.containsKey(peerPubKey)
fun removeSession(peerPubKey: HexKey): SessionEntry? {
globalPendingIce.remove(peerPubKey)
return sessions.remove(peerPubKey)
}
fun allSessionKeys(): Set<HexKey> = sessions.keys.toSet()
// ---- ICE candidate routing (two-layer buffering) ----
/**
* Routes an incoming ICE candidate to the correct destination:
* 1. If session exists AND remote description is set -> add directly
* 2. If session exists but remote description NOT set -> buffer per-session
* 3. If no session exists -> buffer globally (keyed by sender)
*
* Returns the action taken for testability.
*/
fun routeIceCandidate(
senderPubKey: HexKey,
candidate: IceCandidateData,
): IceRouteAction {
val entry = sessions[senderPubKey]
return when {
entry != null && entry.remoteDescriptionSet -> {
entry.session.addIceCandidate(candidate)
IceRouteAction.ADDED_DIRECTLY
}
entry != null -> {
entry.pendingIceCandidates.add(candidate)
IceRouteAction.BUFFERED_PER_SESSION
}
else -> {
globalPendingIce.getOrPut(senderPubKey) { mutableListOf() }.add(candidate)
IceRouteAction.BUFFERED_GLOBALLY
}
}
}
/**
* Flushes all per-session buffered candidates into the PeerConnection.
* Called after setRemoteDescription succeeds.
*/
fun flushPendingIceCandidates(peerPubKey: HexKey): Int {
val entry = sessions[peerPubKey] ?: return 0
entry.remoteDescriptionSet = true
val candidates = entry.pendingIceCandidates.toList()
entry.pendingIceCandidates.clear()
candidates.forEach { entry.session.addIceCandidate(it) }
return candidates.size
}
fun globalPendingCount(peerPubKey: HexKey): Int = globalPendingIce[peerPubKey]?.size ?: 0
fun sessionPendingCount(peerPubKey: HexKey): Int = sessions[peerPubKey]?.pendingIceCandidates?.size ?: 0
// ---- Renegotiation glare handling ----
/**
* Determines how to handle a renegotiation offer when we may have a pending
* local offer (glare condition).
*
* Per NIP-AC spec: "the peer with the higher pubkey wins (their offer takes priority).
* The losing peer MUST roll back their local offer."
*/
fun resolveRenegotiationGlare(
peerPubKey: HexKey,
remoteSdpOffer: String,
onAcceptRemote: (SessionEntry) -> Unit,
): GlareResolution {
val entry = sessions[peerPubKey] ?: return GlareResolution.NO_SESSION
val signalingState = entry.session.getSignalingState()
if (signalingState != SignalingState.HAVE_LOCAL_OFFER) {
onAcceptRemote(entry)
return GlareResolution.NO_GLARE
}
// Glare detected: both sides sent offers simultaneously
return if (localPubKey > peerPubKey) {
GlareResolution.LOCAL_WINS
} else {
entry.session.rollback {
onAcceptRemote(entry)
}
GlareResolution.REMOTE_WINS_ROLLBACK
}
}
// ---- Callee-to-callee mesh ----
/**
* Determines if the local peer should initiate the offer to [peerPubKey].
* Per NIP-AC spec: "the peer with the lexicographically lower pubkey initiates."
*/
fun shouldInitiateOffer(peerPubKey: HexKey): Boolean = localPubKey < peerPubKey
// ---- Answer routing ----
/**
* Routes an incoming SDP answer to the correct session.
* Returns the action taken.
*/
fun routeAnswer(
peerPubKey: HexKey,
sdpAnswer: String,
): AnswerRouteAction {
val entry = sessions[peerPubKey] ?: return AnswerRouteAction.NO_SESSION
val signalingState = entry.session.getSignalingState()
if (signalingState != SignalingState.HAVE_LOCAL_OFFER) {
return AnswerRouteAction.IGNORED_WRONG_STATE
}
entry.session.setRemoteDescription(SdpType.ANSWER, sdpAnswer)
flushPendingIceCandidates(peerPubKey)
return AnswerRouteAction.APPLIED
}
// ---- Cleanup ----
fun disposeAll() {
for (entry in sessions.values) {
entry.session.dispose()
}
sessions.clear()
globalPendingIce.clear()
}
}
enum class IceRouteAction {
ADDED_DIRECTLY,
BUFFERED_PER_SESSION,
BUFFERED_GLOBALLY,
}
enum class GlareResolution {
NO_SESSION,
NO_GLARE,
LOCAL_WINS,
REMOTE_WINS_ROLLBACK,
}
enum class AnswerRouteAction {
APPLIED,
NO_SESSION,
IGNORED_WRONG_STATE,
}
@@ -0,0 +1,80 @@
/*
* 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
/**
* Fake PeerSession that records all operations for test assertions.
* Simulates WebRTC PeerConnection signaling state transitions
* without native libraries.
*/
class FakePeerSession(
private var signalingState: SignalingState = SignalingState.STABLE,
) : PeerSession {
val addedCandidates = mutableListOf<IceCandidateData>()
var lastRemoteDescription: Pair<SdpType, String>? = null
var rolledBack = false
var disposed = false
var lastCreatedOffer: String? = null
var lastCreatedAnswer: String? = null
override fun getSignalingState(): SignalingState = signalingState
override fun setRemoteDescription(
type: SdpType,
sdp: String,
) {
lastRemoteDescription = type to sdp
signalingState =
when (type) {
SdpType.ANSWER -> SignalingState.STABLE
SdpType.OFFER -> SignalingState.HAVE_REMOTE_OFFER
}
}
override fun addIceCandidate(candidate: IceCandidateData) {
addedCandidates.add(candidate)
}
override fun createOffer(onSdpCreated: (String) -> Unit) {
signalingState = SignalingState.HAVE_LOCAL_OFFER
val sdp = "fake-offer-sdp"
lastCreatedOffer = sdp
onSdpCreated(sdp)
}
override fun createAnswer(onSdpCreated: (String) -> Unit) {
val sdp = "fake-answer-sdp"
lastCreatedAnswer = sdp
signalingState = SignalingState.STABLE
onSdpCreated(sdp)
}
override fun rollback(onDone: () -> Unit) {
rolledBack = true
signalingState = SignalingState.STABLE
onDone()
}
override fun dispose() {
disposed = true
signalingState = SignalingState.CLOSED
}
}
@@ -0,0 +1,489 @@
/*
* 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 kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Tests for PeerSessionManager the extracted logic from CallController that
* handles ICE candidate buffering, renegotiation glare, and answer routing.
*
* Uses FakePeerSession to simulate WebRTC PeerConnection behavior without
* native libraries, making these runnable as JVM unit tests.
*/
class PeerSessionManagerTest {
// Identities: alice < bob < carol (lexicographic order of hex chars)
private val alice = "aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111"
private val bob = "bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222"
private val carol = "cccc3333cccc3333cccc3333cccc3333cccc3333cccc3333cccc3333cccc3333"
private fun makeCandidate(label: String = "candidate:1") = IceCandidateData(sdp = label, sdpMid = "0", sdpMLineIndex = 0)
// ========================================================================
// ICE Candidate Buffering — Layer 1: Global Buffer
// ========================================================================
@Test
fun iceCandidateNoSessionBufferedGlobally() {
val manager = PeerSessionManager(localPubKey = bob)
val candidate = makeCandidate("early-candidate")
val action = manager.routeIceCandidate(alice, candidate)
assertEquals(IceRouteAction.BUFFERED_GLOBALLY, action)
assertEquals(1, manager.globalPendingCount(alice))
}
@Test
fun multipleGlobalCandidatesBufferedForSamePeer() {
val manager = PeerSessionManager(localPubKey = bob)
manager.routeIceCandidate(alice, makeCandidate("c1"))
manager.routeIceCandidate(alice, makeCandidate("c2"))
manager.routeIceCandidate(alice, makeCandidate("c3"))
assertEquals(3, manager.globalPendingCount(alice))
}
@Test
fun globalBufferDrainedOnSessionCreation() {
val manager = PeerSessionManager(localPubKey = bob)
val fake = FakePeerSession()
// Buffer 3 candidates before session exists
manager.routeIceCandidate(alice, makeCandidate("c1"))
manager.routeIceCandidate(alice, makeCandidate("c2"))
manager.routeIceCandidate(alice, makeCandidate("c3"))
assertEquals(3, manager.globalPendingCount(alice))
// Create session — global candidates should drain into per-session buffer
val entry = manager.registerSession(alice, fake)
assertEquals(0, manager.globalPendingCount(alice), "Global buffer should be drained")
assertEquals(3, entry.pendingIceCandidates.size, "Candidates should move to per-session buffer")
assertEquals(0, fake.addedCandidates.size, "Candidates should NOT be added directly yet")
}
@Test
fun globalBufferDrainedAndFlushedAfterRemoteDescription() {
val manager = PeerSessionManager(localPubKey = bob)
val fake = FakePeerSession()
// Buffer candidates before session
manager.routeIceCandidate(alice, makeCandidate("c1"))
manager.routeIceCandidate(alice, makeCandidate("c2"))
// Create session (drains global → per-session)
manager.registerSession(alice, fake)
assertEquals(2, manager.sessionPendingCount(alice))
// Flush after remote description set
val flushed = manager.flushPendingIceCandidates(alice)
assertEquals(2, flushed)
assertEquals(2, fake.addedCandidates.size, "Flushed candidates must reach the PeerConnection")
assertEquals("c1", fake.addedCandidates[0].sdp)
assertEquals("c2", fake.addedCandidates[1].sdp)
assertEquals(0, manager.sessionPendingCount(alice))
}
// ========================================================================
// ICE Candidate Buffering — Layer 2: Per-Session Buffer
// ========================================================================
@Test
fun iceCandidateSessionExistsRemoteDescNotSetBufferedPerSession() {
val manager = PeerSessionManager(localPubKey = bob)
val fake = FakePeerSession()
manager.registerSession(alice, fake)
val action = manager.routeIceCandidate(alice, makeCandidate("mid-buffer"))
assertEquals(IceRouteAction.BUFFERED_PER_SESSION, action)
assertEquals(1, manager.sessionPendingCount(alice))
assertEquals(0, fake.addedCandidates.size, "Should NOT add to PeerConnection yet")
}
@Test
fun iceCandidateSessionExistsRemoteDescSetAddedDirectly() {
val manager = PeerSessionManager(localPubKey = bob)
val fake = FakePeerSession()
manager.registerSession(alice, fake)
manager.flushPendingIceCandidates(alice) // marks remoteDescriptionSet = true
val action = manager.routeIceCandidate(alice, makeCandidate("direct"))
assertEquals(IceRouteAction.ADDED_DIRECTLY, action)
assertEquals(1, fake.addedCandidates.size)
assertEquals("direct", fake.addedCandidates[0].sdp)
}
@Test
fun perSessionBufferNotClearedOnSessionCreationOnlyOnFlush() {
val manager = PeerSessionManager(localPubKey = bob)
val fake = FakePeerSession()
// Buffer globally
manager.routeIceCandidate(alice, makeCandidate("global-c"))
// Create session → global drains to per-session
manager.registerSession(alice, fake)
// Buffer more per-session (remote desc not set yet)
manager.routeIceCandidate(alice, makeCandidate("session-c"))
assertEquals(2, manager.sessionPendingCount(alice))
assertEquals(0, fake.addedCandidates.size)
// Now flush
manager.flushPendingIceCandidates(alice)
assertEquals(2, fake.addedCandidates.size)
assertEquals("global-c", fake.addedCandidates[0].sdp)
assertEquals("session-c", fake.addedCandidates[1].sdp)
}
// ========================================================================
// ICE Buffering: Candidates Buffered While Ringing MUST Be Preserved
// ========================================================================
@Test
fun candidatesBufferedWhileRingingNotLostWhenAccepting() {
val manager = PeerSessionManager(localPubKey = bob)
val fake = FakePeerSession()
// Phase 1: Ringing — candidates arrive, no session yet
manager.routeIceCandidate(alice, makeCandidate("ringing-c1"))
manager.routeIceCandidate(alice, makeCandidate("ringing-c2"))
assertEquals(2, manager.globalPendingCount(alice))
// Phase 2: Accept call — create session
manager.registerSession(alice, fake)
assertEquals(0, manager.globalPendingCount(alice), "Global buffer drained")
assertEquals(2, manager.sessionPendingCount(alice), "Candidates preserved in session")
// Phase 3: Set remote description and flush
manager.flushPendingIceCandidates(alice)
assertEquals(2, fake.addedCandidates.size)
assertEquals("ringing-c1", fake.addedCandidates[0].sdp)
assertEquals("ringing-c2", fake.addedCandidates[1].sdp)
}
// ========================================================================
// ICE Buffering: Multi-Peer (Group Call)
// ========================================================================
@Test
fun globalBuffersAreSeparatePerPeer() {
val manager = PeerSessionManager(localPubKey = bob)
manager.routeIceCandidate(alice, makeCandidate("alice-c"))
manager.routeIceCandidate(carol, makeCandidate("carol-c"))
assertEquals(1, manager.globalPendingCount(alice))
assertEquals(1, manager.globalPendingCount(carol))
}
@Test
fun registeringOneSessionDoesNotDrainOtherPeersGlobalBuffer() {
val manager = PeerSessionManager(localPubKey = bob)
manager.routeIceCandidate(alice, makeCandidate("alice-c"))
manager.routeIceCandidate(carol, makeCandidate("carol-c"))
manager.registerSession(alice, FakePeerSession())
assertEquals(0, manager.globalPendingCount(alice), "Alice's global buffer drained")
assertEquals(1, manager.globalPendingCount(carol), "Carol's global buffer untouched")
}
// ========================================================================
// Renegotiation Glare Handling
// ========================================================================
@Test
fun renegotiationNoGlareAcceptsRemoteOffer() {
val manager = PeerSessionManager(localPubKey = bob)
val fake = FakePeerSession(signalingState = SignalingState.STABLE)
manager.registerSession(alice, fake)
var accepted = false
val resolution =
manager.resolveRenegotiationGlare(alice, "remote-sdp") { entry ->
accepted = true
}
assertEquals(GlareResolution.NO_GLARE, resolution)
assertTrue(accepted, "Should accept remote offer when no glare")
}
@Test
fun renegotiationGlareLocalWinsHigherPubkey() {
// carol (higher) is local, alice (lower) is remote → carol wins
val manager = PeerSessionManager(localPubKey = carol)
val fake = FakePeerSession(signalingState = SignalingState.HAVE_LOCAL_OFFER)
manager.registerSession(alice, fake)
var accepted = false
val resolution =
manager.resolveRenegotiationGlare(alice, "remote-sdp") { accepted = true }
assertEquals(GlareResolution.LOCAL_WINS, resolution)
assertFalse(accepted, "Should NOT accept remote offer when local wins")
}
@Test
fun renegotiationGlareRemoteWinsRollback() {
// alice (lower) is local, carol (higher) is remote → carol wins, alice rolls back
val manager = PeerSessionManager(localPubKey = alice)
val fake = FakePeerSession(signalingState = SignalingState.HAVE_LOCAL_OFFER)
manager.registerSession(carol, fake)
var accepted = false
val resolution =
manager.resolveRenegotiationGlare(carol, "remote-sdp") { accepted = true }
assertEquals(GlareResolution.REMOTE_WINS_ROLLBACK, resolution)
assertTrue(fake.rolledBack, "Must call rollback on the session")
assertTrue(accepted, "Must accept remote offer after rollback")
}
@Test
fun renegotiationGlareNoSessionReturnsNoSession() {
val manager = PeerSessionManager(localPubKey = bob)
val resolution =
manager.resolveRenegotiationGlare(alice, "sdp") { }
assertEquals(GlareResolution.NO_SESSION, resolution)
}
// ========================================================================
// Callee-to-Callee Mesh: Initiation Tiebreaker
// ========================================================================
@Test
fun meshInitiationLowerPubkeyShouldInitiate() {
val manager = PeerSessionManager(localPubKey = alice)
assertTrue(manager.shouldInitiateOffer(bob), "Lower pubkey (alice) should initiate to higher (bob)")
assertTrue(manager.shouldInitiateOffer(carol), "Lower pubkey (alice) should initiate to higher (carol)")
}
@Test
fun meshInitiationHigherPubkeyShouldWait() {
val manager = PeerSessionManager(localPubKey = carol)
assertFalse(manager.shouldInitiateOffer(alice), "Higher pubkey (carol) should NOT initiate to lower (alice)")
assertFalse(manager.shouldInitiateOffer(bob), "Higher pubkey (carol) should NOT initiate to lower (bob)")
}
// ========================================================================
// Answer Routing
// ========================================================================
@Test
fun routeAnswerNoSessionReturnsNoSession() {
val manager = PeerSessionManager(localPubKey = alice)
val action = manager.routeAnswer(bob, "answer-sdp")
assertEquals(AnswerRouteAction.NO_SESSION, action)
}
@Test
fun routeAnswerWrongSignalingStateIgnored() {
val manager = PeerSessionManager(localPubKey = alice)
val fake = FakePeerSession(signalingState = SignalingState.STABLE)
manager.registerSession(bob, fake)
val action = manager.routeAnswer(bob, "answer-sdp")
assertEquals(AnswerRouteAction.IGNORED_WRONG_STATE, action)
assertNull(fake.lastRemoteDescription, "Should NOT set remote description")
}
@Test
fun routeAnswerHavingLocalOfferApplied() {
val manager = PeerSessionManager(localPubKey = alice)
val fake = FakePeerSession(signalingState = SignalingState.HAVE_LOCAL_OFFER)
manager.registerSession(bob, fake)
// Buffer some ICE candidates before answer arrives
manager.routeIceCandidate(bob, makeCandidate("pre-answer-c"))
val action = manager.routeAnswer(bob, "answer-sdp")
assertEquals(AnswerRouteAction.APPLIED, action)
assertNotNull(fake.lastRemoteDescription)
assertEquals(SdpType.ANSWER, fake.lastRemoteDescription!!.first)
assertEquals("answer-sdp", fake.lastRemoteDescription!!.second)
// Flush should have happened
assertEquals(1, fake.addedCandidates.size, "Buffered candidates should be flushed after answer")
}
// ========================================================================
// Session Lifecycle
// ========================================================================
@Test
fun removeSessionDisposesAndCleansUp() {
val manager = PeerSessionManager(localPubKey = bob)
val fake = FakePeerSession()
manager.registerSession(alice, fake)
manager.routeIceCandidate(alice, makeCandidate("c"))
val removed = manager.removeSession(alice)
assertNotNull(removed)
assertFalse(manager.hasSession(alice))
assertEquals(0, manager.globalPendingCount(alice))
}
@Test
fun disposeAllDisposesAllSessions() {
val manager = PeerSessionManager(localPubKey = bob)
val fakeAlice = FakePeerSession()
val fakeCarol = FakePeerSession()
manager.registerSession(alice, fakeAlice)
manager.registerSession(carol, fakeCarol)
manager.disposeAll()
assertTrue(fakeAlice.disposed)
assertTrue(fakeCarol.disposed)
assertTrue(manager.allSessionKeys().isEmpty())
}
// ========================================================================
// Full Scenario: P2P Call with ICE Buffering
// ========================================================================
@Test
fun fullP2PFlowIceBufferingThroughAllPhases() {
val manager = PeerSessionManager(localPubKey = bob)
val fake = FakePeerSession()
// Phase 1: Ringing — candidates arrive before session
manager.routeIceCandidate(alice, makeCandidate("ringing-1"))
manager.routeIceCandidate(alice, makeCandidate("ringing-2"))
assertEquals(2, manager.globalPendingCount(alice))
// Phase 2: Accept — create session (drains global → per-session)
manager.registerSession(alice, fake)
assertEquals(2, manager.sessionPendingCount(alice))
// Phase 3: More candidates arrive before remote desc set
manager.routeIceCandidate(alice, makeCandidate("pre-desc-1"))
assertEquals(IceRouteAction.BUFFERED_PER_SESSION, manager.routeIceCandidate(alice, makeCandidate("pre-desc-2")))
assertEquals(4, manager.sessionPendingCount(alice))
// Phase 4: Remote description set → flush
val flushed = manager.flushPendingIceCandidates(alice)
assertEquals(4, flushed)
assertEquals(4, fake.addedCandidates.size)
// Phase 5: Late candidates arrive → added directly
assertEquals(IceRouteAction.ADDED_DIRECTLY, manager.routeIceCandidate(alice, makeCandidate("post-desc")))
assertEquals(5, fake.addedCandidates.size)
}
// ========================================================================
// Full Scenario: Group Call with Mesh ICE Buffering
// ========================================================================
@Test
fun groupCallMeshSetupWithIceBuffering() {
// Bob is local. Alice (caller) and Carol (other callee) are peers.
val manager = PeerSessionManager(localPubKey = bob)
// Phase 1: Ringing — ICE from alice arrives before any session
manager.routeIceCandidate(alice, makeCandidate("alice-ring-c"))
// Phase 2: Accept — create session for alice
val aliceFake = FakePeerSession()
manager.registerSession(alice, aliceFake)
manager.flushPendingIceCandidates(alice)
assertEquals(1, aliceFake.addedCandidates.size)
// Phase 3: Carol's ICE arrives before mesh session exists
manager.routeIceCandidate(carol, makeCandidate("carol-early-c"))
assertEquals(1, manager.globalPendingCount(carol))
// Phase 4: Mesh setup — should bob initiate to carol?
// bob < carol → bob should initiate
assertTrue(manager.shouldInitiateOffer(carol))
// Phase 5: Create session for carol (drains global)
val carolFake = FakePeerSession()
manager.registerSession(carol, carolFake)
assertEquals(1, manager.sessionPendingCount(carol))
// Phase 6: Carol's remote description set → flush
manager.flushPendingIceCandidates(carol)
assertEquals(1, carolFake.addedCandidates.size)
assertEquals("carol-early-c", carolFake.addedCandidates[0].sdp)
}
// ========================================================================
// Full Scenario: Renegotiation with Glare
// ========================================================================
@Test
fun renegotiationGlareFullFlowLowerPubkeyRollsBack() {
// Alice (lower) and Bob (higher) both send renegotiation offers simultaneously
// Alice's manager handles Bob's incoming offer
val aliceManager = PeerSessionManager(localPubKey = alice)
val aliceFake = FakePeerSession(signalingState = SignalingState.HAVE_LOCAL_OFFER)
aliceManager.registerSession(bob, aliceFake)
aliceManager.flushPendingIceCandidates(bob)
var acceptedSdp = ""
val resolution =
aliceManager.resolveRenegotiationGlare(bob, "bob-renego-sdp") { entry ->
acceptedSdp = "bob-renego-sdp"
}
assertEquals(GlareResolution.REMOTE_WINS_ROLLBACK, resolution, "alice (lower) should lose to bob (higher)")
assertTrue(aliceFake.rolledBack, "alice must rollback her local offer")
assertEquals("bob-renego-sdp", acceptedSdp, "alice must accept bob's offer")
// Bob's manager handles Alice's incoming offer
val bobManager = PeerSessionManager(localPubKey = bob)
val bobFake = FakePeerSession(signalingState = SignalingState.HAVE_LOCAL_OFFER)
bobManager.registerSession(alice, bobFake)
bobManager.flushPendingIceCandidates(alice)
var bobAccepted = false
val bobResolution =
bobManager.resolveRenegotiationGlare(alice, "alice-renego-sdp") { bobAccepted = true }
assertEquals(GlareResolution.LOCAL_WINS, bobResolution, "bob (higher) should win over alice (lower)")
assertFalse(bobFake.rolledBack, "bob should NOT rollback")
assertFalse(bobAccepted, "bob should ignore alice's offer")
}
}
@@ -15,17 +15,17 @@ NAT traversal — no custom server infrastructure is required.
The protocol works as follows:
1. **Caller** creates a signed call offer event containing an SDP offer
2. The event is wrapped in an **Ephemeral Gift Wrap** (kind `21059`) and published to relays
2. The event is wrapped in an **ephemeral gift wrap** (kind `21059`) and published to relays
3. **Callee** unwraps the event, verifies the signature, and decides whether to accept
4. If accepted, callee sends back a gift-wrapped call answer event containing an SDP answer
5. Both parties exchange **ICE candidates** as gift-wrapped events for NAT traversal
6. A **direct WebRTC peer connection** is established for audio/video
All signaling events MUST be delivered using **Ephemeral Gift Wraps** (kind `21059`), an ephemeral
variant of [NIP-59](https://github.com/nostr-protocol/nips/blob/master/59.md) Gift Wraps. Events are
signed by the sender's key and wrapped directly (without the seal layer) — the ephemeral gift wrap's
random ephemeral key already hides the sender from relay operators. Relays SHOULD treat kind `21059`
events as ephemeral and not persist them to long-term storage.
All signaling events MUST be delivered using **ephemeral gift wraps** (kind `21059`), an ephemeral
variant of [NIP-59](https://github.com/nostr-protocol/nips/blob/master/59.md) gift wraps. Events are
signed by the sender's key and wrapped directly (without the seal layer) — the gift wrap's random
ephemeral key already hides the sender from relay operators. Relays SHOULD treat kind `21059` events
as ephemeral and not persist them to long-term storage.
## Event Kinds
@@ -179,13 +179,13 @@ Used for mid-call changes such as toggling video on/off. The `content` field con
## Encryption and Delivery
All signaling events MUST be delivered using **Ephemeral Gift Wraps** (kind `21059`):
All signaling events MUST be delivered using **ephemeral gift wraps** (kind `21059`):
1. **Sign** the signaling event with the sender's key
2. **Wrap** the signed event directly using `EphemeralGiftWrapEvent` (kind `21059`) with NIP-44 encryption
3. **Publish** the ephemeral gift wrap to the recipient's relay list
2. **Wrap** the signed event in a kind `21059` event with NIP-44 encryption and a random ephemeral key
3. **Publish** the gift wrap to the recipient's relay list
The seal layer (`SealedRumorEvent`) is NOT used. The ephemeral gift wrap already provides:
The seal layer (kind `13`) is NOT used. The ephemeral gift wrap already provides:
- **NIP-44 encryption** — content is unreadable to relay operators
- **Random ephemeral pubkey** — the relay cannot identify the sender
@@ -194,7 +194,7 @@ The seal layer (`SealedRumorEvent`) is NOT used. The ephemeral gift wrap already
No `expiration` tag is needed on the inner signaling events or the outer wrap. The ephemeral kind itself communicates the transient nature of the data. Clients MUST still perform staleness checks (see Spam Prevention) to discard old events.
Recipients unwrap the ephemeral gift, verify the inner event's signature against the sender's pubkey, and then process the signaling message.
Recipients decrypt the gift wrap using NIP-44 with their private key and the wrap's ephemeral pubkey, verify the inner event's signature against the sender's pubkey, and then process the signaling message.
## Protocol Flow
@@ -203,16 +203,16 @@ Recipients unwrap the ephemeral gift, verify the inner event's signature against
```
Caller Relay Callee
| | |
|-- EphemeralGiftWrap(CallOffer) ------->| |
| |-- EphemeralGiftWrap(CallOffer) ------->|
|-- GiftWrap(CallOffer) ------->| |
| |-- GiftWrap(CallOffer) ------->|
| | |
| | [Callee unwraps, verifies signature]
| | [Callee decrypts wrap, verifies signature]
| | [Checks: is caller followed?]
| | [YES → ring / NO → ignore]
| | |
|<-- EphemeralGiftWrap(CallAnswer) ------|<-- EphemeralGiftWrap(CallAnswer) ------|
|<-- GiftWrap(CallAnswer) ------|<-- GiftWrap(CallAnswer) ------|
| | |
|<-> EphemeralGiftWrap(IceCandidate) <-->|<-> EphemeralGiftWrap(IceCandidate) <-->|
|<-> GiftWrap(IceCandidate) <-->|<-> GiftWrap(IceCandidate) <-->|
| | |
|============= WebRTC P2P Connection Established ===============|
| (relay no longer involved) |
@@ -235,12 +235,12 @@ Either party may send a `CallRenegotiate` (kind 25055) during an active call to
```
Party A Relay Party B
| | |
|-- EphemeralGiftWrap(Renegotiate) ----->| |
| |-- EphemeralGiftWrap(Renegotiate) ----->|
|-- GiftWrap(Renegotiate) ----->| |
| |-- GiftWrap(Renegotiate) ----->|
| | |
| | [Party B creates SDP answer] |
| | |
|<-- EphemeralGiftWrap(CallAnswer) ------|<-- EphemeralGiftWrap(CallAnswer) ------|
|<-- GiftWrap(CallAnswer) ------|<-- GiftWrap(CallAnswer) ------|
| | |
|========= Updated WebRTC P2P Connection ========================|
```
@@ -436,7 +436,7 @@ This NIP does not mandate specific STUN or TURN servers. Clients SHOULD:
### Event Lifecycle
- The `call-id` tag MUST be a UUID that is unique per call session. All signaling events for the same call share the same `call-id`.
- Signaling data is ephemeral and has no long-term value. Using kind `21059` (Ephemeral Gift Wrap) signals to relays that these events are transient and SHOULD NOT be persisted.
- Signaling data is ephemeral and has no long-term value. Using kind `21059` (ephemeral gift wrap) signals to relays that these events are transient and SHOULD NOT be persisted.
- Clients SHOULD implement a ringing timeout (e.g., 60 seconds). If no answer is received, the call transitions to a "timed out" state.
- After a call ends, the call state SHOULD auto-reset to idle after a brief display period (e.g., 2 seconds) to ensure the client is ready for subsequent calls.
@@ -478,6 +478,148 @@ These self-notification events use the same `call-id` as the original call and f
- If SDP offer/answer creation fails, the client SHOULD surface the error instead of hanging silently.
- Clients SHOULD handle `ICE_CONNECTION_FAILED` state by ending the call and notifying the user of a connection failure.
## Test Vectors
Implementations SHOULD validate their state machine and event handling against these test scenarios.
### Event Structure Tests
| # | Test | Verification |
|---|------|-------------|
| E1 | Build Call Offer (kind 25050) | Contains `p`, `call-id`, `call-type`, `alt` tags; content = SDP offer |
| E2 | Build Call Answer (kind 25051) | Contains `p`, `call-id`, `alt` tags; content = SDP answer; NO `call-type` tag |
| E3 | Build ICE Candidate (kind 25052) | Contains `p`, `call-id`, `alt` tags; content = JSON with `candidate`, `sdpMid`, `sdpMLineIndex` |
| E4 | Build Call Hangup (kind 25053) | Contains `p`, `call-id`, `alt` tags; content MAY be empty or contain reason |
| E5 | Build Call Reject (kind 25054) | Contains `p`, `call-id`, `alt` tags; content MAY be `"busy"` for auto-reject |
| E6 | Build Call Renegotiate (kind 25055) | Contains `p`, `call-id`, `alt` tags; content = new SDP offer |
| E7 | Group offer with N callees | N `p` tags present, one per callee |
| E8 | ICE candidate in group call | Still addressed to single peer (1 `p` tag) |
| E9 | All events for same call share `call-id` | Offer, answer, ICE, hangup, reject, renegotiate — same UUID |
| E10 | Single callee offer is not a group call | Exactly 1 `p` tag |
| E11 | P2P call flow event sequence | Offer → Answer → ICE → Hangup produce correct kinds in order |
| E12 | Group answer includes all member `p` tags | All members visible in answer `p` tags |
| E13 | Group hangup includes all member `p` tags | All members visible in hangup `p` tags |
| E14 | Group reject includes all member `p` tags | All members visible in reject `p` tags |
| E15 | Group renegotiate includes all member `p` tags | All members visible in renegotiate `p` tags |
| E16 | Group members = `p` tags + event author | `groupMembers()` union includes the caller's pubkey |
| E17 | ICE candidate JSON serialization round-trips | `serializeCandidate()` → parse → original values |
| E18 | ICE candidate JSON escapes special characters | Quotes and backslashes in SDP survive serialization |
### State Machine Tests
| # | Scenario | Initial State | Event/Action | Expected State |
|---|----------|---------------|-------------|----------------|
| S1 | Receive offer from followed user | Idle | CallOffer | IncomingCall |
| S2 | Receive offer from non-followed user | Idle | CallOffer | Idle (ignored) |
| S3 | Accept incoming call | IncomingCall | acceptCall() | Connecting |
| S4 | ICE peer connected | Connecting | onPeerConnected() | Connected |
| S5 | Peer hangs up (P2P) | Connected | CallHangup | Ended(PEER_HANGUP) |
| S6 | Ended auto-resets | Ended | ~2s timeout | Idle |
| S7 | Initiate outbound call | Idle | initiateCall() | Offering |
| S8 | Receive answer to offer | Offering | CallAnswer | Connecting |
| S9 | Reject incoming call | IncomingCall | rejectCall() | Ended(REJECTED) |
| S10 | Peer rejects our offer (P2P) | Offering | CallReject | Ended(PEER_REJECTED) |
| S11 | Busy auto-reject | Connected | CallOffer (different call-id) | Connected + publish CallReject("busy") |
| S12 | Stale event (>20s old) | Any | Any signaling event | No state change |
| S13 | Duplicate event (same ID) | Any | Re-delivered event | No state change |
| S14 | Self ICE candidate echo | Any active | CallIceCandidate from self | Ignored |
| S15 | Self hangup echo | Connected | CallHangup from self | Ignored (stay Connected) |
| S16 | Self answer in IncomingCall | IncomingCall | CallAnswer from self | Ended(ANSWERED_ELSEWHERE) |
| S17 | Self answer in Offering | Offering | CallAnswer from self | Ignored (stay Offering) |
| S18 | Hangup from Offering | Offering | hangup() | Ended(HANGUP) |
| S19 | Hangup from Connecting | Connecting | hangup() | Ended(HANGUP) |
| S20 | Hangup from Connected | Connected | hangup() | Ended(HANGUP) |
| S21 | Hangup from Idle | Idle | hangup() | Idle (no-op) |
| S22 | Fresh event (<20s old) | Idle | CallOffer | IncomingCall (processed normally) |
| S23 | Answer with wrong call-id | Offering | CallAnswer (wrong id) | Offering (ignored) |
| S24 | Hangup with wrong call-id | Connected | CallHangup (wrong id) | Connected (ignored) |
| S25 | ICE candidates forwarded via callback | Connecting | CallIceCandidate | Forwarded to onIceCandidateReceived |
| S26 | Peer left callback fires on hangup | Connected (group) | CallHangup from one peer | onPeerLeft callback invoked |
| S27 | Reset returns to Idle | Any | reset() | Idle |
| S28 | Video call type preserved through states | IncomingCall(VIDEO) | accept → connect | All states carry VIDEO type |
| S29 | Caller cancels while ringing | IncomingCall | CallHangup from caller | Ended(PEER_HANGUP) |
### Renegotiation Tests
| # | Scenario | Verification |
|---|----------|-------------|
| R1 | Renegotiate in Connected state | Forwarded to callback |
| R2 | Renegotiate in Connecting state | Forwarded to callback |
| R3 | Renegotiate in Idle state | Ignored |
| R4 | Renegotiate with wrong call-id | Ignored |
| R5 | Renegotiation response | MUST be CallAnswer (kind 25051), NOT CallRenegotiate |
| R6 | Glare tiebreaker | Higher pubkey wins; lower pubkey rolls back |
| R7 | Renegotiation preserves call-id | Same `call-id` tag in renegotiate event |
### Group Call Tests
| # | Scenario | Verification |
|---|----------|-------------|
| G1 | Group offer detected | Multiple `p` tags → group call |
| G2 | Peer rejects in group | Removed from group; call continues with remaining |
| G3 | All peers reject | Call ends |
| G4 | Partial disconnect | Close only that peer's connection; continue with remaining |
| G5 | Last peer leaves | Call ends |
| G6 | Discover peer while ringing | Buffer peer; trigger mesh setup after accepting |
| G7 | Mid-call offer (callee-to-callee) | Forwarded via callback with peer pubkey and SDP |
| G8 | Invite new peer | Offer with all existing members + new invitee in `p` tags |
| G9 | Callee-to-callee glare | Lower pubkey initiates; higher waits |
### ICE Candidate Buffering Tests
| # | Scenario | Verification |
|---|----------|-------------|
| B1 | Candidate arrives before session exists | Buffered in global buffer (keyed by sender) |
| B2 | Multiple candidates buffered globally | All preserved per peer |
| B3 | Global buffer drains on session creation | Moved to per-session buffer, global cleared |
| B4 | Global buffer drains and flushes after remote desc | Candidates reach PeerConnection via `addIceCandidate()` |
| B5 | Candidate arrives after session, before remote desc | Buffered per-session |
| B6 | Candidate arrives after session and remote desc | Added directly via `addIceCandidate()` |
| B7 | Per-session buffer not cleared on creation | Only cleared on flush after `setRemoteDescription()` |
| B8 | Candidates buffered while ringing are preserved | Not lost when accepting — drained into new session |
| B9 | Global buffers are separate per peer | Alice's buffer independent of Carol's |
| B10 | Registering one session doesn't drain other peer | Only the target peer's global buffer is drained |
| B11 | Full P2P flow: ICE through all phases | Ringing → accept → pre-desc → flush → post-desc |
| B12 | Group call mesh with ICE buffering | Global buffer per peer, drain on mesh session creation |
### Gift Wrap Round-Trip Tests
| # | Scenario | Verification |
|---|----------|-------------|
| W1 | Call Offer round-trips through gift wrap | Sign → wrap → unwrap → correct typed event, valid signature |
| W2 | Third party cannot decrypt | Gift wrap addressed to Bob, Carol cannot unwrap |
| W3 | Call Answer round-trips | SDP answer content preserved |
| W4 | ICE Candidate round-trips | JSON with candidate/sdpMid/sdpMLineIndex preserved |
| W5 | Hangup round-trips | Reason string preserved (including empty) |
| W6 | Reject round-trips | Including `"busy"` reason |
| W7 | Renegotiate round-trips | New SDP offer preserved |
| W8 | Group offer: per-peer wraps | Each callee can decrypt only their own wrap |
| W9 | Group answer: broadcast wraps | All members can decrypt; inner event identical |
| W10 | Group hangup: sign once, wrap per recipient | All wraps contain same inner event ID |
| W11 | Group reject: sign once, wrap per recipient | All wraps contain same inner event ID |
| W12 | Full P2P flow through gift wraps | All 7 signaling steps round-trip successfully |
| W13 | Wrap uses ephemeral key (not sender's) | Gift wrap pubkey differs from inner event pubkey |
| W14 | Each wrap uses unique ephemeral key | Two wraps for same content have different pubkeys |
| W15 | Inner event signature verifiable after unwrap | Recipient can verify sender's signature |
| W16 | SDP with special characters survives | CRLF, slashes, equals signs in SDP preserved |
| W17 | ICE candidate with special characters survives | JSON escaping + NIP-44 encryption preserves content |
| W18 | Group offer with context: per-peer SDP | Per-peer SDP content but all `p` tags visible |
### Interface-Level Tests (Full Pipeline)
| # | Scenario | Verification |
|---|----------|-------------|
| I1 | Initiate call | Publishes 1 gift wrap (kind 21059) |
| I2 | Accept call | Publishes gift wrap answer(s) |
| I3 | Reject call | Publishes gift wrap reject(s) |
| I4 | Hangup | Publishes gift wrap hangup(s) |
| I5 | Send renegotiation | Publishes 1 gift wrap renegotiate |
| I6 | Send renegotiation answer | Publishes 1 gift wrap answer |
| I7 | Busy auto-reject | Publishes gift wrap reject while staying in current call |
| I8 | Group per-peer offers | Publishes 1 gift wrap per peer |
| I9 | Invite peer | Publishes 1 gift wrap; invitee added to pending set |
| I10 | Full P2P flow | Offer → Answer → Renegotiate → Hangup, all via gift wraps |
## References
- [NIP-01: Basic Protocol](https://github.com/nostr-protocol/nips/blob/master/01.md) — Event structure
@@ -0,0 +1,528 @@
/*
* 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.nipACWebRtcCalls
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.crypto.verify
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNotEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* NIP-AC Gift Wrap Round-Trip Tests
*
* These tests verify the full encrypt/decrypt pipeline for NIP-AC signaling
* events delivered via Ephemeral Gift Wraps (kind 21059):
*
* sign inner event gift wrap (NIP-44 encrypt) unwrap (NIP-44 decrypt) verify inner event
*
* This is the critical interoperability boundary: if two implementations can
* successfully unwrap each other's gift-wrapped signaling events and recover
* the correct typed inner event, they can establish calls.
*
* All tests use real secp256k1 keys and NIP-44 encryption no mocks.
*/
class NipACGiftWrapRoundTripTest {
private val aliceSigner = NostrSignerInternal(KeyPair())
private val bobSigner = NostrSignerInternal(KeyPair())
private val carolSigner = NostrSignerInternal(KeyPair())
private val alice = aliceSigner.pubKey
private val bob = bobSigner.pubKey
private val carol = carolSigner.pubKey
private val callId = "550e8400-e29b-41d4-a716-446655440000"
private val sdpOffer = "v=0\r\no=- 4611731400430051336 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0"
private val sdpAnswer = "v=0\r\no=- 4611731400430051337 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0"
private val factory = WebRtcCallFactory()
// ========================================================================
// 1. Call Offer: sign → wrap → unwrap → verify
// ========================================================================
@Test
fun callOfferRoundTripsThroughGiftWrap() =
runTest {
val result = factory.createCallOffer(sdpOffer, bob, callId, CallType.VIDEO, aliceSigner)
// Wrap is addressed to Bob
val wrap = result.wrap
assertEquals(EphemeralGiftWrapEvent.KIND, wrap.kind)
assertEquals(bob, wrap.recipientPubKey())
// Wrap pubkey is ephemeral (NOT alice)
assertNotEquals(alice, wrap.pubKey, "Gift wrap MUST use an ephemeral pubkey, not the sender's")
// Bob unwraps
val inner = wrap.unwrapThrowing(bobSigner)
assertIs<CallOfferEvent>(inner, "Deserialized inner event must be CallOfferEvent")
assertEquals(alice, inner.pubKey, "Inner event pubkey must be the real sender (alice)")
assertEquals(sdpOffer, inner.sdpOffer())
assertEquals(callId, inner.callId())
assertEquals(CallType.VIDEO, inner.callType())
assertEquals(bob, inner.recipientPubKeys().single())
assertTrue(inner.verify(), "Inner event signature must be valid")
}
@Test
fun callOfferCannotBeDecryptedByThirdParty() =
runTest {
val result = factory.createCallOffer(sdpOffer, bob, callId, CallType.VOICE, aliceSigner)
// Carol should NOT be able to decrypt a wrap addressed to Bob
val inner = result.wrap.unwrapOrNull(carolSigner)
assertNull(inner, "Third party MUST NOT be able to decrypt gift wrap addressed to another user")
}
// ========================================================================
// 2. Call Answer: sign → wrap → unwrap → verify
// ========================================================================
@Test
fun callAnswerRoundTripsThroughGiftWrap() =
runTest {
val result = factory.createCallAnswer(sdpAnswer, alice, callId, bobSigner)
val wrap = result.wrap
assertEquals(EphemeralGiftWrapEvent.KIND, wrap.kind)
assertEquals(alice, wrap.recipientPubKey())
// Alice unwraps
val inner = wrap.unwrapThrowing(aliceSigner)
assertIs<CallAnswerEvent>(inner)
assertEquals(bob, inner.pubKey)
assertEquals(sdpAnswer, inner.sdpAnswer())
assertEquals(callId, inner.callId())
assertTrue(inner.verify())
}
// ========================================================================
// 3. ICE Candidate: sign → wrap → unwrap → verify JSON content
// ========================================================================
@Test
fun iceCandidateRoundTripsThroughGiftWrap() =
runTest {
val candidateJson =
CallIceCandidateEvent.serializeCandidate(
"candidate:842163049 1 udp 1677729535 203.0.113.1 44323 typ srflx raddr 0.0.0.0 rport 0",
"0",
0,
)
val result = factory.createIceCandidate(candidateJson, bob, callId, aliceSigner)
val inner = result.wrap.unwrapThrowing(bobSigner)
assertIs<CallIceCandidateEvent>(inner)
assertEquals(alice, inner.pubKey)
assertEquals(callId, inner.callId())
assertEquals(candidateJson, inner.candidateJson())
assertEquals("0", inner.sdpMid())
assertEquals(0, inner.sdpMLineIndex())
assertTrue(
inner.candidateSdp().contains("842163049"),
"Candidate SDP must survive JSON escaping round-trip",
)
assertTrue(inner.verify())
}
// ========================================================================
// 4. Hangup: sign → wrap → unwrap → verify
// ========================================================================
@Test
fun hangupRoundTripsThroughGiftWrap() =
runTest {
val result = factory.createHangup(bob, callId, "user ended", aliceSigner)
val inner = result.wrap.unwrapThrowing(bobSigner)
assertIs<CallHangupEvent>(inner)
assertEquals(alice, inner.pubKey)
assertEquals(callId, inner.callId())
assertEquals("user ended", inner.reason())
assertTrue(inner.verify())
}
@Test
fun hangupEmptyReasonRoundTrips() =
runTest {
val result = factory.createHangup(bob, callId, "", aliceSigner)
val inner = result.wrap.unwrapThrowing(bobSigner)
assertIs<CallHangupEvent>(inner)
assertNull(inner.reason(), "Empty reason should parse as null")
}
// ========================================================================
// 5. Reject: sign → wrap → unwrap → verify
// ========================================================================
@Test
fun rejectRoundTripsThroughGiftWrap() =
runTest {
val result = factory.createReject(alice, callId, "", bobSigner)
val inner = result.wrap.unwrapThrowing(aliceSigner)
assertIs<CallRejectEvent>(inner)
assertEquals(bob, inner.pubKey)
assertEquals(callId, inner.callId())
assertTrue(inner.verify())
}
@Test
fun busyRejectRoundTripsThroughGiftWrap() =
runTest {
val result = factory.createReject(alice, callId, "busy", bobSigner)
val inner = result.wrap.unwrapThrowing(aliceSigner)
assertIs<CallRejectEvent>(inner)
assertEquals("busy", inner.reason())
}
// ========================================================================
// 6. Renegotiate: sign → wrap → unwrap → verify
// ========================================================================
@Test
fun renegotiateRoundTripsThroughGiftWrap() =
runTest {
val newSdp = "v=0\r\no=- 4611731400430051338 3 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0"
val result = factory.createRenegotiate(newSdp, bob, callId, aliceSigner)
val inner = result.wrap.unwrapThrowing(bobSigner)
assertIs<CallRenegotiateEvent>(inner)
assertEquals(alice, inner.pubKey)
assertEquals(newSdp, inner.sdpOffer())
assertEquals(callId, inner.callId())
assertTrue(inner.verify())
}
// ========================================================================
// 7. Group Call: per-peer wraps only decryptable by intended recipient
// ========================================================================
@Test
fun groupCallOfferPerPeerWrapsEachDecryptableOnlyByTarget() =
runTest {
val result =
factory.createGroupCallOffer(
sdpOffer,
setOf(bob, carol),
callId,
CallType.VIDEO,
aliceSigner,
)
// Inner event has p-tags for all callees
val inner = result.msg
assertIs<CallOfferEvent>(inner)
assertEquals(setOf(bob, carol), inner.recipientPubKeys())
// Two wraps — one per callee
assertEquals(2, result.wraps.size)
for (wrap in result.wraps) {
val recipient = wrap.recipientPubKey()
assertNotNull(recipient)
val recipientSigner =
when (recipient) {
bob -> bobSigner
carol -> carolSigner
else -> error("Unexpected recipient: $recipient")
}
val unwrapped = wrap.unwrapThrowing(recipientSigner)
assertIs<CallOfferEvent>(unwrapped)
assertEquals(alice, unwrapped.pubKey)
assertEquals(sdpOffer, unwrapped.sdpOffer())
assertEquals(callId, unwrapped.callId())
assertTrue(unwrapped.verify())
// The OTHER member should NOT be able to decrypt this wrap
val otherSigner = if (recipient == bob) carolSigner else bobSigner
assertNull(
wrap.unwrapOrNull(otherSigner),
"Wrap for $recipient must not be decryptable by other member",
)
}
}
@Test
fun groupCallAnswerBroadcastWrapsEachDecryptableByTarget() =
runTest {
val allMembers = setOf(alice, bob, carol)
val result = factory.createGroupCallAnswer(sdpAnswer, allMembers, callId, bobSigner)
assertEquals(allMembers.size, result.wraps.size)
for (wrap in result.wraps) {
val recipient = wrap.recipientPubKey()
assertNotNull(recipient)
assertTrue(recipient in allMembers)
val recipientSigner =
when (recipient) {
alice -> aliceSigner
bob -> bobSigner
carol -> carolSigner
else -> error("Unexpected recipient")
}
val unwrapped = wrap.unwrapThrowing(recipientSigner)
assertIs<CallAnswerEvent>(unwrapped)
assertEquals(bob, unwrapped.pubKey, "Inner event author should be bob (answerer)")
assertEquals(sdpAnswer, unwrapped.sdpAnswer())
assertEquals(allMembers, unwrapped.recipientPubKeys())
assertTrue(unwrapped.verify())
}
}
@Test
fun groupHangupAllMembersReceiveIdenticalInnerEvent() =
runTest {
val members = setOf(bob, carol)
val result = factory.createGroupHangup(members, callId, "leaving", aliceSigner)
assertEquals(members.size, result.wraps.size)
// All wraps should contain the same signed inner event
val innerIds = mutableSetOf<String>()
for (wrap in result.wraps) {
val recipient = wrap.recipientPubKey()!!
val recipientSigner = if (recipient == bob) bobSigner else carolSigner
val unwrapped = wrap.unwrapThrowing(recipientSigner)
assertIs<CallHangupEvent>(unwrapped)
assertEquals(alice, unwrapped.pubKey)
assertEquals("leaving", unwrapped.reason())
innerIds.add(unwrapped.id)
}
// "Sign once, wrap per recipient" — same inner event ID
assertEquals(1, innerIds.size, "Group hangup must sign once and wrap per recipient (same inner event id)")
}
@Test
fun groupRejectAllMembersReceiveIdenticalInnerEvent() =
runTest {
val members = setOf(alice, carol)
val result = factory.createGroupReject(members, callId, "busy", bobSigner)
val innerIds = mutableSetOf<String>()
for (wrap in result.wraps) {
val recipient = wrap.recipientPubKey()!!
val recipientSigner = if (recipient == alice) aliceSigner else carolSigner
val unwrapped = wrap.unwrapThrowing(recipientSigner)
assertIs<CallRejectEvent>(unwrapped)
assertEquals(bob, unwrapped.pubKey)
innerIds.add(unwrapped.id)
}
assertEquals(1, innerIds.size, "Group reject must sign once and wrap per recipient")
}
// ========================================================================
// 8. Full P2P Call Flow: Alice calls Bob, complete gift-wrap round-trip
// ========================================================================
@Test
fun fullP2PFlowAllSignalingEventsRoundTrip() =
runTest {
// Step 1: Alice sends offer to Bob
val offerResult = factory.createCallOffer(sdpOffer, bob, callId, CallType.VIDEO, aliceSigner)
val offerInner = offerResult.wrap.unwrapThrowing(bobSigner)
assertIs<CallOfferEvent>(offerInner)
assertEquals(sdpOffer, offerInner.sdpOffer())
// Step 2: Bob sends answer to Alice
val answerResult = factory.createCallAnswer(sdpAnswer, alice, callId, bobSigner)
val answerInner = answerResult.wrap.unwrapThrowing(aliceSigner)
assertIs<CallAnswerEvent>(answerInner)
assertEquals(sdpAnswer, answerInner.sdpAnswer())
// Step 3: Both exchange ICE candidates
val aliceIceJson = CallIceCandidateEvent.serializeCandidate("candidate:1 1 udp 2122260223 192.168.1.1 44323 typ host", "0", 0)
val aliceIceResult = factory.createIceCandidate(aliceIceJson, bob, callId, aliceSigner)
val aliceIceInner = aliceIceResult.wrap.unwrapThrowing(bobSigner)
assertIs<CallIceCandidateEvent>(aliceIceInner)
val bobIceJson = CallIceCandidateEvent.serializeCandidate("candidate:2 1 udp 2122260223 192.168.1.2 44324 typ host", "0", 0)
val bobIceResult = factory.createIceCandidate(bobIceJson, alice, callId, bobSigner)
val bobIceInner = bobIceResult.wrap.unwrapThrowing(aliceSigner)
assertIs<CallIceCandidateEvent>(bobIceInner)
// Step 4: Alice sends renegotiation (voice → video)
val renegoResult = factory.createRenegotiate("new-video-sdp", bob, callId, aliceSigner)
val renegoInner = renegoResult.wrap.unwrapThrowing(bobSigner)
assertIs<CallRenegotiateEvent>(renegoInner)
assertEquals("new-video-sdp", renegoInner.sdpOffer())
// Step 5: Bob answers renegotiation
val renegoAnswerResult = factory.createCallAnswer("renego-answer-sdp", alice, callId, bobSigner)
val renegoAnswerInner = renegoAnswerResult.wrap.unwrapThrowing(aliceSigner)
assertIs<CallAnswerEvent>(renegoAnswerInner)
// Step 6: Alice hangs up
val hangupResult = factory.createHangup(bob, callId, signer = aliceSigner)
val hangupInner = hangupResult.wrap.unwrapThrowing(bobSigner)
assertIs<CallHangupEvent>(hangupInner)
assertEquals(callId, hangupInner.callId())
// All inner events signed by real keys, all verified
for (event in listOf(offerInner, answerInner, aliceIceInner, bobIceInner, renegoInner, renegoAnswerInner, hangupInner)) {
assertTrue(event.verify(), "Inner event ${event::class.simpleName} signature must be valid")
}
}
// ========================================================================
// 9. Wrap Metadata: ephemeral key, p-tag, kind
// ========================================================================
@Test
fun wrapMetadataIsCorrect() =
runTest {
val result = factory.createCallOffer(sdpOffer, bob, callId, CallType.VOICE, aliceSigner)
val wrap = result.wrap
assertEquals(EphemeralGiftWrapEvent.KIND, wrap.kind, "Wrap must be kind 21059")
assertNotEquals(alice, wrap.pubKey, "Wrap pubkey must be ephemeral, not sender")
assertEquals(bob, wrap.recipientPubKey(), "Wrap p-tag must be the recipient")
assertTrue(wrap.content.isNotEmpty(), "Wrap content must be non-empty (encrypted)")
assertTrue(wrap.verify(), "Wrap signature must be valid (signed by ephemeral key)")
}
@Test
fun eachWrapUsesUniqueEphemeralKey() =
runTest {
val result1 = factory.createCallOffer(sdpOffer, bob, callId, CallType.VOICE, aliceSigner)
val result2 = factory.createCallOffer(sdpOffer, bob, callId, CallType.VOICE, aliceSigner)
assertNotEquals(
result1.wrap.pubKey,
result2.wrap.pubKey,
"Each gift wrap MUST use a fresh ephemeral key",
)
}
// ========================================================================
// 10. Inner Event Signature Verification
// ========================================================================
@Test
fun innerEventSignatureIsVerifiableAfterUnwrap() =
runTest {
val result = factory.createCallOffer(sdpOffer, bob, callId, CallType.VIDEO, aliceSigner)
val inner = result.wrap.unwrapThrowing(bobSigner)
// Verify that the inner event's signature is valid against alice's pubkey
assertEquals(alice, inner.pubKey)
assertTrue(inner.verify(), "Recipient must be able to verify sender's signature on inner event")
}
// ========================================================================
// 11. SDP Content with Special Characters Survives Round-Trip
// ========================================================================
@Test
fun sdpWithSpecialCharsSurvivesGiftWrapRoundTrip() =
runTest {
val complexSdp =
"v=0\r\n" +
"o=- 4611731400430051336 2 IN IP4 127.0.0.1\r\n" +
"s=-\r\n" +
"t=0 0\r\n" +
"a=group:BUNDLE 0 1\r\n" +
"a=msid-semantic: WMS stream0\r\n" +
"m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n" +
"c=IN IP4 0.0.0.0\r\n" +
"a=rtcp:9 IN IP4 0.0.0.0\r\n" +
"a=ice-ufrag:abc+def/ghi\r\n" +
"a=ice-pwd:jkl=mno+pqr/stu\r\n" +
"a=fingerprint:sha-256 AB:CD:EF:01:23:45:67:89\r\n"
val result = factory.createCallOffer(complexSdp, bob, callId, CallType.VOICE, aliceSigner)
val inner = result.wrap.unwrapThrowing(bobSigner)
assertIs<CallOfferEvent>(inner)
assertEquals(complexSdp, inner.sdpOffer(), "Complex SDP with special chars must survive gift wrap round-trip")
}
@Test
fun iceCandidateWithSpecialCharsSurvivesGiftWrapRoundTrip() =
runTest {
val candidateSdp = """candidate:842163049 1 udp 1677729535 203.0.113.1 44323 typ srflx raddr 0.0.0.0 rport 0 generation 0 ufrag abc+def network-id 1"""
val json = CallIceCandidateEvent.serializeCandidate(candidateSdp, "audio", 0)
val result = factory.createIceCandidate(json, bob, callId, aliceSigner)
val inner = result.wrap.unwrapThrowing(bobSigner)
assertIs<CallIceCandidateEvent>(inner)
assertEquals(candidateSdp, inner.candidateSdp(), "ICE candidate SDP must survive JSON+NIP44 round-trip")
assertEquals("audio", inner.sdpMid())
}
// ========================================================================
// 12. Group Offer with Context: per-peer SDP, all p-tags visible
// ========================================================================
@Test
fun groupOfferWithContextPerPeerSdpAllPTagsVisible() =
runTest {
// Caller creates per-peer offer using the group-context overload
val bobResult =
factory.createCallOffer(
"bob-specific-sdp",
bob,
setOf(bob, carol),
callId,
CallType.VIDEO,
aliceSigner,
)
// Wrap is only for Bob
assertEquals(bob, bobResult.wrap.recipientPubKey())
// Bob unwraps and sees all group members in p-tags
val inner = bobResult.wrap.unwrapThrowing(bobSigner)
assertIs<CallOfferEvent>(inner)
assertEquals("bob-specific-sdp", inner.sdpOffer())
assertEquals(setOf(bob, carol), inner.recipientPubKeys(), "Inner event must list all group members")
assertTrue(inner.isGroupCall(), "Multiple p-tags should indicate a group call")
assertTrue(inner.verify())
// Carol cannot decrypt Bob's wrap
assertNull(bobResult.wrap.unwrapOrNull(carolSigner))
}
}
@@ -0,0 +1,460 @@
/*
* 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.nipACWebRtcCalls
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* NIP-AC Protocol Compliance Test Vectors
*
* These tests verify that event structures conform to the NIP-AC specification
* for WebRTC signaling over Nostr. They serve as reference test vectors that
* any NIP-AC implementation can use to validate compliance.
*
* Test categories:
* 1. Event structure validation (kinds, required tags, content)
* 2. P2P call flow (offer answer ICE hangup)
* 3. Call rejection flow
* 4. Mid-call renegotiation (voice video switch)
* 5. Group call flows (multi-party mesh)
* 6. Self-event filtering rules
* 7. Staleness / spam prevention
*/
class NipACStateMachineTest {
// ---- Test identities ----
private val alice = "aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111"
private val bob = "bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222"
private val carol = "cccc3333cccc3333cccc3333cccc3333cccc3333cccc3333cccc3333cccc3333"
private val dave = "dddd4444dddd4444dddd4444dddd4444dddd4444dddd4444dddd4444dddd4444"
private val callId = "550e8400-e29b-41d4-a716-446655440000"
private val sdpOffer = "v=0\r\no=- 4611731400430051336 2 IN IP4 127.0.0.1\r\n..."
private val sdpAnswer = "v=0\r\no=- 4611731400430051337 2 IN IP4 127.0.0.1\r\n..."
// ========================================================================
// 1. Event Structure Validation
// ========================================================================
@Test
fun callOfferMustIncludeAllRequiredTags() {
val template = CallOfferEvent.build(sdpOffer, bob, callId, CallType.VIDEO)
assertEquals(CallOfferEvent.KIND, template.kind)
assertEquals(sdpOffer, template.content)
val pTag = template.tags.firstOrNull { it[0] == "p" }
assertNotNull(pTag, "Call offer MUST include p tag")
assertEquals(bob, pTag[1])
val callIdTag = template.tags.firstOrNull { it[0] == "call-id" }
assertNotNull(callIdTag, "Call offer MUST include call-id tag")
assertEquals(callId, callIdTag[1])
val callTypeTag = template.tags.firstOrNull { it[0] == "call-type" }
assertNotNull(callTypeTag, "Call offer MUST include call-type tag")
assertEquals("video", callTypeTag[1])
val altTag = template.tags.firstOrNull { it[0] == "alt" }
assertNotNull(altTag, "Call offer MUST include alt tag (NIP-31)")
}
@Test
fun callAnswerMustIncludeRequiredTags() {
val template = CallAnswerEvent.build(sdpAnswer, alice, callId)
assertEquals(CallAnswerEvent.KIND, template.kind)
assertEquals(sdpAnswer, template.content)
assertNotNull(template.tags.firstOrNull { it[0] == "p" }, "Call answer MUST include p tag")
assertNotNull(template.tags.firstOrNull { it[0] == "call-id" }, "Call answer MUST include call-id tag")
assertNotNull(template.tags.firstOrNull { it[0] == "alt" }, "Call answer MUST include alt tag")
}
@Test
fun callAnswerMustNotIncludeCallTypeTag() {
val template = CallAnswerEvent.build(sdpAnswer, alice, callId)
val callTypeTag = template.tags.firstOrNull { it[0] == "call-type" }
assertNull(callTypeTag, "Call answer MUST NOT include call-type tag (only offers have it)")
}
@Test
fun iceCandidateMustIncludeRequiredTags() {
val candidateJson = """{"candidate":"candidate:842163049 1 udp 1677729535 203.0.113.1 44323 typ srflx","sdpMid":"0","sdpMLineIndex":0}"""
val template = CallIceCandidateEvent.build(candidateJson, bob, callId)
assertEquals(CallIceCandidateEvent.KIND, template.kind)
assertEquals(candidateJson, template.content)
assertNotNull(template.tags.firstOrNull { it[0] == "p" })
assertNotNull(template.tags.firstOrNull { it[0] == "call-id" })
assertNotNull(template.tags.firstOrNull { it[0] == "alt" })
}
@Test
fun hangupMayHaveEmptyContent() {
val template = CallHangupEvent.build(bob, callId)
assertEquals("", template.content, "Hangup content MAY be empty")
}
@Test
fun hangupMayHaveReasonContent() {
val template = CallHangupEvent.build(bob, callId, reason = "user ended call")
assertEquals("user ended call", template.content)
}
@Test
fun rejectMayHaveBusyReason() {
val template = CallRejectEvent.build(alice, callId, reason = "busy")
assertEquals("busy", template.content, "Reject with 'busy' content for auto-reject while in call")
}
@Test
fun renegotiateMustContainNewSdpOffer() {
val newSdp = "v=0\r\no=- 4611731400430051338 3 IN IP4 127.0.0.1\r\n..."
val template = CallRenegotiateEvent.build(newSdp, bob, callId)
assertEquals(CallRenegotiateEvent.KIND, template.kind)
assertEquals(newSdp, template.content, "Renegotiate content MUST be new SDP offer")
}
// ========================================================================
// 2. P2P Call Flow: Offer → Answer → ICE → Connected → Hangup
// ========================================================================
@Test
fun p2pCallFlowProducesCorrectEventSequence() {
// Step 1: Alice offers to Bob (voice call)
val offer = CallOfferEvent.build(sdpOffer, bob, callId, CallType.VOICE)
assertEquals(25050, offer.kind)
assertEquals(bob, offer.tags.first { it[0] == "p" }[1])
assertEquals("voice", offer.tags.first { it[0] == "call-type" }[1])
// Step 2: Bob answers
val answer = CallAnswerEvent.build(sdpAnswer, alice, callId)
assertEquals(25051, answer.kind)
assertEquals(alice, answer.tags.first { it[0] == "p" }[1])
assertEquals(callId, answer.tags.first { it[0] == "call-id" }[1])
// Step 3: ICE candidates exchange
val iceJson =
CallIceCandidateEvent.serializeCandidate(
"candidate:842163049 1 udp 1677729535 203.0.113.1 44323 typ srflx",
"0",
0,
)
val ice = CallIceCandidateEvent.build(iceJson, bob, callId)
assertEquals(25052, ice.kind)
// Step 4: Hangup
val hangup = CallHangupEvent.build(bob, callId)
assertEquals(25053, hangup.kind)
}
// ========================================================================
// 3. Call Rejection Flow
// ========================================================================
@Test
fun rejectEventHasCorrectStructure() {
val reject = CallRejectEvent.build(alice, callId)
assertEquals(25054, reject.kind)
assertEquals(alice, reject.tags.first { it[0] == "p" }[1])
assertEquals(callId, reject.tags.first { it[0] == "call-id" }[1])
}
@Test
fun busyRejectHasContentBusy() {
val reject = CallRejectEvent.build(alice, callId, reason = "busy")
assertEquals("busy", reject.content)
}
// ========================================================================
// 4. Mid-Call Renegotiation (Voice ↔ Video Switch)
// ========================================================================
@Test
fun renegotiationFlowProducesOfferThenAnswer() {
// Party A sends renegotiate with new SDP
val newSdp = "v=0\r\no=- 4611731400430051338 3 IN IP4 127.0.0.1\r\n..."
val renegotiate = CallRenegotiateEvent.build(newSdp, bob, callId)
assertEquals(25055, renegotiate.kind)
assertEquals(newSdp, renegotiate.content)
// Party B responds with a CallAnswer (kind 25051), NOT a renegotiate
val renegAnswer = CallAnswerEvent.build("answer-for-renego", alice, callId)
assertEquals(25051, renegAnswer.kind, "Renegotiation response MUST be CallAnswer (25051)")
}
@Test
fun renegotiationPreservesCallId() {
val renegotiate = CallRenegotiateEvent.build(sdpOffer, bob, callId)
assertEquals(callId, renegotiate.tags.first { it[0] == "call-id" }[1])
}
// ========================================================================
// 5. Group Call Flows
// ========================================================================
@Test
fun groupCallOfferIncludesPTagForEveryMember() {
val callees = setOf(bob, carol, dave)
val template = CallOfferEvent.build(sdpOffer, callees, callId, CallType.VIDEO)
val pTagValues =
template.tags
.filter { it[0] == "p" }
.map { it[1] }
.toSet()
assertEquals(callees, pTagValues, "Group offer MUST include p tag for every callee")
}
@Test
fun groupCallOfferIsDetectedByMultiplePTags() {
// Single callee → not a group call
val singleOffer = CallOfferEvent.build(sdpOffer, bob, callId, CallType.VOICE)
assertEquals(1, singleOffer.tags.count { it[0] == "p" })
// Multiple callees → group call
val groupOffer = CallOfferEvent.build(sdpOffer, setOf(bob, carol), callId, CallType.VOICE)
assertEquals(2, groupOffer.tags.count { it[0] == "p" })
}
@Test
fun groupCallAnswerIncludesAllMemberPTags() {
val allMembers = setOf(alice, bob, carol)
val template = CallAnswerEvent.build(sdpAnswer, allMembers, callId)
val pTagValues =
template.tags
.filter { it[0] == "p" }
.map { it[1] }
.toSet()
assertEquals(allMembers, pTagValues, "Group answer MUST include p tag for every member")
}
@Test
fun groupCallHangupIncludesAllMemberPTags() {
val allMembers = setOf(alice, bob, carol)
val template = CallHangupEvent.build(allMembers, callId)
val pTagValues =
template.tags
.filter { it[0] == "p" }
.map { it[1] }
.toSet()
assertEquals(allMembers, pTagValues)
}
@Test
fun groupCallRejectIncludesAllMemberPTags() {
val allMembers = setOf(alice, bob, carol)
val template = CallRejectEvent.build(allMembers, callId)
val pTagValues =
template.tags
.filter { it[0] == "p" }
.map { it[1] }
.toSet()
assertEquals(allMembers, pTagValues)
}
@Test
fun groupCallRenegotiateIncludesAllMemberPTags() {
val allMembers = setOf(alice, bob, carol)
val template = CallRenegotiateEvent.build(sdpOffer, allMembers, callId)
val pTagValues =
template.tags
.filter { it[0] == "p" }
.map { it[1] }
.toSet()
assertEquals(allMembers, pTagValues)
}
@Test
fun groupMembersIncludesEventAuthorPlusPTags() {
// Build a signed-style offer event to test groupMembers()
val template = CallOfferEvent.build(sdpOffer, setOf(bob, carol), callId, CallType.VIDEO)
// Template doesn't have pubKey set, so test via the tag helper
val pTags =
template.tags
.filter { it[0] == "p" }
.map { it[1] }
.toSet()
assertEquals(setOf(bob, carol), pTags)
}
@Test
fun iceCandidateInGroupCallIsAddressedToSinglePeer() {
// Per spec: ICE candidates remain addressed to a single peer
val ice =
CallIceCandidateEvent.build(
"""{"candidate":"c","sdpMid":"0","sdpMLineIndex":0}""",
bob,
callId,
)
val pTags = ice.tags.filter { it[0] == "p" }
assertEquals(1, pTags.size, "ICE candidate MUST be addressed to single peer, even in group call")
}
// ========================================================================
// 6. ICE Candidate Serialization
// ========================================================================
@Test
fun iceCandidateSerializationRoundTrips() {
val sdp = "candidate:842163049 1 udp 1677729535 203.0.113.1 44323 typ srflx raddr 0.0.0.0 rport 0 generation 0"
val mid = "0"
val index = 0
val json = CallIceCandidateEvent.serializeCandidate(sdp, mid, index)
val template = CallIceCandidateEvent.build(json, bob, callId)
// Construct event to test parsing
val event = CallIceCandidateEvent("fakeid", alice, TimeUtils.now(), template.tags, json, "fakesig")
assertEquals(sdp, event.candidateSdp())
assertEquals(mid, event.sdpMid())
assertEquals(index, event.sdpMLineIndex())
}
@Test
fun iceCandidateSerializationEscapesQuotes() {
// Verify that quotes are properly escaped in the JSON output
val sdp = """candidate:1 1 udp 2122260223 192.168.1.1 44323 typ host"""
val json = CallIceCandidateEvent.serializeCandidate(sdp, "audio", 0)
assertTrue(json.startsWith("{"), "Must be valid JSON object")
assertTrue(json.endsWith("}"), "Must be valid JSON object")
assertTrue(json.contains(""""candidate":"""), "Must contain candidate key")
assertTrue(json.contains(""""sdpMid":"audio""""), "Must contain sdpMid")
assertTrue(json.contains(""""sdpMLineIndex":0"""), "Must contain sdpMLineIndex")
// Roundtrip through event parsing
val event = CallIceCandidateEvent("fakeid", alice, TimeUtils.now(), arrayOf(), json, "fakesig")
assertEquals(sdp, event.candidateSdp())
assertEquals("audio", event.sdpMid())
assertEquals(0, event.sdpMLineIndex())
}
// ========================================================================
// 7. Staleness Check
// ========================================================================
@Test
fun eventsOlderThan20SecondsMustBeDiscarded() {
// Per spec: "Clients MUST discard signaling events older than 20 seconds"
val now = TimeUtils.now()
val freshCreatedAt = now - 5 // 5 seconds ago — fresh
val staleCreatedAt = now - 25 // 25 seconds ago — stale
assertTrue(now - freshCreatedAt <= 20, "Event 5s old should be fresh")
assertTrue(now - staleCreatedAt > 20, "Event 25s old should be stale")
}
// ========================================================================
// 8. Call-ID Consistency
// ========================================================================
@Test
fun allSignalingEventsForSameCallShareCallId() {
val offer = CallOfferEvent.build(sdpOffer, bob, callId, CallType.VOICE)
val answer = CallAnswerEvent.build(sdpAnswer, alice, callId)
val ice = CallIceCandidateEvent.build("{}", bob, callId)
val hangup = CallHangupEvent.build(bob, callId)
val reject = CallRejectEvent.build(alice, callId)
val renego = CallRenegotiateEvent.build(sdpOffer, bob, callId)
val events = listOf(offer, answer, ice, hangup, reject, renego)
for (event in events) {
val parsedCallId = event.tags.first { it[0] == "call-id" }[1]
assertEquals(callId, parsedCallId, "All events for same call MUST share call-id")
}
}
// Renegotiation glare and callee-to-callee mesh tiebreakers are tested in
// PeerSessionManagerTest (commons/commonTest/) where the actual logic lives.
// ========================================================================
// 9. Event Kind Constants
// ========================================================================
@Test
fun eventKindsMustMatchSpec() {
assertEquals(25050, CallOfferEvent.KIND, "Call Offer kind")
assertEquals(25051, CallAnswerEvent.KIND, "Call Answer kind")
assertEquals(25052, CallIceCandidateEvent.KIND, "ICE Candidate kind")
assertEquals(25053, CallHangupEvent.KIND, "Call Hangup kind")
assertEquals(25054, CallRejectEvent.KIND, "Call Reject kind")
assertEquals(25055, CallRenegotiateEvent.KIND, "Call Renegotiate kind")
}
// ========================================================================
// 11. Invite New Peer to Active Group Call
// ========================================================================
@Test
fun inviteNewPeerOfferIncludesAllExistingMembersPlusInvitee() {
// When inviting dave into an active call with alice, bob, carol:
val allMembers = setOf(alice, bob, carol, dave)
val inviteOffer = CallOfferEvent.build(sdpOffer, allMembers, callId, CallType.VIDEO)
val pTags =
inviteOffer.tags
.filter { it[0] == "p" }
.map { it[1] }
.toSet()
assertEquals(allMembers, pTags, "Invite offer MUST include all existing members plus new invitee")
}
// ========================================================================
// 12. Multi-Device Support: Self-Addressed Events
// ========================================================================
@Test
fun selfAddressedAnswerForMultiDeviceSupport() {
// Per spec: callee should publish answer addressed to own pubkey
// for "answered elsewhere" notification to other devices
val selfAnswer = CallAnswerEvent.build(sdpAnswer, setOf(alice, bob), callId)
val pTags =
selfAnswer.tags
.filter { it[0] == "p" }
.map { it[1] }
.toSet()
// The callee (bob) includes self in p-tags for group broadcast
assertTrue(bob in pTags || alice in pTags, "Self-addressed answer must include own pubkey in p-tags")
}
@Test
fun selfAddressedRejectForMultiDeviceSupport() {
val selfReject = CallRejectEvent.build(setOf(alice, bob), callId)
val pTags =
selfReject.tags
.filter { it[0] == "p" }
.map { it[1] }
.toSet()
assertTrue(pTags.size >= 2, "Self-reject in group should address all members including self")
}
}