diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index 41e0cba3a..ccb326430 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -1242,9 +1242,12 @@ uint32_t dc_init_webxdc_integration (dc_context_t* context, uint32_t c * This needs to be a one-to-one chat. * @param place_call_info any data that other devices receive * in #DC_EVENT_INCOMING_CALL. + * @param has_video Whether the call has video initially. + * This allows the recipient's client to adjust incoming call UX. + * A call can be upgraded to include video later. * @return ID of the system message announcing the call. */ -uint32_t dc_place_outgoing_call (dc_context_t* context, uint32_t chat_id, const char* place_call_info); +uint32_t dc_place_outgoing_call (dc_context_t* context, uint32_t chat_id, const char* place_call_info, int has_video); /** diff --git a/deltachat-ffi/src/lib.rs b/deltachat-ffi/src/lib.rs index ccd8a5c24..07010d72a 100644 --- a/deltachat-ffi/src/lib.rs +++ b/deltachat-ffi/src/lib.rs @@ -1181,6 +1181,7 @@ pub unsafe extern "C" fn dc_place_outgoing_call( context: *mut dc_context_t, chat_id: u32, place_call_info: *const libc::c_char, + has_video: bool, ) -> u32 { if context.is_null() || chat_id == 0 { eprintln!("ignoring careless call to dc_place_outgoing_call()"); @@ -1190,7 +1191,7 @@ pub unsafe extern "C" fn dc_place_outgoing_call( let chat_id = ChatId::new(chat_id); let place_call_info = to_string_lossy(place_call_info); - block_on(ctx.place_outgoing_call(chat_id, place_call_info)) + block_on(ctx.place_outgoing_call(chat_id, place_call_info, has_video)) .context("Failed to place call") .log_err(ctx) .map(|msg_id| msg_id.to_u32()) diff --git a/deltachat-jsonrpc/src/api.rs b/deltachat-jsonrpc/src/api.rs index 8eed35963..4f59dd642 100644 --- a/deltachat-jsonrpc/src/api.rs +++ b/deltachat-jsonrpc/src/api.rs @@ -2167,10 +2167,11 @@ impl CommandApi { account_id: u32, chat_id: u32, place_call_info: String, + has_video: bool, ) -> Result { let ctx = self.get_context(account_id).await?; let msg_id = ctx - .place_outgoing_call(ChatId::new(chat_id), place_call_info) + .place_outgoing_call(ChatId::new(chat_id), place_call_info, has_video) .await?; Ok(msg_id.to_u32()) } diff --git a/deltachat-jsonrpc/src/api/types/calls.rs b/deltachat-jsonrpc/src/api/types/calls.rs index e779f1c89..f8e0b6865 100644 --- a/deltachat-jsonrpc/src/api/types/calls.rs +++ b/deltachat-jsonrpc/src/api/types/calls.rs @@ -1,6 +1,6 @@ use anyhow::{Context as _, Result}; -use deltachat::calls::{call_state, sdp_has_video, CallState}; +use deltachat::calls::{call_state, CallState}; use deltachat::context::Context; use deltachat::message::MsgId; use serde::Serialize; @@ -15,7 +15,7 @@ pub struct JsonrpcCallInfo { /// even if incoming call event was missed. pub sdp_offer: String, - /// True if SDP offer has a video. + /// True if the call is started as a video call. pub has_video: bool, /// Call state. @@ -30,7 +30,7 @@ impl JsonrpcCallInfo { format!("Attempting to get call state of non-call message {msg_id}") })?; let sdp_offer = call_info.place_call_info.clone(); - let has_video = sdp_has_video(&sdp_offer).unwrap_or_default(); + let has_video = call_info.has_video_initially(); let state = JsonrpcCallState::from_msg_id(context, msg_id).await?; Ok(JsonrpcCallInfo { diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/chat.py b/deltachat-rpc-client/src/deltachat_rpc_client/chat.py index eef858b92..556af9c05 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/chat.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/chat.py @@ -303,7 +303,7 @@ class Chat: f.flush() self._rpc.send_msg(self.account.id, self.id, {"viewtype": ViewType.VCARD, "file": f.name}) - def place_outgoing_call(self, place_call_info: str) -> Message: + def place_outgoing_call(self, place_call_info: str, has_video_initially: bool) -> Message: """Starts an outgoing call.""" - msg_id = self._rpc.place_outgoing_call(self.account.id, self.id, place_call_info) + msg_id = self._rpc.place_outgoing_call(self.account.id, self.id, place_call_info, has_video_initially) return Message(self.account, msg_id) diff --git a/deltachat-rpc-client/tests/test_calls.py b/deltachat-rpc-client/tests/test_calls.py index e3ac28d50..6e61a63c0 100644 --- a/deltachat-rpc-client/tests/test_calls.py +++ b/deltachat-rpc-client/tests/test_calls.py @@ -10,15 +10,15 @@ def test_calls(acfactory) -> None: alice_contact_bob = alice.create_contact(bob, "Bob") alice_chat_bob = alice_contact_bob.create_chat() bob.create_chat(alice) # Accept the chat so incoming call causes a notification. - outgoing_call_message = alice_chat_bob.place_outgoing_call(place_call_info) + outgoing_call_message = alice_chat_bob.place_outgoing_call(place_call_info, has_video_initially=True) assert outgoing_call_message.get_call_info().state.kind == "Alerting" 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 + assert incoming_call_event.has_video incoming_call_message = Message(bob, incoming_call_event.msg_id) assert incoming_call_message.get_call_info().state.kind == "Alerting" - assert not incoming_call_message.get_call_info().has_video + assert incoming_call_message.get_call_info().has_video incoming_call_message.accept_incoming_call(accept_call_info) assert incoming_call_message.get_call_info().sdp_offer == place_call_info @@ -41,46 +41,38 @@ def test_video_call(acfactory) -> None: # # `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) bob.create_chat(alice) # Accept the chat so incoming call causes a notification. 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) + alice_chat_bob.place_outgoing_call("offer", has_video_initially=True) incoming_call_event = bob.wait_for_event(EventType.INCOMING_CALL) - assert incoming_call_event.place_call_info == place_call_info + assert incoming_call_event.place_call_info == "offer" assert incoming_call_event.has_video incoming_call_message = Message(bob, incoming_call_event.msg_id) assert incoming_call_message.get_call_info().has_video +def test_audio_call(acfactory) -> None: + alice, bob = acfactory.get_online_accounts(2) + + bob.create_chat(alice) # Accept the chat so incoming call causes a notification. + alice_contact_bob = alice.create_contact(bob, "Bob") + alice_chat_bob = alice_contact_bob.create_chat() + alice_chat_bob.place_outgoing_call("offer", has_video_initially=False) + + incoming_call_event = bob.wait_for_event(EventType.INCOMING_CALL) + assert incoming_call_event.place_call_info == "offer" + assert not incoming_call_event.has_video + + incoming_call_message = Message(bob, incoming_call_event.msg_id) + assert not incoming_call_message.get_call_info().has_video + + def test_ice_servers(acfactory) -> None: alice = acfactory.get_online_account() @@ -92,7 +84,7 @@ def test_no_contact_request_call(acfactory) -> None: alice, bob = acfactory.get_online_accounts(2) alice_chat_bob = alice.create_chat(bob) - alice_chat_bob.place_outgoing_call("offer") + alice_chat_bob.place_outgoing_call("offer", has_video_initially=True) alice_chat_bob.send_text("Hello!") # Notification for "Hello!" message should arrive @@ -119,7 +111,7 @@ def test_who_can_call_me_nobody(acfactory) -> None: bob.create_chat(alice) alice_chat_bob = alice.create_chat(bob) - alice_chat_bob.place_outgoing_call("offer") + alice_chat_bob.place_outgoing_call("offer", has_video_initially=True) alice_chat_bob.send_text("Hello!") # Notification for "Hello!" message should arrive @@ -144,7 +136,7 @@ def test_who_can_call_me_everybody(acfactory) -> None: bob.set_config("who_can_call_me", "0") alice_chat_bob = alice.create_chat(bob) - alice_chat_bob.place_outgoing_call("offer") + alice_chat_bob.place_outgoing_call("offer", has_video_initially=True) incoming_call_event = bob.wait_for_event(EventType.INCOMING_CALL) incoming_call_message = Message(bob, incoming_call_event.msg_id) diff --git a/src/calls.rs b/src/calls.rs index 2ab98c055..5f9f69580 100644 --- a/src/calls.rs +++ b/src/calls.rs @@ -19,9 +19,7 @@ use crate::tools::{normalize_text, time}; use anyhow::{Context as _, Result, ensure}; use deltachat_derive::{FromSql, ToSql}; use num_traits::FromPrimitive; -use sdp::SessionDescription; use serde::Serialize; -use std::io::Cursor; use std::str::FromStr; use std::time::Duration; use tokio::task; @@ -126,6 +124,14 @@ impl CallInfo { self.msg.param.exists(CALL_ACCEPTED_TIMESTAMP) } + /// Returns true if the call is started as a video call. + pub fn has_video_initially(&self) -> bool { + self.msg + .param + .get_bool(Param::WebrtcHasVideoInitially) + .unwrap_or(false) + } + /// Returns true if the call is missed /// because the caller canceled it /// explicitly before ringing stopped. @@ -185,6 +191,7 @@ impl Context { &self, chat_id: ChatId, place_call_info: String, + has_video_initially: bool, ) -> Result { let chat = Chat::load_from_db(self, chat_id).await?; ensure!( @@ -199,6 +206,8 @@ impl Context { ..Default::default() }; call.param.set(Param::WebrtcRoom, &place_call_info); + call.param + .set_int(Param::WebrtcHasVideoInitially, has_video_initially.into()); call.id = send_msg(self, chat_id, &mut call).await?; let wait = RINGING_SECONDS; @@ -344,13 +353,6 @@ 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 - } - }; let can_call_me = match who_can_call_me(self).await? { WhoCanCallMe::Contacts => ChatIdBlocked::lookup_by_contact(self, from_id) .await? @@ -377,7 +379,7 @@ impl Context { msg_id: call.msg.id, chat_id: call.msg.chat_id, place_call_info: call.place_call_info.to_string(), - has_video, + has_video: call.has_video_initially(), }); } let wait = call.remaining_ring_seconds(); @@ -507,19 +509,6 @@ impl Context { } } -/// Returns true if SDP offer has a video. -pub 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) -} - /// State of the call for display in the message bubble. #[derive(Debug, PartialEq, Eq)] pub enum CallState { diff --git a/src/calls/calls_tests.rs b/src/calls/calls_tests.rs index 23b3947a2..de582261e 100644 --- a/src/calls/calls_tests.rs +++ b/src/calls/calls_tests.rs @@ -25,13 +25,6 @@ async fn assert_text(t: &TestContext, call_id: MsgId, text: &str) -> Result<()> 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; @@ -52,7 +45,7 @@ async fn setup_call() -> Result { bob2.create_chat(&alice).await; let test_msg_id = alice - .place_outgoing_call(alice_chat.id, PLACE_INFO.to_string()) + .place_outgoing_call(alice_chat.id, PLACE_INFO.to_string(), true) .await?; let sent1 = alice.pop_sent_msg().await; assert_eq!(sent1.sender_msg_id, test_msg_id); @@ -68,6 +61,7 @@ async fn setup_call() -> Result { assert!(!info.is_incoming()); assert!(!info.is_accepted()); assert_eq!(info.place_call_info, PLACE_INFO); + assert_eq!(info.has_video_initially(), true); assert_text(t, m.id, "Outgoing call").await?; assert_eq!(call_state(t, m.id).await?, CallState::Alerting); } @@ -89,6 +83,7 @@ async fn setup_call() -> Result { assert!(info.is_incoming()); assert!(!info.is_accepted()); assert_eq!(info.place_call_info, PLACE_INFO); + assert_eq!(info.has_video_initially(), true); assert_text(t, m.id, "Incoming call").await?; assert_eq!(call_state(t, m.id).await?, CallState::Alerting); } @@ -525,13 +520,6 @@ 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); -} - /// Tests that calls are forwarded as text messages. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_forward_call() -> Result<()> { @@ -542,7 +530,7 @@ async fn test_forward_call() -> Result<()> { let alice_bob_chat = alice.create_chat(bob).await; let alice_msg_id = alice - .place_outgoing_call(alice_bob_chat.id, PLACE_INFO.to_string()) + .place_outgoing_call(alice_bob_chat.id, PLACE_INFO.to_string(), true) .await .context("Failed to place a call")?; let alice_call = Message::load_from_db(alice, alice_msg_id).await?; diff --git a/src/headerdef.rs b/src/headerdef.rs index 7ccae1f97..979cb8749 100644 --- a/src/headerdef.rs +++ b/src/headerdef.rs @@ -91,6 +91,7 @@ pub enum HeaderDef { ChatDispositionNotificationTo, ChatWebrtcRoom, ChatWebrtcAccepted, + ChatWebrtcHasVideoInitially, /// This message deletes the messages listed in the value by rfc724_mid. ChatDelete, diff --git a/src/mimefactory.rs b/src/mimefactory.rs index 6a46eb97c..6ff337533 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -1840,6 +1840,12 @@ impl MimeFactory { mail_builder::headers::raw::Raw::new(b_encode(answer)).into(), )); } + if let Some(has_video) = msg.param.get(Param::WebrtcHasVideoInitially) { + headers.push(( + "Chat-Webrtc-Has-Video-Initially", + mail_builder::headers::raw::Raw::new(b_encode(has_video)).into(), + )) + } if msg.viewtype == Viewtype::Voice || msg.viewtype == Viewtype::Audio diff --git a/src/mimeparser.rs b/src/mimeparser.rs index 33a02830c..ce03f77d5 100644 --- a/src/mimeparser.rs +++ b/src/mimeparser.rs @@ -801,6 +801,9 @@ impl MimeMessage { let accepted = self .get_header(HeaderDef::ChatWebrtcAccepted) .map(|s| s.to_string()); + let has_video = self + .get_header(HeaderDef::ChatWebrtcHasVideoInitially) + .map(|s| s.to_string()); if let Some(part) = self.parts.first_mut() { if let Some(room) = room { if content == "call" { @@ -810,6 +813,9 @@ impl MimeMessage { } else if let Some(accepted) = accepted { part.param.set(Param::WebrtcAccepted, accepted); } + if let Some(has_video) = has_video { + part.param.set(Param::WebrtcHasVideoInitially, has_video); + } } } diff --git a/src/param.rs b/src/param.rs index 335c54bcd..30c4f008a 100644 --- a/src/param.rs +++ b/src/param.rs @@ -148,6 +148,9 @@ pub enum Param { /// For Messages WebrtcAccepted = b'7', + /// For Messages + WebrtcHasVideoInitially = b'z', + /// For Messages: space-separated list of messaged IDs of forwarded copies. /// /// This is used when a [crate::message::Message] is in the