refactor: clean up PeerSessionManager and CallController integration

- Split PeerSession.kt out of PeerSessionManager.kt (types, interface,
  manager are now in separate files)
- Remove webRtcSessions duplication in CallController — PeerSessionManager
  is now the single source of truth for session tracking; WebRtcCallSession
  is retrieved via the adapter cast when WebRTC-specific APIs are needed
- Initialize PeerSessionManager eagerly with localPubKey (passed to
  CallController constructor) instead of lazy suspend init — fixes early
  ICE candidates being silently dropped before first suspend call
- Extract FakePeerSession into its own file for reuse across test files
- Remove assertion-only glare tiebreaker tests from NipACStateMachineTest
  (now properly tested with real logic in PeerSessionManagerTest)

https://claude.ai/code/session_01AfRYTRCvtKqqDxeKQujUrx
This commit is contained in:
Claude
2026-04-05 15:38:28 +00:00
parent e9f1fcbd2a
commit 6fbbcbbf3b
7 changed files with 207 additions and 212 deletions
@@ -81,21 +81,14 @@ class CallController(
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val publishWrap: suspend (EphemeralGiftWrapEvent) -> Unit, private val publishWrap: suspend (EphemeralGiftWrapEvent) -> Unit,
private val signerProvider: suspend () -> com.vitorpamplona.quartz.nip01Core.signers.NostrSigner, private val signerProvider: suspend () -> com.vitorpamplona.quartz.nip01Core.signers.NostrSigner,
localPubKey: HexKey,
) { ) {
// ---- Per-peer session state (delegated to PeerSessionManager) ---- // ---- Per-peer session state (delegated to PeerSessionManager) ----
// Lazily initialized — needs signerProvider() for localPubKey private var peerSessionMgr = PeerSessionManager(localPubKey)
private var peerSessionMgr: PeerSessionManager? = null
private suspend fun sessionManager(): PeerSessionManager { /** Retrieves the underlying WebRtcCallSession for a peer (for WebRTC-specific APIs like addTrack). */
if (peerSessionMgr == null) { private fun webRtcSession(peerPubKey: HexKey): WebRtcCallSession? = (peerSessionMgr.getSession(peerPubKey)?.session as? WebRtcPeerSessionAdapter)?.webRtcSession
peerSessionMgr = PeerSessionManager(signerProvider().pubKey)
}
return peerSessionMgr!!
}
/** Map from peer pubkey to the WebRtcCallSession for direct access to WebRTC-specific APIs. */
private val webRtcSessions = ConcurrentHashMap<HexKey, WebRtcCallSession>()
// ---- Shared WebRTC resources ---- // ---- Shared WebRTC resources ----
@@ -276,14 +269,12 @@ class CallController(
// Set state to Offering before creating peer sessions // Set state to Offering before creating peer sessions
callManager.beginOffering(callId, peerPubKeys, callType) callManager.beginOffering(callId, peerPubKeys, callType)
val mgr = sessionManager()
// Create a PeerConnection + offer for each callee // Create a PeerConnection + offer for each callee
for (peerPubKey in peerPubKeys) { for (peerPubKey in peerPubKeys) {
try { try {
val webRtcSession = withContext(Dispatchers.IO) { createWebRtcSession(peerPubKey) } val webRtcSession = withContext(Dispatchers.IO) { createWebRtcSession(peerPubKey) }
val adapter = WebRtcPeerSessionAdapter(webRtcSession) val adapter = WebRtcPeerSessionAdapter(webRtcSession)
mgr.registerSession(peerPubKey, adapter) peerSessionMgr.registerSession(peerPubKey, adapter)
Log.d(TAG) { "initiateCall: PeerConnection created for ${peerPubKey.take(8)}" } Log.d(TAG) { "initiateCall: PeerConnection created for ${peerPubKey.take(8)}" }
webRtcSession.createOffer { sdp -> webRtcSession.createOffer { sdp ->
Log.d(TAG) { "initiateCall: offer created for ${peerPubKey.take(8)}, sdpLength=${sdp.description.length}" } Log.d(TAG) { "initiateCall: offer created for ${peerPubKey.take(8)}, sdpLength=${sdp.description.length}" }
@@ -323,8 +314,6 @@ class CallController(
return@launch return@launch
} }
val mgr = sessionManager()
val webRtcSession = val webRtcSession =
try { try {
withContext(Dispatchers.IO) { createWebRtcSession(callerPubKey) } withContext(Dispatchers.IO) { createWebRtcSession(callerPubKey) }
@@ -335,12 +324,12 @@ class CallController(
} }
val adapter = WebRtcPeerSessionAdapter(webRtcSession) val adapter = WebRtcPeerSessionAdapter(webRtcSession)
val entry = mgr.registerSession(callerPubKey, adapter) val entry = peerSessionMgr.registerSession(callerPubKey, adapter)
Log.d(TAG) { "acceptIncomingCall: setting remote description (OFFER)..." } Log.d(TAG) { "acceptIncomingCall: setting remote description (OFFER)..." }
adapter.setRemoteDescription(SdpType.OFFER, sdpOffer) adapter.setRemoteDescription(SdpType.OFFER, sdpOffer)
Log.d(TAG) { "acceptIncomingCall: flushing ${entry.pendingIceCandidates.size} pending ICE candidates..." } Log.d(TAG) { "acceptIncomingCall: flushing ${entry.pendingIceCandidates.size} pending ICE candidates..." }
mgr.flushPendingIceCandidates(callerPubKey) peerSessionMgr.flushPendingIceCandidates(callerPubKey)
Log.d(TAG) { "acceptIncomingCall: creating answer..." } Log.d(TAG) { "acceptIncomingCall: creating answer..." }
webRtcSession.createAnswer { sdp -> webRtcSession.createAnswer { sdp ->
@@ -365,15 +354,9 @@ class CallController(
peerPubKey: HexKey, peerPubKey: HexKey,
sdpAnswer: String, sdpAnswer: String,
) { ) {
val mgr = peerSessionMgr Log.d(TAG) { "onCallAnswerReceived: from=${peerPubKey.take(8)}, knownSessions=${peerSessionMgr.allSessionKeys().map { it.take(8) }}" }
if (mgr == null) {
Log.d(TAG) { "onCallAnswerReceived: sessionManager not initialized — ignoring" }
return
}
Log.d(TAG) { "onCallAnswerReceived: from=${peerPubKey.take(8)}, knownSessions=${mgr.allSessionKeys().map { it.take(8) }}" } val action = peerSessionMgr.routeAnswer(peerPubKey, sdpAnswer)
val action = mgr.routeAnswer(peerPubKey, sdpAnswer)
Log.d(TAG) { "onCallAnswerReceived: action=$action" } Log.d(TAG) { "onCallAnswerReceived: action=$action" }
when (action) { when (action) {
AnswerRouteAction.APPLIED -> { AnswerRouteAction.APPLIED -> {
@@ -401,12 +384,7 @@ class CallController(
try { try {
val senderPubKey = event.pubKey val senderPubKey = event.pubKey
val candidate = IceCandidateData(event.candidateSdp(), event.sdpMid(), event.sdpMLineIndex()) val candidate = IceCandidateData(event.candidateSdp(), event.sdpMid(), event.sdpMLineIndex())
val mgr = peerSessionMgr val action = peerSessionMgr.routeIceCandidate(senderPubKey, candidate)
if (mgr == null) {
Log.d(TAG) { "Buffering ICE candidate from ${senderPubKey.take(8)} (manager not initialized)" }
return
}
val action = mgr.routeIceCandidate(senderPubKey, candidate)
Log.d(TAG) { "ICE candidate from ${senderPubKey.take(8)}: $action" } Log.d(TAG) { "ICE candidate from ${senderPubKey.take(8)}: $action" }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to parse ICE candidate", e) Log.e(TAG, "Failed to parse ICE candidate", e)
@@ -436,16 +414,14 @@ class CallController(
* peer with the lexicographically lower pubkey initiates. * peer with the lexicographically lower pubkey initiates.
*/ */
fun onNewPeerInGroupCall(peerPubKey: HexKey) { fun onNewPeerInGroupCall(peerPubKey: HexKey) {
val mgr = peerSessionMgr if (peerSessionMgr.hasSession(peerPubKey)) {
if (mgr != null && mgr.hasSession(peerPubKey)) {
Log.d(TAG) { "onNewPeerInGroupCall: session already exists for ${peerPubKey.take(8)} — skipping" } Log.d(TAG) { "onNewPeerInGroupCall: session already exists for ${peerPubKey.take(8)} — skipping" }
return return
} }
scope.launch { scope.launch {
val sm = sessionManager() Log.d(TAG) { "onNewPeerInGroupCall: peer=${peerPubKey.take(8)}, shouldInitiate=${peerSessionMgr.shouldInitiateOffer(peerPubKey)}" }
Log.d(TAG) { "onNewPeerInGroupCall: peer=${peerPubKey.take(8)}, shouldInitiate=${sm.shouldInitiateOffer(peerPubKey)}" } if (peerSessionMgr.shouldInitiateOffer(peerPubKey)) {
if (sm.shouldInitiateOffer(peerPubKey)) {
Log.d(TAG) { "Initiating callee-to-callee connection to ${peerPubKey.take(8)} (I have lower pubkey)" } Log.d(TAG) { "Initiating callee-to-callee connection to ${peerPubKey.take(8)} (I have lower pubkey)" }
createAndOfferToPeer(peerPubKey) createAndOfferToPeer(peerPubKey)
} else { } else {
@@ -456,7 +432,6 @@ class CallController(
private suspend fun createAndOfferToPeer(peerPubKey: HexKey) { private suspend fun createAndOfferToPeer(peerPubKey: HexKey) {
if (peerConnectionFactory == null) return if (peerConnectionFactory == null) return
val mgr = sessionManager()
val webRtcSession = val webRtcSession =
try { try {
@@ -467,7 +442,7 @@ class CallController(
} }
val adapter = WebRtcPeerSessionAdapter(webRtcSession) val adapter = WebRtcPeerSessionAdapter(webRtcSession)
mgr.registerSession(peerPubKey, adapter) peerSessionMgr.registerSession(peerPubKey, adapter)
webRtcSession.createOffer { sdp -> webRtcSession.createOffer { sdp ->
Log.d(TAG) { "Callee-to-callee offer created for ${peerPubKey.take(8)}, sdpLength=${sdp.description.length}" } Log.d(TAG) { "Callee-to-callee offer created for ${peerPubKey.take(8)}, sdpLength=${sdp.description.length}" }
@@ -485,8 +460,7 @@ class CallController(
peerPubKey: HexKey, peerPubKey: HexKey,
sdpOffer: String, sdpOffer: String,
) { ) {
val mgr = peerSessionMgr if (peerSessionMgr.hasSession(peerPubKey)) {
if (mgr != null && mgr.hasSession(peerPubKey)) {
Log.d(TAG) { "Mid-call offer from ${peerPubKey.take(8)} but session already exists — ignoring" } Log.d(TAG) { "Mid-call offer from ${peerPubKey.take(8)} but session already exists — ignoring" }
return return
} }
@@ -498,8 +472,6 @@ class CallController(
return@launch return@launch
} }
val sm = sessionManager()
val webRtcSession = val webRtcSession =
try { try {
withContext(Dispatchers.IO) { createWebRtcSession(peerPubKey) } withContext(Dispatchers.IO) { createWebRtcSession(peerPubKey) }
@@ -509,9 +481,9 @@ class CallController(
} }
val adapter = WebRtcPeerSessionAdapter(webRtcSession) val adapter = WebRtcPeerSessionAdapter(webRtcSession)
sm.registerSession(peerPubKey, adapter) peerSessionMgr.registerSession(peerPubKey, adapter)
adapter.setRemoteDescription(SdpType.OFFER, sdpOffer) adapter.setRemoteDescription(SdpType.OFFER, sdpOffer)
sm.flushPendingIceCandidates(peerPubKey) peerSessionMgr.flushPendingIceCandidates(peerPubKey)
webRtcSession.createAnswer { sdp -> webRtcSession.createAnswer { sdp ->
Log.d(TAG) { "Callee-to-callee answer for ${peerPubKey.take(8)}, sdpLength=${sdp.description.length}" } Log.d(TAG) { "Callee-to-callee answer for ${peerPubKey.take(8)}, sdpLength=${sdp.description.length}" }
@@ -526,13 +498,12 @@ class CallController(
private fun onRenegotiationOfferReceived(event: CallRenegotiateEvent) { private fun onRenegotiationOfferReceived(event: CallRenegotiateEvent) {
val peerPubKey = event.pubKey val peerPubKey = event.pubKey
val mgr = peerSessionMgr ?: return
val sdpOffer = event.sdpOffer() val sdpOffer = event.sdpOffer()
Log.d(TAG) { "Renegotiation offer from ${peerPubKey.take(8)}, sdpLength=${sdpOffer.length}" } Log.d(TAG) { "Renegotiation offer from ${peerPubKey.take(8)}, sdpLength=${sdpOffer.length}" }
scope.launch { scope.launch {
val resolution = val resolution =
mgr.resolveRenegotiationGlare(peerPubKey, sdpOffer) { entry -> peerSessionMgr.resolveRenegotiationGlare(peerPubKey, sdpOffer) { entry ->
applyRenegotiationOffer(entry.session, peerPubKey, sdpOffer) applyRenegotiationOffer(entry.session, peerPubKey, sdpOffer)
} }
Log.d(TAG) { "Renegotiation glare resolution with ${peerPubKey.take(8)}: $resolution" } Log.d(TAG) { "Renegotiation glare resolution with ${peerPubKey.take(8)}: $resolution" }
@@ -553,7 +524,7 @@ class CallController(
} }
private fun performRenegotiation(peerPubKey: HexKey) { private fun performRenegotiation(peerPubKey: HexKey) {
val webRtcSession = webRtcSessions[peerPubKey] ?: return val webRtcSession = webRtcSession(peerPubKey) ?: return
val state = callManager.state.value val state = callManager.state.value
if (state !is CallState.Connected && state !is CallState.Connecting) return if (state !is CallState.Connected && state !is CallState.Connecting) return
@@ -580,8 +551,10 @@ class CallController(
if (localVideoTrackInternal == null) { if (localVideoTrackInternal == null) {
// Voice → video upgrade: create video source/track and add to all sessions // Voice → video upgrade: create video source/track and add to all sessions
createVideoResources() createVideoResources()
webRtcSessions.values.forEach { session -> peerSessionMgr.allSessionKeys().forEach { key ->
localVideoTrackInternal?.let { session.addTrack(it, VIDEO_MAX_BITRATE_BPS) } webRtcSession(key)?.let { session ->
localVideoTrackInternal?.let { track -> session.addTrack(track, VIDEO_MAX_BITRATE_BPS) }
}
} }
} else { } else {
localVideoTrackInternal?.setEnabled(true) localVideoTrackInternal?.setEnabled(true)
@@ -689,7 +662,7 @@ class CallController(
// ---- Per-peer PeerConnection creation ---- // ---- Per-peer PeerConnection creation ----
private fun createWebRtcSession(peerPubKey: HexKey): WebRtcCallSession { private fun createWebRtcSession(peerPubKey: HexKey): WebRtcCallSession {
Log.d(TAG) { "createWebRtcSession: ${peerPubKey.take(8)}, existing sessions=${webRtcSessions.keys.map { it.take(8) }}" } Log.d(TAG) { "createWebRtcSession: ${peerPubKey.take(8)}, existing sessions=${peerSessionMgr.allSessionKeys().map { it.take(8) }}" }
val factory = peerConnectionFactory ?: throw IllegalStateException("PeerConnectionFactory not initialized") val factory = peerConnectionFactory ?: throw IllegalStateException("PeerConnectionFactory not initialized")
val session = val session =
@@ -722,7 +695,6 @@ class CallController(
localAudioTrackInternal?.let { session.addTrack(it) } localAudioTrackInternal?.let { session.addTrack(it) }
localVideoTrackInternal?.let { session.addTrack(it, VIDEO_MAX_BITRATE_BPS) } localVideoTrackInternal?.let { session.addTrack(it, VIDEO_MAX_BITRATE_BPS) }
webRtcSessions[peerPubKey] = session
return session return session
} }
@@ -791,17 +763,11 @@ class CallController(
private fun onPeerDisconnected(peerPubKey: HexKey) { private fun onPeerDisconnected(peerPubKey: HexKey) {
Log.d(TAG) { "Peer ${peerPubKey.take(8)} disconnected (ICE FAILED)" } Log.d(TAG) { "Peer ${peerPubKey.take(8)} disconnected (ICE FAILED)" }
val mgr = peerSessionMgr
if (mgr == null) {
scope.launch { callManager.hangup() }
return
}
val allDisconnected = val allDisconnected =
mgr.allSessionKeys().all { key -> peerSessionMgr.allSessionKeys().all { key ->
key == peerPubKey || key == peerPubKey ||
mgr.getSession(key)?.session?.getSignalingState() == SignalingState.CLOSED || peerSessionMgr.getSession(key)?.session?.getSignalingState() == SignalingState.CLOSED ||
mgr.getSession(key)?.remoteDescriptionSet != true peerSessionMgr.getSession(key)?.remoteDescriptionSet != true
} }
if (allDisconnected) { if (allDisconnected) {
Log.d(TAG) { "onPeerDisconnected: all peers disconnected, hanging up" } Log.d(TAG) { "onPeerDisconnected: all peers disconnected, hanging up" }
@@ -818,9 +784,8 @@ class CallController(
* but the call continues with remaining peers. * but the call continues with remaining peers.
*/ */
fun disposePeerSession(peerPubKey: HexKey) { fun disposePeerSession(peerPubKey: HexKey) {
val entry = peerSessionMgr?.removeSession(peerPubKey) val entry = peerSessionMgr.removeSession(peerPubKey)
val webRtcSession = webRtcSessions.remove(peerPubKey) if (entry != null) {
if (entry != null || webRtcSession != null) {
Log.d(TAG) { "disposePeerSession: closing session for ${peerPubKey.take(8)}" } Log.d(TAG) { "disposePeerSession: closing session for ${peerPubKey.take(8)}" }
try { try {
entry?.session?.dispose() entry?.session?.dispose()
@@ -851,7 +816,7 @@ class CallController(
// ---- Cleanup ---- // ---- Cleanup ----
fun cleanup() { fun cleanup() {
Log.d(TAG) { "cleanup: disposing ${webRtcSessions.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 // Each block is wrapped individually so that a failure in one
// (e.g. a WebRTC native crash) does not prevent the rest from // (e.g. a WebRTC native crash) does not prevent the rest from
// running. Without this, a single exception could leave the // running. Without this, a single exception could leave the
@@ -877,12 +842,11 @@ class CallController(
// Dispose all peer sessions // Dispose all peer sessions
try { try {
peerSessionMgr?.disposeAll() peerSessionMgr.disposeAll()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "cleanup: sessionManager.disposeAll() failed", e) Log.e(TAG, "cleanup: sessionManager.disposeAll() failed", e)
} }
peerSessionMgr = null peerSessionMgr = PeerSessionManager(peerSessionMgr.localPubKey)
webRtcSessions.clear()
// Dispose shared resources — each in its own try-catch so one // Dispose shared resources — each in its own try-catch so one
// failure does not prevent the others from being released. // failure does not prevent the others from being released.
@@ -218,6 +218,7 @@ class AccountViewModel(
scope = viewModelScope, scope = viewModelScope,
publishWrap = { wrap -> account.publishCallSignaling(wrap) }, publishWrap = { wrap -> account.publishCallSignaling(wrap) },
signerProvider = { account.signer }, signerProvider = { account.signer },
localPubKey = account.signer.pubKey,
) )
// Set callbacks before exposing controller to avoid timing races // Set callbacks before exposing controller to avoid timing races
@@ -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()
}
@@ -22,57 +22,6 @@ package com.vitorpamplona.amethyst.commons.call
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* 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,
}
/**
* Abstraction over a single peer connection's signaling operations.
* Implemented by the platform-specific WebRTC wrapper.
*/
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()
}
enum class SdpType {
OFFER,
ANSWER,
}
/** /**
* Manages per-peer session state and ICE candidate buffering. * Manages per-peer session state and ICE candidate buffering.
* *
@@ -85,7 +34,7 @@ enum class SdpType {
* It is platform-independent and testable without real WebRTC. * It is platform-independent and testable without real WebRTC.
*/ */
class PeerSessionManager( class PeerSessionManager(
private val localPubKey: HexKey, val localPubKey: HexKey,
) { ) {
data class SessionEntry( data class SessionEntry(
val session: PeerSession, val session: PeerSession,
@@ -126,9 +75,9 @@ class PeerSessionManager(
/** /**
* Routes an incoming ICE candidate to the correct destination: * Routes an incoming ICE candidate to the correct destination:
* 1. If session exists AND remote description is set add directly * 1. If session exists AND remote description is set -> add directly
* 2. If session exists but remote description NOT set buffer per-session * 2. If session exists but remote description NOT set -> buffer per-session
* 3. If no session exists buffer globally (keyed by sender) * 3. If no session exists -> buffer globally (keyed by sender)
* *
* Returns the action taken for testability. * Returns the action taken for testability.
*/ */
@@ -137,15 +86,21 @@ class PeerSessionManager(
candidate: IceCandidateData, candidate: IceCandidateData,
): IceRouteAction { ): IceRouteAction {
val entry = sessions[senderPubKey] val entry = sessions[senderPubKey]
if (entry != null && entry.remoteDescriptionSet) { return when {
entry.session.addIceCandidate(candidate) entry != null && entry.remoteDescriptionSet -> {
return IceRouteAction.ADDED_DIRECTLY entry.session.addIceCandidate(candidate)
} else if (entry != null) { IceRouteAction.ADDED_DIRECTLY
entry.pendingIceCandidates.add(candidate) }
return IceRouteAction.BUFFERED_PER_SESSION
} else { entry != null -> {
globalPendingIce.getOrPut(senderPubKey) { mutableListOf() }.add(candidate) entry.pendingIceCandidates.add(candidate)
return IceRouteAction.BUFFERED_GLOBALLY IceRouteAction.BUFFERED_PER_SESSION
}
else -> {
globalPendingIce.getOrPut(senderPubKey) { mutableListOf() }.add(candidate)
IceRouteAction.BUFFERED_GLOBALLY
}
} }
} }
@@ -184,17 +139,14 @@ class PeerSessionManager(
val signalingState = entry.session.getSignalingState() val signalingState = entry.session.getSignalingState()
if (signalingState != SignalingState.HAVE_LOCAL_OFFER) { if (signalingState != SignalingState.HAVE_LOCAL_OFFER) {
// No glare — accept the remote offer directly
onAcceptRemote(entry) onAcceptRemote(entry)
return GlareResolution.NO_GLARE return GlareResolution.NO_GLARE
} }
// Glare detected: both sides sent offers simultaneously // Glare detected: both sides sent offers simultaneously
return if (localPubKey > peerPubKey) { return if (localPubKey > peerPubKey) {
// We win — our offer takes priority, ignore remote
GlareResolution.LOCAL_WINS GlareResolution.LOCAL_WINS
} else { } else {
// We lose — rollback our local offer, accept remote
entry.session.rollback { entry.session.rollback {
onAcceptRemote(entry) onAcceptRemote(entry)
} }
@@ -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
}
}
@@ -487,61 +487,3 @@ class PeerSessionManagerTest {
assertFalse(bobAccepted, "bob should ignore alice's offer") assertFalse(bobAccepted, "bob should ignore alice's offer")
} }
} }
/**
* Fake PeerSession that records all operations for test assertions.
* No real WebRTC involved.
*/
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
if (type == SdpType.ANSWER) {
signalingState = SignalingState.STABLE
} else if (type == SdpType.OFFER) {
signalingState = 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
}
}
@@ -394,27 +394,11 @@ class NipACStateMachineTest {
} }
} }
// ======================================================================== // Renegotiation glare and callee-to-callee mesh tiebreakers are tested in
// 9. Renegotiation Glare: Pubkey Comparison Tiebreaker // PeerSessionManagerTest (commons/commonTest/) where the actual logic lives.
// ========================================================================
@Test
fun renegotiationGlareTiebreaker_higherPubkeyWins() {
// Per spec: "the peer with the higher pubkey wins"
// alice < bob (lexicographically: 'a' < 'b')
assertTrue(alice < bob, "Precondition: alice pubkey < bob pubkey")
// So bob's offer takes priority, alice must rollback
}
@Test
fun calleeToCalleeMeshGlare_lowerPubkeyInitiates() {
// Per spec: "the peer with the lexicographically lower pubkey initiates the offer"
assertTrue(alice < bob)
// Alice (lower) should initiate, Bob (higher) waits
}
// ======================================================================== // ========================================================================
// 10. Event Kind Constants // 9. Event Kind Constants
// ======================================================================== // ========================================================================
@Test @Test