use crate::adapters::srtp_negotiator::{SrtpNegotiator, SrtpPair};
use crate::api::events::{Event, MediaSecurityKeying, MediaSecurityProfile, MediaSecurityState};
use crate::api::lifecycle::{LifecycleIndex, SessionEventPublisher};
use crate::api::unified::MediaMode;
use crate::cleanup_diag::{self, CleanupStage};
use crate::errors::{Result, SessionError};
use crate::session_store::SessionStore;
use crate::state_table::types::SessionId;
use dashmap::DashMap;
use rvoip_media_core::types::AudioFrame;
use rvoip_media_core::{
relay::controller::{
AudioSource, BridgeError, BridgeHandle, MediaConfig, MediaSessionController,
MediaSessionInfo,
},
DialogId,
};
use rvoip_sip_core::sdp::SdpBuilder;
use rvoip_sip_core::types::sdp::{CryptoAttribute, CryptoSuite, ParsedAttribute, SdpSession};
use std::net::{IpAddr, SocketAddr};
use std::str::FromStr;
use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
use std::sync::Arc;
use tokio::sync::mpsc;
const DIAG_OFF: u8 = 1;
const DIAG_ON: u8 = 2;
const AUDIO_RECEIVER_CHANNEL_FRAMES: usize = 128;
static SRTP_DIAGNOSTICS: AtomicU8 = AtomicU8::new(DIAG_OFF);
static MEDIA_SDP_DIAGNOSTICS: AtomicU8 = AtomicU8::new(DIAG_OFF);
#[cfg(feature = "perf-infra-memory-diagnostics")]
fn spawn_memory_tracked<F>(kind: &'static str, future: F) -> tokio::task::JoinHandle<F::Output>
where
F: std::future::Future + Send + 'static,
F::Output: Send + 'static,
{
rvoip_infra_common::memory_diagnostics::spawn_tracked(kind, future)
}
#[cfg(not(feature = "perf-infra-memory-diagnostics"))]
fn spawn_memory_tracked<F>(_: &'static str, future: F) -> tokio::task::JoinHandle<F::Output>
where
F: std::future::Future + Send + 'static,
F::Output: Send + 'static,
{
tokio::spawn(future)
}
pub mod cleanup_session_diag {
use std::sync::atomic::{AtomicU64, Ordering};
static CLEANED: AtomicU64 = AtomicU64::new(0);
pub fn record_cleanup() {
CLEANED.fetch_add(1, Ordering::Relaxed);
}
pub fn cleaned_total() -> u64 {
CLEANED.load(Ordering::Relaxed)
}
}
fn sdp_origin_session_id(raw_id: &str) -> String {
let candidate = raw_id
.strip_prefix("media-session-")
.or_else(|| raw_id.strip_prefix("session-"))
.unwrap_or(raw_id);
if !candidate.is_empty() && candidate.bytes().all(|b| b.is_ascii_digit()) {
return candidate.to_string();
}
if let Ok(uuid) = uuid::Uuid::parse_str(candidate) {
let bytes = uuid.as_u128().to_be_bytes();
let low = u64::from_be_bytes(bytes[8..16].try_into().expect("uuid low bytes"));
return low.max(1).to_string();
}
let mut hash = 14_695_981_039_346_656_037u64;
for byte in raw_id.bytes() {
hash ^= byte as u64;
hash = hash.wrapping_mul(1_099_511_628_211);
}
hash.max(1).to_string()
}
fn direction_attribute(direction: crate::types::MediaDirection) -> &'static str {
match direction {
crate::types::MediaDirection::SendRecv => "sendrecv",
crate::types::MediaDirection::SendOnly => "sendonly",
crate::types::MediaDirection::RecvOnly => "recvonly",
crate::types::MediaDirection::Inactive => "inactive",
}
}
fn audio_direction(session: &SdpSession) -> Option<rvoip_sip_core::MediaDirection> {
session
.media_descriptions
.iter()
.find(|m| m.media == "audio")
.and_then(|m| m.direction)
.or(session.direction)
}
fn sip_direction_to_session(
direction: rvoip_sip_core::MediaDirection,
) -> crate::types::MediaDirection {
match direction {
rvoip_sip_core::MediaDirection::SendRecv => crate::types::MediaDirection::SendRecv,
rvoip_sip_core::MediaDirection::SendOnly => crate::types::MediaDirection::SendOnly,
rvoip_sip_core::MediaDirection::RecvOnly => crate::types::MediaDirection::RecvOnly,
rvoip_sip_core::MediaDirection::Inactive => crate::types::MediaDirection::Inactive,
}
}
fn answer_direction_for_offer(
offer_direction: &Option<rvoip_sip_core::MediaDirection>,
) -> crate::types::MediaDirection {
match offer_direction.unwrap_or(rvoip_sip_core::MediaDirection::SendRecv) {
rvoip_sip_core::MediaDirection::SendRecv => crate::types::MediaDirection::SendRecv,
rvoip_sip_core::MediaDirection::SendOnly => crate::types::MediaDirection::RecvOnly,
rvoip_sip_core::MediaDirection::RecvOnly => crate::types::MediaDirection::SendOnly,
rvoip_sip_core::MediaDirection::Inactive => crate::types::MediaDirection::Inactive,
}
}
fn local_direction_from_remote_answer(
answer_direction: &Option<rvoip_sip_core::MediaDirection>,
) -> crate::types::MediaDirection {
match answer_direction.unwrap_or(rvoip_sip_core::MediaDirection::SendRecv) {
rvoip_sip_core::MediaDirection::SendRecv => crate::types::MediaDirection::SendRecv,
rvoip_sip_core::MediaDirection::SendOnly => crate::types::MediaDirection::RecvOnly,
rvoip_sip_core::MediaDirection::RecvOnly => crate::types::MediaDirection::SendOnly,
rvoip_sip_core::MediaDirection::Inactive => crate::types::MediaDirection::Inactive,
}
}
fn srtp_diagnostics_enabled() -> bool {
SRTP_DIAGNOSTICS.load(Ordering::Relaxed) == DIAG_ON
}
fn media_diagnostics_enabled() -> bool {
MEDIA_SDP_DIAGNOSTICS.load(Ordering::Relaxed) == DIAG_ON
}
fn sdp_diagnostics_enabled() -> bool {
srtp_diagnostics_enabled() || media_diagnostics_enabled()
}
pub(crate) fn set_sdp_diagnostics(srtp_enabled: bool, media_enabled: bool) {
SRTP_DIAGNOSTICS.store(
if srtp_enabled { DIAG_ON } else { DIAG_OFF },
Ordering::Relaxed,
);
MEDIA_SDP_DIAGNOSTICS.store(
if media_enabled { DIAG_ON } else { DIAG_OFF },
Ordering::Relaxed,
);
}
fn emit_srtp_diag(line: String) {
eprintln!("SRTP_DIAG {}", line);
tracing::info!("SRTP_DIAG {}", line);
}
fn emit_media_diag(line: String) {
eprintln!("MEDIA_DIAG {}", line);
tracing::info!("MEDIA_DIAG {}", line);
}
fn emit_sdp_diag(line: String) {
if srtp_diagnostics_enabled() {
emit_srtp_diag(line.clone());
}
if media_diagnostics_enabled() {
emit_media_diag(line);
}
}
fn crypto_attribute_diag(count: usize) -> String {
if count > 0 {
format!("crypto_attrs={} sdp_attribute=a=crypto", count)
} else {
"crypto_attrs=0".to_string()
}
}
fn audio_transport(session: &SdpSession) -> Option<&str> {
session
.media_descriptions
.iter()
.find(|m| m.media == "audio")
.map(|m| m.protocol.as_str())
}
pub(crate) fn rtpmap_for_pt(pt: u8) -> Option<&'static str> {
match pt {
0 => Some("PCMU/8000"),
8 => Some("PCMA/8000"),
9 => Some("G722/8000"),
13 => Some("CN/8000"),
18 => Some("G729/8000"),
101 => Some("telephone-event/8000"),
111 => Some("opus/48000/2"),
_ => None,
}
}
#[cfg(test)]
pub(crate) fn fmtp_for_pt(pt: u8) -> Option<&'static str> {
fmtp_for_pt_with_g729_annex_b(pt, true)
}
pub(crate) fn fmtp_for_pt_with_g729_annex_b(pt: u8, g729_annex_b: bool) -> Option<&'static str> {
match pt {
18 if g729_annex_b => Some("annexb=yes"),
18 => Some("annexb=no"),
101 => Some("0-15"),
_ => None,
}
}
fn parse_annex_b_param(parameters: &str) -> Option<bool> {
parameters.split(';').find_map(|part| {
let (name, value) = part.trim().split_once('=')?;
if !name.trim().eq_ignore_ascii_case("annexb") {
return None;
}
match value.trim().trim_matches('"').to_ascii_lowercase().as_str() {
"yes" | "true" | "1" => Some(true),
"no" | "false" | "0" => Some(false),
_ => None,
}
})
}
fn audio_fmtp_annex_b(session: &SdpSession, payload_type: u8) -> Option<bool> {
let format = payload_type.to_string();
session
.media_descriptions
.iter()
.find(|m| m.media == "audio")
.and_then(|m| m.get_fmtp(&format))
.and_then(|fmtp| parse_annex_b_param(&fmtp.parameters))
}
fn negotiated_g729_annex_b(session: &SdpSession, local_annex_b: bool) -> bool {
local_annex_b && audio_fmtp_annex_b(session, 18).unwrap_or(true)
}
fn select_primary_audio_payload(formats: &[String]) -> Option<u8> {
let mut parsed = formats.iter().filter_map(|fmt| fmt.parse::<u8>().ok());
parsed.find(|pt| !matches!(*pt, 13 | 101)).or_else(|| {
formats
.iter()
.filter_map(|fmt| fmt.parse::<u8>().ok())
.next()
})
}
fn select_primary_audio_payload_from_session(session: &SdpSession) -> Option<u8> {
session
.media_descriptions
.iter()
.find(|m| m.media == "audio")
.and_then(|m| select_primary_audio_payload(&m.formats))
}
fn codec_name_for_payload(payload_type: u8, g729_annex_b: bool) -> String {
match payload_type {
0 => "PCMU",
8 => "PCMA",
9 => "G722",
13 => "CN",
18 if g729_annex_b => "G729BA",
18 => "G729A",
101 => "telephone-event",
111 => "opus",
_ => return format!("PT{}", payload_type),
}
.to_string()
}
pub(crate) fn build_port_zero_rejection_sdp(
origin_session_id: &str,
origin_version: u64,
local_ip: &str,
offered_transport: &str,
) -> Result<String> {
let version_str = origin_version.to_string();
let session = SdpBuilder::new("Session")
.origin("-", origin_session_id, &version_str, "IN", "IP4", local_ip)
.connection("IN", "IP4", local_ip)
.time("0", "0")
.media_audio(0, offered_transport)
.formats(&["0"])
.done()
.build()
.map_err(|e| {
SessionError::SDPNegotiationFailed(format!(
"SdpBuilder failed to build port-zero rejection answer: {}",
e
))
})?;
Ok(session.to_string())
}
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
pub enum AudioFormat {
Wav,
Raw,
Mp3,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct RecordingConfig {
pub file_path: String,
pub format: AudioFormat,
pub sample_rate: u32,
pub channels: u16,
pub include_mixed: bool,
pub separate_tracks: bool,
}
impl Default for RecordingConfig {
fn default() -> Self {
Self {
file_path: "/tmp/recording.wav".to_string(),
format: AudioFormat::Wav,
sample_rate: 8000,
channels: 1,
include_mixed: false,
separate_tracks: false,
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct RecordingStatus {
pub is_recording: bool,
pub is_paused: bool,
pub duration_seconds: f64,
pub file_size_bytes: u64,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct NegotiatedConfig {
pub local_addr: SocketAddr,
pub remote_addr: SocketAddr,
pub codec: String,
pub payload_type: u8,
pub local_direction: crate::types::MediaDirection,
pub remote_direction: crate::types::MediaDirection,
}
pub struct MediaAdapter {
pub(crate) controller: Arc<MediaSessionController>,
pub(crate) store: Arc<SessionStore>,
pub(crate) session_to_dialog: Arc<DashMap<SessionId, DialogId>>,
pub(crate) dialog_to_session: Arc<DashMap<DialogId, SessionId>>,
media_sessions: Arc<DashMap<SessionId, MediaSessionInfo>>,
audio_receivers: Arc<DashMap<SessionId, mpsc::Sender<AudioFrame>>>,
session_create_attempt_total: Arc<AtomicU64>,
session_create_success_total: Arc<AtomicU64>,
session_create_failed_total: Arc<AtomicU64>,
cleanup_attempt_total: Arc<AtomicU64>,
cleanup_mapped_total: Arc<AtomicU64>,
cleanup_fallback_total: Arc<AtomicU64>,
cleanup_media_session_removed_total: Arc<AtomicU64>,
cleanup_audio_receiver_removed_total: Arc<AtomicU64>,
audio_subscriber_created_total: Arc<AtomicU64>,
audio_subscriber_disconnected_total: Arc<AtomicU64>,
audio_send_frames_total: Arc<AtomicU64>,
audio_send_samples_total: Arc<AtomicU64>,
local_ip: IpAddr,
media_port_start: u16,
media_port_end: u16,
media_mode: MediaMode,
audio_mixers: Arc<DashMap<crate::types::MediaSessionId, Vec<crate::types::MediaSessionId>>>,
offer_srtp: bool,
srtp_required: bool,
srtp_offered_suites: Vec<CryptoSuite>,
pending_srtp_offerers: Arc<DashMap<SessionId, SrtpNegotiator>>,
pub(crate) negotiated_srtp: Arc<DashMap<SessionId, SrtpPair>>,
pub(crate) global_coordinator: Arc<
tokio::sync::RwLock<
Option<Arc<rvoip_infra_common::events::coordinator::GlobalEventCoordinator>>,
>,
>,
pub(crate) app_event_publisher: Arc<tokio::sync::RwLock<Option<SessionEventPublisher>>>,
public_rtp_addr: std::sync::RwLock<Option<SocketAddr>>,
comfort_noise_enabled: bool,
strict_codec_matching: bool,
offered_codecs: Vec<u8>,
g729_annex_b: bool,
}
impl MediaAdapter {
pub fn new(
controller: Arc<MediaSessionController>,
store: Arc<SessionStore>,
local_ip: IpAddr,
port_start: u16,
port_end: u16,
) -> Self {
Self {
controller,
store,
session_to_dialog: Arc::new(DashMap::new()),
dialog_to_session: Arc::new(DashMap::new()),
media_sessions: Arc::new(DashMap::new()),
audio_receivers: Arc::new(DashMap::new()),
session_create_attempt_total: Arc::new(AtomicU64::new(0)),
session_create_success_total: Arc::new(AtomicU64::new(0)),
session_create_failed_total: Arc::new(AtomicU64::new(0)),
cleanup_attempt_total: Arc::new(AtomicU64::new(0)),
cleanup_mapped_total: Arc::new(AtomicU64::new(0)),
cleanup_fallback_total: Arc::new(AtomicU64::new(0)),
cleanup_media_session_removed_total: Arc::new(AtomicU64::new(0)),
cleanup_audio_receiver_removed_total: Arc::new(AtomicU64::new(0)),
audio_subscriber_created_total: Arc::new(AtomicU64::new(0)),
audio_subscriber_disconnected_total: Arc::new(AtomicU64::new(0)),
audio_send_frames_total: Arc::new(AtomicU64::new(0)),
audio_send_samples_total: Arc::new(AtomicU64::new(0)),
local_ip,
media_port_start: port_start,
media_port_end: port_end,
media_mode: MediaMode::Enabled,
audio_mixers: Arc::new(DashMap::new()),
offer_srtp: false,
srtp_required: false,
srtp_offered_suites: vec![
CryptoSuite::AesCm128HmacSha1_80,
CryptoSuite::AesCm128HmacSha1_32,
],
pending_srtp_offerers: Arc::new(DashMap::new()),
negotiated_srtp: Arc::new(DashMap::new()),
global_coordinator: Arc::new(tokio::sync::RwLock::new(None)),
app_event_publisher: Arc::new(tokio::sync::RwLock::new(None)),
public_rtp_addr: std::sync::RwLock::new(None),
comfort_noise_enabled: false,
strict_codec_matching: true,
offered_codecs: vec![0, 8, 101],
g729_annex_b: true,
}
}
pub fn set_comfort_noise(&mut self, enabled: bool) {
self.comfort_noise_enabled = enabled;
self.controller.set_comfort_noise_enabled(enabled);
}
pub fn set_media_mode(&mut self, mode: MediaMode) {
self.media_mode = mode;
}
pub(crate) async fn rtp_packets_received(&self, session_id: &SessionId) -> Option<u64> {
let dialog_id = self.session_to_dialog.get(session_id)?.clone();
self.controller
.get_session_info(&dialog_id)
.await
.and_then(|info| info.rtp_stats.map(|stats| stats.packets_received))
}
pub fn set_strict_codec_matching(&mut self, strict: bool) {
self.strict_codec_matching = strict;
}
pub fn set_offered_codecs(&mut self, codecs: Vec<u8>) {
self.offered_codecs = codecs;
}
pub fn set_g729_annex_b(&mut self, enabled: bool) {
self.g729_annex_b = enabled;
}
#[cfg(feature = "perf-tests")]
pub(crate) fn perf_diagnostic_counts(&self) -> serde_json::Value {
let audio_receiver_queue_frames: usize = self
.audio_receivers
.iter()
.map(|entry| {
let sender = entry.value();
sender.max_capacity().saturating_sub(sender.capacity())
})
.sum();
let audio_receiver_capacity_frames: usize = self
.audio_receivers
.iter()
.map(|entry| entry.value().max_capacity())
.sum();
#[cfg(feature = "perf-media-diagnostics")]
let controller_diagnostics = self.controller.diagnostic_counts();
#[cfg(not(feature = "perf-media-diagnostics"))]
let controller_diagnostics = serde_json::json!({
"enabled": false,
"compiled": false,
"feature": "perf-media-diagnostics",
});
serde_json::json!({
"session_to_dialog": self.session_to_dialog.len(),
"dialog_to_session": self.dialog_to_session.len(),
"media_sessions": self.media_sessions.len(),
"audio_receivers": self.audio_receivers.len(),
"audio_receiver_queue_frames": audio_receiver_queue_frames,
"audio_receiver_capacity_frames": audio_receiver_capacity_frames,
"pending_srtp_offerers": self.pending_srtp_offerers.len(),
"negotiated_srtp": self.negotiated_srtp.len(),
"audio_mixers": self.audio_mixers.len(),
"controller": controller_diagnostics,
"lifecycle": {
"session_create_attempt_total": self.session_create_attempt_total.load(Ordering::Relaxed),
"session_create_success_total": self.session_create_success_total.load(Ordering::Relaxed),
"session_create_failed_total": self.session_create_failed_total.load(Ordering::Relaxed),
"cleanup_attempt_total": self.cleanup_attempt_total.load(Ordering::Relaxed),
"cleanup_mapped_total": self.cleanup_mapped_total.load(Ordering::Relaxed),
"cleanup_fallback_total": self.cleanup_fallback_total.load(Ordering::Relaxed),
"cleanup_media_session_removed_total": self.cleanup_media_session_removed_total.load(Ordering::Relaxed),
"cleanup_audio_receiver_removed_total": self.cleanup_audio_receiver_removed_total.load(Ordering::Relaxed),
"audio_subscriber_created_total": self.audio_subscriber_created_total.load(Ordering::Relaxed),
"audio_subscriber_disconnected_total": self.audio_subscriber_disconnected_total.load(Ordering::Relaxed),
},
"audio_send": {
"frames_total": self.audio_send_frames_total.load(Ordering::Relaxed),
"samples_total": self.audio_send_samples_total.load(Ordering::Relaxed),
},
})
}
fn effective_offered_formats(&self) -> Vec<u8> {
if !self.comfort_noise_enabled {
return self.offered_codecs.clone();
}
let mut out = Vec::with_capacity(self.offered_codecs.len() + 1);
for pt in &self.offered_codecs {
if *pt == 101 {
out.push(13);
}
out.push(*pt);
}
if !out.contains(&13) {
out.push(13);
}
out
}
async fn apply_negotiated_media_config(
&self,
dialog_id: &DialogId,
remote_addr: SocketAddr,
codec: &str,
) -> Result<()> {
let mut config = self
.controller
.get_session_info(dialog_id)
.await
.ok_or_else(|| {
SessionError::MediaError(format!("No media session for dialog {}", dialog_id))
})?
.config;
config.remote_addr = Some(remote_addr);
config.preferred_codec = Some(codec.to_string());
self.controller
.update_media(dialog_id.clone(), config)
.await
.map_err(|e| {
SessionError::MediaError(format!("Failed to apply negotiated media config: {}", e))
})
}
pub fn set_public_rtp_addr(&self, addr: Option<SocketAddr>) {
if let Ok(mut guard) = self.public_rtp_addr.write() {
*guard = addr;
}
}
pub(crate) fn public_rtp_addr(&self) -> Option<SocketAddr> {
self.public_rtp_addr.read().ok().and_then(|g| *g)
}
pub fn local_ip(&self) -> IpAddr {
self.local_ip
}
pub async fn set_global_coordinator(
&self,
coordinator: Arc<rvoip_infra_common::events::coordinator::GlobalEventCoordinator>,
) {
*self.global_coordinator.write().await = Some(coordinator.clone());
let mut publisher = self.app_event_publisher.write().await;
if publisher.is_none() {
*publisher = Some(SessionEventPublisher::new(
coordinator,
LifecycleIndex::new(),
));
}
}
pub(crate) async fn set_app_event_publisher(&self, publisher: SessionEventPublisher) {
*self.app_event_publisher.write().await = Some(publisher);
}
pub fn set_srtp_policy(
&mut self,
offer_srtp: bool,
srtp_required: bool,
suites: Vec<CryptoSuite>,
) {
self.offer_srtp = offer_srtp;
self.srtp_required = srtp_required;
if !suites.is_empty() {
self.srtp_offered_suites = suites;
}
}
pub async fn start_session(&self, session_id: &SessionId) -> Result<()> {
if let Some(dialog_id) = self
.session_to_dialog
.get(session_id)
.map(|entry| entry.value().clone())
{
if self.controller.get_session_info(&dialog_id).await.is_some() {
tracing::debug!("Media session already started for session {}", session_id.0);
return Ok(());
}
}
let _media_id = self.create_session(session_id).await?;
Ok(())
}
fn extract_audio_crypto(session: &SdpSession) -> Vec<CryptoAttribute> {
session
.media_descriptions
.iter()
.find(|m| m.media == "audio")
.map(|m| {
m.generic_attributes
.iter()
.filter_map(|a| match a {
ParsedAttribute::Crypto(c) => Some(c.clone()),
_ => None,
})
.collect()
})
.unwrap_or_default()
}
pub async fn negotiate_sdp_as_uac(
&self,
session_id: &SessionId,
remote_sdp: &str,
) -> Result<NegotiatedConfig> {
let (remote_ip, remote_port) = self.parse_sdp_connection(remote_sdp)?;
let srtp_diagnostics = srtp_diagnostics_enabled();
if sdp_diagnostics_enabled() {
if let Ok(parsed) = SdpSession::from_str(remote_sdp) {
emit_sdp_diag(format!(
"remote_sdp_answer session={} media={}:{} transport={} {}",
session_id.0,
remote_ip,
remote_port,
audio_transport(&parsed).unwrap_or("unknown"),
crypto_attribute_diag(Self::extract_audio_crypto(&parsed).len())
));
}
}
if let Some((_, offerer_state)) = self.pending_srtp_offerers.remove(session_id) {
let parsed = SdpSession::from_str(remote_sdp).map_err(|e| {
SessionError::SDPNegotiationFailed(format!(
"Failed to parse remote SDP for SDES answer extraction: {}",
e
))
})?;
let attrs = Self::extract_audio_crypto(&parsed);
if let Some(chosen) = attrs.first() {
let pair = offerer_state.accept_answer(chosen)?;
self.negotiated_srtp.insert(session_id.clone(), pair);
tracing::info!(
"SDES answer accepted for session {}: tag {} suite {:?}",
session_id.0,
chosen.tag,
chosen.suite
);
if srtp_diagnostics {
emit_srtp_diag(format!(
"sdes_answer_accepted session={} suite={:?}",
session_id.0, chosen.suite
));
}
} else if self.srtp_required {
return Err(SessionError::SDPNegotiationFailed(
"srtp_required is set but the SDP answer carries no a=crypto: line".into(),
));
} else {
tracing::warn!(
"Session {} offered SRTP but the answer didn't accept it; \
proceeding plaintext (Config::srtp_required = false)",
session_id.0
);
let _ = offerer_state; }
}
let parsed_answer = SdpSession::from_str(remote_sdp).ok();
let answer_direction = parsed_answer.as_ref().and_then(audio_direction);
let payload_type = parsed_answer
.as_ref()
.and_then(select_primary_audio_payload_from_session)
.unwrap_or(0);
let negotiated_annex_b = parsed_answer
.as_ref()
.filter(|_| payload_type == 18)
.map(|answer| negotiated_g729_annex_b(answer, self.g729_annex_b))
.unwrap_or(false);
let negotiated_codec = codec_name_for_payload(payload_type, negotiated_annex_b);
let signaling_only_port = self.signaling_only_local_port();
let dialog_id = if signaling_only_port.is_some() {
None
} else {
self.session_to_dialog
.get(session_id)
.map(|entry| entry.value().clone())
};
if let Some(dialog_id) = dialog_id {
let remote_addr = SocketAddr::new(remote_ip, remote_port);
self.apply_negotiated_media_config(&dialog_id, remote_addr, &negotiated_codec)
.await?;
if let Some((_, pair)) = self.negotiated_srtp.remove(session_id) {
let suite = pair.suite;
self.controller
.install_srtp_contexts(&dialog_id, pair.send_ctx, pair.recv_ctx)
.await
.map_err(|e| {
SessionError::MediaError(format!("Failed to install SRTP contexts: {}", e))
})?;
tracing::info!(
"🔒 SRTP contexts installed for session {} (suite {:?})",
session_id.0,
suite
);
self.record_media_security_negotiated(session_id, suite, true)
.await;
if srtp_diagnostics {
emit_srtp_diag(format!(
"srtp_contexts_installed session={} role=uac suite={:?}",
session_id.0, suite
));
}
}
self.controller
.establish_media_flow(&dialog_id, remote_addr)
.await
.map_err(|e| {
SessionError::MediaError(format!("Failed to establish media flow: {}", e))
})?;
tracing::info!(
"✅ Updated RTP remote address to {} for session {}",
remote_addr,
session_id.0
);
} else if signaling_only_port.is_some() {
if let Some((_, pair)) = self.negotiated_srtp.remove(session_id) {
self.record_media_security_negotiated(session_id, pair.suite, false)
.await;
}
}
let local_port = match signaling_only_port {
Some(port) => port,
None => self.get_local_port(session_id)?,
};
let config = NegotiatedConfig {
local_addr: SocketAddr::new(self.local_ip, local_port),
remote_addr: SocketAddr::new(remote_ip, remote_port),
codec: negotiated_codec,
payload_type,
local_direction: local_direction_from_remote_answer(&answer_direction),
remote_direction: answer_direction
.map(sip_direction_to_session)
.unwrap_or(crate::types::MediaDirection::SendRecv),
};
Ok(config)
}
pub async fn negotiate_sdp_as_uas(
&self,
session_id: &SessionId,
remote_sdp: &str,
) -> Result<(String, NegotiatedConfig)> {
let parsed_offer = SdpSession::from_str(remote_sdp).map_err(|e| {
SessionError::SDPNegotiationFailed(format!("Failed to parse remote SDP: {}", e))
})?;
let (remote_ip, remote_port) = self.parse_sdp_connection(remote_sdp)?;
let srtp_diagnostics = srtp_diagnostics_enabled();
if sdp_diagnostics_enabled() {
emit_sdp_diag(format!(
"remote_sdp_offer session={} media={}:{} transport={} {}",
session_id.0,
remote_ip,
remote_port,
audio_transport(&parsed_offer).unwrap_or("unknown"),
crypto_attribute_diag(Self::extract_audio_crypto(&parsed_offer).len())
));
}
let offered_crypto = Self::extract_audio_crypto(&parsed_offer);
let offered_transport = audio_transport(&parsed_offer)
.unwrap_or("RTP/AVP")
.to_string();
let (answer_attr, srtp_pair, reject_with_port_zero) =
if !offered_crypto.is_empty() && self.offer_srtp {
let answerer = SrtpNegotiator::new_answerer();
let (chosen, pair) = answerer.process_offer(&offered_crypto)?;
self.negotiated_srtp.insert(session_id.clone(), pair);
tracing::info!(
"SDES offer accepted for session {}: tag {} suite {:?}",
session_id.0,
chosen.tag,
chosen.suite
);
if srtp_diagnostics {
emit_srtp_diag(format!(
"sdes_offer_accepted session={} suite={:?}",
session_id.0, chosen.suite
));
}
(Some(chosen), true, false)
} else if offered_crypto.is_empty() && self.srtp_required {
return Err(SessionError::SDPNegotiationFailed(
"srtp_required is set but the SDP offer carries no a=crypto: line".into(),
));
} else if !offered_crypto.is_empty() && !self.offer_srtp {
tracing::info!(
"Session {} received SRTP offer but local policy is offer_srtp=false; \
rejecting m-line with port=0 per RFC 3264 §6",
session_id.0
);
if srtp_diagnostics {
emit_srtp_diag(format!(
"sdes_offer_rejected session={} reason=local_policy",
session_id.0
));
}
(None, false, true)
} else {
(None, false, false)
};
let _ = srtp_pair;
if reject_with_port_zero {
let (origin_session_id, origin_version) = self.next_sdp_origin(session_id).await?;
let advertised_ip = self
.public_rtp_addr()
.map(|sa| sa.ip())
.unwrap_or(self.local_ip);
let sdp_answer = build_port_zero_rejection_sdp(
&origin_session_id,
origin_version,
&advertised_ip.to_string(),
offered_transport.as_str(),
)?;
let config = NegotiatedConfig {
local_addr: SocketAddr::new(advertised_ip, 0),
remote_addr: SocketAddr::new(remote_ip, remote_port),
codec: "PCMU".to_string(),
payload_type: 0,
local_direction: crate::types::MediaDirection::Inactive,
remote_direction: crate::types::MediaDirection::Inactive,
};
return Ok((sdp_answer, config));
}
let signaling_only_port = self.signaling_only_local_port();
let local_port = match signaling_only_port {
Some(port) => port,
None => self.get_local_port(session_id)?,
};
let formats = compute_answer_formats(
&parsed_offer,
&self.effective_offered_formats(),
self.strict_codec_matching,
self.offer_srtp,
self.srtp_required,
)?;
let negotiated_payload_type = select_primary_audio_payload(&formats).unwrap_or(0);
let negotiated_annex_b = if negotiated_payload_type == 18 {
negotiated_g729_annex_b(&parsed_offer, self.g729_annex_b)
} else {
false
};
let negotiated_codec = codec_name_for_payload(negotiated_payload_type, negotiated_annex_b);
let offered_direction = audio_direction(&parsed_offer);
let answer_direction = answer_direction_for_offer(&offered_direction);
let dialog_id = if signaling_only_port.is_some() {
None
} else {
self.session_to_dialog
.get(session_id)
.map(|entry| entry.value().clone())
};
if let Some(dialog_id) = dialog_id {
let remote_addr = SocketAddr::new(remote_ip, remote_port);
self.apply_negotiated_media_config(&dialog_id, remote_addr, &negotiated_codec)
.await?;
if let Some((_, pair)) = self.negotiated_srtp.remove(session_id) {
let suite = pair.suite;
self.controller
.install_srtp_contexts(&dialog_id, pair.send_ctx, pair.recv_ctx)
.await
.map_err(|e| {
SessionError::MediaError(format!(
"Failed to install SRTP contexts (UAS): {}",
e
))
})?;
tracing::info!(
"🔒 SRTP contexts installed for session {} (UAS, suite {:?})",
session_id.0,
suite
);
self.record_media_security_negotiated(session_id, suite, true)
.await;
if srtp_diagnostics {
emit_srtp_diag(format!(
"srtp_contexts_installed session={} role=uas suite={:?}",
session_id.0, suite
));
}
}
self.controller
.establish_media_flow(&dialog_id, remote_addr)
.await
.map_err(|e| {
SessionError::MediaError(format!("Failed to establish media flow: {}", e))
})?;
tracing::info!(
"✅ Updated RTP remote address to {} for session {} (UAS)",
remote_addr,
session_id.0
);
} else if signaling_only_port.is_some() {
if let Some((_, pair)) = self.negotiated_srtp.remove(session_id) {
self.record_media_security_negotiated(session_id, pair.suite, false)
.await;
}
}
let (origin_session_id, origin_version) = self.next_sdp_origin(session_id).await?;
let origin_version = origin_version.to_string();
let public = self.public_rtp_addr();
let advertised_ip = public.map(|sa| sa.ip()).unwrap_or(self.local_ip);
let local_ip_str = advertised_ip.to_string();
let advertised_port = public
.filter(|sa| sa.port() != 0)
.map(|sa| sa.port())
.unwrap_or(local_port);
let answer_transport = if answer_attr.is_some() {
"RTP/SAVP"
} else {
"RTP/AVP"
};
if sdp_diagnostics_enabled() {
emit_sdp_diag(format!(
"local_sdp_answer session={} media={}:{} transport={} {} direction={}",
session_id.0,
advertised_ip,
advertised_port,
answer_transport,
crypto_attribute_diag(usize::from(answer_attr.is_some())),
direction_attribute(answer_direction)
));
}
let formats_str: Vec<&str> = formats.iter().map(|s| s.as_str()).collect();
let mut media_builder = SdpBuilder::new("Session")
.origin(
"-",
&origin_session_id,
&origin_version,
"IN",
"IP4",
&local_ip_str,
)
.connection("IN", "IP4", &local_ip_str)
.time("0", "0")
.media_audio(advertised_port, answer_transport)
.formats(&formats_str);
for fmt in &formats {
let Ok(pt) = fmt.parse::<u8>() else {
continue;
};
if let Some(rtpmap) = rtpmap_for_pt(pt) {
media_builder = media_builder.rtpmap(fmt.as_str(), rtpmap);
}
if let Some(fmtp) = fmtp_for_pt_with_g729_annex_b(pt, negotiated_annex_b) {
media_builder = media_builder.fmtp(fmt.as_str(), fmtp);
}
}
if let Some(attr) = answer_attr {
media_builder = media_builder.crypto_attribute(attr);
}
let session = media_builder
.attribute(direction_attribute(answer_direction), None::<String>)
.done()
.build()
.map_err(|e| {
SessionError::SDPNegotiationFailed(format!(
"SdpBuilder failed to build answer: {}",
e
))
})?;
let sdp_answer = session.to_string();
let config = NegotiatedConfig {
local_addr: SocketAddr::new(self.local_ip, local_port),
remote_addr: SocketAddr::new(remote_ip, remote_port),
codec: negotiated_codec,
payload_type: negotiated_payload_type,
local_direction: answer_direction,
remote_direction: offered_direction
.map(sip_direction_to_session)
.unwrap_or(crate::types::MediaDirection::SendRecv),
};
Ok((sdp_answer, config))
}
async fn record_media_security_negotiated(
&self,
session_id: &SessionId,
suite: CryptoSuite,
contexts_installed: bool,
) {
let state = MediaSecurityState {
keying: MediaSecurityKeying::Sdes,
suite,
profile: MediaSecurityProfile::RtpSavp,
contexts_installed,
};
match self.store.get_session(session_id).await {
Ok(mut session) => {
session.media_security = Some(state.clone());
if let Err(e) = self.store.update_session(session).await {
tracing::warn!(
"Failed to persist media security state for session {}: {}",
session_id.0,
e
);
}
}
Err(e) => {
tracing::debug!(
"Session {} was gone before media security state could be persisted: {}",
session_id.0,
e
);
}
}
let api_event = Event::MediaSecurityNegotiated {
call_id: session_id.clone(),
keying: state.keying,
suite: state.suite,
profile: state.profile,
contexts_installed: state.contexts_installed,
};
if let Some(publisher) = self.app_event_publisher.read().await.clone() {
publisher.publish(api_event);
} else if let Some(coordinator) = self.global_coordinator.read().await.clone() {
let wrapped = crate::adapters::SessionApiCrossCrateEvent::new(api_event);
spawn_memory_tracked(
"sip.media_adapter.media_security_publish_task",
async move {
if let Err(e) = coordinator.publish(wrapped).await {
tracing::warn!("Failed to publish MediaSecurityNegotiated event: {}", e);
}
},
);
} else {
tracing::debug!(
"MediaSecurityNegotiated publish skipped for session {}: no event publisher yet",
session_id.0
);
}
}
pub async fn play_audio_file(&self, session_id: &SessionId, file_path: &str) -> Result<()> {
let _dialog_id = self
.session_to_dialog
.get(session_id)
.ok_or_else(|| {
SessionError::MediaError(format!("No media session for {}", session_id.0))
})?
.clone();
tracing::info!(
"Playing audio file {} for session {}",
file_path,
session_id.0
);
tracing::debug!("Audio playback started: {}", file_path);
Ok(())
}
pub async fn start_recording_old(&self, session_id: &SessionId) -> Result<String> {
let _dialog_id = self
.session_to_dialog
.get(session_id)
.ok_or_else(|| {
SessionError::MediaError(format!("No media session for {}", session_id.0))
})?
.clone();
let recording_path = format!("/tmp/recording_{}.wav", session_id.0);
tracing::info!(
"Starting recording for session {} at {}",
session_id.0,
recording_path
);
tracing::debug!("Recording started at: {}", recording_path);
if let Ok(session) = self.store.get_session(session_id).await {
let _ = self.store.update_session(session).await;
}
Ok(recording_path)
}
pub async fn create_bridge(&self, session1: &SessionId, session2: &SessionId) -> Result<()> {
let _dialog1 = self
.session_to_dialog
.get(session1)
.ok_or_else(|| {
SessionError::MediaError(format!("No media session for {}", session1.0))
})?
.clone();
let _dialog2 = self
.session_to_dialog
.get(session2)
.ok_or_else(|| {
SessionError::MediaError(format!("No media session for {}", session2.0))
})?
.clone();
tracing::info!(
"Creating media bridge between {} and {}",
session1.0,
session2.0
);
if let Ok(mut session1_state) = self.store.get_session(session1).await {
session1_state.bridged_to = Some(session2.clone());
let _ = self.store.update_session(session1_state).await;
}
if let Ok(mut session2_state) = self.store.get_session(session2).await {
session2_state.bridged_to = Some(session1.clone());
let _ = self.store.update_session(session2_state).await;
}
tracing::debug!("Bridge created between {} and {}", session1.0, session2.0);
Ok(())
}
pub async fn set_audio_source(
&self,
session_id: &SessionId,
source: AudioSource,
) -> Result<()> {
let dialog_id = self
.session_to_dialog
.get(session_id)
.ok_or_else(|| {
SessionError::MediaError(format!("No media session for {}", session_id.0))
})?
.clone();
self.controller
.set_audio_source(&dialog_id, source)
.await
.map_err(|e| SessionError::MediaError(format!("Failed to set audio source: {}", e)))
}
pub async fn bridge_rtp_sessions(
&self,
session_a: &SessionId,
session_b: &SessionId,
) -> std::result::Result<BridgeHandle, BridgeError> {
let dialog_a = self
.session_to_dialog
.get(session_a)
.ok_or_else(|| BridgeError::SessionNotFound(session_a.0.clone()))?
.clone();
let dialog_b = self
.session_to_dialog
.get(session_b)
.ok_or_else(|| BridgeError::SessionNotFound(session_b.0.clone()))?
.clone();
let handle = self.controller.bridge_sessions(dialog_a, dialog_b).await?;
if let Ok(mut a_state) = self.store.get_session(session_a).await {
a_state.bridged_to = Some(session_b.clone());
let _ = self.store.update_session(a_state).await;
}
if let Ok(mut b_state) = self.store.get_session(session_b).await {
b_state.bridged_to = Some(session_a.clone());
let _ = self.store.update_session(b_state).await;
}
Ok(handle)
}
pub async fn destroy_bridge(&self, session_id: &SessionId) -> Result<()> {
let bridged_session = if let Ok(session) = self.store.get_session(session_id).await {
session.bridged_to.clone()
} else {
None
};
if let Some(other_session) = bridged_session {
tracing::info!(
"Destroying bridge between {} and {}",
session_id.0,
other_session.0
);
if let Ok(mut session1_state) = self.store.get_session(session_id).await {
session1_state.bridged_to = None;
let _ = self.store.update_session(session1_state).await;
}
if let Ok(mut session2_state) = self.store.get_session(&other_session).await {
session2_state.bridged_to = None;
let _ = self.store.update_session(session2_state).await;
}
tracing::debug!(
"Bridge destroyed between {} and {}",
session_id.0,
other_session.0
);
}
Ok(())
}
pub async fn stop_recording_old(&self, session_id: &SessionId) -> Result<()> {
let _dialog_id = self
.session_to_dialog
.get(session_id)
.ok_or_else(|| {
SessionError::MediaError(format!("No media session for {}", session_id.0))
})?
.clone();
tracing::info!("Stopping recording for session {}", session_id.0);
tracing::debug!("Recording stopped");
Ok(())
}
pub async fn send_audio_frame(
&self,
session_id: &SessionId,
audio_frame: AudioFrame,
) -> Result<()> {
let dialog_id = self
.session_to_dialog
.get(session_id)
.ok_or_else(|| {
SessionError::MediaError(format!("No media session for {}", session_id.0))
})?
.clone();
tracing::trace!(
"📤 Sending audio frame for session {} ({} samples) via RTP",
session_id.0,
audio_frame.samples.len()
);
let AudioFrame {
samples: pcm_samples,
timestamp,
..
} = audio_frame;
self.audio_send_frames_total.fetch_add(1, Ordering::Relaxed);
self.audio_send_samples_total
.fetch_add(pcm_samples.len() as u64, Ordering::Relaxed);
self.controller
.encode_and_send_audio_frame(&dialog_id, pcm_samples, timestamp)
.await
.map_err(|e| {
SessionError::MediaError(format!("Failed to send audio frame via RTP: {}", e))
})?;
tracing::trace!(
"✅ Audio frame sent successfully via RTP for session {}",
session_id.0
);
Ok(())
}
pub async fn create_session(
&self,
session_id: &SessionId,
) -> Result<crate::types::MediaSessionId> {
self.session_create_attempt_total
.fetch_add(1, Ordering::Relaxed);
let dialog_id = DialogId::new(format!("media-{}", session_id.0));
tracing::info!(
"🚀 Creating media session for session {} with dialog ID {}",
session_id.0,
dialog_id
);
self.session_to_dialog
.insert(session_id.clone(), dialog_id.clone());
self.dialog_to_session
.insert(dialog_id.clone(), session_id.clone());
if self.signaling_only_local_port().is_some() {
tracing::info!(
"signaling-only media mode: skipped media-core allocation for session {}",
session_id.0
);
self.session_create_success_total
.fetch_add(1, Ordering::Relaxed);
return Ok(dialog_id);
}
let media_config = MediaConfig {
local_addr: SocketAddr::new(self.local_ip, 0), remote_addr: None, preferred_codec: Some("PCMU".to_string()), parameters: std::collections::HashMap::new(),
};
self.controller
.start_media(dialog_id.clone(), media_config)
.await
.map_err(|e| {
self.session_create_failed_total
.fetch_add(1, Ordering::Relaxed);
SessionError::MediaError(format!("Failed to start media session: {}", e))
})?;
self.install_dtmf_callback(session_id.clone(), dialog_id.clone())
.await;
if let Some(info) = self.controller.get_session_info(&dialog_id).await {
self.media_sessions.insert(session_id.clone(), info.clone());
let media_id = dialog_id.clone();
self.controller.store_session_mapping(
session_id.0.clone(),
rvoip_media_core::MediaSessionId::from_dialog(&dialog_id),
);
tracing::info!(
"✅ Media session created successfully for dialog {}",
dialog_id
);
self.session_create_success_total
.fetch_add(1, Ordering::Relaxed);
return Ok(media_id);
}
self.session_create_failed_total
.fetch_add(1, Ordering::Relaxed);
Err(SessionError::MediaError(
"Failed to get session info after creation".to_string(),
))
}
pub async fn generate_local_sdp(&self, session_id: &SessionId) -> Result<String> {
self.generate_local_sdp_offer(session_id, crate::types::MediaDirection::SendRecv)
.await
}
pub async fn generate_local_sdp_offer(
&self,
session_id: &SessionId,
direction: crate::types::MediaDirection,
) -> Result<String> {
let dialog_id = self
.session_to_dialog
.get(session_id)
.ok_or_else(|| {
SessionError::SessionNotFound(format!(
"No dialog mapping for session {}",
session_id.0
))
})?
.clone();
if self.signaling_only_local_port().is_some() {
return self
.generate_signaling_only_sdp_offer(session_id, direction)
.await;
}
let info = self
.controller
.get_session_info(&dialog_id)
.await
.ok_or_else(|| {
SessionError::MediaError(format!(
"Failed to get session info for dialog {}",
dialog_id
))
})?;
self.media_sessions.insert(session_id.clone(), info.clone());
let public = self.public_rtp_addr();
let port = public
.filter(|sa| sa.port() != 0)
.map(|sa| sa.port())
.unwrap_or_else(|| info.rtp_port.unwrap_or(info.config.local_addr.port()));
let (origin_session_id, origin_version) = self.next_sdp_origin(session_id).await?;
let origin_version = origin_version.to_string();
let advertised_ip = public.map(|sa| sa.ip()).unwrap_or(self.local_ip);
let local_ip_str = advertised_ip.to_string();
let (transport, crypto_attrs) = if self.offer_srtp {
let (negotiator, attrs) = SrtpNegotiator::new_offerer(&self.srtp_offered_suites)?;
self.pending_srtp_offerers
.insert(session_id.clone(), negotiator);
("RTP/SAVP", attrs)
} else {
("RTP/AVP", Vec::new())
};
let crypto_attr_count = crypto_attrs.len();
if sdp_diagnostics_enabled() {
emit_sdp_diag(format!(
"local_sdp_offer session={} media={}:{} transport={} {} direction={}",
session_id.0,
advertised_ip,
port,
transport,
crypto_attribute_diag(crypto_attr_count),
direction_attribute(direction)
));
}
let format_pts = self.effective_offered_formats();
let format_strings: Vec<String> = format_pts.iter().map(|pt| pt.to_string()).collect();
let formats_ref: Vec<&str> = format_strings.iter().map(|s| s.as_str()).collect();
let mut media_builder = SdpBuilder::new("Session")
.origin(
"-",
&origin_session_id,
&origin_version,
"IN",
"IP4",
&local_ip_str,
)
.connection("IN", "IP4", &local_ip_str)
.time("0", "0")
.media_audio(port, transport)
.formats(&formats_ref);
for (pt, pt_str) in format_pts.iter().zip(format_strings.iter()) {
if let Some(rtpmap) = rtpmap_for_pt(*pt) {
media_builder = media_builder.rtpmap(pt_str.as_str(), rtpmap);
}
if let Some(fmtp) = fmtp_for_pt_with_g729_annex_b(*pt, self.g729_annex_b) {
media_builder = media_builder.fmtp(pt_str.as_str(), fmtp);
}
}
for attr in crypto_attrs {
media_builder = media_builder.crypto_attribute(attr);
}
let session = media_builder
.attribute(direction_attribute(direction), None::<String>)
.done()
.build()
.map_err(|e| {
SessionError::SDPNegotiationFailed(format!(
"SdpBuilder failed to build offer: {}",
e
))
})?;
let sdp = session.to_string();
tracing::info!(
"✅ Generated SDP for session {} with local port {} direction {}",
session_id.0,
port,
direction_attribute(direction)
);
Ok(sdp)
}
async fn install_dtmf_callback(&self, session_id: SessionId, dialog_id: DialogId) {
let publisher = self.app_event_publisher.read().await.clone();
let coordinator = self.global_coordinator.read().await.clone();
if publisher.is_none() && coordinator.is_none() {
tracing::debug!(
"DTMF callback install skipped for session {}: no event publisher yet",
session_id.0
);
return;
};
let (tx, mut rx) = mpsc::channel::<rvoip_media_core::DtmfNotification>(32);
if let Err(e) = self
.controller
.set_dtmf_callback(dialog_id.clone(), tx)
.await
{
tracing::warn!(
"Failed to register DTMF callback for session {} (dialog {}): {}",
session_id.0,
dialog_id,
e
);
return;
}
let sid = session_id.clone();
let did = dialog_id.clone();
spawn_memory_tracked("sip.media_adapter.dtmf_bridge_task", async move {
while let Some(notification) = rx.recv().await {
let api_event = crate::api::events::Event::DtmfReceived {
call_id: sid.clone(),
digit: notification.digit,
};
if let Some(publisher) = publisher.as_ref() {
publisher.publish(api_event);
} else if let Some(coordinator) = coordinator.as_ref() {
let wrapped = crate::adapters::SessionApiCrossCrateEvent::new(api_event);
if let Err(e) = coordinator.publish(wrapped).await {
tracing::warn!(
"Failed to publish DtmfReceived for session {}: {}",
sid.0,
e
);
}
}
tracing::info!(
"📢 Published DtmfReceived digit='{}' for session {}",
notification.digit,
sid.0
);
}
tracing::debug!(
"DTMF bridge task exited for session {} (dialog {})",
sid.0,
did
);
});
}
pub async fn subscribe_to_audio_frames(
&self,
session_id: &SessionId,
) -> Result<crate::types::AudioFrameSubscriber> {
let dialog_id = self
.session_to_dialog
.get(session_id)
.ok_or_else(|| {
SessionError::SessionNotFound(format!("No media session for {}", session_id.0))
})?
.clone();
tracing::info!(
"🎧 Setting up audio subscription for session {} (dialog: {})",
session_id.0,
dialog_id
);
let (tx, rx) = mpsc::channel(AUDIO_RECEIVER_CHANNEL_FRAMES);
self.controller
.set_audio_frame_callback(dialog_id.clone(), tx.clone())
.await
.map_err(|e| {
SessionError::MediaError(format!("Failed to set audio callback: {}", e))
})?;
self.audio_receivers.insert(session_id.clone(), tx);
self.audio_subscriber_created_total
.fetch_add(1, Ordering::Relaxed);
tracing::info!(
"🎧 Created audio frame subscriber for session {} with dialog {}",
session_id.0,
dialog_id
);
Ok(crate::types::AudioFrameSubscriber::new(
session_id.clone(),
rx,
))
}
#[allow(dead_code)]
pub(crate) async fn forward_audio_frame_to_subscriber(
&self,
session_id: &SessionId,
audio_frame: rvoip_media_core::types::AudioFrame,
) -> Result<()> {
if let Some(tx) = self.audio_receivers.get(session_id) {
if let Err(_) = tx.send(audio_frame).await {
self.audio_receivers.remove(session_id);
self.audio_subscriber_disconnected_total
.fetch_add(1, Ordering::Relaxed);
tracing::debug!(
"Audio frame subscriber disconnected for session {}",
session_id.0
);
}
}
Ok(())
}
pub async fn create_media_session(&self) -> Result<crate::types::MediaSessionId> {
let media_id = crate::types::MediaSessionId::new_v4();
Ok(media_id)
}
pub async fn stop_media_session(&self, _media_id: crate::types::MediaSessionId) -> Result<()> {
Ok(())
}
pub async fn set_media_direction(
&self,
media_id: crate::types::MediaSessionId,
direction: crate::types::MediaDirection,
) -> Result<()> {
let media_direction = match direction {
crate::types::MediaDirection::SendRecv => {
rvoip_media_core::types::MediaDirection::SendRecv
}
crate::types::MediaDirection::SendOnly => {
rvoip_media_core::types::MediaDirection::SendOnly
}
crate::types::MediaDirection::RecvOnly => {
rvoip_media_core::types::MediaDirection::RecvOnly
}
crate::types::MediaDirection::Inactive => {
rvoip_media_core::types::MediaDirection::Inactive
}
};
self.controller
.set_media_direction(&media_id, media_direction)
.await
.map_err(|e| SessionError::MediaError(format!("Failed to set media direction: {}", e)))
}
pub async fn create_hold_sdp_for_session(&self, session_id: &SessionId) -> Result<String> {
self.generate_local_sdp_offer(session_id, crate::types::MediaDirection::SendOnly)
.await
}
pub async fn create_active_sdp_for_session(&self, session_id: &SessionId) -> Result<String> {
self.generate_local_sdp_offer(session_id, crate::types::MediaDirection::SendRecv)
.await
}
pub async fn create_hold_sdp(&self) -> Result<String> {
self.create_directional_fallback_sdp(crate::types::MediaDirection::SendOnly)
}
pub async fn create_active_sdp(&self) -> Result<String> {
self.create_directional_fallback_sdp(crate::types::MediaDirection::SendRecv)
}
fn create_directional_fallback_sdp(
&self,
direction: crate::types::MediaDirection,
) -> Result<String> {
let local_ip = self.local_ip.to_string();
let formats: &[&str] = if self.comfort_noise_enabled {
&["0", "8", "13", "101"]
} else {
&["0", "8", "101"]
};
let mut media_builder = SdpBuilder::new("Session")
.origin("-", "0", "0", "IN", "IP4", &local_ip)
.connection("IN", "IP4", &local_ip)
.time("0", "0")
.media_audio(self.media_port_start, "RTP/AVP")
.formats(formats)
.rtpmap("0", "PCMU/8000")
.rtpmap("8", "PCMA/8000");
if self.comfort_noise_enabled {
media_builder = media_builder.rtpmap("13", "CN/8000");
}
let session = media_builder
.rtpmap("101", "telephone-event/8000")
.fmtp("101", "0-15")
.attribute(direction_attribute(direction), None::<String>)
.done()
.build()
.map_err(|e| {
SessionError::SDPNegotiationFailed(format!(
"SdpBuilder failed to build {} SDP: {}",
direction_attribute(direction),
e
))
})?;
Ok(session.to_string())
}
async fn next_sdp_origin(&self, session_id: &SessionId) -> Result<(String, u64)> {
let mut session = self.store.get_session(session_id).await.map_err(|e| {
SessionError::SessionNotFound(format!(
"No session state for SDP origin {}: {}",
session_id.0, e
))
})?;
if session.sdp_origin_session_id.is_empty() {
session.sdp_origin_session_id = sdp_origin_session_id(&session.session_id.0);
}
session.sdp_origin_version = session.sdp_origin_version.saturating_add(1);
let out = (
session.sdp_origin_session_id.clone(),
session.sdp_origin_version,
);
self.store.update_session(session).await.map_err(|e| {
SessionError::MediaError(format!("Failed to persist SDP origin version: {}", e))
})?;
Ok(out)
}
pub async fn send_dtmf(
&self,
media_id: crate::types::MediaSessionId,
digit: char,
) -> Result<()> {
let dialog_id = media_id;
self.controller
.send_dtmf_packet(&dialog_id, digit, 100)
.await
.map_err(|e| SessionError::MediaError(format!("DTMF send failed: {}", e)))?;
tracing::debug!("☎️ Queued DTMF '{}' for media_id {:?}", digit, dialog_id);
Ok(())
}
pub async fn send_dtmf_rfc4733(
&self,
session_id: &SessionId,
digit: char,
duration_ms: u32,
) -> Result<()> {
let dialog_id = self
.session_to_dialog
.get(session_id)
.ok_or_else(|| {
SessionError::SessionNotFound(format!("No media session for {}", session_id.0))
})?
.clone();
self.controller
.send_dtmf_packet(&dialog_id, digit, duration_ms)
.await
.map_err(|e| SessionError::MediaError(format!("DTMF send failed: {}", e)))?;
tracing::info!(
"☎️ Queued DTMF '{}' for session {} (dialog {}, duration={}ms)",
digit,
session_id.0,
dialog_id,
duration_ms
);
Ok(())
}
pub async fn set_mute(
&self,
media_id: crate::types::MediaSessionId,
muted: bool,
) -> Result<()> {
tracing::debug!(
"Setting mute state to {} for media session {:?}",
muted,
media_id
);
Ok(())
}
pub async fn start_recording_media(
&self,
media_id: crate::types::MediaSessionId,
) -> Result<()> {
tracing::info!("Starting recording for media session {:?}", media_id);
Ok(())
}
pub async fn stop_recording_media(&self, media_id: crate::types::MediaSessionId) -> Result<()> {
tracing::info!("Stopping recording for media session {:?}", media_id);
Ok(())
}
pub async fn create_audio_mixer(&self) -> Result<crate::types::MediaSessionId> {
let mixer_id = crate::types::MediaSessionId::new_v4();
self.audio_mixers.insert(mixer_id.clone(), Vec::new());
tracing::info!("Created audio mixer {:?}", mixer_id);
Ok(mixer_id)
}
pub async fn redirect_to_mixer(
&self,
media_id: crate::types::MediaSessionId,
mixer_id: crate::types::MediaSessionId,
) -> Result<()> {
if let Some(mut mixer) = self.audio_mixers.get_mut(&mixer_id) {
mixer.push(media_id.clone());
}
tracing::debug!("Redirected media {:?} to mixer {:?}", media_id, mixer_id);
Ok(())
}
pub async fn remove_from_mixer(
&self,
media_id: crate::types::MediaSessionId,
mixer_id: crate::types::MediaSessionId,
) -> Result<()> {
if let Some(mut mixer) = self.audio_mixers.get_mut(&mixer_id) {
mixer.retain(|id| id != &media_id);
}
tracing::debug!("Removed media {:?} from mixer {:?}", media_id, mixer_id);
Ok(())
}
pub async fn destroy_mixer(&self, mixer_id: crate::types::MediaSessionId) -> Result<()> {
self.audio_mixers.remove(&mixer_id);
tracing::info!("Destroyed audio mixer {:?}", mixer_id);
Ok(())
}
pub async fn cleanup_session(&self, session_id: &SessionId) -> Result<()> {
let guard = cleanup_diag::stage_guard(CleanupStage::MediaCleanup, &session_id.0);
self.cleanup_attempt_total.fetch_add(1, Ordering::Relaxed);
let removed = self.session_to_dialog.remove(session_id);
let dialog_id = match removed {
Some((_, d)) => {
self.cleanup_mapped_total.fetch_add(1, Ordering::Relaxed);
Some(d)
}
None => {
self.cleanup_fallback_total.fetch_add(1, Ordering::Relaxed);
Some(DialogId::new(format!("media-{}", session_id.0)))
}
};
if let Some(dialog_id) = dialog_id {
if self.signaling_only_local_port().is_none() {
let _ = self
.controller
.remove_audio_frame_callback(&dialog_id)
.await;
let _ = self.controller.stop_media(&dialog_id).await;
}
self.dialog_to_session.remove(&dialog_id);
}
if self.media_sessions.remove(session_id).is_some() {
self.cleanup_media_session_removed_total
.fetch_add(1, Ordering::Relaxed);
}
if self.audio_receivers.remove(session_id).is_some() {
self.cleanup_audio_receiver_removed_total
.fetch_add(1, Ordering::Relaxed);
}
cleanup_session_diag::record_cleanup();
tracing::debug!(
"Cleaned up media adapter resources for session {}",
session_id.0
);
guard.finish_success();
Ok(())
}
fn get_local_port(&self, session_id: &SessionId) -> Result<u16> {
self.media_sessions
.get(session_id)
.and_then(|info| info.rtp_port)
.ok_or_else(|| {
SessionError::SessionNotFound(format!("No local port for session {}", session_id.0))
})
}
async fn generate_signaling_only_sdp_offer(
&self,
session_id: &SessionId,
direction: crate::types::MediaDirection,
) -> Result<String> {
let (origin_session_id, origin_version) = self.next_sdp_origin(session_id).await?;
let origin_version = origin_version.to_string();
let advertised_ip = self
.public_rtp_addr()
.map(|sa| sa.ip())
.unwrap_or(self.local_ip);
let local_ip_str = advertised_ip.to_string();
let port = self.signaling_only_local_port().ok_or_else(|| {
SessionError::MediaError(
"signaling-only SDP requested while media mode is enabled".to_string(),
)
})?;
let (transport, crypto_attrs) = if self.offer_srtp {
let (negotiator, attrs) = SrtpNegotiator::new_offerer(&self.srtp_offered_suites)?;
self.pending_srtp_offerers
.insert(session_id.clone(), negotiator);
("RTP/SAVP", attrs)
} else {
("RTP/AVP", Vec::new())
};
let format_pts = self.effective_offered_formats();
let format_strings: Vec<String> = format_pts.iter().map(|pt| pt.to_string()).collect();
let formats_ref: Vec<&str> = format_strings.iter().map(|s| s.as_str()).collect();
let mut media_builder = SdpBuilder::new("Session")
.origin(
"-",
&origin_session_id,
&origin_version,
"IN",
"IP4",
&local_ip_str,
)
.connection("IN", "IP4", &local_ip_str)
.time("0", "0")
.media_audio(port, transport)
.formats(&formats_ref);
for (pt, pt_str) in format_pts.iter().zip(format_strings.iter()) {
if let Some(rtpmap) = rtpmap_for_pt(*pt) {
media_builder = media_builder.rtpmap(pt_str.as_str(), rtpmap);
}
if let Some(fmtp) = fmtp_for_pt_with_g729_annex_b(*pt, self.g729_annex_b) {
media_builder = media_builder.fmtp(pt_str.as_str(), fmtp);
}
}
for attr in crypto_attrs {
media_builder = media_builder.crypto_attribute(attr);
}
let session = media_builder
.attribute(direction_attribute(direction), None::<String>)
.done()
.build()
.map_err(|e| {
SessionError::SDPNegotiationFailed(format!(
"SdpBuilder failed to build signaling-only offer: {}",
e
))
})?;
tracing::info!(
"signaling-only media mode: generated SDP for session {} with advertised port {}",
session_id.0,
port
);
Ok(session.to_string())
}
fn signaling_only_local_port(&self) -> Option<u16> {
match self.media_mode {
MediaMode::Enabled => None,
MediaMode::SignalingOnly { sdp_rtp_port } => Some(
self.public_rtp_addr()
.filter(|addr| addr.port() != 0)
.map(|addr| addr.port())
.unwrap_or(sdp_rtp_port),
),
}
}
fn parse_sdp_connection(&self, sdp: &str) -> Result<(IpAddr, u16)> {
let session = SdpSession::from_str(sdp).map_err(|e| {
SessionError::SDPNegotiationFailed(format!("Failed to parse SDP: {}", e))
})?;
let media = session
.media_descriptions
.iter()
.find(|m| m.media == "audio")
.ok_or_else(|| {
SessionError::SDPNegotiationFailed("SDP has no audio m= section".into())
})?;
let port = media.port as u16;
let conn = media
.connection_info
.as_ref()
.or(session.connection_info.as_ref())
.ok_or_else(|| {
SessionError::SDPNegotiationFailed(
"SDP has no c= line at session or audio level".into(),
)
})?;
let ip = match &conn.connection_address {
host_str => host_str.parse::<IpAddr>().map_err(|e| {
SessionError::SDPNegotiationFailed(format!(
"SDP c= address {:?} is not a valid IP: {}",
host_str, e
))
})?,
};
Ok((ip, port))
}
pub async fn start_recording(&self, session_id: &SessionId) -> Result<String> {
let config = RecordingConfig::default();
self.start_recording_with_config(session_id, config).await
}
pub async fn start_recording_with_config(
&self,
session_id: &SessionId,
config: RecordingConfig,
) -> Result<String> {
let _dialog_id = self
.session_to_dialog
.get(session_id)
.ok_or_else(|| {
SessionError::MediaError(format!("No dialog for session {}", session_id.0))
})?
.clone();
tracing::info!(
"Starting recording for session {} with path: {}",
session_id.0,
config.file_path
);
let recording_id = format!("rec_{}_{}", session_id.0, chrono::Utc::now().timestamp());
tracing::info!(
"✅ Recording started for session {} with ID: {}",
session_id.0,
recording_id
);
Ok(recording_id)
}
pub async fn stop_recording(&self, session_id: &SessionId) -> Result<()> {
tracing::info!("Stopping recording for session {}", session_id.0);
Ok(())
}
pub async fn stop_recording_with_id(
&self,
session_id: &SessionId,
recording_id: &str,
) -> Result<()> {
let _dialog_id = self
.session_to_dialog
.get(session_id)
.ok_or_else(|| {
SessionError::MediaError(format!("No dialog for session {}", session_id.0))
})?
.clone();
tracing::info!(
"Stopping recording {} for session {}",
recording_id,
session_id.0
);
tracing::info!("✅ Recording stopped for session {}", session_id.0);
Ok(())
}
pub async fn pause_recording(&self, session_id: &SessionId, recording_id: &str) -> Result<()> {
let _dialog_id = self
.session_to_dialog
.get(session_id)
.ok_or_else(|| {
SessionError::MediaError(format!("No dialog for session {}", session_id.0))
})?
.clone();
tracing::debug!(
"Pausing recording {} for session {}",
recording_id,
session_id.0
);
Ok(())
}
pub async fn resume_recording(&self, session_id: &SessionId, recording_id: &str) -> Result<()> {
let _dialog_id = self
.session_to_dialog
.get(session_id)
.ok_or_else(|| {
SessionError::MediaError(format!("No dialog for session {}", session_id.0))
})?
.clone();
tracing::debug!(
"Resuming recording {} for session {}",
recording_id,
session_id.0
);
Ok(())
}
pub async fn get_recording_status(
&self,
session_id: &SessionId,
_recording_id: &str,
) -> Result<RecordingStatus> {
let _dialog_id = self
.session_to_dialog
.get(session_id)
.ok_or_else(|| {
SessionError::MediaError(format!("No dialog for session {}", session_id.0))
})?
.clone();
Ok(RecordingStatus {
is_recording: false,
is_paused: false,
duration_seconds: 0.0,
file_size_bytes: 0,
})
}
pub async fn start_bridge_recording(
&self,
session1: &SessionId,
session2: &SessionId,
config: RecordingConfig,
) -> Result<String> {
tracing::info!(
"Starting bridge recording for sessions {} <-> {}",
session1.0,
session2.0
);
let mut recording_config = config;
recording_config.include_mixed = true;
recording_config.separate_tracks = true;
self.start_recording_with_config(session1, recording_config)
.await
}
pub async fn set_conference_recording_enabled(&self, enabled: bool) -> Result<()> {
tracing::info!("Conference recording enabled: {}", enabled);
Ok(())
}
}
impl Clone for MediaAdapter {
fn clone(&self) -> Self {
Self {
controller: self.controller.clone(),
store: self.store.clone(),
session_to_dialog: self.session_to_dialog.clone(),
dialog_to_session: self.dialog_to_session.clone(),
media_sessions: self.media_sessions.clone(),
audio_receivers: self.audio_receivers.clone(),
session_create_attempt_total: self.session_create_attempt_total.clone(),
session_create_success_total: self.session_create_success_total.clone(),
session_create_failed_total: self.session_create_failed_total.clone(),
cleanup_attempt_total: self.cleanup_attempt_total.clone(),
cleanup_mapped_total: self.cleanup_mapped_total.clone(),
cleanup_fallback_total: self.cleanup_fallback_total.clone(),
cleanup_media_session_removed_total: self.cleanup_media_session_removed_total.clone(),
cleanup_audio_receiver_removed_total: self.cleanup_audio_receiver_removed_total.clone(),
audio_subscriber_created_total: self.audio_subscriber_created_total.clone(),
audio_subscriber_disconnected_total: self.audio_subscriber_disconnected_total.clone(),
audio_send_frames_total: self.audio_send_frames_total.clone(),
audio_send_samples_total: self.audio_send_samples_total.clone(),
audio_mixers: self.audio_mixers.clone(),
local_ip: self.local_ip,
media_port_start: self.media_port_start,
media_port_end: self.media_port_end,
media_mode: self.media_mode,
offer_srtp: self.offer_srtp,
srtp_required: self.srtp_required,
srtp_offered_suites: self.srtp_offered_suites.clone(),
pending_srtp_offerers: self.pending_srtp_offerers.clone(),
negotiated_srtp: self.negotiated_srtp.clone(),
global_coordinator: self.global_coordinator.clone(),
app_event_publisher: self.app_event_publisher.clone(),
public_rtp_addr: std::sync::RwLock::new(self.public_rtp_addr()),
comfort_noise_enabled: self.comfort_noise_enabled,
strict_codec_matching: self.strict_codec_matching,
offered_codecs: self.offered_codecs.clone(),
g729_annex_b: self.g729_annex_b,
}
}
}
pub(crate) fn compute_answer_formats(
offer: &SdpSession,
offered_codecs: &[u8],
strict: bool,
offer_srtp: bool,
srtp_required: bool,
) -> Result<Vec<String>> {
let supported: Vec<String> = offered_codecs.iter().map(|pt| pt.to_string()).collect();
if !strict {
return Ok(supported);
}
let caps = rvoip_sip_dialog::sdp::AnswerCapabilities {
supported_formats: supported,
accept_srtp: offer_srtp,
require_srtp: srtp_required,
};
let m = rvoip_sip_dialog::sdp::match_offer(offer, &caps)
.map_err(|e| SessionError::SDPNegotiationFailed(format!("{}", e)))?;
let line = m
.media_lines
.iter()
.find(|l| l.media == "audio")
.ok_or_else(|| {
SessionError::SDPNegotiationFailed("matcher returned no audio media line".into())
})?;
if !line.accepted {
return Err(SessionError::SDPNegotiationFailed(
"no codec overlap with offer".into(),
));
}
Ok(line.negotiated_formats.clone())
}
#[cfg(test)]
mod sdp_format_tests {
use super::*;
#[test]
fn offer_direction_maps_to_correct_answer_direction() {
use rvoip_sip_core::MediaDirection as SipDirection;
assert_eq!(
answer_direction_for_offer(&Some(SipDirection::SendRecv)),
crate::types::MediaDirection::SendRecv
);
assert_eq!(
answer_direction_for_offer(&Some(SipDirection::SendOnly)),
crate::types::MediaDirection::RecvOnly
);
assert_eq!(
answer_direction_for_offer(&Some(SipDirection::RecvOnly)),
crate::types::MediaDirection::SendOnly
);
assert_eq!(
answer_direction_for_offer(&Some(SipDirection::Inactive)),
crate::types::MediaDirection::Inactive
);
assert_eq!(
answer_direction_for_offer(&None),
crate::types::MediaDirection::SendRecv
);
}
#[test]
fn remote_answer_direction_maps_to_local_direction() {
use rvoip_sip_core::MediaDirection as SipDirection;
assert_eq!(
local_direction_from_remote_answer(&Some(SipDirection::SendRecv)),
crate::types::MediaDirection::SendRecv
);
assert_eq!(
local_direction_from_remote_answer(&Some(SipDirection::SendOnly)),
crate::types::MediaDirection::RecvOnly
);
assert_eq!(
local_direction_from_remote_answer(&Some(SipDirection::RecvOnly)),
crate::types::MediaDirection::SendOnly
);
assert_eq!(
local_direction_from_remote_answer(&Some(SipDirection::Inactive)),
crate::types::MediaDirection::Inactive
);
assert_eq!(
local_direction_from_remote_answer(&None),
crate::types::MediaDirection::SendRecv
);
}
#[test]
fn g729_fmtp_reflects_annex_b_flag() {
assert_eq!(fmtp_for_pt_with_g729_annex_b(18, true), Some("annexb=yes"));
assert_eq!(fmtp_for_pt_with_g729_annex_b(18, false), Some("annexb=no"));
assert_eq!(fmtp_for_pt(101), Some("0-15"));
}
#[test]
fn g729_annex_b_negotiation_honors_remote_no() {
let sdp = "v=0\r\n\
o=- 1 1 IN IP4 127.0.0.1\r\n\
s=Session\r\n\
c=IN IP4 127.0.0.1\r\n\
t=0 0\r\n\
m=audio 17000 RTP/AVP 18 101\r\n\
a=rtpmap:18 G729/8000\r\n\
a=fmtp:18 annexb=no\r\n\
a=rtpmap:101 telephone-event/8000\r\n\
a=fmtp:101 0-15\r\n";
let session = SdpSession::from_str(sdp).expect("sdp parses");
assert!(!negotiated_g729_annex_b(&session, true));
assert!(!negotiated_g729_annex_b(&session, false));
assert_eq!(
select_primary_audio_payload_from_session(&session),
Some(18)
);
assert_eq!(codec_name_for_payload(18, false), "G729A");
}
#[test]
fn audio_direction_prefers_media_level_direction() {
use rvoip_sip_core::MediaDirection as SipDirection;
let sdp = SdpBuilder::new("Session")
.origin("-", "1", "0", "IN", "IP4", "127.0.0.1")
.connection("IN", "IP4", "127.0.0.1")
.time("0", "0")
.direction(SipDirection::SendRecv)
.media_audio(16000, "RTP/AVP")
.formats(&["0"])
.rtpmap("0", "PCMU/8000")
.direction(SipDirection::SendOnly)
.done()
.build()
.expect("offer builds");
assert_eq!(audio_direction(&sdp), Some(SipDirection::SendOnly));
}
fn build_offer(dialog_id: &str, elapsed_secs: u64, ip: &str, port: u16) -> String {
let elapsed = elapsed_secs.to_string();
let origin_session_id = sdp_origin_session_id(dialog_id);
SdpBuilder::new("Session")
.origin("-", &origin_session_id, &elapsed, "IN", "IP4", ip)
.connection("IN", "IP4", ip)
.time("0", "0")
.media_audio(port, "RTP/AVP")
.formats(&["0", "8", "101"])
.rtpmap("0", "PCMU/8000")
.rtpmap("8", "PCMA/8000")
.rtpmap("101", "telephone-event/8000")
.fmtp("101", "0-15")
.attribute("sendrecv", None::<String>)
.done()
.build()
.expect("offer builds")
.to_string()
}
fn build_answer(sess_id: &str, ip: &str, port: u16) -> String {
SdpBuilder::new("Session")
.origin("-", sess_id, "0", "IN", "IP4", ip)
.connection("IN", "IP4", ip)
.time("0", "0")
.media_audio(port, "RTP/AVP")
.formats(&["0", "8", "101"])
.rtpmap("0", "PCMU/8000")
.rtpmap("8", "PCMA/8000")
.rtpmap("101", "telephone-event/8000")
.fmtp("101", "0-15")
.attribute("sendrecv", None::<String>)
.done()
.build()
.expect("answer builds")
.to_string()
}
fn legacy_offer(dialog_id: &str, elapsed_secs: u64, ip: &str, port: u16) -> String {
let origin_session_id = sdp_origin_session_id(dialog_id);
format!(
"v=0\r\n\
o=- {} {} IN IP4 {}\r\n\
s=Session\r\n\
c=IN IP4 {}\r\n\
t=0 0\r\n\
m=audio {} RTP/AVP 0 8 101\r\n\
a=rtpmap:0 PCMU/8000\r\n\
a=rtpmap:8 PCMA/8000\r\n\
a=rtpmap:101 telephone-event/8000\r\n\
a=fmtp:101 0-15\r\n\
a=sendrecv\r\n",
origin_session_id, elapsed_secs, ip, ip, port,
)
}
fn legacy_answer(sess_id: &str, ip: &str, port: u16) -> String {
format!(
"v=0\r\n\
o=- {} {} IN IP4 {}\r\n\
s=Session\r\n\
c=IN IP4 {}\r\n\
t=0 0\r\n\
m=audio {} RTP/AVP 0 8 101\r\n\
a=rtpmap:0 PCMU/8000\r\n\
a=rtpmap:8 PCMA/8000\r\n\
a=rtpmap:101 telephone-event/8000\r\n\
a=fmtp:101 0-15\r\n\
a=sendrecv\r\n",
sess_id, 0u64, ip, ip, port,
)
}
#[test]
fn offer_matches_legacy_format_byte_for_byte() {
let dialog_id = "test-dialog-uuid";
let elapsed = 42u64;
let ip = "127.0.0.1";
let port = 16000;
let new = build_offer(dialog_id, elapsed, ip, port);
let old = legacy_offer(dialog_id, elapsed, ip, port);
assert_eq!(
new, old,
"SdpBuilder offer drifted from legacy format-string output"
);
}
#[test]
fn answer_matches_legacy_format_byte_for_byte() {
let sess_id = "1234567890";
let ip = "192.168.1.42";
let port = 16002;
let new = build_answer(sess_id, ip, port);
let old = legacy_answer(sess_id, ip, port);
assert_eq!(
new, old,
"SdpBuilder answer drifted from legacy format-string output"
);
}
#[test]
fn offer_round_trips_through_typed_parser() {
let sdp_str = build_offer("d", 0, "10.0.0.1", 5004);
let parsed = SdpSession::from_str(&sdp_str).expect("parses back");
assert_eq!(parsed.session_name, "Session");
assert_eq!(parsed.media_descriptions.len(), 1);
let m = &parsed.media_descriptions[0];
assert_eq!(m.media, "audio");
assert_eq!(m.port, 5004);
assert_eq!(m.protocol, "RTP/AVP");
assert_eq!(m.formats, vec!["0", "8", "101"]);
}
#[test]
fn offer_origin_uses_numeric_session_id() {
let sdp = build_offer(
"media-session-3e071bea-bda5-4758-bf05-8bce16c690e6",
0,
"10.0.0.1",
5004,
);
let origin = sdp
.lines()
.find(|line| line.starts_with("o="))
.expect("origin line");
let fields: Vec<&str> = origin.trim_start_matches("o=").split_whitespace().collect();
assert_eq!(fields.len(), 6, "origin line was: {}", origin);
assert!(
fields[1].bytes().all(|b| b.is_ascii_digit()),
"SDP sess-id must be numeric for PJMEDIA/Asterisk interop: {}",
origin
);
assert!(
fields[1].len() <= 20,
"SDP sess-id should fit common 64-bit parser limits: {}",
origin
);
}
#[test]
fn offer_advertises_telephone_event_on_plaintext() {
let sdp = build_offer("d", 0, "127.0.0.1", 16000);
assert!(
sdp.contains("m=audio 16000 RTP/AVP 0 8 101\r\n"),
"plaintext offer must advertise PT 101 alongside PCMU + PCMA:\n{}",
sdp
);
assert!(
sdp.contains("a=rtpmap:101 telephone-event/8000\r\n"),
"plaintext offer must carry the RFC 4733 telephone-event rtpmap:\n{}",
sdp
);
assert!(
sdp.contains("a=fmtp:101 0-15\r\n"),
"plaintext offer must carry the RFC 4733 fmtp 0-15 range:\n{}",
sdp
);
}
fn build_srtp_offer(ip: &str, port: u16, attrs: Vec<CryptoAttribute>) -> String {
let mut media_builder = SdpBuilder::new("Session")
.origin("-", "1", "0", "IN", "IP4", ip)
.connection("IN", "IP4", ip)
.time("0", "0")
.media_audio(port, "RTP/SAVP")
.formats(&["0", "8", "101"])
.rtpmap("0", "PCMU/8000")
.rtpmap("8", "PCMA/8000")
.rtpmap("101", "telephone-event/8000")
.fmtp("101", "0-15");
for attr in attrs {
media_builder = media_builder.crypto_attribute(attr);
}
media_builder
.attribute("sendrecv", None::<String>)
.done()
.build()
.expect("srtp offer builds")
.to_string()
}
#[test]
fn srtp_offer_uses_savp_profile_and_carries_crypto_lines() {
use crate::adapters::srtp_negotiator::SrtpNegotiator;
let suites = vec![
CryptoSuite::AesCm128HmacSha1_80,
CryptoSuite::AesCm128HmacSha1_32,
];
let (_, attrs) = SrtpNegotiator::new_offerer(&suites).unwrap();
let sdp = build_srtp_offer("127.0.0.1", 16000, attrs);
assert!(
sdp.contains("m=audio 16000 RTP/SAVP 0 8 101\r\n"),
"SRTP offer should use RTP/SAVP profile per RFC 4568 §3.1.4 \
with the unified PCMU+PCMA+telephone-event format set:\n{}",
sdp
);
assert!(
sdp.contains("a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:"),
"SRTP offer should carry tag-1 _80 crypto line:\n{}",
sdp
);
assert!(
sdp.contains("a=crypto:2 AES_CM_128_HMAC_SHA1_32 inline:"),
"SRTP offer should carry tag-2 _32 crypto line:\n{}",
sdp
);
let parsed = SdpSession::from_str(&sdp).expect("parses");
let m = &parsed.media_descriptions[0];
assert_eq!(m.protocol, "RTP/SAVP");
let crypto_count = m
.generic_attributes
.iter()
.filter(|a| matches!(a, ParsedAttribute::Crypto(_)))
.count();
assert_eq!(crypto_count, 2);
}
#[test]
fn extract_audio_crypto_finds_both_offered_lines() {
use crate::adapters::srtp_negotiator::SrtpNegotiator;
let (_, attrs) = SrtpNegotiator::new_offerer(&[
CryptoSuite::AesCm128HmacSha1_80,
CryptoSuite::AesCm128HmacSha1_32,
])
.unwrap();
let sdp = build_srtp_offer("127.0.0.1", 16000, attrs);
let parsed = SdpSession::from_str(&sdp).expect("parses");
let extracted = MediaAdapter::extract_audio_crypto(&parsed);
assert_eq!(extracted.len(), 2);
assert_eq!(extracted[0].tag, 1);
assert_eq!(extracted[1].tag, 2);
}
#[test]
fn extract_audio_crypto_ignores_unknown_crypto_suite_and_keeps_supported_lines() {
let sdp = concat!(
"v=0\r\n",
"o=- 1 0 IN IP4 127.0.0.1\r\n",
"s=Session\r\n",
"c=IN IP4 127.0.0.1\r\n",
"t=0 0\r\n",
"m=audio 16000 RTP/SAVP 0 8 101\r\n",
"a=rtpmap:0 PCMU/8000\r\n",
"a=crypto:99 AEAD_AES_128_GCM inline:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==\r\n",
"a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\r\n",
"a=sendrecv\r\n",
);
let parsed = SdpSession::from_str(sdp).expect("unknown crypto suite should not fail SDP");
let extracted = MediaAdapter::extract_audio_crypto(&parsed);
assert_eq!(extracted.len(), 1);
assert_eq!(extracted[0].tag, 1);
assert_eq!(extracted[0].suite, CryptoSuite::AesCm128HmacSha1_80);
}
#[test]
fn extract_audio_crypto_parses_asterisk_default_aes256_name() {
let sdp = concat!(
"v=0\r\n",
"o=- 1 0 IN IP4 127.0.0.1\r\n",
"s=Session\r\n",
"c=IN IP4 127.0.0.1\r\n",
"t=0 0\r\n",
"m=audio 16000 RTP/SAVP 0 8 101\r\n",
"a=rtpmap:0 PCMU/8000\r\n",
"a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\r\n",
"a=crypto:2 AES_256_CM_HMAC_SHA1_80 inline:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==\r\n",
"a=sendrecv\r\n",
);
let parsed = SdpSession::from_str(sdp).expect("Asterisk AES-256 crypto name parses");
let extracted = MediaAdapter::extract_audio_crypto(&parsed);
assert_eq!(extracted.len(), 2);
assert_eq!(extracted[0].suite, CryptoSuite::AesCm128HmacSha1_80);
assert_eq!(extracted[1].suite, CryptoSuite::AesCm256HmacSha1_80);
}
#[test]
fn public_rtp_addr_override_replaces_local_ip_and_port_in_offer() {
let public: SocketAddr = "203.0.113.42:30000".parse().unwrap();
let local_fallback: std::net::IpAddr = "192.168.1.10".parse().unwrap();
let local_port_fallback: u16 = 16000;
let public_opt = Some(public);
let advertised_ip = public_opt.map(|sa| sa.ip()).unwrap_or(local_fallback);
let port = public_opt
.filter(|sa| sa.port() != 0)
.map(|sa| sa.port())
.unwrap_or(local_port_fallback);
let dialog_id = "dlg";
let origin_session_id = sdp_origin_session_id(dialog_id);
let sdp = build_offer(dialog_id, 0, &advertised_ip.to_string(), port);
assert!(
sdp.contains("c=IN IP4 203.0.113.42\r\n"),
"c= must carry public IP when override set:\n{}",
sdp
);
assert!(
sdp.contains(&format!(
"o=- {} 0 IN IP4 203.0.113.42\r\n",
origin_session_id
)),
"o= must carry public IP when override set:\n{}",
sdp
);
assert!(
sdp.contains("m=audio 30000 RTP/AVP"),
"m=audio must carry public port when override set:\n{}",
sdp
);
}
#[test]
fn public_rtp_addr_unset_falls_back_to_local_ip_and_local_port() {
let public_opt: Option<SocketAddr> = None;
let local_fallback: std::net::IpAddr = "192.168.1.10".parse().unwrap();
let local_port_fallback: u16 = 16000;
let advertised_ip = public_opt.map(|sa| sa.ip()).unwrap_or(local_fallback);
let port = public_opt
.filter(|sa| sa.port() != 0)
.map(|sa| sa.port())
.unwrap_or(local_port_fallback);
let sdp = build_offer("dlg", 0, &advertised_ip.to_string(), port);
assert!(
sdp.contains("c=IN IP4 192.168.1.10\r\n"),
"c= falls back to local_ip when no override:\n{}",
sdp
);
assert!(
sdp.contains("m=audio 16000 RTP/AVP"),
"m=audio falls back to local_port when no override:\n{}",
sdp
);
}
#[test]
fn cn_enabled_offer_advertises_pt13_and_rtpmap() {
let ip = "127.0.0.1";
let port = 16000;
let sdp = SdpBuilder::new("Session")
.origin("-", "dlg", "0", "IN", "IP4", ip)
.connection("IN", "IP4", ip)
.time("0", "0")
.media_audio(port, "RTP/AVP")
.formats(&["0", "8", "13", "101"])
.rtpmap("0", "PCMU/8000")
.rtpmap("8", "PCMA/8000")
.rtpmap("13", "CN/8000")
.rtpmap("101", "telephone-event/8000")
.fmtp("101", "0-15")
.attribute("sendrecv", None::<String>)
.done()
.build()
.expect("offer builds")
.to_string();
assert!(
sdp.contains("m=audio 16000 RTP/AVP 0 8 13 101\r\n"),
"format list must include 13 between PCMA and telephone-event:\n{}",
sdp
);
assert!(
sdp.contains("a=rtpmap:13 CN/8000\r\n"),
"RFC 3389 CN rtpmap must appear:\n{}",
sdp
);
assert!(sdp.contains("a=rtpmap:0 PCMU/8000\r\n"));
assert!(sdp.contains("a=rtpmap:8 PCMA/8000\r\n"));
assert!(sdp.contains("a=rtpmap:101 telephone-event/8000\r\n"));
}
#[test]
fn strict_default_answers_with_intersection_only() {
let offer_sdp = SdpBuilder::new("Session")
.origin("-", "1", "0", "IN", "IP4", "127.0.0.1")
.connection("IN", "IP4", "127.0.0.1")
.time("0", "0")
.media_audio(16000, "RTP/AVP")
.formats(&["0", "101"])
.rtpmap("0", "PCMU/8000")
.rtpmap("101", "telephone-event/8000")
.fmtp("101", "0-15")
.attribute("sendrecv", None::<String>)
.done()
.build()
.expect("offer builds")
.to_string();
let offer = SdpSession::from_str(&offer_sdp).expect("offer parses");
let formats = compute_answer_formats(
&offer,
&[0, 8, 101],
true,
false,
false,
)
.expect("strict-mode match succeeds");
assert_eq!(
formats,
vec!["0".to_string(), "101".to_string()],
"strict answer must drop PCMA when the offer didn't list it"
);
}
#[test]
fn permissive_mode_answers_with_full_set() {
let offer_sdp = SdpBuilder::new("Session")
.origin("-", "1", "0", "IN", "IP4", "127.0.0.1")
.connection("IN", "IP4", "127.0.0.1")
.time("0", "0")
.media_audio(16000, "RTP/AVP")
.formats(&["0", "101"])
.rtpmap("0", "PCMU/8000")
.rtpmap("101", "telephone-event/8000")
.fmtp("101", "0-15")
.attribute("sendrecv", None::<String>)
.done()
.build()
.expect("offer builds")
.to_string();
let offer = SdpSession::from_str(&offer_sdp).expect("offer parses");
let formats = compute_answer_formats(
&offer,
&[0, 8, 101],
false,
false,
false,
)
.expect("permissive mode never errors on overlap");
assert_eq!(
formats,
vec!["0".to_string(), "8".to_string(), "101".to_string()],
"permissive answer keeps the full legacy set"
);
}
#[test]
fn strict_default_no_overlap_returns_negotiation_failed() {
let offer_sdp = SdpBuilder::new("Session")
.origin("-", "1", "0", "IN", "IP4", "127.0.0.1")
.connection("IN", "IP4", "127.0.0.1")
.time("0", "0")
.media_audio(16000, "RTP/AVP")
.formats(&["97", "98"])
.rtpmap("97", "VP8/90000")
.rtpmap("98", "VP9/90000")
.attribute("sendrecv", None::<String>)
.done()
.build()
.expect("offer builds")
.to_string();
let offer = SdpSession::from_str(&offer_sdp).expect("offer parses");
let err = compute_answer_formats(
&offer,
&[0, 8, 101],
true,
false,
false,
)
.unwrap_err();
assert!(
matches!(err, SessionError::SDPNegotiationFailed(_)),
"no overlap must surface as SDPNegotiationFailed → 488 NAH; got {:?}",
err
);
}
#[test]
fn strict_with_cn_enabled_keeps_cn_in_intersection() {
let offer_sdp = SdpBuilder::new("Session")
.origin("-", "1", "0", "IN", "IP4", "127.0.0.1")
.connection("IN", "IP4", "127.0.0.1")
.time("0", "0")
.media_audio(16000, "RTP/AVP")
.formats(&["0", "13", "101"])
.rtpmap("0", "PCMU/8000")
.rtpmap("13", "CN/8000")
.rtpmap("101", "telephone-event/8000")
.fmtp("101", "0-15")
.attribute("sendrecv", None::<String>)
.done()
.build()
.expect("offer builds")
.to_string();
let offer = SdpSession::from_str(&offer_sdp).expect("offer parses");
let formats = compute_answer_formats(
&offer,
&[0, 8, 13, 101],
true,
false,
false,
)
.expect("CN-on-both-sides match succeeds");
assert_eq!(
formats,
vec!["0".to_string(), "13".to_string(), "101".to_string()],
"intersection must include 13 when both sides advertise it"
);
}
#[test]
fn cn_disabled_offer_omits_pt13_and_rtpmap() {
let sdp = build_offer("dlg", 0, "127.0.0.1", 16000);
assert!(
sdp.contains("m=audio 16000 RTP/AVP 0 8 101\r\n"),
"default offer must keep the pre-Sprint-3 format set:\n{}",
sdp
);
assert!(
!sdp.contains("CN/8000"),
"default offer must not advertise CN: \n{}",
sdp
);
}
#[tokio::test]
async fn opus_offered_codec_appears_in_generated_offer() {
use crate::session_store::SessionStore;
use crate::state_table::types::Role;
use rvoip_media_core::relay::controller::MediaSessionController;
use std::net::Ipv4Addr;
let controller = Arc::new(MediaSessionController::new());
let store = Arc::new(SessionStore::new());
let session_id = SessionId("opus-offer-test".to_string());
store
.create_session(session_id.clone(), Role::UAC, false)
.await
.expect("create session");
let mut adapter = MediaAdapter::new(
controller,
store.clone(),
IpAddr::V4(Ipv4Addr::LOCALHOST),
16000,
16100,
);
adapter.set_offered_codecs(vec![0, 8, 111, 101]);
adapter
.start_session(&session_id)
.await
.expect("start session");
let sdp = adapter
.generate_local_sdp_offer(&session_id, crate::types::MediaDirection::SendRecv)
.await
.expect("offer builds");
assert!(
sdp.contains("a=rtpmap:111 opus/48000/2"),
"Opus rtpmap must appear in the offer when 111 is in offered_codecs:\n{}",
sdp
);
assert!(
sdp.contains("a=rtpmap:0 PCMU/8000"),
"legacy PCMU rtpmap must still appear:\n{}",
sdp
);
assert!(
sdp.contains(" 111 ") || sdp.contains(" 111\r\n"),
"m-line format list must include PT 111:\n{}",
sdp
);
}
#[tokio::test]
async fn g729_offer_advertises_annex_b_yes() {
use crate::session_store::SessionStore;
use crate::state_table::types::Role;
use rvoip_media_core::relay::controller::MediaSessionController;
use std::net::Ipv4Addr;
let controller = Arc::new(MediaSessionController::new());
let store = Arc::new(SessionStore::new());
let session_id = SessionId("g729-annexb-yes-offer-test".to_string());
store
.create_session(session_id.clone(), Role::UAC, false)
.await
.expect("create session");
let mut adapter = MediaAdapter::new(
controller,
store.clone(),
IpAddr::V4(Ipv4Addr::LOCALHOST),
16000,
16100,
);
adapter.set_offered_codecs(vec![18, 101]);
adapter.set_g729_annex_b(true);
adapter
.start_session(&session_id)
.await
.expect("start session");
let sdp = adapter
.generate_local_sdp_offer(&session_id, crate::types::MediaDirection::SendRecv)
.await
.expect("offer builds");
assert!(
sdp.contains(" RTP/AVP 18 101\r\n"),
"m-line must advertise PT18:\n{}",
sdp
);
assert!(
sdp.contains("a=rtpmap:18 G729/8000"),
"G.729 rtpmap must appear:\n{}",
sdp
);
assert!(
sdp.contains("a=fmtp:18 annexb=yes"),
"Annex B enabled offer must advertise annexb=yes:\n{}",
sdp
);
}
#[tokio::test]
async fn g729_offer_advertises_annex_b_no() {
use crate::session_store::SessionStore;
use crate::state_table::types::Role;
use rvoip_media_core::relay::controller::MediaSessionController;
use std::net::Ipv4Addr;
let controller = Arc::new(MediaSessionController::new());
let store = Arc::new(SessionStore::new());
let session_id = SessionId("g729-annexb-no-offer-test".to_string());
store
.create_session(session_id.clone(), Role::UAC, false)
.await
.expect("create session");
let mut adapter = MediaAdapter::new(
controller,
store.clone(),
IpAddr::V4(Ipv4Addr::LOCALHOST),
16000,
16100,
);
adapter.set_offered_codecs(vec![18, 101]);
adapter.set_g729_annex_b(false);
adapter
.start_session(&session_id)
.await
.expect("start session");
let sdp = adapter
.generate_local_sdp_offer(&session_id, crate::types::MediaDirection::SendRecv)
.await
.expect("offer builds");
assert!(
sdp.contains("a=fmtp:18 annexb=no"),
"Annex B disabled offer must advertise annexb=no:\n{}",
sdp
);
}
#[cfg(feature = "g729")]
#[tokio::test]
async fn g729_uas_negotiation_updates_media_core_codec() {
use crate::session_store::SessionStore;
use crate::state_table::types::Role;
use rvoip_media_core::relay::controller::MediaSessionController;
use std::net::Ipv4Addr;
let controller = Arc::new(MediaSessionController::new());
let store = Arc::new(SessionStore::new());
let session_id = SessionId("g729-negotiated-media-config-test".to_string());
store
.create_session(session_id.clone(), Role::UAS, false)
.await
.expect("create session");
let mut adapter = MediaAdapter::new(
controller.clone(),
store.clone(),
IpAddr::V4(Ipv4Addr::LOCALHOST),
16000,
16100,
);
adapter.set_offered_codecs(vec![18, 101]);
adapter.set_g729_annex_b(false);
adapter
.start_session(&session_id)
.await
.expect("start session");
let offer_sdp = SdpBuilder::new("Session")
.origin("-", "1", "0", "IN", "IP4", "127.0.0.1")
.connection("IN", "IP4", "127.0.0.1")
.time("0", "0")
.media_audio(35000, "RTP/AVP")
.formats(&["18", "101"])
.rtpmap("18", "G729/8000")
.fmtp("18", "annexb=no")
.rtpmap("101", "telephone-event/8000")
.fmtp("101", "0-15")
.attribute("sendrecv", None::<String>)
.done()
.build()
.expect("offer builds")
.to_string();
let (answer_sdp, config) = adapter
.negotiate_sdp_as_uas(&session_id, &offer_sdp)
.await
.expect("G.729 offer negotiates");
assert!(
answer_sdp.contains("a=fmtp:18 annexb=no"),
"G.729A answer must keep annexb=no:\n{}",
answer_sdp
);
assert_eq!(config.codec, "G729A");
assert_eq!(config.payload_type, 18);
let dialog_id = adapter
.session_to_dialog
.get(&session_id)
.expect("dialog mapping exists")
.value()
.clone();
let info = controller
.get_session_info(&dialog_id)
.await
.expect("media session exists");
assert_eq!(info.config.preferred_codec, Some("G729A".to_string()));
assert_eq!(
info.config.remote_addr,
Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 35000))
);
adapter
.cleanup_session(&session_id)
.await
.expect("cleanup media session");
}
#[tokio::test]
async fn default_offered_codecs_omit_opus() {
use crate::session_store::SessionStore;
use crate::state_table::types::Role;
use rvoip_media_core::relay::controller::MediaSessionController;
use std::net::Ipv4Addr;
let controller = Arc::new(MediaSessionController::new());
let store = Arc::new(SessionStore::new());
let session_id = SessionId("default-codecs-test".to_string());
store
.create_session(session_id.clone(), Role::UAC, false)
.await
.expect("create session");
let adapter = MediaAdapter::new(
controller,
store.clone(),
IpAddr::V4(Ipv4Addr::LOCALHOST),
16000,
16100,
);
adapter
.start_session(&session_id)
.await
.expect("start session");
let sdp = adapter
.generate_local_sdp_offer(&session_id, crate::types::MediaDirection::SendRecv)
.await
.expect("offer builds");
assert!(
!sdp.contains("opus"),
"default offer must not advertise Opus:\n{}",
sdp
);
}
#[tokio::test]
async fn next_sdp_origin_increments_version_across_calls() {
use crate::session_store::SessionStore;
use crate::state_table::types::Role;
use rvoip_media_core::relay::controller::MediaSessionController;
use std::net::Ipv4Addr;
let controller = Arc::new(MediaSessionController::new());
let store = Arc::new(SessionStore::new());
let session_id = SessionId("test-version-bump".to_string());
store
.create_session(session_id.clone(), Role::UAC, false)
.await
.expect("create session");
let adapter = MediaAdapter::new(
controller,
store.clone(),
IpAddr::V4(Ipv4Addr::LOCALHOST),
16000,
16100,
);
let (sess1, v_initial) = adapter
.next_sdp_origin(&session_id)
.await
.expect("initial offer");
let (sess2, v_hold) = adapter
.next_sdp_origin(&session_id)
.await
.expect("hold re-INVITE");
let (sess3, v_resume) = adapter
.next_sdp_origin(&session_id)
.await
.expect("resume re-INVITE");
assert_eq!(
sess1, sess2,
"session-id must not change between offer and hold re-INVITE"
);
assert_eq!(
sess1, sess3,
"session-id must not change between offer and resume re-INVITE"
);
assert!(
v_hold > v_initial,
"hold re-INVITE version ({}) must exceed initial offer ({})",
v_hold,
v_initial
);
assert!(
v_resume > v_hold,
"resume re-INVITE version ({}) must exceed hold ({})",
v_resume,
v_hold
);
}
#[test]
fn port_zero_rejection_emits_m_audio_zero_with_offered_proto() {
let sdp = build_port_zero_rejection_sdp("1234", 7, "192.0.2.10", "RTP/SAVP")
.expect("rejection SDP builds");
assert!(
sdp.contains("m=audio 0 RTP/SAVP"),
"rejection answer must carry port=0 and the offered proto:\n{}",
sdp
);
assert!(
sdp.contains("o=- 1234 7 IN IP4 192.0.2.10"),
"rejection answer must carry the supplied origin line:\n{}",
sdp
);
assert!(
sdp.contains("c=IN IP4 192.0.2.10"),
"rejection answer must include a c= line:\n{}",
sdp
);
assert!(
!sdp.contains("a=crypto"),
"rejection answer must not advertise any crypto attributes:\n{}",
sdp
);
}
#[test]
fn port_zero_rejection_echoes_avp_proto_when_offered() {
let sdp = build_port_zero_rejection_sdp("1", 0, "10.0.0.1", "RTP/AVP")
.expect("rejection SDP builds");
assert!(
sdp.contains("m=audio 0 RTP/AVP"),
"rejection must echo the offered proto verbatim:\n{}",
sdp
);
}
#[test]
fn public_rtp_addr_with_zero_port_keeps_local_port() {
let public: SocketAddr = "203.0.113.42:0".parse().unwrap();
let public_opt = Some(public);
let local_fallback: std::net::IpAddr = "192.168.1.10".parse().unwrap();
let local_port_fallback: u16 = 16000;
let advertised_ip = public_opt.map(|sa| sa.ip()).unwrap_or(local_fallback);
let port = public_opt
.filter(|sa| sa.port() != 0)
.map(|sa| sa.port())
.unwrap_or(local_port_fallback);
assert_eq!(advertised_ip.to_string(), "203.0.113.42");
assert_eq!(port, 16000, "zero port must defer to local_port_fallback");
}
#[tokio::test]
async fn uas_plain_avp_answer_uses_allocated_rtp_port() {
use crate::session_store::SessionStore;
use crate::state_table::types::Role;
use rvoip_media_core::relay::controller::MediaSessionController;
use std::net::Ipv4Addr;
let controller = Arc::new(MediaSessionController::new());
let store = Arc::new(SessionStore::new());
let session_id = SessionId("uas-plain-avp-answer-test".to_string());
store
.create_session(session_id.clone(), Role::UAS, false)
.await
.expect("create session");
let adapter = MediaAdapter::new(
controller,
store,
IpAddr::V4(Ipv4Addr::LOCALHOST),
16000,
16100,
);
adapter
.start_session(&session_id)
.await
.expect("start media session");
let offer_sdp = SdpBuilder::new("Session")
.origin("-", "1", "0", "IN", "IP4", "127.0.0.1")
.connection("IN", "IP4", "127.0.0.1")
.time("0", "0")
.media_audio(35000, "RTP/AVP")
.formats(&["0"])
.rtpmap("0", "PCMU/8000")
.attribute("sendrecv", None::<String>)
.done()
.build()
.expect("offer builds")
.to_string();
let (answer_sdp, config) = adapter
.negotiate_sdp_as_uas(&session_id, &offer_sdp)
.await
.expect("plain RTP offer negotiates");
let parsed = SdpSession::from_str(&answer_sdp).expect("answer parses");
let media = parsed
.media_descriptions
.iter()
.find(|media| media.media == "audio")
.expect("audio media line");
assert_eq!(media.protocol, "RTP/AVP");
assert_eq!(media.formats, vec!["0".to_string()]);
assert_eq!(media.port, config.local_addr.port());
assert_ne!(
media.port, 5060,
"SDP answer must advertise the allocated RTP port, not the SIP listener port"
);
adapter
.cleanup_session(&session_id)
.await
.expect("cleanup media session");
}
}