Skip to main content

active_call/call/
sip.rs

1use crate::call::active_call::ActiveCallStateRef;
2use crate::callrecord::CallRecordHangupReason;
3use crate::event::EventSender;
4use crate::media::TrackId;
5use crate::media::stream::MediaStream;
6use crate::useragent::invitation::PendingDialog;
7use anyhow::Result;
8use chrono::Utc;
9use rsipstack::dialog::DialogId;
10use rsipstack::dialog::dialog::{
11    Dialog, DialogState, DialogStateReceiver, DialogStateSender, TerminatedReason,
12};
13use rsipstack::dialog::dialog_layer::DialogLayer;
14use rsipstack::dialog::invitation::InviteOption;
15use std::collections::HashMap;
16use std::sync::Arc;
17use tokio_util::sync::CancellationToken;
18use tracing::{info, warn};
19
20pub struct DialogStateReceiverGuard {
21    pub(super) dialog_layer: Arc<DialogLayer>,
22    pub(super) receiver: DialogStateReceiver,
23    pub(super) dialog_id: Option<DialogId>,
24    pub(super) hangup_headers: Option<Vec<rsipstack::rsip::Header>>,
25}
26
27impl DialogStateReceiverGuard {
28    pub fn new(
29        dialog_layer: Arc<DialogLayer>,
30        receiver: DialogStateReceiver,
31        hangup_headers: Option<Vec<rsipstack::rsip::Header>>,
32    ) -> Self {
33        Self {
34            dialog_layer,
35            receiver,
36            dialog_id: None,
37            hangup_headers,
38        }
39    }
40    pub async fn recv(&mut self) -> Option<DialogState> {
41        let state = self.receiver.recv().await;
42        if let Some(ref s) = state {
43            self.dialog_id = Some(s.id().clone());
44        }
45        state
46    }
47
48    fn take_dialog(&mut self) -> Option<Dialog> {
49        let id = match self.dialog_id.take() {
50            Some(id) => id,
51            None => return None,
52        };
53
54        match self.dialog_layer.get_dialog(&id) {
55            Some(dialog) => {
56                info!(%id, "dialog removed on  drop");
57                self.dialog_layer.remove_dialog(&id);
58                return Some(dialog);
59            }
60            _ => {}
61        }
62        None
63    }
64
65    pub async fn drop_async(&mut self) {
66        if let Some(dialog) = self.take_dialog() {
67            if let Err(e) = dialog.hangup_with_headers(self.hangup_headers.take()).await {
68                warn!(id=%dialog.id(), "error hanging up dialog on drop: {}", e);
69            }
70        }
71    }
72}
73
74impl Drop for DialogStateReceiverGuard {
75    fn drop(&mut self) {
76        if let Some(dialog) = self.take_dialog() {
77            crate::spawn(async move {
78                if let Err(e) = dialog.hangup().await {
79                    warn!(id=%dialog.id(), "error hanging up dialog on drop: {}", e);
80                }
81            });
82        }
83    }
84}
85
86pub(super) struct InviteDialogStates {
87    pub is_client: bool,
88    pub session_id: String,
89    pub track_id: TrackId,
90    pub cancel_token: CancellationToken,
91    pub event_sender: EventSender,
92    pub call_state: ActiveCallStateRef,
93    pub media_stream: Arc<MediaStream>,
94    pub terminated_reason: Option<TerminatedReason>,
95    pub has_early_media: bool,
96}
97
98impl InviteDialogStates {
99    pub(super) fn on_terminated(&mut self) {
100        let mut call_state_ref = match self.call_state.try_write() {
101            Ok(cs) => cs,
102            Err(_) => {
103                return;
104            }
105        };
106        let reason = &self.terminated_reason;
107        call_state_ref.last_status_code = match reason {
108            Some(TerminatedReason::UacCancel) => 487,
109            Some(TerminatedReason::UacBye) => 200,
110            Some(TerminatedReason::UacBusy) => 486,
111            Some(TerminatedReason::UasBye) => 200,
112            Some(TerminatedReason::UasBusy) => 486,
113            Some(TerminatedReason::UasDecline) => 603,
114            Some(TerminatedReason::UacOther(code)) => code.code(),
115            Some(TerminatedReason::UasOther(code)) => code.code(),
116            _ => 500, // Default to internal server error
117        };
118
119        if call_state_ref.hangup_reason.is_none() {
120            call_state_ref.hangup_reason.replace(match reason {
121                Some(TerminatedReason::UacCancel) => CallRecordHangupReason::Canceled,
122                Some(TerminatedReason::UacBye) | Some(TerminatedReason::UacBusy) => {
123                    CallRecordHangupReason::ByCaller
124                }
125                Some(TerminatedReason::UasBye) | Some(TerminatedReason::UasBusy) => {
126                    CallRecordHangupReason::ByCallee
127                }
128                Some(TerminatedReason::UasDecline) => CallRecordHangupReason::ByCallee,
129                Some(TerminatedReason::UacOther(_)) => CallRecordHangupReason::ByCaller,
130                Some(TerminatedReason::UasOther(_)) => CallRecordHangupReason::ByCallee,
131                _ => CallRecordHangupReason::BySystem,
132            });
133        };
134        let initiator = match reason {
135            Some(TerminatedReason::UacCancel) => "caller".to_string(),
136            Some(TerminatedReason::UacBye) | Some(TerminatedReason::UacBusy) => {
137                "caller".to_string()
138            }
139            Some(TerminatedReason::UasBye)
140            | Some(TerminatedReason::UasBusy)
141            | Some(TerminatedReason::UasDecline) => "callee".to_string(),
142            _ => "system".to_string(),
143        };
144        self.event_sender
145            .send(crate::event::SessionEvent::TrackEnd {
146                track_id: self.track_id.clone(),
147                timestamp: crate::media::get_timestamp(),
148                duration: call_state_ref
149                    .answer_time
150                    .map(|t| (Utc::now() - t).num_milliseconds())
151                    .unwrap_or_default() as u64,
152                ssrc: call_state_ref.ssrc,
153                play_id: None,
154            })
155            .ok();
156        let hangup_event =
157            call_state_ref.build_hangup_event(self.track_id.clone(), Some(initiator));
158        self.event_sender.send(hangup_event).ok();
159    }
160}
161
162impl Drop for InviteDialogStates {
163    fn drop(&mut self) {
164        self.on_terminated();
165        self.cancel_token.cancel();
166    }
167}
168
169impl DialogStateReceiverGuard {
170    pub(self) async fn dialog_event_loop(&mut self, states: &mut InviteDialogStates) -> Result<()> {
171        while let Some(event) = self.recv().await {
172            match event {
173                DialogState::Calling(dialog_id) => {
174                    info!(session_id=states.session_id, %dialog_id, "dialog calling");
175                    states.call_state.write().await.session_id = dialog_id.to_string();
176                }
177                DialogState::Trying(_) => {}
178                DialogState::Early(dialog_id, resp) => {
179                    let code = resp.status_code.code();
180                    let body = resp.body();
181                    let answer = String::from_utf8_lossy(body);
182                    let has_sdp = !answer.is_empty();
183                    info!(session_id=states.session_id, %dialog_id, has_sdp=%has_sdp, "dialog early ({}): \n{}", code, answer);
184
185                    {
186                        let mut cs = states.call_state.write().await;
187                        if cs.ring_time.is_none() {
188                            cs.ring_time.replace(Utc::now());
189                        }
190                        cs.last_status_code = code;
191                    }
192
193                    if !states.is_client {
194                        continue;
195                    }
196
197                    let refer = states.call_state.read().await.is_refer;
198
199                    states
200                        .event_sender
201                        .send(crate::event::SessionEvent::Ringing {
202                            track_id: states.track_id.clone(),
203                            timestamp: crate::media::get_timestamp(),
204                            early_media: has_sdp,
205                            refer: Some(refer),
206                        })?;
207
208                    if has_sdp {
209                        states.has_early_media = true;
210                        {
211                            let mut cs = states.call_state.write().await;
212                            if cs.answer.is_none() {
213                                cs.answer = Some(answer.to_string());
214                            }
215                        }
216                        states
217                            .media_stream
218                            .update_remote_description(&states.track_id, &answer.to_string())
219                            .await?;
220                    }
221                }
222                DialogState::Confirmed(dialog_id, msg) => {
223                    info!(session_id=states.session_id, %dialog_id, has_early_media=%states.has_early_media, "dialog confirmed");
224                    {
225                        let mut cs = states.call_state.write().await;
226                        cs.session_id = dialog_id.to_string();
227                        cs.answer_time.replace(Utc::now());
228                        cs.last_status_code = 200;
229                    }
230                    if states.is_client {
231                        let answer = String::from_utf8_lossy(msg.body());
232                        let answer = answer.trim();
233                        if !answer.is_empty() {
234                            if states.has_early_media {
235                                info!(
236                                    session_id = states.session_id,
237                                    "updating remote description with final answer after early media (force=true)"
238                                );
239                                // Force update when transitioning from early media (183) to confirmed (200 OK)
240                                // This ensures media parameters are properly updated even if SDP appears similar
241                                if let Err(e) = states
242                                    .media_stream
243                                    .update_remote_description_force(
244                                        &states.track_id,
245                                        &answer.to_string(),
246                                    )
247                                    .await
248                                {
249                                    tracing::warn!(
250                                        session_id = states.session_id,
251                                        "failed to force update remote description on confirmed: {}",
252                                        e
253                                    );
254                                }
255                            } else {
256                                if let Err(e) = states
257                                    .media_stream
258                                    .update_remote_description(
259                                        &states.track_id,
260                                        &answer.to_string(),
261                                    )
262                                    .await
263                                {
264                                    tracing::warn!(
265                                        session_id = states.session_id,
266                                        "failed to update remote description on confirmed: {}",
267                                        e
268                                    );
269                                }
270                            }
271                        }
272                    }
273                }
274                DialogState::Info(dialog_id, req, tx_handle) => {
275                    let body_str = String::from_utf8_lossy(req.body());
276                    info!(session_id=states.session_id, %dialog_id, body=%body_str, "dialog info received");
277                    if body_str.starts_with("Signal=") {
278                        let digit = body_str.trim_start_matches("Signal=").chars().next();
279                        if let Some(digit) = digit {
280                            let is_refer = states.call_state.read().await.is_refer;
281                            states.event_sender.send(crate::event::SessionEvent::Dtmf {
282                                track_id: states.track_id.clone(),
283                                timestamp: crate::media::get_timestamp(),
284                                digit: digit.to_string(),
285                                refer: Some(is_refer),
286                            })?;
287                        }
288                    }
289                    tx_handle.reply(rsipstack::rsip::StatusCode::OK).await.ok();
290                }
291                DialogState::Message(dialog_id, req, tx_handle) => {
292                    let body_str = String::from_utf8_lossy(req.body()).to_string();
293                    let content_type = req.headers.iter().find_map(|h| {
294                        if let rsipstack::rsip::Header::ContentType(content_type) = h {
295                            Some(content_type.value().to_string())
296                        } else {
297                            None
298                        }
299                    });
300                    info!(
301                        session_id=states.session_id,
302                        %dialog_id,
303                        content_type=content_type.as_deref(),
304                        body=%body_str,
305                        "dialog message received"
306                    );
307                    let is_refer = states.call_state.read().await.is_refer;
308                    states
309                        .event_sender
310                        .send(crate::event::SessionEvent::Message {
311                            track_id: states.track_id.clone(),
312                            timestamp: crate::media::get_timestamp(),
313                            body: body_str,
314                            content_type,
315                            refer: Some(is_refer),
316                        })
317                        .ok();
318                    tx_handle.reply(rsipstack::rsip::StatusCode::OK).await.ok();
319                }
320                DialogState::Updated(dialog_id, _req, tx_handle) => {
321                    info!(session_id = states.session_id, %dialog_id, "dialog update received");
322                    let is_refer = states.call_state.read().await.is_refer;
323                    let mut answer_sdp = None;
324                    if let Some(sdp_body) = _req.body().get(..) {
325                        let sdp_str = String::from_utf8_lossy(sdp_body);
326                        if !sdp_str.is_empty()
327                            && (_req.method == rsipstack::rsip::Method::Invite
328                                || _req.method == rsipstack::rsip::Method::Update)
329                        {
330                            info!(session_id=states.session_id, %dialog_id, method=%_req.method, "handling re-invite/update offer");
331
332                            // Detect hold state from SDP
333                            let is_on_hold =
334                                crate::media::negotiate::detect_hold_state_from_sdp(&sdp_str);
335                            info!(session_id=states.session_id, %dialog_id, is_on_hold=%is_on_hold, "detected hold state from re-invite SDP");
336
337                            // Update media stream hold state
338                            if is_on_hold {
339                                states
340                                    .media_stream
341                                    .hold_track(Some(states.track_id.clone()))
342                                    .await;
343                            } else {
344                                states
345                                    .media_stream
346                                    .resume_track(Some(states.track_id.clone()))
347                                    .await;
348                            }
349
350                            // Emit hold event
351                            states
352                                .event_sender
353                                .send(crate::event::SessionEvent::Hold {
354                                    track_id: states.track_id.clone(),
355                                    timestamp: crate::media::get_timestamp(),
356                                    on_hold: is_on_hold,
357                                    refer: Some(is_refer),
358                                })
359                                .ok();
360
361                            match states
362                                .media_stream
363                                .handshake(&states.track_id, sdp_str.to_string(), None)
364                                .await
365                            {
366                                Ok(sdp) => answer_sdp = Some(sdp),
367                                Err(e) => {
368                                    warn!(
369                                        session_id = states.session_id,
370                                        "failed to handle re-invite: {}", e
371                                    );
372                                }
373                            }
374                        } else {
375                            info!(session_id=states.session_id, %dialog_id, "updating remote description:\n{}", sdp_str);
376
377                            // Also check hold state for non-INVITE/UPDATE messages with SDP
378                            let is_on_hold =
379                                crate::media::negotiate::detect_hold_state_from_sdp(&sdp_str);
380                            if is_on_hold {
381                                states
382                                    .media_stream
383                                    .hold_track(Some(states.track_id.clone()))
384                                    .await;
385                                states
386                                    .event_sender
387                                    .send(crate::event::SessionEvent::Hold {
388                                        track_id: states.track_id.clone(),
389                                        timestamp: crate::media::get_timestamp(),
390                                        on_hold: true,
391                                        refer: Some(is_refer),
392                                    })
393                                    .ok();
394                            } else {
395                                states
396                                    .media_stream
397                                    .resume_track(Some(states.track_id.clone()))
398                                    .await;
399                                states
400                                    .event_sender
401                                    .send(crate::event::SessionEvent::Hold {
402                                        track_id: states.track_id.clone(),
403                                        timestamp: crate::media::get_timestamp(),
404                                        on_hold: false,
405                                        refer: Some(is_refer),
406                                    })
407                                    .ok();
408                            }
409
410                            states
411                                .media_stream
412                                .update_remote_description(&states.track_id, &sdp_str.to_string())
413                                .await?;
414                        }
415                    }
416
417                    if let Some(sdp) = answer_sdp {
418                        tx_handle
419                            .respond(
420                                rsipstack::rsip::StatusCode::OK,
421                                Some(vec![rsipstack::rsip::Header::ContentType(
422                                    "application/sdp".to_string().into(),
423                                )]),
424                                Some(sdp.into()),
425                            )
426                            .await
427                            .ok();
428                    } else {
429                        tx_handle.reply(rsipstack::rsip::StatusCode::OK).await.ok();
430                    }
431                }
432                DialogState::Options(dialog_id, _req, tx_handle) => {
433                    info!(session_id = states.session_id, %dialog_id, "dialog options received");
434                    tx_handle.reply(rsipstack::rsip::StatusCode::OK).await.ok();
435                }
436                DialogState::Refer(dialog_id, req, tx_handle) => {
437                    let refer_to = req.headers.iter().find_map(|h| {
438                        if let rsipstack::rsip::Header::ReferTo(h) = h {
439                            return Some(h.value().to_string());
440                        }
441                        None
442                    }).unwrap_or_default();
443                    let referred_by = req.headers.iter().find_map(|h| {
444                        if let rsipstack::rsip::Header::ReferredBy(h) = h {
445                            return Some(h.value().to_string());
446                        }
447                        None
448                    });
449                    info!(session_id = states.session_id, %dialog_id, %refer_to, "received REFER");
450                    tx_handle.reply(rsipstack::rsip::StatusCode::Other(202, "Accepted".into())).await.ok();
451                    let is_refer = states.call_state.read().await.is_refer;
452                    states.event_sender.send(crate::event::SessionEvent::TransferRequest {
453                        track_id: states.track_id.clone(),
454                        timestamp: crate::media::get_timestamp(),
455                        refer_to,
456                        referred_by,
457                        refer: Some(is_refer),
458                    }).ok();
459                }
460                DialogState::Terminated(dialog_id, reason) => {
461                    info!(
462                        session_id = states.session_id,
463                        ?dialog_id,
464                        ?reason,
465                        "dialog terminated"
466                    );
467                    states.terminated_reason = Some(reason.clone());
468                    return Ok(());
469                }
470                other_state => {
471                    info!(
472                        session_id = states.session_id,
473                        %other_state,
474                        "dialog received other state"
475                    );
476                }
477            }
478        }
479        Ok(())
480    }
481
482    pub(super) async fn process_dialog(&mut self, mut states: InviteDialogStates) {
483        let token = states.cancel_token.clone();
484        tokio::select! {
485            _ = token.cancelled() => {
486                states.terminated_reason = Some(TerminatedReason::UacCancel);
487            }
488            _ = self.dialog_event_loop(&mut states) => {}
489        };
490
491        // Update hangup headers from ActiveCallState if available
492        {
493            let state = states.call_state.read().await;
494            if let Some(extras) = &state.extras {
495                if let Some(h_val) = extras.get("_hangup_headers") {
496                    if let Ok(headers_map) =
497                        serde_json::from_value::<HashMap<String, String>>(h_val.clone())
498                    {
499                        let mut headers = Vec::new();
500                        for (k, v) in headers_map {
501                            headers.push(rsipstack::rsip::Header::Other(k.into(), v.into()));
502                        }
503                        if !headers.is_empty() {
504                            if let Some(existing) = &mut self.hangup_headers {
505                                existing.extend(headers);
506                            } else {
507                                self.hangup_headers = Some(headers);
508                            }
509                        }
510                    }
511                }
512            }
513        }
514
515        self.drop_async().await;
516    }
517}
518
519#[derive(Clone)]
520pub struct Invitation {
521    pub dialog_layer: Arc<DialogLayer>,
522    pub pending_dialogs: Arc<std::sync::Mutex<HashMap<DialogId, PendingDialog>>>,
523}
524
525impl Invitation {
526    pub fn new(dialog_layer: Arc<DialogLayer>) -> Self {
527        Self {
528            dialog_layer,
529            pending_dialogs: Arc::new(std::sync::Mutex::new(HashMap::new())),
530        }
531    }
532
533    pub fn add_pending(&self, dialog_id: DialogId, pending: PendingDialog) {
534        self.pending_dialogs
535            .lock()
536            .map(|mut ps| ps.insert(dialog_id, pending))
537            .ok();
538    }
539
540    pub fn get_pending_call(&self, dialog_id: &DialogId) -> Option<PendingDialog> {
541        self.pending_dialogs
542            .lock()
543            .ok()
544            .and_then(|mut ps| ps.remove(dialog_id))
545    }
546
547    pub fn has_pending_call(&self, dialog_id: &DialogId) -> bool {
548        self.pending_dialogs
549            .lock()
550            .ok()
551            .map(|ps| ps.contains_key(dialog_id))
552            .unwrap_or(false)
553    }
554
555    pub fn find_dialog_id_by_session_id(&self, session_id: &str) -> Option<DialogId> {
556        self.pending_dialogs.lock().ok().and_then(|ps| {
557            ps.iter()
558                .find(|(id, _)| id.to_string() == session_id)
559                .map(|(id, _)| id.clone())
560        })
561    }
562
563    pub async fn hangup(
564        &self,
565        dialog_id: DialogId,
566        code: Option<rsipstack::rsip::StatusCode>,
567        reason: Option<String>,
568    ) -> Result<()> {
569        if let Some(call) = self.get_pending_call(&dialog_id) {
570            call.dialog.reject(code, reason).ok();
571        }
572        match self.dialog_layer.get_dialog(&dialog_id) {
573            Some(dialog) => {
574                self.dialog_layer.remove_dialog(&dialog_id);
575                dialog.hangup().await.ok();
576            }
577            None => {}
578        }
579        Ok(())
580    }
581
582    pub async fn reject(&self, dialog_id: DialogId) -> Result<()> {
583        if let Some(call) = self.get_pending_call(&dialog_id) {
584            call.dialog.reject(None, None).ok();
585        }
586        match self.dialog_layer.get_dialog(&dialog_id) {
587            Some(dialog) => {
588                self.dialog_layer.remove_dialog(&dialog_id);
589                dialog.hangup().await.ok();
590            }
591            None => {}
592        }
593        Ok(())
594    }
595
596    pub async fn invite(
597        &self,
598        invite_option: InviteOption,
599        state_sender: DialogStateSender,
600    ) -> Result<(DialogId, Option<Vec<u8>>), rsipstack::Error> {
601        let (dialog, resp) = self
602            .dialog_layer
603            .do_invite(invite_option, state_sender)
604            .await?;
605
606        let offer = match resp {
607            Some(resp) => match resp.status_code.kind() {
608                rsipstack::rsip::StatusCodeKind::Successful => {
609                    let offer = resp.body.clone();
610                    Some(offer)
611                }
612                _ => {
613                    let reason = resp
614                        .reason_phrase()
615                        .unwrap_or(&resp.status_code.to_string())
616                        .to_string();
617                    return Err(rsipstack::Error::DialogError(
618                        reason,
619                        dialog.id(),
620                        resp.status_code,
621                    ));
622                }
623            },
624            None => {
625                return Err(rsipstack::Error::DialogError(
626                    "no response received".to_string(),
627                    dialog.id(),
628                    rsipstack::rsip::StatusCode::NotAcceptableHere,
629                ));
630            }
631        };
632        Ok((dialog.id(), offer))
633    }
634}
635
636#[cfg(test)]
637mod tests {
638    use super::*;
639    use crate::call::active_call::ActiveCallState;
640    use crate::media::stream::MediaStreamBuilder;
641    use std::sync::Arc;
642    use tokio::sync::RwLock;
643    use tokio_util::sync::CancellationToken;
644
645    // SDP used to simulate an early-media 183 Session Progress response.
646    const EARLY_MEDIA_SDP: &str = "v=0\r\n\
647        o=- 1000 1 IN IP4 192.168.1.100\r\n\
648        s=SIP Call\r\n\
649        t=0 0\r\n\
650        m=audio 10000 RTP/AVP 0\r\n\
651        c=IN IP4 192.168.1.100\r\n\
652        a=rtpmap:0 PCMU/8000\r\n\
653        a=sendrecv\r\n";
654
655    fn make_response_with_body(body: Vec<u8>) -> rsipstack::rsip::Response {
656        let mut resp = rsipstack::rsip::Response::default();
657        resp.body = body;
658        resp
659    }
660
661    /// Verify that when a 183 Session Progress with SDP arrives (`DialogState::Early`),
662    /// the early SDP is stored in `call_state.answer` so it can serve as a fallback
663    /// when the final 200 OK has an empty body.
664    #[tokio::test]
665    async fn test_early_sdp_stored_in_call_state() {
666        let (event_tx, _event_rx) = tokio::sync::broadcast::channel(16);
667        let media_stream = Arc::new(
668            MediaStreamBuilder::new(event_tx.clone())
669                .with_id("test-stream".to_string())
670                .build(),
671        );
672        let call_state: ActiveCallStateRef = Arc::new(RwLock::new(ActiveCallState::default()));
673        let cancel_token = CancellationToken::new();
674
675        let mut states = InviteDialogStates {
676            is_client: true,
677            session_id: "test-session".to_string(),
678            track_id: "test-track".to_string(),
679            cancel_token: cancel_token.clone(),
680            event_sender: event_tx.clone(),
681            call_state: call_state.clone(),
682            media_stream: media_stream.clone(),
683            terminated_reason: None,
684            has_early_media: false,
685        };
686
687        // Simulate DialogState::Early with SDP body (183 Session Progress)
688        let early_resp = make_response_with_body(EARLY_MEDIA_SDP.as_bytes().to_vec());
689
690        // Manually execute the Early branch logic (same as dialog_event_loop)
691        let body = early_resp.body();
692        let answer = String::from_utf8_lossy(body);
693        let has_sdp = !answer.is_empty();
694        if states.is_client && has_sdp {
695            states.has_early_media = true;
696            {
697                let mut cs = states.call_state.write().await;
698                if cs.answer.is_none() {
699                    cs.answer = Some(answer.to_string());
700                }
701            }
702            // (update_remote_description skipped — no real RTC peer)
703        }
704
705        // Assert: early SDP is stored in call_state.answer
706        {
707            let cs = call_state.read().await;
708            assert!(
709                cs.answer.is_some(),
710                "call_state.answer should be set after 183 with SDP"
711            );
712            assert_eq!(
713                cs.answer.as_deref().unwrap(),
714                EARLY_MEDIA_SDP,
715                "call_state.answer should contain the early SDP"
716            );
717        }
718        assert!(states.has_early_media, "has_early_media should be true");
719    }
720
721    /// Verify that when a 200 OK arrives with an empty body after early media has been
722    /// negotiated, `call_state.answer` retains the early SDP (not overwritten with "").
723    ///
724    /// This is the regression test for the bug where a late 200 OK with empty body would
725    /// cause `SessionEvent::Answer { sdp: "" }` to be emitted, making the answer event
726    /// appear as if no SDP was negotiated.
727    #[tokio::test]
728    async fn test_confirmed_empty_body_keeps_early_sdp() {
729        let (event_tx, _event_rx) = tokio::sync::broadcast::channel(16);
730        let media_stream = Arc::new(
731            MediaStreamBuilder::new(event_tx.clone())
732                .with_id("test-stream-2".to_string())
733                .build(),
734        );
735        let call_state: ActiveCallStateRef = Arc::new(RwLock::new(ActiveCallState::default()));
736        let cancel_token = CancellationToken::new();
737
738        let mut states = InviteDialogStates {
739            is_client: true,
740            session_id: "test-session-2".to_string(),
741            track_id: "test-track-2".to_string(),
742            cancel_token: cancel_token.clone(),
743            event_sender: event_tx.clone(),
744            call_state: call_state.clone(),
745            media_stream: media_stream.clone(),
746            terminated_reason: None,
747            has_early_media: false,
748        };
749
750        // Step 1: simulate 183 with SDP → set has_early_media and cs.answer
751        {
752            let answer_str = EARLY_MEDIA_SDP.to_string();
753            states.has_early_media = true;
754            let mut cs = states.call_state.write().await;
755            if cs.answer.is_none() {
756                cs.answer = Some(answer_str);
757            }
758        }
759
760        // Step 2: simulate 200 OK with empty body (Confirmed handler logic)
761        let confirmed_resp = make_response_with_body(vec![]); // empty body
762        {
763            let mut cs = states.call_state.write().await;
764            cs.answer_time.replace(chrono::Utc::now());
765            cs.last_status_code = 200;
766        }
767        // The Confirmed handler in dialog_event_loop only calls update_remote_description
768        // when body is non-empty; it does NOT overwrite cs.answer.
769        let body = confirmed_resp.body();
770        let answer = String::from_utf8_lossy(body);
771        let answer_trimmed = answer.trim();
772        // Replicate Confirmed handler: only act on non-empty body
773        if states.is_client && !answer_trimmed.is_empty() {
774            // (Would call update_remote_description or update_remote_description_force)
775            // This branch should NOT execute for empty-body 200 OK
776            panic!("Confirmed handler should not update SDP for empty body");
777        }
778
779        // Assert: call_state.answer still holds the early SDP
780        {
781            let cs = call_state.read().await;
782            assert!(
783                cs.answer.is_some(),
784                "call_state.answer must not be None after 200 OK with empty body"
785            );
786            let stored_answer = cs.answer.as_deref().unwrap();
787            assert!(
788                !stored_answer.is_empty(),
789                "call_state.answer must not be empty after 200 OK with empty body"
790            );
791            assert_eq!(
792                stored_answer, EARLY_MEDIA_SDP,
793                "call_state.answer should still be the early SDP after 200 OK with empty body"
794            );
795        }
796    }
797
798    /// Verify that `create_outgoing_sip_track`'s fallback logic works:
799    /// when the 200 OK body is empty but `call_state.answer` has the early SDP,
800    /// the fallback path is taken and the early SDP is returned (not an empty string).
801    ///
802    /// This test directly validates the fix in `create_outgoing_sip_track` by
803    /// simulating the state that would exist after a 183+early-media exchange.
804    #[tokio::test]
805    async fn test_answer_fallback_to_early_sdp_when_200ok_empty() {
806        // Set up call state as it would be after early media (183 with SDP) was processed
807        let call_state: ActiveCallStateRef = Arc::new(RwLock::new(ActiveCallState::default()));
808
809        // Simulate what the Early (183) handler does: store the early SDP in cs.answer
810        {
811            let mut cs = call_state.write().await;
812            cs.answer = Some(EARLY_MEDIA_SDP.to_string());
813        }
814
815        // Simulate what create_outgoing_sip_track does when 200 OK has empty body:
816        //   answer = Some(vec![])  →  s = ""  →  s.trim().is_empty() → fallback
817        let raw_answer: Option<Vec<u8>> = Some(vec![]); // empty body from 200 OK
818
819        let resolved_answer = match raw_answer {
820            Some(bytes) => {
821                let s = String::from_utf8_lossy(&bytes).to_string();
822                if s.trim().is_empty() {
823                    // Fallback: use early SDP stored by the 183 handler
824                    let cs = call_state.read().await;
825                    match cs.answer.clone() {
826                        Some(early_sdp) if !early_sdp.is_empty() => {
827                            (early_sdp, true /* already applied */)
828                        }
829                        _ => (s, false),
830                    }
831                } else {
832                    (s, false)
833                }
834            }
835            None => {
836                let cs = call_state.read().await;
837                match cs.answer.clone() {
838                    Some(early_sdp) if !early_sdp.is_empty() => (early_sdp, true),
839                    _ => panic!("Expected early SDP fallback"),
840                }
841            }
842        };
843
844        let (answer, already_applied) = resolved_answer;
845
846        // The answer returned to setup_caller_track (and used in SessionEvent::Answer)
847        // must be the early SDP, not an empty string.
848        assert!(
849            !answer.is_empty(),
850            "Resolved answer must not be empty — should contain the early SDP"
851        );
852        assert_eq!(
853            answer, EARLY_MEDIA_SDP,
854            "Resolved answer should be the early SDP from the 183 handler"
855        );
856        assert!(
857            already_applied,
858            "remote_description_already_applied should be true when using early SDP fallback"
859        );
860    }
861
862    /// Verify the normal case: when 200 OK carries its own SDP body,
863    /// that SDP is used directly (not the early SDP) and remote description
864    /// should be applied.
865    #[tokio::test]
866    async fn test_answer_uses_200ok_sdp_when_present() {
867        const FINAL_SDP: &str = "v=0\r\n\
868            o=- 2000 2 IN IP4 10.0.0.1\r\n\
869            s=SIP Call\r\n\
870            t=0 0\r\n\
871            m=audio 20000 RTP/AVP 0\r\n\
872            c=IN IP4 10.0.0.1\r\n\
873            a=rtpmap:0 PCMU/8000\r\n\
874            a=sendrecv\r\n";
875
876        let call_state: ActiveCallStateRef = Arc::new(RwLock::new(ActiveCallState::default()));
877
878        // Even with early SDP stored, when 200 OK has SDP body it should be used
879        {
880            let mut cs = call_state.write().await;
881            cs.answer = Some(EARLY_MEDIA_SDP.to_string());
882        }
883
884        let raw_answer: Option<Vec<u8>> = Some(FINAL_SDP.as_bytes().to_vec());
885
886        let resolved_answer = match raw_answer {
887            Some(bytes) => {
888                let s = String::from_utf8_lossy(&bytes).to_string();
889                if s.trim().is_empty() {
890                    let cs = call_state.read().await;
891                    match cs.answer.clone() {
892                        Some(early_sdp) if !early_sdp.is_empty() => (early_sdp, true),
893                        _ => (s, false),
894                    }
895                } else {
896                    (s, false) // ← normal case: use 200 OK SDP, apply it
897                }
898            }
899            None => panic!("Unexpected"),
900        };
901
902        let (answer, already_applied) = resolved_answer;
903
904        assert_eq!(
905            answer, FINAL_SDP,
906            "When 200 OK has SDP, it should be used (not the early SDP)"
907        );
908        assert!(
909            !already_applied,
910            "remote_description_already_applied should be false when 200 OK has SDP body"
911        );
912    }
913}