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:
@@ -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
|
||||
|
||||
+83
@@ -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()
|
||||
}
|
||||
}
|
||||
+1
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
+215
@@ -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,
|
||||
}
|
||||
+1183
File diff suppressed because it is too large
Load Diff
+80
@@ -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
|
||||
}
|
||||
}
|
||||
+489
@@ -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
|
||||
|
||||
+528
@@ -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))
|
||||
}
|
||||
}
|
||||
+460
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user