From 6fbbcbbf3b7506c24000f71a506f6fb3275b9155 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 15:38:28 +0000 Subject: [PATCH] refactor: clean up PeerSessionManager and CallController integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../amethyst/service/call/CallController.kt | 100 ++++++------------ .../ui/screen/loggedIn/AccountViewModel.kt | 1 + .../amethyst/commons/call/PeerSession.kt | 72 +++++++++++++ .../commons/call/PeerSessionManager.kt | 86 ++++----------- .../amethyst/commons/call/FakePeerSession.kt | 80 ++++++++++++++ .../commons/call/PeerSessionManagerTest.kt | 58 ---------- .../nipACWebRtcCalls/NipACStateMachineTest.kt | 22 +--- 7 files changed, 207 insertions(+), 212 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/PeerSession.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/call/FakePeerSession.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index 22c583639..8f32b3a34 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -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() + /** 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. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index bf3ba6f9d..fae2302e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/PeerSession.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/PeerSession.kt new file mode 100644 index 000000000..c709947d7 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/PeerSession.kt @@ -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() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/PeerSessionManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/PeerSessionManager.kt index 7ececb271..bce345f4a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/PeerSessionManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/PeerSessionManager.kt @@ -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) } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/call/FakePeerSession.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/call/FakePeerSession.kt new file mode 100644 index 000000000..6179a395d --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/call/FakePeerSession.kt @@ -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() + var lastRemoteDescription: Pair? = 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 + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/call/PeerSessionManagerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/call/PeerSessionManagerTest.kt index f396563bc..d07feb608 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/call/PeerSessionManagerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/call/PeerSessionManagerTest.kt @@ -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() - var lastRemoteDescription: Pair? = 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 - } -} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NipACStateMachineTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NipACStateMachineTest.kt index 0384cfa4e..c6a0b87ac 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NipACStateMachineTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NipACStateMachineTest.kt @@ -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