diff --git a/Cargo.lock b/Cargo.lock index 588554d6d..760eaad36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1356,6 +1356,7 @@ dependencies = [ "rustls", "rustls-pki-types", "sanitize-filename", + "sdp", "serde", "serde_json", "serde_urlencoded", @@ -5266,6 +5267,18 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sdp" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd277015eada44a0bb810a4b84d3bf6e810573fa62fb442f457edf6a1087a69" +dependencies = [ + "rand 0.8.5", + "substring", + "thiserror 1.0.69", + "url", +] + [[package]] name = "sec1" version = "0.7.3" @@ -5763,6 +5776,15 @@ dependencies = [ "rand 0.9.0", ] +[[package]] +name = "substring" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ee6433ecef213b2e72f587ef64a2f5943e7cd16fbd82dbe8bc07486c534c86" +dependencies = [ + "autocfg", +] + [[package]] name = "subtle" version = "2.6.1" diff --git a/Cargo.toml b/Cargo.toml index c74839f91..8f25bbbeb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,6 +89,7 @@ rusqlite = { workspace = true, features = ["sqlcipher"] } rustls-pki-types = "1.12.0" rustls = { version = "0.23.22", default-features = false } sanitize-filename = { workspace = true } +sdp = "0.8.0" serde_json = { workspace = true } serde_urlencoded = "0.7.1" serde = { workspace = true, features = ["derive"] } diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index f07c8ebce..bb759de4d 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -6732,6 +6732,7 @@ void dc_event_unref(dc_event_t* event); * * @param data1 (int) msg_id ID of the message referring to the call. * @param data2 (char*) place_call_info, text passed to dc_place_outgoing_call() + * @param data2 (int) 1 if incoming call is a video call, 0 otherwise */ #define DC_EVENT_INCOMING_CALL 2550 diff --git a/deltachat-ffi/src/lib.rs b/deltachat-ffi/src/lib.rs index 9dc2d0680..22e45f81d 100644 --- a/deltachat-ffi/src/lib.rs +++ b/deltachat-ffi/src/lib.rs @@ -679,7 +679,6 @@ pub unsafe extern "C" fn dc_event_get_data2_int(event: *mut dc_event_t) -> libc: | EventType::ChatModified(_) | EventType::ChatDeleted { .. } | EventType::WebxdcRealtimeAdvertisementReceived { .. } - | EventType::IncomingCall { .. } | EventType::IncomingCallAccepted { .. } | EventType::OutgoingCallAccepted { .. } | EventType::CallEnded { .. } @@ -701,6 +700,8 @@ pub unsafe extern "C" fn dc_event_get_data2_int(event: *mut dc_event_t) -> libc: .. } => status_update_serial.to_u32() as libc::c_int, EventType::WebxdcRealtimeData { data, .. } => data.len() as libc::c_int, + EventType::IncomingCall { has_video, .. } => *has_video as libc::c_int, + #[allow(unreachable_patterns)] #[cfg(test)] _ => unreachable!("This is just to silence a rust_analyzer false-positive"), diff --git a/deltachat-jsonrpc/src/api/types/events.rs b/deltachat-jsonrpc/src/api/types/events.rs index d162aefd9..1028522c5 100644 --- a/deltachat-jsonrpc/src/api/types/events.rs +++ b/deltachat-jsonrpc/src/api/types/events.rs @@ -427,6 +427,8 @@ pub enum EventType { msg_id: u32, /// User-defined info as passed to place_outgoing_call() place_call_info: String, + /// True if incoming call is a video call. + has_video: bool, }, /// Incoming call accepted. @@ -604,9 +606,11 @@ impl From for EventType { CoreEventType::IncomingCall { msg_id, place_call_info, + has_video, } => IncomingCall { msg_id: msg_id.to_u32(), place_call_info, + has_video, }, CoreEventType::IncomingCallAccepted { msg_id } => IncomingCallAccepted { msg_id: msg_id.to_u32(), diff --git a/deltachat-rpc-client/tests/test_calls.py b/deltachat-rpc-client/tests/test_calls.py index ea1eb9a84..f1c2bfebb 100644 --- a/deltachat-rpc-client/tests/test_calls.py +++ b/deltachat-rpc-client/tests/test_calls.py @@ -13,6 +13,7 @@ def test_calls(acfactory) -> None: incoming_call_event = bob.wait_for_event(EventType.INCOMING_CALL) assert incoming_call_event.place_call_info == place_call_info + assert not incoming_call_event.has_video # Cannot be parsed as SDP, so false by default incoming_call_message = Message(bob, incoming_call_event.msg_id) incoming_call_message.accept_incoming_call(accept_call_info) @@ -23,3 +24,45 @@ def test_calls(acfactory) -> None: end_call_event = bob.wait_for_event(EventType.CALL_ENDED) assert end_call_event.msg_id == outgoing_call_message.id + + +def test_video_call(acfactory) -> None: + # Example from + # with `s= ` replaced with `s=-`. + # + # `s=` cannot be empty according to RFC 3264, + # so it is more clear as `s=-`. + place_call_info = """v=0\r +o=alice 2890844526 2890844526 IN IP6 2001:db8::3\r +s=-\r +c=IN IP6 2001:db8::3\r +t=0 0\r +a=group:BUNDLE foo bar\r +\r +m=audio 10000 RTP/AVP 0 8 97\r +b=AS:200\r +a=mid:foo\r +a=rtcp-mux\r +a=rtpmap:0 PCMU/8000\r +a=rtpmap:8 PCMA/8000\r +a=rtpmap:97 iLBC/8000\r +a=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid\r +\r +m=video 10002 RTP/AVP 31 32\r +b=AS:1000\r +a=mid:bar\r +a=rtcp-mux\r +a=rtpmap:31 H261/90000\r +a=rtpmap:32 MPV/90000\r +a=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid\r +""" + + alice, bob = acfactory.get_online_accounts(2) + + alice_contact_bob = alice.create_contact(bob, "Bob") + alice_chat_bob = alice_contact_bob.create_chat() + alice_chat_bob.place_outgoing_call(place_call_info) + + incoming_call_event = bob.wait_for_event(EventType.INCOMING_CALL) + assert incoming_call_event.place_call_info == place_call_info + assert incoming_call_event.has_video diff --git a/src/calls.rs b/src/calls.rs index 5b14fa408..6978d8ce5 100644 --- a/src/calls.rs +++ b/src/calls.rs @@ -8,12 +8,14 @@ use crate::contact::ContactId; use crate::context::Context; use crate::events::EventType; use crate::headerdef::HeaderDef; -use crate::log::info; +use crate::log::{info, warn}; use crate::message::{self, Message, MsgId, Viewtype}; use crate::mimeparser::{MimeMessage, SystemMessage}; use crate::param::Param; use crate::tools::time; -use anyhow::{Result, ensure}; +use anyhow::{Context as _, Result, ensure}; +use sdp::SessionDescription; +use std::io::Cursor; use std::time::Duration; use tokio::task; use tokio::time::sleep; @@ -259,6 +261,7 @@ impl Context { ) -> Result<()> { if mime_message.is_call() { let call = self.load_call_by_id(call_id).await?; + if call.is_incoming() { if call.is_stale() { call.update_text(self, "Missed call").await?; @@ -266,9 +269,17 @@ impl Context { } else { call.update_text(self, "Incoming call").await?; self.emit_msgs_changed(call.msg.chat_id, call_id); // ringing calls are not additionally notified + let has_video = match sdp_has_video(&call.place_call_info) { + Ok(has_video) => has_video, + Err(err) => { + warn!(self, "Failed to determine if SDP offer has video: {err:#}."); + false + } + }; self.emit_event(EventType::IncomingCall { msg_id: call.msg.id, place_call_info: call.place_call_info.to_string(), + has_video, }); let wait = call.remaining_ring_seconds(); task::spawn(Context::emit_end_call_if_unaccepted( @@ -369,5 +380,18 @@ impl Context { } } +/// Returns true if SDP offer has a video. +fn sdp_has_video(sdp: &str) -> Result { + let mut cursor = Cursor::new(sdp); + let session_description = + SessionDescription::unmarshal(&mut cursor).context("Failed to parse SDP")?; + for media_description in &session_description.media_descriptions { + if media_description.media_name.media == "video" { + return Ok(true); + } + } + Ok(false) +} + #[cfg(test)] mod calls_tests; diff --git a/src/calls/calls_tests.rs b/src/calls/calls_tests.rs index bacc2fd13..321e3b62a 100644 --- a/src/calls/calls_tests.rs +++ b/src/calls/calls_tests.rs @@ -19,9 +19,16 @@ async fn assert_text(t: &TestContext, call_id: MsgId, text: &str) -> Result<()> } // Offer and answer examples from -const PLACE_INFO: &str = "v=0\r\no=alice 2890844526 2890844526 IN IP4 host.anywhere.com\r\ns=\r\nc=IN IP4 host.anywhere.com\r\nt=0 0\r\nm=audio 62986 RTP/AVP 0 4 18\r\na=rtpmap:0 PCMU/8000\r\na=rtpmap:4 G723/8000\r\na=rtpmap:18 G729/8000\r\na=inactive\r\n"; +const PLACE_INFO: &str = "v=0\r\no=alice 2890844526 2890844526 IN IP4 host.anywhere.com\r\ns=-\r\nc=IN IP4 host.anywhere.com\r\nt=0 0\r\nm=audio 62986 RTP/AVP 0 4 18\r\na=rtpmap:0 PCMU/8000\r\na=rtpmap:4 G723/8000\r\na=rtpmap:18 G729/8000\r\na=inactive\r\n"; const ACCEPT_INFO: &str = "v=0\r\no=bob 2890844730 2890844731 IN IP4 host.example.com\r\ns=\r\nc=IN IP4 host.example.com\r\nt=0 0\r\nm=audio 54344 RTP/AVP 0 4\r\na=rtpmap:0 PCMU/8000\r\na=rtpmap:4 G723/8000\r\na=inactive\r\n"; +/// Example from +/// with `s= ` replaced with `s=-`. +/// +/// `s=` cannot be empty according to RFC 3264, +/// so it is more clear as `s=-`. +const PLACE_INFO_VIDEO: &str = "v=0\r\no=alice 2890844526 2890844526 IN IP6 2001:db8::3\r\ns=-\r\nc=IN IP6 2001:db8::3\r\nt=0 0\r\na=group:BUNDLE foo bar\r\n\r\nm=audio 10000 RTP/AVP 0 8 97\r\nb=AS:200\r\na=mid:foo\r\na=rtcp-mux\r\na=rtpmap:0 PCMU/8000\r\na=rtpmap:8 PCMA/8000\r\na=rtpmap:97 iLBC/8000\r\na=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid\r\n\r\nm=video 10002 RTP/AVP 31 32\r\nb=AS:1000\r\na=mid:bar\r\na=rtcp-mux\r\na=rtpmap:31 H261/90000\r\na=rtpmap:32 MPV/90000\r\na=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid\r\n"; + async fn setup_call() -> Result { let mut tcm = TestContextManager::new(); let alice = tcm.alice().await; @@ -417,3 +424,10 @@ async fn test_update_call_text() -> Result<()> { Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_sdp_has_video() { + assert!(sdp_has_video("foobar").is_err()); + assert_eq!(sdp_has_video(PLACE_INFO).unwrap(), false); + assert_eq!(sdp_has_video(PLACE_INFO_VIDEO).unwrap(), true); +} diff --git a/src/events/payload.rs b/src/events/payload.rs index 1a6f1a679..83e8fa923 100644 --- a/src/events/payload.rs +++ b/src/events/payload.rs @@ -386,6 +386,8 @@ pub enum EventType { msg_id: MsgId, /// User-defined info as passed to place_outgoing_call() place_call_info: String, + /// True if incoming call is a video call. + has_video: bool, }, /// Incoming call accepted.