#![deny(missing_docs)]
use rvoip_media_core::types::AudioFrame;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;
use crate::api::audio::{AudioReceiver, AudioSender, AudioStream};
use crate::api::dialog_package::{DialogInfo, DialogPackageState};
use crate::api::events::{Event, MediaSecurityState, TransferTargetEvidence};
use crate::api::lifecycle::{CallLifecycleSnapshot, CallTerminalInfo};
use crate::api::unified::UnifiedCoordinator;
use crate::errors::{Result, SessionError};
use crate::state_table::types::SessionId;
use crate::types::{CallState, SessionInfo};
const AUDIO_STREAM_CHANNEL_FRAMES: usize = 128;
pub type CallId = SessionId;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransferWaitMode {
NotifyFinal,
TargetRinging,
TargetAnswered,
ReplacementTerminated,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TransferOutcome {
ReferCompleted {
call_id: CallId,
target: String,
status_code: u16,
reason: String,
},
TargetRinging {
call_id: CallId,
status_code: u16,
reason: String,
},
TargetAnswered {
call_id: CallId,
target_uri: String,
evidence: TransferTargetEvidence,
},
ReplacementTerminated {
call_id: CallId,
dialog: DialogInfo,
reason: Option<String>,
},
Failed {
call_id: CallId,
status_code: u16,
reason: String,
},
}
impl TryFrom<Event> for TransferOutcome {
type Error = SessionError;
fn try_from(event: Event) -> std::result::Result<Self, Self::Error> {
match event {
Event::ReferCompleted {
call_id,
target,
status_code,
reason,
} => Ok(Self::ReferCompleted {
call_id,
target,
status_code,
reason,
}),
Event::ReferProgress {
call_id,
status_code,
reason,
} => Ok(Self::TargetRinging {
call_id,
status_code,
reason,
}),
Event::TransferTargetAnswered {
transfer_call_id,
target_uri,
evidence,
} => Ok(Self::TargetAnswered {
call_id: transfer_call_id,
target_uri,
evidence,
}),
Event::TransferReplacementDialogTerminated {
transfer_call_id,
dialog,
reason,
} => Ok(Self::ReplacementTerminated {
call_id: transfer_call_id,
dialog,
reason,
}),
Event::TransferFailed {
call_id,
status_code,
reason,
} => Ok(Self::Failed {
call_id,
status_code,
reason,
}),
other => Err(SessionError::Other(format!(
"event is not a transfer outcome: {:?}",
other
))),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SipReason {
pub protocol: String,
pub cause: u16,
pub text: Option<String>,
}
impl SipReason {
pub fn sip(cause: u16, text: impl Into<String>) -> Self {
Self {
protocol: "SIP".to_string(),
cause,
text: Some(text.into()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TransferDialogMatcher {
Any,
TargetUri,
}
#[derive(Clone)]
pub struct TransferLifecycleOptions {
pub dialog_subscription: Option<crate::api::dialog_subscription::DialogSubscriptionHandle>,
pub target_match: TransferDialogMatcher,
pub wait_for_replacement_termination: bool,
}
impl Default for TransferLifecycleOptions {
fn default() -> Self {
Self {
dialog_subscription: None,
target_match: TransferDialogMatcher::TargetUri,
wait_for_replacement_termination: false,
}
}
}
#[derive(Clone)]
pub struct SessionHandle {
pub(crate) call_id: CallId,
pub(crate) coordinator: Arc<UnifiedCoordinator>,
}
impl SessionHandle {
pub(crate) fn new(call_id: CallId, coordinator: Arc<UnifiedCoordinator>) -> Self {
Self {
call_id,
coordinator,
}
}
pub fn id(&self) -> &CallId {
&self.call_id
}
pub fn bye(&self) -> crate::api::send::ByeBuilder {
self.coordinator.bye(&self.call_id)
}
pub fn cancel(&self) -> crate::api::send::CancelBuilder {
self.coordinator.cancel(&self.call_id)
}
pub fn refer(&self, refer_to: impl Into<String>) -> crate::api::send::ReferBuilder {
self.coordinator.refer(&self.call_id, refer_to)
}
pub fn notify(&self, event_package: impl Into<String>) -> crate::api::send::NotifyBuilder {
self.coordinator.notify(&self.call_id, event_package)
}
pub fn info(&self, content_type: impl Into<String>) -> crate::api::send::InfoBuilder {
self.coordinator.info(&self.call_id, content_type)
}
pub fn update(&self) -> crate::api::send::UpdateBuilder {
self.coordinator.update(&self.call_id)
}
pub fn reinvite(&self) -> crate::api::send::ReInviteBuilder {
self.coordinator.reinvite(&self.call_id)
}
pub async fn hangup(&self) -> Result<()> {
self.coordinator.hangup(&self.call_id).await
}
pub async fn hangup_and_wait(&self, timeout: Option<Duration>) -> Result<String> {
let rx = self.coordinator.lifecycle_watcher(&self.call_id);
if let Some(reason) = terminal_reason(&self.lifecycle().await?) {
return Ok(reason);
}
match self.coordinator.hangup(&self.call_id).await {
Ok(()) => {}
Err(e) if e.is_session_gone() => {
if let Some(reason) = terminal_reason(&self.lifecycle().await?) {
return Ok(reason);
}
return Err(e);
}
Err(e) => return Err(e),
}
let result =
wait_for_lifecycle(self, rx, timeout, "hangup_and_wait timed out", |snapshot| {
Ok(terminal_reason(snapshot))
})
.await;
if matches!(result, Err(SessionError::Timeout(_))) {
self.spawn_late_answer_teardown_observer();
}
result
}
pub async fn hangup_with_reason(
&self,
reason: SipReason,
timeout: Option<Duration>,
) -> Result<String> {
let rx = self.coordinator.lifecycle_watcher(&self.call_id);
if let Some(cached) = terminal_reason(&self.lifecycle().await?) {
return Ok(cached);
}
self.bye().with_sip_reason(reason).send().await?;
wait_for_lifecycle(
self,
rx,
timeout,
"hangup_with_reason timed out",
|snapshot| Ok(terminal_reason(snapshot)),
)
.await
}
pub async fn hold(&self) -> Result<()> {
self.coordinator.hold(&self.call_id).await
}
pub async fn resume(&self) -> Result<()> {
self.coordinator.resume(&self.call_id).await
}
pub async fn mute(&self) -> Result<()> {
use crate::state_table::types::EventType;
self.coordinator
.helpers
.state_machine
.process_event(&self.call_id, EventType::MuteCall)
.await?;
Ok(())
}
pub async fn unmute(&self) -> Result<()> {
use crate::state_table::types::EventType;
self.coordinator
.helpers
.state_machine
.process_event(&self.call_id, EventType::UnmuteCall)
.await?;
Ok(())
}
pub async fn transfer_blind(&self, target: &str) -> Result<()> {
self.refer(target).send().await
}
pub async fn transfer_blind_and_wait(
&self,
target: &str,
timeout: Option<Duration>,
) -> Result<Event> {
self.transfer_blind_and_wait_for(target, TransferWaitMode::NotifyFinal, timeout)
.await
}
pub async fn transfer_blind_and_wait_for(
&self,
target: &str,
mode: TransferWaitMode,
timeout: Option<Duration>,
) -> Result<Event> {
let mut events = self.events().await?;
self.transfer_blind(target).await?;
let fut = async {
let mut target_progress_seen = false;
loop {
match events.next().await {
Some(event @ Event::ReferProgress { status_code, .. })
if (180..=199).contains(&status_code) =>
{
target_progress_seen = true;
if mode == TransferWaitMode::TargetRinging {
return Ok(event);
}
}
Some(event @ Event::ReferCompleted { .. }) => match mode {
TransferWaitMode::NotifyFinal => return Ok(event),
TransferWaitMode::TargetRinging => {
return Err(SessionError::Other(
"terminal REFER NOTIFY arrived before target ringing evidence"
.to_string(),
))
}
TransferWaitMode::TargetAnswered if target_progress_seen => {
return Ok(event)
}
TransferWaitMode::TargetAnswered => {
return Err(SessionError::Other(
"terminal REFER NOTIFY arrived before target-answer evidence"
.to_string(),
))
}
TransferWaitMode::ReplacementTerminated => {
return Err(SessionError::Other(
"ReplacementTerminated requires transfer lifecycle options"
.to_string(),
))
}
},
Some(event @ Event::TransferTargetAnswered { .. })
if mode == TransferWaitMode::TargetAnswered =>
{
return Ok(event)
}
Some(event @ Event::TransferReplacementDialogTerminated { .. })
if mode == TransferWaitMode::ReplacementTerminated =>
{
return Ok(event)
}
Some(event @ Event::TransferFailed { .. }) => return Ok(event),
Some(_) => {}
None => {
return Err(SessionError::Other(
"Event channel closed while waiting for transfer".to_string(),
))
}
}
}
};
match timeout {
Some(duration) => tokio::time::timeout(duration, fut).await.map_err(|_| {
SessionError::Timeout("transfer_blind_and_wait timed out".to_string())
})?,
None => fut.await,
}
}
pub async fn transfer_blind_and_wait_for_outcome(
&self,
target: &str,
mode: TransferWaitMode,
timeout: Option<Duration>,
) -> Result<TransferOutcome> {
let event = self
.transfer_blind_and_wait_for(target, mode, timeout)
.await?;
TransferOutcome::try_from(event)
}
pub async fn transfer_blind_and_wait_for_with_options(
&self,
target: &str,
mode: TransferWaitMode,
options: TransferLifecycleOptions,
timeout: Option<Duration>,
) -> Result<Event> {
if mode != TransferWaitMode::ReplacementTerminated && options.dialog_subscription.is_none()
{
return self
.transfer_blind_and_wait_for(target, mode, timeout)
.await;
}
let mut transfer_events = self.events().await?;
let mut dialog_events = if let Some(subscription) = options.dialog_subscription.as_ref() {
Some(subscription.events().await?)
} else {
None
};
self.transfer_blind(target).await?;
let target = target.to_string();
let fut = async {
let mut target_progress_seen = false;
loop {
if let Some(dialog_events) = dialog_events.as_mut() {
tokio::select! {
event = transfer_events.next() => {
if let Some(result) = handle_transfer_wait_event(event, mode, &mut target_progress_seen)? {
return Ok(result);
}
}
event = dialog_events.next() => {
if let Some(result) = handle_dialog_wait_event(
event,
&self.call_id,
&target,
mode,
&options,
)? {
return Ok(result);
}
}
}
} else if let Some(result) = handle_transfer_wait_event(
transfer_events.next().await,
mode,
&mut target_progress_seen,
)? {
return Ok(result);
}
}
};
match timeout {
Some(duration) => tokio::time::timeout(duration, fut).await.map_err(|_| {
SessionError::Timeout(
"transfer_blind_and_wait_for_with_options timed out".to_string(),
)
})?,
None => fut.await,
}
}
pub async fn accept_refer(&self) -> Result<()> {
self.coordinator.accept_refer(&self.call_id).await
}
pub async fn reject_refer(&self, status_code: u16, reason: &str) -> Result<()> {
self.coordinator
.reject_refer(&self.call_id, status_code, reason)
.await
}
pub async fn transfer_attended(&self, target: &str, replaces: &str) -> Result<()> {
self.refer(target).with_replaces(replaces).send().await
}
pub async fn dialog_identity(&self) -> Result<Option<crate::api::types::DialogIdentity>> {
self.coordinator.dialog_identity(&self.call_id).await
}
pub async fn send_dtmf(&self, digit: char) -> Result<()> {
self.coordinator.send_dtmf(&self.call_id, digit).await
}
pub async fn send_info(&self, content_type: &str, body: &[u8]) -> Result<()> {
self.info(content_type)
.with_body(bytes::Bytes::copy_from_slice(body))
.send()
.await
}
pub async fn send_notify(
&self,
event_package: &str,
body: Option<String>,
subscription_state: Option<String>,
) -> Result<()> {
let mut b = self.notify(event_package);
if let Some(text) = body {
b = b.with_body(bytes::Bytes::from(text.into_bytes()));
}
if let Some(state) = subscription_state {
b = b.with_subscription_state(state);
}
b.send().await
}
pub async fn audio(&self) -> Result<AudioStream> {
let mut subscriber = self.coordinator.subscribe_to_audio(&self.call_id).await?;
let (recv_tx, recv_rx) = mpsc::channel::<AudioFrame>(AUDIO_STREAM_CHANNEL_FRAMES);
tokio::spawn(async move {
while let Some(frame) = subscriber.receiver.recv().await {
if recv_tx.send(frame).await.is_err() {
break; }
}
});
let coordinator = self.coordinator.clone();
let call_id = self.call_id.clone();
let (send_tx, mut send_rx) = mpsc::channel::<AudioFrame>(AUDIO_STREAM_CHANNEL_FRAMES);
tokio::spawn(async move {
while let Some(frame) = send_rx.recv().await {
if let Err(e) = coordinator.send_audio(&call_id, frame).await {
tracing::debug!("[SessionHandle] audio send error for {}: {}", call_id, e);
break;
}
}
});
Ok(AudioStream::new(
AudioSender::new(send_tx),
AudioReceiver::new(recv_rx),
))
}
pub async fn state(&self) -> Result<CallState> {
self.coordinator.get_state(&self.call_id).await
}
pub async fn session_info(&self) -> Result<SessionInfo> {
self.coordinator.get_session_info(&self.call_id).await
}
pub async fn media_security(&self) -> Result<Option<MediaSecurityState>> {
Ok(self.lifecycle().await?.media_security)
}
pub async fn lifecycle(&self) -> Result<CallLifecycleSnapshot> {
Ok(self.coordinator.lifecycle_snapshot(&self.call_id).await)
}
pub async fn wait_for_media_security(
&self,
timeout: Option<Duration>,
) -> Result<MediaSecurityState> {
let rx = self.coordinator.lifecycle_watcher(&self.call_id);
wait_for_lifecycle(
self,
rx,
timeout,
"wait_for_media_security timed out",
|snapshot| {
if let Some(security) = snapshot.media_security.clone() {
return Ok(Some(security));
}
if let Some(err) = terminal_error(snapshot, "media security was negotiated") {
return Err(err);
}
Ok(None)
},
)
.await
}
pub async fn is_active(&self) -> bool {
matches!(self.state().await, Ok(CallState::Active))
}
pub async fn is_on_hold(&self) -> bool {
matches!(self.state().await, Ok(CallState::OnHold))
}
pub async fn events(&self) -> Result<crate::api::stream_peer::EventReceiver> {
let rx = self.coordinator.subscribe_events().await?;
Ok(crate::api::stream_peer::EventReceiver::filtered(
rx,
self.call_id.clone(),
))
}
pub async fn wait_for_progress<F>(
&self,
predicate: F,
timeout: Option<Duration>,
) -> Result<Event>
where
F: Fn(&Event) -> bool,
{
let rx = self.coordinator.lifecycle_watcher(&self.call_id);
wait_for_lifecycle(
self,
rx,
timeout,
"wait_for_progress timed out",
|snapshot| {
for progress in &snapshot.progress {
let event = progress.to_event();
if predicate(&event) {
return Ok(Some(event));
}
}
if snapshot.answered.is_some() || snapshot.state.is_some_and(is_answered_state) {
return Err(SessionError::Other(
"call answered before matching provisional progress".to_string(),
));
}
if let Some(err) = terminal_error(snapshot, "matching provisional progress") {
return Err(err);
}
Ok(None)
},
)
.await
}
pub async fn wait_for_answered(&self, timeout: Option<Duration>) -> Result<SessionHandle> {
let rx = self.coordinator.lifecycle_watcher(&self.call_id);
wait_for_lifecycle(
self,
rx,
timeout,
"wait_for_answered timed out",
|snapshot| {
if snapshot.answered.is_some() || snapshot.state.is_some_and(is_answered_state) {
return Ok(Some(self.clone()));
}
if let Some(err) = terminal_error(snapshot, "answer") {
return Err(err);
}
if snapshot.state.is_some_and(|state| state.is_final()) {
return Err(SessionError::Other(
"call reached terminal state before answer".to_string(),
));
}
Ok(None)
},
)
.await
}
pub async fn wait_for_end(&self, timeout: Option<Duration>) -> Result<String> {
let rx = self.coordinator.lifecycle_watcher(&self.call_id);
wait_for_lifecycle(self, rx, timeout, "wait_for_end timed out", |snapshot| {
Ok(terminal_reason(snapshot))
})
.await
}
fn spawn_late_answer_teardown_observer(&self) {
let timeout = self.coordinator.setup_teardown_timeout_duration();
if timeout.is_zero() {
return;
}
let handle = self.clone();
tokio::spawn(async move {
let mut rx = handle.coordinator.lifecycle_watcher(&handle.call_id);
let watch = async {
loop {
let snapshot = match handle.lifecycle().await {
Ok(snapshot) => snapshot,
Err(err) => {
tracing::debug!(
"late answer teardown observer failed to read lifecycle for {}: {}",
handle.call_id,
err
);
return;
}
};
if snapshot.terminal.is_some()
|| snapshot.state.is_some_and(|state| state.is_final())
{
return;
}
if snapshot.answered.is_some() || snapshot.state.is_some_and(is_answered_state)
{
if let Err(err) = handle.coordinator.hangup(&handle.call_id).await {
tracing::debug!(
"late answer teardown observer failed to hang up {}: {}",
handle.call_id,
err
);
}
return;
}
if rx.changed().await.is_err() {
return;
}
}
};
let _ = tokio::time::timeout(timeout, watch).await;
});
}
}
fn handle_transfer_wait_event(
event: Option<Event>,
mode: TransferWaitMode,
target_progress_seen: &mut bool,
) -> Result<Option<Event>> {
match event {
Some(event @ Event::ReferProgress { status_code, .. })
if (180..=199).contains(&status_code) =>
{
*target_progress_seen = true;
if mode == TransferWaitMode::TargetRinging {
Ok(Some(event))
} else {
Ok(None)
}
}
Some(event @ Event::ReferCompleted { .. }) => match mode {
TransferWaitMode::NotifyFinal => Ok(Some(event)),
TransferWaitMode::TargetRinging => Err(SessionError::Other(
"terminal REFER NOTIFY arrived before target ringing evidence".to_string(),
)),
TransferWaitMode::TargetAnswered if *target_progress_seen => Ok(Some(event)),
TransferWaitMode::TargetAnswered => Err(SessionError::Other(
"terminal REFER NOTIFY arrived before target-answer evidence".to_string(),
)),
TransferWaitMode::ReplacementTerminated => Ok(None),
},
Some(event @ Event::TransferTargetAnswered { .. })
if mode == TransferWaitMode::TargetAnswered =>
{
Ok(Some(event))
}
Some(event @ Event::TransferReplacementDialogTerminated { .. })
if mode == TransferWaitMode::ReplacementTerminated =>
{
Ok(Some(event))
}
Some(event @ Event::TransferFailed { .. }) => Ok(Some(event)),
Some(_) => Ok(None),
None => Err(SessionError::Other(
"Event channel closed while waiting for transfer".to_string(),
)),
}
}
fn handle_dialog_wait_event(
event: Option<Event>,
transfer_call_id: &CallId,
target: &str,
mode: TransferWaitMode,
options: &TransferLifecycleOptions,
) -> Result<Option<Event>> {
match event {
Some(Event::DialogPackageNotify { dialogs, .. }) => {
for dialog in dialogs {
if let Some(event) =
dialog_lifecycle_event(dialog, transfer_call_id, target, mode, options)
{
return Ok(Some(event));
}
}
Ok(None)
}
Some(Event::DialogStateChanged { dialog, .. }) => Ok(dialog_lifecycle_event(
dialog,
transfer_call_id,
target,
mode,
options,
)),
Some(_) => Ok(None),
None => Err(SessionError::Other(
"Dialog event channel closed while waiting for transfer lifecycle".to_string(),
)),
}
}
fn dialog_lifecycle_event(
dialog: DialogInfo,
transfer_call_id: &CallId,
target: &str,
mode: TransferWaitMode,
options: &TransferLifecycleOptions,
) -> Option<Event> {
if !dialog_matches_target(&dialog, target, &options.target_match) {
return None;
}
if dialog.is_terminated() {
return Some(Event::TransferReplacementDialogTerminated {
transfer_call_id: transfer_call_id.clone(),
reason: dialog.raw_event.clone(),
dialog,
});
}
match mode {
TransferWaitMode::TargetRinging
if matches!(
dialog.state,
DialogPackageState::Trying
| DialogPackageState::Proceeding
| DialogPackageState::Early
| DialogPackageState::Confirmed
) =>
{
Some(Event::TransferReplacementDialogObserved {
transfer_call_id: transfer_call_id.clone(),
dialog,
})
}
TransferWaitMode::TargetAnswered if dialog.state == DialogPackageState::Confirmed => {
Some(Event::TransferTargetAnswered {
transfer_call_id: transfer_call_id.clone(),
target_uri: target.to_string(),
evidence: TransferTargetEvidence::DialogPackage { dialog },
})
}
_ => None,
}
}
fn dialog_matches_target(
dialog: &DialogInfo,
target: &str,
matcher: &TransferDialogMatcher,
) -> bool {
match matcher {
TransferDialogMatcher::Any => true,
TransferDialogMatcher::TargetUri => {
let target = normalize_uri_for_match(target);
dialog
.local_uri
.iter()
.chain(dialog.remote_uri.iter())
.map(|uri| normalize_uri_for_match(uri))
.any(|uri| uri == target || uri.contains(&target) || target.contains(&uri))
}
}
}
fn is_answered_state(state: CallState) -> bool {
matches!(
state,
CallState::Active
| CallState::HoldPending
| CallState::OnHold
| CallState::Resuming
| CallState::Muted
| CallState::Bridged
| CallState::Transferring
| CallState::TransferringCall
| CallState::ConsultationCall
)
}
fn terminal_reason(snapshot: &CallLifecycleSnapshot) -> Option<String> {
snapshot.terminal.as_ref().map(|terminal| terminal.reason())
}
fn terminal_error(snapshot: &CallLifecycleSnapshot, context: &str) -> Option<SessionError> {
match snapshot.terminal.as_ref()? {
CallTerminalInfo::Ended { reason } => Some(SessionError::Other(format!(
"call ended before {context}: {reason}"
))),
CallTerminalInfo::Failed {
status_code,
reason,
} => Some(SessionError::Other(format!(
"call failed before {context}: {status_code} {reason}"
))),
CallTerminalInfo::Cancelled => Some(SessionError::Other(format!(
"call cancelled before {context}"
))),
}
}
async fn wait_for_lifecycle<T, F>(
handle: &SessionHandle,
mut rx: tokio::sync::watch::Receiver<u64>,
timeout: Option<Duration>,
timeout_message: &'static str,
mut evaluate: F,
) -> Result<T>
where
F: FnMut(&CallLifecycleSnapshot) -> Result<Option<T>>,
{
let fut = async {
loop {
let snapshot = handle.lifecycle().await?;
if let Some(value) = evaluate(&snapshot)? {
return Ok(value);
}
if rx.changed().await.is_err() {
let snapshot = handle.lifecycle().await?;
if let Some(value) = evaluate(&snapshot)? {
return Ok(value);
}
return Err(SessionError::Other(
"Lifecycle waiter closed before matching event".to_string(),
));
}
}
};
match timeout {
Some(duration) => tokio::time::timeout(duration, fut)
.await
.map_err(|_| SessionError::Timeout(timeout_message.to_string()))?,
None => fut.await,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::events::{MediaSecurityKeying, MediaSecurityProfile};
use crate::api::unified::Config;
use rvoip_sip_core::types::sdp::CryptoSuite;
fn test_config(port: u16) -> Config {
let mut config = Config::local("handle-api-test", port);
config.media_port_start = port + 1000;
config.media_port_end = port + 1100;
config
}
async fn publish_synthetic(coordinator: &UnifiedCoordinator, event: Event) {
coordinator
.publish_app_event_for_test(event)
.await
.expect("publish synthetic event");
}
#[tokio::test]
async fn session_handle_wait_for_answered_observes_typed_event() {
let coordinator = UnifiedCoordinator::new(test_config(35680)).await.unwrap();
let call_id = SessionId::new();
let handle = SessionHandle::new(call_id.clone(), coordinator.clone());
let waiter = tokio::spawn({
let handle = handle.clone();
async move { handle.wait_for_answered(Some(Duration::from_secs(2))).await }
});
tokio::time::sleep(Duration::from_millis(50)).await;
publish_synthetic(
&coordinator,
Event::CallAnswered {
call_id: call_id.clone(),
sdp: None,
},
)
.await;
let answered = waiter.await.unwrap().unwrap();
assert_eq!(answered.id(), &call_id);
coordinator.shutdown();
}
#[tokio::test]
async fn session_handle_wait_for_media_security_observes_typed_event() {
let coordinator = UnifiedCoordinator::new(test_config(35690)).await.unwrap();
let call_id = SessionId::new();
let handle = SessionHandle::new(call_id.clone(), coordinator.clone());
let waiter = tokio::spawn({
let handle = handle.clone();
async move {
handle
.wait_for_media_security(Some(Duration::from_secs(2)))
.await
}
});
tokio::time::sleep(Duration::from_millis(50)).await;
publish_synthetic(
&coordinator,
Event::MediaSecurityNegotiated {
call_id,
keying: MediaSecurityKeying::Sdes,
suite: CryptoSuite::AesCm128HmacSha1_80,
profile: MediaSecurityProfile::RtpSavp,
contexts_installed: true,
},
)
.await;
let security = waiter.await.unwrap().unwrap();
assert_eq!(security.keying, MediaSecurityKeying::Sdes);
assert_eq!(security.suite, CryptoSuite::AesCm128HmacSha1_80);
assert_eq!(security.profile, MediaSecurityProfile::RtpSavp);
assert!(security.contexts_installed);
coordinator.shutdown();
}
#[test]
fn transfer_outcome_maps_raw_transfer_events() {
let call_id = SessionId::new();
let outcome = TransferOutcome::try_from(Event::ReferCompleted {
call_id: call_id.clone(),
target: "sip:1003@example.com".into(),
status_code: 200,
reason: "OK".into(),
})
.unwrap();
assert!(matches!(
outcome,
TransferOutcome::ReferCompleted {
status_code: 200,
..
}
));
let outcome = TransferOutcome::try_from(Event::ReferProgress {
call_id: call_id.clone(),
status_code: 180,
reason: "Ringing".into(),
})
.unwrap();
assert!(matches!(
outcome,
TransferOutcome::TargetRinging {
status_code: 180,
..
}
));
let outcome = TransferOutcome::try_from(Event::TransferTargetAnswered {
transfer_call_id: call_id.clone(),
target_uri: "sip:1003@example.com".into(),
evidence: TransferTargetEvidence::ReferProgressThenFinal {
progress_status_code: 180,
progress_reason: "Ringing".into(),
final_status_code: 200,
final_reason: "OK".into(),
},
})
.unwrap();
assert!(matches!(outcome, TransferOutcome::TargetAnswered { .. }));
let outcome = TransferOutcome::try_from(Event::TransferFailed {
call_id,
status_code: 603,
reason: "Decline".into(),
})
.unwrap();
assert!(matches!(
outcome,
TransferOutcome::Failed {
status_code: 603,
..
}
));
}
}
fn normalize_uri_for_match(uri: &str) -> String {
uri.trim()
.trim_matches('<')
.trim_matches('>')
.split(';')
.next()
.unwrap_or(uri)
.to_ascii_lowercase()
}