api: add has_video attribute to incoming call events

This allows UI to show if incoming call is a video or audio call
and disable camera by default for audio calls.
This commit is contained in:
link2xt
2025-09-15 20:54:44 +00:00
committed by l
parent 66271db8c0
commit 129e970727
9 changed files with 116 additions and 4 deletions

22
Cargo.lock generated
View File

@@ -1356,6 +1356,7 @@ dependencies = [
"rustls", "rustls",
"rustls-pki-types", "rustls-pki-types",
"sanitize-filename", "sanitize-filename",
"sdp",
"serde", "serde",
"serde_json", "serde_json",
"serde_urlencoded", "serde_urlencoded",
@@ -5266,6 +5267,18 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 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]] [[package]]
name = "sec1" name = "sec1"
version = "0.7.3" version = "0.7.3"
@@ -5763,6 +5776,15 @@ dependencies = [
"rand 0.9.0", "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]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"

View File

@@ -89,6 +89,7 @@ rusqlite = { workspace = true, features = ["sqlcipher"] }
rustls-pki-types = "1.12.0" rustls-pki-types = "1.12.0"
rustls = { version = "0.23.22", default-features = false } rustls = { version = "0.23.22", default-features = false }
sanitize-filename = { workspace = true } sanitize-filename = { workspace = true }
sdp = "0.8.0"
serde_json = { workspace = true } serde_json = { workspace = true }
serde_urlencoded = "0.7.1" serde_urlencoded = "0.7.1"
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }

View File

@@ -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 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 (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 #define DC_EVENT_INCOMING_CALL 2550

View File

@@ -679,7 +679,6 @@ pub unsafe extern "C" fn dc_event_get_data2_int(event: *mut dc_event_t) -> libc:
| EventType::ChatModified(_) | EventType::ChatModified(_)
| EventType::ChatDeleted { .. } | EventType::ChatDeleted { .. }
| EventType::WebxdcRealtimeAdvertisementReceived { .. } | EventType::WebxdcRealtimeAdvertisementReceived { .. }
| EventType::IncomingCall { .. }
| EventType::IncomingCallAccepted { .. } | EventType::IncomingCallAccepted { .. }
| EventType::OutgoingCallAccepted { .. } | EventType::OutgoingCallAccepted { .. }
| EventType::CallEnded { .. } | 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, } => status_update_serial.to_u32() as libc::c_int,
EventType::WebxdcRealtimeData { data, .. } => data.len() 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)] #[allow(unreachable_patterns)]
#[cfg(test)] #[cfg(test)]
_ => unreachable!("This is just to silence a rust_analyzer false-positive"), _ => unreachable!("This is just to silence a rust_analyzer false-positive"),

View File

@@ -427,6 +427,8 @@ pub enum EventType {
msg_id: u32, msg_id: u32,
/// User-defined info as passed to place_outgoing_call() /// User-defined info as passed to place_outgoing_call()
place_call_info: String, place_call_info: String,
/// True if incoming call is a video call.
has_video: bool,
}, },
/// Incoming call accepted. /// Incoming call accepted.
@@ -604,9 +606,11 @@ impl From<CoreEventType> for EventType {
CoreEventType::IncomingCall { CoreEventType::IncomingCall {
msg_id, msg_id,
place_call_info, place_call_info,
has_video,
} => IncomingCall { } => IncomingCall {
msg_id: msg_id.to_u32(), msg_id: msg_id.to_u32(),
place_call_info, place_call_info,
has_video,
}, },
CoreEventType::IncomingCallAccepted { msg_id } => IncomingCallAccepted { CoreEventType::IncomingCallAccepted { msg_id } => IncomingCallAccepted {
msg_id: msg_id.to_u32(), msg_id: msg_id.to_u32(),

View File

@@ -13,6 +13,7 @@ def test_calls(acfactory) -> None:
incoming_call_event = bob.wait_for_event(EventType.INCOMING_CALL) 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 == 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 = Message(bob, incoming_call_event.msg_id)
incoming_call_message.accept_incoming_call(accept_call_info) 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) end_call_event = bob.wait_for_event(EventType.CALL_ENDED)
assert end_call_event.msg_id == outgoing_call_message.id assert end_call_event.msg_id == outgoing_call_message.id
def test_video_call(acfactory) -> None:
# Example from <https://datatracker.ietf.org/doc/rfc9143/>
# 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

View File

@@ -8,12 +8,14 @@ use crate::contact::ContactId;
use crate::context::Context; use crate::context::Context;
use crate::events::EventType; use crate::events::EventType;
use crate::headerdef::HeaderDef; use crate::headerdef::HeaderDef;
use crate::log::info; use crate::log::{info, warn};
use crate::message::{self, Message, MsgId, Viewtype}; use crate::message::{self, Message, MsgId, Viewtype};
use crate::mimeparser::{MimeMessage, SystemMessage}; use crate::mimeparser::{MimeMessage, SystemMessage};
use crate::param::Param; use crate::param::Param;
use crate::tools::time; 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 std::time::Duration;
use tokio::task; use tokio::task;
use tokio::time::sleep; use tokio::time::sleep;
@@ -259,6 +261,7 @@ impl Context {
) -> Result<()> { ) -> Result<()> {
if mime_message.is_call() { if mime_message.is_call() {
let call = self.load_call_by_id(call_id).await?; let call = self.load_call_by_id(call_id).await?;
if call.is_incoming() { if call.is_incoming() {
if call.is_stale() { if call.is_stale() {
call.update_text(self, "Missed call").await?; call.update_text(self, "Missed call").await?;
@@ -266,9 +269,17 @@ impl Context {
} else { } else {
call.update_text(self, "Incoming call").await?; call.update_text(self, "Incoming call").await?;
self.emit_msgs_changed(call.msg.chat_id, call_id); // ringing calls are not additionally notified 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 { self.emit_event(EventType::IncomingCall {
msg_id: call.msg.id, msg_id: call.msg.id,
place_call_info: call.place_call_info.to_string(), place_call_info: call.place_call_info.to_string(),
has_video,
}); });
let wait = call.remaining_ring_seconds(); let wait = call.remaining_ring_seconds();
task::spawn(Context::emit_end_call_if_unaccepted( 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<bool> {
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)] #[cfg(test)]
mod calls_tests; mod calls_tests;

View File

@@ -19,9 +19,16 @@ async fn assert_text(t: &TestContext, call_id: MsgId, text: &str) -> Result<()>
} }
// Offer and answer examples from <https://www.rfc-editor.org/rfc/rfc3264> // Offer and answer examples from <https://www.rfc-editor.org/rfc/rfc3264>
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"; 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 <https://datatracker.ietf.org/doc/rfc9143/>
/// 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<CallSetup> { async fn setup_call() -> Result<CallSetup> {
let mut tcm = TestContextManager::new(); let mut tcm = TestContextManager::new();
let alice = tcm.alice().await; let alice = tcm.alice().await;
@@ -417,3 +424,10 @@ async fn test_update_call_text() -> Result<()> {
Ok(()) 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);
}

View File

@@ -386,6 +386,8 @@ pub enum EventType {
msg_id: MsgId, msg_id: MsgId,
/// User-defined info as passed to place_outgoing_call() /// User-defined info as passed to place_outgoing_call()
place_call_info: String, place_call_info: String,
/// True if incoming call is a video call.
has_video: bool,
}, },
/// Incoming call accepted. /// Incoming call accepted.