diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt b/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt index b00417e..6978b07 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt @@ -440,22 +440,16 @@ class CallManager( var onStaleOffer: ((Long, Long) -> Unit)? = null /** Accept the call that is ringing. */ - fun accept(turn: JSONObject?) { - val (fromUser, offer) = pendingOffer ?: return - pendingOffer = null - val sdp = offer.sdp ?: return - state = State.CONNECTING - armConnectGuard() - val pc = open(turn, fromUser, offer.callId, offer.video) ?: run { - state = State.IDLE - return - } - current = Active(offer.callId, fromUser, incoming = true, video = offer.video) - pc.setRemoteDescription( - SimpleSdpObserver("answer:setRemote"), - SessionDescription(SessionDescription.Type.OFFER, sdp), - ) - pc.createAnswer(object : SimpleSdpObserver() { + /** + * Build and send the answer. Split out so it can only be reached from + * `setRemoteDescription`'s success callback — the ordering IS the fix. + */ + private fun answerNow( + pc: PeerConnection, + fromUser: Long, + offer: Content.CallSignal, + ) { + pc.createAnswer(object : SimpleSdpObserver("answer:create") { override fun onCreateSuccess(answer: SessionDescription) { pc.setLocalDescription(SimpleSdpObserver("answer:setLocal"), answer) // Same as the offer: the answer is complete only after @@ -471,6 +465,39 @@ class CallManager( }, MediaConstraints()) } + fun accept(turn: JSONObject?) { + val (fromUser, offer) = pendingOffer ?: return + pendingOffer = null + val sdp = offer.sdp ?: return + state = State.CONNECTING + armConnectGuard() + val pc = open(turn, fromUser, offer.callId, offer.video) ?: run { + state = State.IDLE + return + } + current = Active(offer.callId, fromUser, incoming = true, video = offer.video) + // 🔴 **`createAnswer` waits for the remote description to be APPLIED.** + // These two were fired back to back: `setRemoteDescription` is + // asynchronous, and WebRTC cannot answer an offer it has not finished + // reading. When it lost that race `createAnswer` failed, + // `setLocalDescription` never ran, and the send path found no local + // description and gave up — "NOT SENDING the offer/answer ... the other + // side will never see this call". Accepting a call did nothing, the + // caller rang on until it timed out, and neither end said why. + // + // ⚠️ It is a RACE, which is what made it look intermittent: on a quiet + // device the description often lands first and the call connects, so + // the same build both works and fails depending on load. + pc.setRemoteDescription( + object : SimpleSdpObserver("answer:setRemote") { + override fun onSetSuccess() { + main.post { answerNow(pc, fromUser, offer) } + } + }, + SessionDescription(SessionDescription.Type.OFFER, sdp), + ) + } + /** Refuse a ringing call, and say so rather than letting it ring out. */ fun decline() { val (fromUser, offer) = pendingOffer ?: return