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 publishWrap: suspend (EphemeralGiftWrapEvent) -> Unit,
private val signerProvider: suspend () -> com.vitorpamplona.quartz.nip01Core.signers.NostrSigner,
localPubKey: HexKey,
) {
// ---- Per-peer session state (delegated to PeerSessionManager) ----
// Lazily initialized — needs signerProvider() for localPubKey
private var peerSessionMgr: PeerSessionManager? = null
private var peerSessionMgr = PeerSessionManager(localPubKey)
private suspend fun sessionManager(): PeerSessionManager {
if (peerSessionMgr == null) {
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>()
/** 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 ----
@@ -276,14 +269,12 @@ class CallController(
// Set state to Offering before creating peer sessions
callManager.beginOffering(callId, peerPubKeys, callType)
val mgr = sessionManager()
// Create a PeerConnection + offer for each callee
for (peerPubKey in peerPubKeys) {
try {
val webRtcSession = withContext(Dispatchers.IO) { createWebRtcSession(peerPubKey) }
val adapter = WebRtcPeerSessionAdapter(webRtcSession)
mgr.registerSession(peerPubKey, adapter)
peerSessionMgr.registerSession(peerPubKey, adapter)
Log.d(TAG) { "initiateCall: PeerConnection created for ${peerPubKey.take(8)}" }
webRtcSession.createOffer { sdp ->
Log.d(TAG) { "initiateCall: offer created for ${peerPubKey.take(8)}, sdpLength=${sdp.description.length}" }
@@ -323,8 +314,6 @@ class CallController(
return@launch
}
val mgr = sessionManager()
val webRtcSession =
try {
withContext(Dispatchers.IO) { createWebRtcSession(callerPubKey) }
@@ -335,12 +324,12 @@ class CallController(
}
val adapter = WebRtcPeerSessionAdapter(webRtcSession)
val entry = mgr.registerSession(callerPubKey, adapter)
val entry = peerSessionMgr.registerSession(callerPubKey, adapter)
Log.d(TAG) { "acceptIncomingCall: setting remote description (OFFER)..." }
adapter.setRemoteDescription(SdpType.OFFER, sdpOffer)
Log.d(TAG) { "acceptIncomingCall: flushing ${entry.pendingIceCandidates.size} pending ICE candidates..." }
mgr.flushPendingIceCandidates(callerPubKey)
peerSessionMgr.flushPendingIceCandidates(callerPubKey)
Log.d(TAG) { "acceptIncomingCall: creating answer..." }
webRtcSession.createAnswer { sdp ->
@@ -365,15 +354,9 @@ class CallController(
peerPubKey: HexKey,
sdpAnswer: String,
) {
val mgr = peerSessionMgr
if (mgr == null) {
Log.d(TAG) { "onCallAnswerReceived: sessionManager not initialized — ignoring" }
return
}
Log.d(TAG) { "onCallAnswerReceived: from=${peerPubKey.take(8)}, knownSessions=${peerSessionMgr.allSessionKeys().map { it.take(8) }}" }
Log.d(TAG) { "onCallAnswerReceived: from=${peerPubKey.take(8)}, knownSessions=${mgr.allSessionKeys().map { it.take(8) }}" }
val action = mgr.routeAnswer(peerPubKey, sdpAnswer)
val action = peerSessionMgr.routeAnswer(peerPubKey, sdpAnswer)
Log.d(TAG) { "onCallAnswerReceived: action=$action" }
when (action) {
AnswerRouteAction.APPLIED -> {
@@ -401,12 +384,7 @@ class CallController(
try {
val senderPubKey = event.pubKey
val candidate = IceCandidateData(event.candidateSdp(), event.sdpMid(), event.sdpMLineIndex())
val mgr = peerSessionMgr
if (mgr == null) {
Log.d(TAG) { "Buffering ICE candidate from ${senderPubKey.take(8)} (manager not initialized)" }
return
}
val action = mgr.routeIceCandidate(senderPubKey, candidate)
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)
@@ -436,16 +414,14 @@ class CallController(
* peer with the lexicographically lower pubkey initiates.
*/
fun onNewPeerInGroupCall(peerPubKey: HexKey) {
val mgr = peerSessionMgr
if (mgr != null && mgr.hasSession(peerPubKey)) {
if (peerSessionMgr.hasSession(peerPubKey)) {
Log.d(TAG) { "onNewPeerInGroupCall: session already exists for ${peerPubKey.take(8)} — skipping" }
return
}
scope.launch {
val sm = sessionManager()
Log.d(TAG) { "onNewPeerInGroupCall: peer=${peerPubKey.take(8)}, shouldInitiate=${sm.shouldInitiateOffer(peerPubKey)}" }
if (sm.shouldInitiateOffer(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 {
@@ -456,7 +432,6 @@ class CallController(
private suspend fun createAndOfferToPeer(peerPubKey: HexKey) {
if (peerConnectionFactory == null) return
val mgr = sessionManager()
val webRtcSession =
try {
@@ -467,7 +442,7 @@ class CallController(
}
val adapter = WebRtcPeerSessionAdapter(webRtcSession)
mgr.registerSession(peerPubKey, adapter)
peerSessionMgr.registerSession(peerPubKey, adapter)
webRtcSession.createOffer { sdp ->
Log.d(TAG) { "Callee-to-callee offer created for ${peerPubKey.take(8)}, sdpLength=${sdp.description.length}" }
@@ -485,8 +460,7 @@ class CallController(
peerPubKey: HexKey,
sdpOffer: String,
) {
val mgr = peerSessionMgr
if (mgr != null && mgr.hasSession(peerPubKey)) {
if (peerSessionMgr.hasSession(peerPubKey)) {
Log.d(TAG) { "Mid-call offer from ${peerPubKey.take(8)} but session already exists — ignoring" }
return
}
@@ -498,8 +472,6 @@ class CallController(
return@launch
}
val sm = sessionManager()
val webRtcSession =
try {
withContext(Dispatchers.IO) { createWebRtcSession(peerPubKey) }
@@ -509,9 +481,9 @@ class CallController(
}
val adapter = WebRtcPeerSessionAdapter(webRtcSession)
sm.registerSession(peerPubKey, adapter)
peerSessionMgr.registerSession(peerPubKey, adapter)
adapter.setRemoteDescription(SdpType.OFFER, sdpOffer)
sm.flushPendingIceCandidates(peerPubKey)
peerSessionMgr.flushPendingIceCandidates(peerPubKey)
webRtcSession.createAnswer { sdp ->
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) {
val peerPubKey = event.pubKey
val mgr = peerSessionMgr ?: return
val sdpOffer = event.sdpOffer()
Log.d(TAG) { "Renegotiation offer from ${peerPubKey.take(8)}, sdpLength=${sdpOffer.length}" }
scope.launch {
val resolution =
mgr.resolveRenegotiationGlare(peerPubKey, sdpOffer) { entry ->
peerSessionMgr.resolveRenegotiationGlare(peerPubKey, sdpOffer) { entry ->
applyRenegotiationOffer(entry.session, peerPubKey, sdpOffer)
}
Log.d(TAG) { "Renegotiation glare resolution with ${peerPubKey.take(8)}: $resolution" }
@@ -553,7 +524,7 @@ class CallController(
}
private fun performRenegotiation(peerPubKey: HexKey) {
val webRtcSession = webRtcSessions[peerPubKey] ?: return
val webRtcSession = webRtcSession(peerPubKey) ?: return
val state = callManager.state.value
if (state !is CallState.Connected && state !is CallState.Connecting) return
@@ -580,8 +551,10 @@ class CallController(
if (localVideoTrackInternal == null) {
// Voice → video upgrade: create video source/track and add to all sessions
createVideoResources()
webRtcSessions.values.forEach { session ->
localVideoTrackInternal?.let { 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)
@@ -689,7 +662,7 @@ class CallController(
// ---- Per-peer PeerConnection creation ----
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 session =
@@ -722,7 +695,6 @@ class CallController(
localAudioTrackInternal?.let { session.addTrack(it) }
localVideoTrackInternal?.let { session.addTrack(it, VIDEO_MAX_BITRATE_BPS) }
webRtcSessions[peerPubKey] = session
return session
}
@@ -791,17 +763,11 @@ class CallController(
private fun onPeerDisconnected(peerPubKey: HexKey) {
Log.d(TAG) { "Peer ${peerPubKey.take(8)} disconnected (ICE FAILED)" }
val mgr = peerSessionMgr
if (mgr == null) {
scope.launch { callManager.hangup() }
return
}
val allDisconnected =
mgr.allSessionKeys().all { key ->
peerSessionMgr.allSessionKeys().all { key ->
key == peerPubKey ||
mgr.getSession(key)?.session?.getSignalingState() == SignalingState.CLOSED ||
mgr.getSession(key)?.remoteDescriptionSet != true
peerSessionMgr.getSession(key)?.session?.getSignalingState() == SignalingState.CLOSED ||
peerSessionMgr.getSession(key)?.remoteDescriptionSet != true
}
if (allDisconnected) {
Log.d(TAG) { "onPeerDisconnected: all peers disconnected, hanging up" }
@@ -818,9 +784,8 @@ class CallController(
* but the call continues with remaining peers.
*/
fun disposePeerSession(peerPubKey: HexKey) {
val entry = peerSessionMgr?.removeSession(peerPubKey)
val webRtcSession = webRtcSessions.remove(peerPubKey)
if (entry != null || webRtcSession != null) {
val entry = peerSessionMgr.removeSession(peerPubKey)
if (entry != null) {
Log.d(TAG) { "disposePeerSession: closing session for ${peerPubKey.take(8)}" }
try {
entry?.session?.dispose()
@@ -851,7 +816,7 @@ class CallController(
// ---- 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
// (e.g. a WebRTC native crash) does not prevent the rest from
// running. Without this, a single exception could leave the
@@ -877,12 +842,11 @@ class CallController(
// Dispose all peer sessions
try {
peerSessionMgr?.disposeAll()
peerSessionMgr.disposeAll()
} catch (e: Exception) {
Log.e(TAG, "cleanup: sessionManager.disposeAll() failed", e)
}
peerSessionMgr = null
webRtcSessions.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.
@@ -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
@@ -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
/**
* 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.
*
@@ -85,7 +34,7 @@ enum class SdpType {
* It is platform-independent and testable without real WebRTC.
*/
class PeerSessionManager(
private val localPubKey: HexKey,
val localPubKey: HexKey,
) {
data class SessionEntry(
val session: PeerSession,
@@ -126,9 +75,9 @@ class PeerSessionManager(
/**
* 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)
* 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.
*/
@@ -137,15 +86,21 @@ class PeerSessionManager(
candidate: IceCandidateData,
): IceRouteAction {
val entry = sessions[senderPubKey]
if (entry != null && entry.remoteDescriptionSet) {
entry.session.addIceCandidate(candidate)
return IceRouteAction.ADDED_DIRECTLY
} else if (entry != null) {
entry.pendingIceCandidates.add(candidate)
return IceRouteAction.BUFFERED_PER_SESSION
} else {
globalPendingIce.getOrPut(senderPubKey) { mutableListOf() }.add(candidate)
return IceRouteAction.BUFFERED_GLOBALLY
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
}
}
}
@@ -184,17 +139,14 @@ class PeerSessionManager(
val signalingState = entry.session.getSignalingState()
if (signalingState != SignalingState.HAVE_LOCAL_OFFER) {
// No glare — accept the remote offer directly
onAcceptRemote(entry)
return GlareResolution.NO_GLARE
}
// Glare detected: both sides sent offers simultaneously
return if (localPubKey > peerPubKey) {
// We win — our offer takes priority, ignore remote
GlareResolution.LOCAL_WINS
} else {
// We lose — rollback our local offer, accept remote
entry.session.rollback {
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")
}
}
/**
* 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 {
}
}
// ========================================================================
// 9. Renegotiation Glare: Pubkey Comparison Tiebreaker
// ========================================================================
@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
}
// Renegotiation glare and callee-to-callee mesh tiebreakers are tested in
// PeerSessionManagerTest (commons/commonTest/) where the actual logic lives.
// ========================================================================
// 10. Event Kind Constants
// 9. Event Kind Constants
// ========================================================================
@Test