feat: add call ringing API (#6650)

this PR adds a "ringing" api that can be used for calls later.

see deltachat.h for details about the API; jsonrpc is left out until
things are settled for the needs of android/iOS

UI using this PR already successfully are
https://github.com/deltachat/deltachat-ios/pull/2638 and
https://github.com/deltachat/deltachat-android/pull/3785 ; the "payload"
passed forth and back is optimised for
https://github.com/deltachat/calls-webapp

---------

Co-authored-by: l <link2xt@testrun.org>
This commit is contained in:
bjoern
2025-08-30 23:48:38 +02:00
committed by GitHub
parent 4258088fb4
commit 0bbd910883
16 changed files with 1129 additions and 40 deletions

View File

@@ -1215,6 +1215,103 @@ void dc_set_webxdc_integration (dc_context_t* context, const char* f
uint32_t dc_init_webxdc_integration (dc_context_t* context, uint32_t chat_id);
/**
* Start an outgoing call.
* This sends a message with all relevant information to the callee,
* who will get informed by an #DC_EVENT_INCOMING_CALL event and rings.
*
* Possible actions during ringing:
*
* - caller cancels the call using dc_end_call():
* callee receives #DC_EVENT_CALL_ENDED
*
* - callee accepts using dc_accept_incoming_call():
* caller receives #DC_EVENT_OUTGOING_CALL_ACCEPTED.
* callee's devices receive #DC_EVENT_INCOMING_CALL_ACCEPTED, call starts
*
* - callee rejects using dc_end_call():
* caller receives #DC_EVENT_CALL_ENDED after 1 minute timeout.
* callee's other devices receive #DC_EVENT_CALL_ENDED
*
* - callee is already in a call:
* in this case, UI may decide to show a notification instead of ringing.
* otherwise, this is same as timeout
*
* - timeout:
* after 1 minute without action,
* caller and callee receive #DC_EVENT_CALL_ENDED
* to prevent endless ringing of callee
* in case caller got offline without being able to send cancellation message
*
* Actions during the call:
*
* - caller ends the call using dc_end_call():
* callee receives #DC_EVENT_CALL_ENDED
*
* - callee ends the call using dc_end_call():
* caller receives #DC_EVENT_CALL_ENDED
*
* Note, that the events are for updating the call screen,
* possible status messages are added and updated as usual, including the known events.
* In the UI, the sorted chatlist is used as an overview about calls as well as messages.
* To place a call with a contact that has no chat yet, use dc_create_chat_by_contact_id() first.
*
* UI will usually allow only one call at the same time,
* this has to be tracked by UI across profile, the core does not track this.
*
* @memberof dc_context_t
* @param context The context object.
* @param chat_id The chat to place a call for.
* This needs to be a one-to-one chat.
* @param place_call_info any data that other devices receive
* in #DC_EVENT_INCOMING_CALL.
* @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);
/**
* Accept incoming call.
*
* This implicitly accepts the contact request, if not yet done.
* All affected devices will receive
* either #DC_EVENT_OUTGOING_CALL_ACCEPTED or #DC_EVENT_INCOMING_CALL_ACCEPTED.
*
* @memberof dc_context_t
* @param context The context object.
* @param msg_id The ID of the call to accept.
* This is the ID reported by #DC_EVENT_INCOMING_CALL
* and equals to the ID of the corresponding info message.
* @param accept_call_info any data that other devices receive
* in #DC_EVENT_OUTGOING_CALL_ACCEPTED or #DC_EVENT_INCOMING_CALL_ACCEPTED.
* @return 1=success, 0=error
*/
int dc_accept_incoming_call (dc_context_t* context, uint32_t msg_id, const char* accept_call_info);
/**
* End incoming or outgoing call.
*
* From the view of the caller, a "cancellation",
* from the view of callee, a "rejection".
* If the call was accepted, this is a "hangup".
*
* For accepted calls,
* all participant devices get informed about the ended call via #DC_EVENT_CALL_ENDED.
* For not accepted calls, only the caller will inform the callee.
*
* If the callee rejects, the caller will get a timeout or give up at some point -
* same as for all other reasons the call cannot be established: Device not in reach, device muted, connectivity etc.
* This is to protect privacy of the callee, avoiding to check if callee is online.
*
* @memberof dc_context_t
* @param context The context object.
* @param msg_id the ID of the call.
* @return 1=success, 0=error
*/
int dc_end_call (dc_context_t* context, uint32_t msg_id);
/**
* Save a draft for a chat in the database.
*
@@ -4546,6 +4643,8 @@ int dc_msg_is_info (const dc_msg_t* msg);
* and also offer a way to fix the encryption, eg. by a button offering a QR scan
* - DC_INFO_WEBXDC_INFO_MESSAGE (32) - Info-message created by webxdc app sending `update.info`
* - DC_INFO_CHAT_E2EE (50) - Info-message for "Chat is end-to-end-encrypted"
* - DC_INFO_OUTGOING_CALL (60) - Info-message refers to an outgoing call
* - DC_INFO_INCOMING_CALL (65) - Info-message refers to an incoming call
*
* For the messages that refer to a CONTACT,
* dc_msg_get_info_contact_id() returns the contact ID.
@@ -4602,6 +4701,8 @@ uint32_t dc_msg_get_info_contact_id (const dc_msg_t* msg);
#define DC_INFO_INVALID_UNENCRYPTED_MAIL 13
#define DC_INFO_WEBXDC_INFO_MESSAGE 32
#define DC_INFO_CHAT_E2EE 50
#define DC_INFO_OUTGOING_CALL 60
#define DC_INFO_INCOMING_CALL 65
/**
@@ -6636,6 +6737,63 @@ void dc_event_unref(dc_event_t* event);
*/
#define DC_EVENT_CHANNEL_OVERFLOW 2400
/**
* Incoming call.
* UI will usually start ringing,
* or show a notification if there is already a call in some profile.
*
* Together with this event,
* an info-message is added to the corresponding chat.
* The info-message, however, is _not_ additionally notified using #DC_EVENT_INCOMING_MSG,
* if needed, this has to be done by the UI explicitly.
*
* If user takes action, dc_accept_incoming_call() or dc_end_call() should be called.
*
* Otherwise, ringing should end on #DC_EVENT_CALL_ENDED
* or #DC_EVENT_INCOMING_CALL_ACCEPTED
*
* @param data1 (int) msg_id ID of the info-message referring to the call.
* @param data2 (char*) place_call_info, text passed to dc_place_outgoing_call()
*/
#define DC_EVENT_INCOMING_CALL 2550
/**
* The callee accepted an incoming call on another device using dc_accept_incoming_call().
* The caller gets the event #DC_EVENT_OUTGOING_CALL_ACCEPTED at the same time.
*
* The event is sent unconditionally when the corresponding message is received.
* UI should only take action in case call UI was opened before, otherwise the event should be ignored.
*
* @param data1 (int) msg_id ID of the info-message referring to the call
* @param data2 (char*) accept_call_info, text passed to dc_place_outgoing_call()
*/
#define DC_EVENT_INCOMING_CALL_ACCEPTED 2560
/**
* A call placed using dc_place_outgoing_call() was accepted by the callee using dc_accept_incoming_call().
*
* The event is sent unconditionally when the corresponding message is received.
* UI should only take action in case call UI was opened before, otherwise the event should be ignored.
*
* @param data1 (int) msg_id ID of the info-message referring to the call
* @param data2 (char*) accept_call_info, text passed to dc_accept_incoming_call()
*/
#define DC_EVENT_OUTGOING_CALL_ACCEPTED 2570
/**
* An incoming or outgoing call was ended using dc_end_call().
* Moreover, the event is sent when the call was not accepted within 1 minute timeout.
*
* The event is sent unconditionally when the corresponding message is received.
* UI should only take action in case call UI was opened before, otherwise the event should be ignored.
*
* @param data1 (int) msg_id ID of the info-message referring to the call
*/
#define DC_EVENT_CALL_ENDED 2580
/**
* @}
*/

View File

@@ -556,6 +556,10 @@ pub unsafe extern "C" fn dc_event_get_id(event: *mut dc_event_t) -> libc::c_int
EventType::AccountsChanged => 2302,
EventType::AccountsItemChanged => 2303,
EventType::EventChannelOverflow { .. } => 2400,
EventType::IncomingCall { .. } => 2550,
EventType::IncomingCallAccepted { .. } => 2560,
EventType::OutgoingCallAccepted { .. } => 2570,
EventType::CallEnded { .. } => 2580,
#[allow(unreachable_patterns)]
#[cfg(test)]
_ => unreachable!("This is just to silence a rust_analyzer false-positive"),
@@ -619,7 +623,11 @@ pub unsafe extern "C" fn dc_event_get_data1_int(event: *mut dc_event_t) -> libc:
EventType::WebxdcRealtimeData { msg_id, .. }
| EventType::WebxdcStatusUpdate { msg_id, .. }
| EventType::WebxdcRealtimeAdvertisementReceived { msg_id }
| EventType::WebxdcInstanceDeleted { msg_id, .. } => msg_id.to_u32() as libc::c_int,
| EventType::WebxdcInstanceDeleted { msg_id, .. }
| EventType::IncomingCall { msg_id, .. }
| EventType::IncomingCallAccepted { msg_id, .. }
| EventType::OutgoingCallAccepted { msg_id, .. }
| EventType::CallEnded { msg_id, .. } => msg_id.to_u32() as libc::c_int,
EventType::ChatlistItemChanged { chat_id } => {
chat_id.unwrap_or_default().to_u32() as libc::c_int
}
@@ -671,6 +679,10 @@ 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 { .. }
| EventType::EventChannelOverflow { .. } => 0,
EventType::MsgsChanged { msg_id, .. }
| EventType::ReactionsChanged { msg_id, .. }
@@ -767,8 +779,23 @@ pub unsafe extern "C" fn dc_event_get_data2_str(event: *mut dc_event_t) -> *mut
| EventType::ChatlistChanged
| EventType::AccountsChanged
| EventType::AccountsItemChanged
| EventType::WebxdcRealtimeAdvertisementReceived { .. }
| EventType::EventChannelOverflow { .. } => ptr::null_mut(),
| EventType::WebxdcRealtimeAdvertisementReceived { .. } => ptr::null_mut(),
EventType::IncomingCall {
place_call_info, ..
} => {
let data2 = place_call_info.to_c_string().unwrap_or_default();
data2.into_raw()
}
EventType::IncomingCallAccepted {
accept_call_info, ..
}
| EventType::OutgoingCallAccepted {
accept_call_info, ..
} => {
let data2 = accept_call_info.to_c_string().unwrap_or_default();
data2.into_raw()
}
EventType::CallEnded { .. } | EventType::EventChannelOverflow { .. } => ptr::null_mut(),
EventType::ConfigureProgress { comment, .. } => {
if let Some(comment) = comment {
comment.to_c_string().unwrap_or_default().into_raw()
@@ -1167,6 +1194,61 @@ pub unsafe extern "C" fn dc_init_webxdc_integration(
.unwrap_or(0)
}
#[no_mangle]
pub unsafe extern "C" fn dc_place_outgoing_call(
context: *mut dc_context_t,
chat_id: u32,
place_call_info: *const libc::c_char,
) -> u32 {
if context.is_null() || chat_id == 0 {
eprintln!("ignoring careless call to dc_place_outgoing_call()");
return 0;
}
let ctx = &*context;
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))
.context("Failed to place call")
.log_err(ctx)
.map(|msg_id| msg_id.to_u32())
.unwrap_or_log_default(ctx, "Failed to place call")
}
#[no_mangle]
pub unsafe extern "C" fn dc_accept_incoming_call(
context: *mut dc_context_t,
msg_id: u32,
accept_call_info: *const libc::c_char,
) -> libc::c_int {
if context.is_null() || msg_id == 0 {
eprintln!("ignoring careless call to dc_accept_incoming_call()");
return 0;
}
let ctx = &*context;
let msg_id = MsgId::new(msg_id);
let accept_call_info = to_string_lossy(accept_call_info);
block_on(ctx.accept_incoming_call(msg_id, accept_call_info))
.context("Failed to accept call")
.is_ok() as libc::c_int
}
#[no_mangle]
pub unsafe extern "C" fn dc_end_call(context: *mut dc_context_t, msg_id: u32) -> libc::c_int {
if context.is_null() || msg_id == 0 {
eprintln!("ignoring careless call to dc_end_call()");
return 0;
}
let ctx = &*context;
let msg_id = MsgId::new(msg_id);
block_on(ctx.end_call(msg_id))
.context("Failed to end call")
.log_err(ctx)
.is_ok() as libc::c_int
}
#[no_mangle]
pub unsafe extern "C" fn dc_set_draft(
context: *mut dc_context_t,

View File

@@ -416,6 +416,37 @@ pub enum EventType {
/// Number of events skipped.
n: u64,
},
/// Incoming call.
IncomingCall {
/// ID of the info message referring to the call.
msg_id: u32,
/// User-defined info as passed to place_outgoing_call()
place_call_info: String,
},
/// Incoming call accepted.
/// This is esp. interesting to stop ringing on other devices.
IncomingCallAccepted {
/// ID of the info message referring to the call.
msg_id: u32,
/// User-defined info passed to dc_accept_incoming_call()
accept_call_info: String,
},
/// Outgoing call accepted.
OutgoingCallAccepted {
/// ID of the info message referring to the call.
msg_id: u32,
/// User-defined info passed to dc_accept_incoming_call(
accept_call_info: String,
},
/// Call ended.
CallEnded {
/// ID of the info message referring to the call.
msg_id: u32,
},
}
impl From<CoreEventType> for EventType {
@@ -566,6 +597,30 @@ impl From<CoreEventType> for EventType {
CoreEventType::EventChannelOverflow { n } => EventChannelOverflow { n },
CoreEventType::AccountsChanged => AccountsChanged,
CoreEventType::AccountsItemChanged => AccountsItemChanged,
CoreEventType::IncomingCall {
msg_id,
place_call_info,
} => IncomingCall {
msg_id: msg_id.to_u32(),
place_call_info,
},
CoreEventType::IncomingCallAccepted {
msg_id,
accept_call_info,
} => IncomingCallAccepted {
msg_id: msg_id.to_u32(),
accept_call_info,
},
CoreEventType::OutgoingCallAccepted {
msg_id,
accept_call_info,
} => OutgoingCallAccepted {
msg_id: msg_id.to_u32(),
accept_call_info,
},
CoreEventType::CallEnded { msg_id } => CallEnded {
msg_id: msg_id.to_u32(),
},
#[allow(unreachable_patterns)]
#[cfg(test)]
_ => unreachable!("This is just to silence a rust_analyzer false-positive"),

View File

@@ -437,6 +437,11 @@ pub enum SystemMessageType {
/// This message contains a users iroh node address.
IrohNodeAddr,
OutgoingCall,
IncomingCall,
CallAccepted,
CallEnded,
}
impl From<deltachat::mimeparser::SystemMessage> for SystemMessageType {
@@ -463,6 +468,10 @@ impl From<deltachat::mimeparser::SystemMessage> for SystemMessageType {
SystemMessage::IrohNodeAddr => SystemMessageType::IrohNodeAddr,
SystemMessage::SecurejoinWait => SystemMessageType::SecurejoinWait,
SystemMessage::SecurejoinWaitTimeout => SystemMessageType::SecurejoinWaitTimeout,
SystemMessage::OutgoingCall => SystemMessageType::OutgoingCall,
SystemMessage::IncomingCall => SystemMessageType::IncomingCall,
SystemMessage::CallAccepted => SystemMessageType::CallAccepted,
SystemMessage::CallEnded => SystemMessageType::CallEnded,
}
}
}

View File

@@ -160,32 +160,6 @@ def test_html_message(acfactory, lp):
assert html_text in msg2.html
def test_videochat_invitation_message(acfactory, lp):
ac1, ac2 = acfactory.get_online_accounts(2)
chat = acfactory.get_accepted_chat(ac1, ac2)
text = "You are invited to a video chat, click https://meet.jit.si/WxEGad0gGzX to join."
lp.sec("ac1: prepare and send text message to ac2")
msg1 = chat.send_text("message0")
assert not msg1.is_videochat_invitation()
lp.sec("wait for ac2 to receive message")
msg2 = ac2._evtracker.wait_next_incoming_message()
assert msg2.text == "message0"
assert not msg2.is_videochat_invitation()
lp.sec("ac1: prepare and send videochat invitation to ac2")
msg1 = Message.new_empty(ac1, "videochat")
msg1.set_text(text)
msg1 = chat.send_msg(msg1)
assert msg1.is_videochat_invitation()
lp.sec("wait for ac2 to receive message")
msg2 = ac2._evtracker.wait_next_incoming_message()
assert msg2.text == text
assert msg2.is_videochat_invitation()
def test_webxdc_message(acfactory, data, lp):
ac1, ac2 = acfactory.get_online_accounts(2)
chat = acfactory.get_accepted_chat(ac1, ac2)

307
src/calls.rs Normal file
View File

@@ -0,0 +1,307 @@
//! # Handle calls.
//!
//! Internally, calls are bound to the user-visible info message initializing the call.
//! This means, the "Call ID" is a "Message ID" currently - similar to webxdc.
use crate::chat::{Chat, ChatId, send_msg};
use crate::constants::Chattype;
use crate::context::Context;
use crate::events::EventType;
use crate::headerdef::HeaderDef;
use crate::message::{self, Message, MsgId, Viewtype, rfc724_mid_exists};
use crate::mimeparser::{MimeMessage, SystemMessage};
use crate::param::Param;
use crate::sync::SyncData;
use crate::tools::time;
use anyhow::{Result, ensure};
use std::time::Duration;
use tokio::task;
use tokio::time::sleep;
/// How long callee's or caller's phone ring.
///
/// For the callee, this is to prevent endless ringing
/// in case the initial "call" is received, but then the caller went offline.
/// Moreover, this prevents outdated calls to ring
/// in case the initial "call" message arrives delayed.
///
/// For the caller, this means they should also not wait longer,
/// as the callee won't start the call afterwards.
const RINGING_SECONDS: i64 = 60;
/// Information about the status of a call.
#[derive(Debug, Default)]
pub struct CallInfo {
/// Incoming or outgoing call?
pub is_incoming: bool,
/// Was an incoming call accepted on this device?
/// For privacy reasons, only for accepted incoming calls, callee sends a message to caller on `end_call()`.
/// On other devices and for outgoing calls, `is_accepted` is never set.
pub is_accepted: bool,
/// User-defined text as given to place_outgoing_call()
pub place_call_info: String,
/// User-defined text as given to accept_incoming_call()
pub accept_call_info: String,
/// Info message referring to the call.
pub msg: Message,
}
impl CallInfo {
fn is_stale_call(&self) -> bool {
self.remaining_ring_seconds() <= 0
}
fn remaining_ring_seconds(&self) -> i64 {
let remaining_seconds = self.msg.timestamp_sent + RINGING_SECONDS - time();
remaining_seconds.clamp(0, RINGING_SECONDS)
}
async fn update_text(&self, context: &Context, text: &str) -> Result<()> {
context
.sql
.execute(
"UPDATE msgs SET txt=?, txt_normalized=? WHERE id=?",
(text, message::normalize_text(text), self.msg.id),
)
.await?;
Ok(())
}
}
impl Context {
/// Start an outgoing call.
pub async fn place_outgoing_call(
&self,
chat_id: ChatId,
place_call_info: String,
) -> Result<MsgId> {
let chat = Chat::load_from_db(self, chat_id).await?;
ensure!(chat.typ == Chattype::Single && !chat.is_self_talk());
let mut call = Message {
viewtype: Viewtype::Text,
text: "Calling...".into(),
..Default::default()
};
call.param.set_cmd(SystemMessage::OutgoingCall);
call.param.set(Param::WebrtcRoom, &place_call_info);
call.id = send_msg(self, chat_id, &mut call).await?;
let wait = RINGING_SECONDS;
task::spawn(Context::emit_end_call_if_unaccepted(
self.clone(),
wait.try_into()?,
call.id,
));
Ok(call.id)
}
/// Accept an incoming call.
pub async fn accept_incoming_call(
&self,
call_id: MsgId,
accept_call_info: String,
) -> Result<()> {
let mut call: CallInfo = self.load_call_by_id(call_id).await?;
ensure!(call.is_incoming);
let chat = Chat::load_from_db(self, call.msg.chat_id).await?;
if chat.is_contact_request() {
chat.id.accept(self).await?;
}
call.msg
.mark_call_as_accepted(self, accept_call_info.to_string())
.await?;
// send an acceptance message around: to the caller as well as to the other devices of the callee
let mut msg = Message {
viewtype: Viewtype::Text,
text: "Call accepted".into(),
..Default::default()
};
msg.param.set_cmd(SystemMessage::CallAccepted);
msg.param
.set(Param::WebrtcAccepted, accept_call_info.to_string());
msg.set_quote(self, Some(&call.msg)).await?;
msg.id = send_msg(self, call.msg.chat_id, &mut msg).await?;
self.emit_event(EventType::IncomingCallAccepted {
msg_id: call.msg.id,
accept_call_info,
});
Ok(())
}
/// Cancel, reject or hangup an incoming or outgoing call.
pub async fn end_call(&self, call_id: MsgId) -> Result<()> {
let call: CallInfo = self.load_call_by_id(call_id).await?;
if call.is_accepted || !call.is_incoming {
let mut msg = Message {
viewtype: Viewtype::Text,
text: "Call ended".into(),
..Default::default()
};
msg.param.set_cmd(SystemMessage::CallEnded);
msg.set_quote(self, Some(&call.msg)).await?;
msg.id = send_msg(self, call.msg.chat_id, &mut msg).await?;
} else if call.is_incoming {
// to protect privacy, we do not send a message to others from callee for unaccepted calls
self.add_sync_item(SyncData::RejectIncomingCall {
msg: call.msg.rfc724_mid,
})
.await?;
self.scheduler.interrupt_inbox().await;
}
self.emit_event(EventType::CallEnded {
msg_id: call.msg.id,
});
Ok(())
}
async fn emit_end_call_if_unaccepted(
context: Context,
wait: u64,
call_id: MsgId,
) -> Result<()> {
sleep(Duration::from_secs(wait)).await;
let call = context.load_call_by_id(call_id).await?;
if !call.is_accepted {
context.emit_event(EventType::CallEnded {
msg_id: call.msg.id,
});
}
Ok(())
}
pub(crate) async fn handle_call_msg(
&self,
mime_message: &MimeMessage,
call_id: MsgId,
) -> Result<()> {
match mime_message.is_system_message {
SystemMessage::IncomingCall => {
let call = self.load_call_by_id(call_id).await?;
if call.is_incoming {
if call.is_stale_call() {
call.update_text(self, "Missed call").await?;
self.emit_incoming_msg(call.msg.chat_id, call_id);
} else {
self.emit_msgs_changed(call.msg.chat_id, call_id);
self.emit_event(EventType::IncomingCall {
msg_id: call.msg.id,
place_call_info: call.place_call_info.to_string(),
});
let wait = call.remaining_ring_seconds();
task::spawn(Context::emit_end_call_if_unaccepted(
self.clone(),
wait.try_into()?,
call.msg.id,
));
}
} else {
self.emit_msgs_changed(call.msg.chat_id, call_id);
}
}
SystemMessage::CallAccepted => {
let call = self.load_call_by_id(call_id).await?;
self.emit_msgs_changed(call.msg.chat_id, call_id);
if call.is_incoming {
self.emit_event(EventType::IncomingCallAccepted {
msg_id: call.msg.id,
accept_call_info: call.accept_call_info,
});
} else {
let accept_call_info = mime_message
.get_header(HeaderDef::ChatWebrtcAccepted)
.unwrap_or_default();
call.msg
.clone()
.mark_call_as_accepted(self, accept_call_info.to_string())
.await?;
self.emit_event(EventType::OutgoingCallAccepted {
msg_id: call.msg.id,
accept_call_info: accept_call_info.to_string(),
});
}
}
SystemMessage::CallEnded => {
let call = self.load_call_by_id(call_id).await?;
self.emit_msgs_changed(call.msg.chat_id, call_id);
self.emit_event(EventType::CallEnded {
msg_id: call.msg.id,
});
}
_ => {}
}
Ok(())
}
pub(crate) async fn sync_call_rejection(&self, rfc724_mid: &str) -> Result<()> {
if let Some((msg_id, _)) = rfc724_mid_exists(self, rfc724_mid).await? {
self.emit_event(EventType::CallEnded { msg_id });
}
Ok(())
}
async fn load_call_by_id(&self, call_id: MsgId) -> Result<CallInfo> {
let call = Message::load_from_db(self, call_id).await?;
self.load_call_by_message(call)
}
fn load_call_by_message(&self, call: Message) -> Result<CallInfo> {
ensure!(
call.get_info_type() == SystemMessage::IncomingCall
|| call.get_info_type() == SystemMessage::OutgoingCall
);
Ok(CallInfo {
is_incoming: call.get_info_type() == SystemMessage::IncomingCall,
is_accepted: call.is_call_accepted()?,
place_call_info: call
.param
.get(Param::WebrtcRoom)
.unwrap_or_default()
.to_string(),
accept_call_info: call
.param
.get(Param::WebrtcAccepted)
.unwrap_or_default()
.to_string(),
msg: call,
})
}
}
impl Message {
async fn mark_call_as_accepted(
&mut self,
context: &Context,
accept_call_info: String,
) -> Result<()> {
ensure!(
self.get_info_type() == SystemMessage::IncomingCall
|| self.get_info_type() == SystemMessage::OutgoingCall
);
self.param.set_int(Param::Arg, 1);
self.param.set(Param::WebrtcAccepted, accept_call_info);
self.update_param(context).await?;
Ok(())
}
fn is_call_accepted(&self) -> Result<bool> {
ensure!(
self.get_info_type() == SystemMessage::IncomingCall
|| self.get_info_type() == SystemMessage::OutgoingCall
);
Ok(self.param.get_int(Param::Arg) == Some(1))
}
}
#[cfg(test)]
mod calls_tests;

372
src/calls/calls_tests.rs Normal file
View File

@@ -0,0 +1,372 @@
use super::*;
use crate::config::Config;
use crate::test_utils::{TestContext, TestContextManager, sync};
struct CallSetup {
pub alice: TestContext,
pub alice2: TestContext,
pub alice_call: Message,
pub bob: TestContext,
pub bob2: TestContext,
pub bob_call: Message,
pub bob2_call: Message,
}
async fn setup_call() -> Result<CallSetup> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let alice2 = tcm.alice().await;
let bob = tcm.bob().await;
let bob2 = tcm.bob().await;
for t in [&alice, &alice2, &bob, &bob2] {
t.set_config_bool(Config::SyncMsgs, true).await?;
}
// Alice creates a chat with Bob and places an outgoing call there.
// Alice's other device sees the same message as an outgoing call.
let alice_chat = alice.create_chat(&bob).await;
let test_msg_id = alice
.place_outgoing_call(alice_chat.id, "place_info".to_string())
.await?;
let sent1 = alice.pop_sent_msg().await;
assert_eq!(sent1.sender_msg_id, test_msg_id);
let alice_call = Message::load_from_db(&alice, sent1.sender_msg_id).await?;
let alice2_call = alice2.recv_msg(&sent1).await;
for (t, m) in [(&alice, &alice_call), (&alice2, &alice2_call)] {
assert!(m.is_info());
assert_eq!(m.get_info_type(), SystemMessage::OutgoingCall);
let info = t.load_call_by_id(m.id).await?;
assert!(!info.is_accepted);
assert_eq!(info.place_call_info, "place_info");
}
// Bob receives the message referring to the call on two devices;
// it is an incoming call from the view of Bob
let bob_call = bob.recv_msg(&sent1).await;
let bob2_call = bob2.recv_msg(&sent1).await;
for (t, m) in [(&bob, &bob_call), (&bob2, &bob2_call)] {
assert!(m.is_info());
assert_eq!(m.get_info_type(), SystemMessage::IncomingCall);
t.evtracker
.get_matching(|evt| matches!(evt, EventType::IncomingCall { .. }))
.await;
let info = t.load_call_by_id(m.id).await?;
assert!(!info.is_accepted);
assert_eq!(info.place_call_info, "place_info");
}
Ok(CallSetup {
alice,
alice2,
alice_call,
bob,
bob2,
bob_call,
bob2_call,
})
}
async fn accept_call() -> Result<CallSetup> {
let CallSetup {
alice,
alice2,
alice_call,
bob,
bob2,
bob_call,
bob2_call,
} = setup_call().await?;
// Bob accepts the incoming call, this does not add an additional message to the chat
bob.accept_incoming_call(bob_call.id, "accepted_info".to_string())
.await?;
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::IncomingCallAccepted { .. }))
.await;
let sent2 = bob.pop_sent_msg().await;
let info = bob.load_call_by_id(bob_call.id).await?;
assert!(info.is_accepted);
assert_eq!(info.place_call_info, "place_info");
assert_eq!(info.accept_call_info, "accepted_info");
let bob_accept_msg = bob2.recv_msg(&sent2).await;
assert!(bob_accept_msg.is_info());
assert_eq!(bob_accept_msg.get_info_type(), SystemMessage::CallAccepted);
bob2.evtracker
.get_matching(|evt| matches!(evt, EventType::IncomingCallAccepted { .. }))
.await;
let info = bob2.load_call_by_id(bob2_call.id).await?;
assert!(!info.is_accepted); // "accepted" is only true on the device that does the call
// Alice receives the acceptance message
let alice_accept_msg = alice.recv_msg(&sent2).await;
assert!(alice_accept_msg.is_info());
assert_eq!(
alice_accept_msg.get_info_type(),
SystemMessage::CallAccepted
);
alice
.evtracker
.get_matching(|evt| matches!(evt, EventType::OutgoingCallAccepted { .. }))
.await;
let info = alice.load_call_by_id(alice_call.id).await?;
assert!(info.is_accepted);
assert_eq!(info.place_call_info, "place_info");
assert_eq!(info.accept_call_info, "accepted_info");
let alice2_accept_msg = alice2.recv_msg(&sent2).await;
assert!(alice2_accept_msg.is_info());
assert_eq!(
alice2_accept_msg.get_info_type(),
SystemMessage::CallAccepted
);
alice2
.evtracker
.get_matching(|evt| matches!(evt, EventType::OutgoingCallAccepted { .. }))
.await;
Ok(CallSetup {
alice,
alice2,
alice_call,
bob,
bob2,
bob_call,
bob2_call,
})
}
fn assert_is_call_ended_info_msg(msg: Message) {
assert!(msg.is_info());
assert_eq!(msg.get_info_type(), SystemMessage::CallEnded);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_accept_call_callee_ends() -> Result<()> {
// Alice calls Bob, Bob accepts
let CallSetup {
alice,
alice2,
bob,
bob2,
bob_call,
..
} = accept_call().await?;
// Bob has accepted the call and also ends it
bob.end_call(bob_call.id).await?;
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
let sent3 = bob.pop_sent_msg().await;
let bob2_end_call_msg = bob2.recv_msg(&sent3).await;
assert_is_call_ended_info_msg(bob2_end_call_msg);
bob2.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
// Alice receives the ending message
let alice_end_call_msg = alice.recv_msg(&sent3).await;
assert_is_call_ended_info_msg(alice_end_call_msg);
alice
.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
let alice2_end_call_msg = alice2.recv_msg(&sent3).await;
assert_is_call_ended_info_msg(alice2_end_call_msg);
alice2
.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_accept_call_caller_ends() -> Result<()> {
// Alice calls Bob, Bob accepts
let CallSetup {
alice,
alice2,
bob,
bob2,
bob_call,
..
} = accept_call().await?;
// Bob has accepted the call but Alice ends it
alice.end_call(bob_call.id).await?;
alice
.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
let sent3 = alice.pop_sent_msg().await;
let alice2_end_call_msg = alice2.recv_msg(&sent3).await;
assert_is_call_ended_info_msg(alice2_end_call_msg);
alice2
.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
// Bob receives the ending message
let bob_end_call_msg = bob.recv_msg(&sent3).await;
assert_is_call_ended_info_msg(bob_end_call_msg);
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
let bob2_end_call_msg = bob2.recv_msg(&sent3).await;
assert_is_call_ended_info_msg(bob2_end_call_msg);
bob2.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_callee_rejects_call() -> Result<()> {
// Alice calls Bob
let CallSetup {
bob,
bob2,
bob_call,
..
} = setup_call().await?;
// Bob does not want to talk with Alice.
// To protect Bob's privacy, no message is sent to Alice (who will time out).
// To let Bob close the call window on all devices, a sync message is used instead.
bob.end_call(bob_call.id).await?;
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
sync(&bob, &bob2).await;
bob2.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_caller_cancels_call() -> Result<()> {
// Alice calls Bob
let CallSetup {
alice,
alice2,
alice_call,
bob,
bob2,
..
} = setup_call().await?;
// Alice changes their mind before Bob picks up
alice.end_call(alice_call.id).await?;
alice
.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
let sent3 = alice.pop_sent_msg().await;
let alice2_call_ended_msg = alice2.recv_msg(&sent3).await;
assert_is_call_ended_info_msg(alice2_call_ended_msg);
alice2
.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
// Bob receives the ending message
let bob_call_ended_msg = bob.recv_msg(&sent3).await;
assert_is_call_ended_info_msg(bob_call_ended_msg);
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
let bob2_call_ended_msg = bob2.recv_msg(&sent3).await;
assert_is_call_ended_info_msg(bob2_call_ended_msg);
bob2.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_is_stale_call() -> Result<()> {
// a call started now is not stale
let call_info = CallInfo {
msg: Message {
timestamp_sent: time(),
..Default::default()
},
..Default::default()
};
assert!(!call_info.is_stale_call());
let remaining_seconds = call_info.remaining_ring_seconds();
assert!(remaining_seconds == RINGING_SECONDS || remaining_seconds == RINGING_SECONDS - 1);
// call started 5 seconds ago, this is not stale as well
let call_info = CallInfo {
msg: Message {
timestamp_sent: time() - 5,
..Default::default()
},
..Default::default()
};
assert!(!call_info.is_stale_call());
let remaining_seconds = call_info.remaining_ring_seconds();
assert!(remaining_seconds == RINGING_SECONDS - 5 || remaining_seconds == RINGING_SECONDS - 6);
// a call started one hour ago is clearly stale
let call_info = CallInfo {
msg: Message {
timestamp_sent: time() - 3600,
..Default::default()
},
..Default::default()
};
assert!(call_info.is_stale_call());
assert_eq!(call_info.remaining_ring_seconds(), 0);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_mark_call_as_accepted() -> Result<()> {
let CallSetup {
alice, alice_call, ..
} = setup_call().await?;
assert!(!alice_call.is_call_accepted()?);
let mut alice_call = Message::load_from_db(&alice, alice_call.id).await?;
assert!(!alice_call.is_call_accepted()?);
alice_call
.mark_call_as_accepted(&alice, "accepted_info".to_string())
.await?;
assert!(alice_call.is_call_accepted()?);
let alice_call = Message::load_from_db(&alice, alice_call.id).await?;
assert!(alice_call.is_call_accepted()?);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_udpate_call_text() -> Result<()> {
let CallSetup {
alice, alice_call, ..
} = setup_call().await?;
let call_info = alice.load_call_by_id(alice_call.id).await?;
call_info.update_text(&alice, "foo bar").await?;
let alice_call = Message::load_from_db(&alice, alice_call.id).await?;
assert_eq!(alice_call.get_text(), "foo bar");
Ok(())
}

View File

@@ -376,6 +376,36 @@ pub enum EventType {
/// This event is emitted from the account whose property changed.
AccountsItemChanged,
/// Incoming call.
IncomingCall {
/// ID of the message referring to the call.
msg_id: MsgId,
/// User-defined info as passed to place_outgoing_call()
place_call_info: String,
},
/// Incoming call accepted.
IncomingCallAccepted {
/// ID of the message referring to the call.
msg_id: MsgId,
/// User-defined info as passed to accept_incoming_call()
accept_call_info: String,
},
/// Outgoing call accepted.
OutgoingCallAccepted {
/// ID of the message referring to the call.
msg_id: MsgId,
/// User-defined info as passed to accept_incoming_call()
accept_call_info: String,
},
/// Call ended.
CallEnded {
/// ID of the message referring to the call.
msg_id: MsgId,
},
/// Event for using in tests, e.g. as a fence between normally generated events.
#[cfg(test)]
Test,

View File

@@ -86,6 +86,7 @@ pub enum HeaderDef {
ChatDispositionNotificationTo,
ChatWebrtcRoom,
ChatWebrtcAccepted,
/// This message deletes the messages listed in the value by rfc724_mid.
ChatDelete,

View File

@@ -53,6 +53,7 @@ pub use events::*;
mod aheader;
pub mod blob;
pub mod calls;
pub mod chat;
pub mod chatlist;
pub mod config;

View File

@@ -973,6 +973,10 @@ impl Message {
| SystemMessage::WebxdcStatusUpdate
| SystemMessage::WebxdcInfoMessage
| SystemMessage::IrohNodeAddr
| SystemMessage::OutgoingCall
| SystemMessage::IncomingCall
| SystemMessage::CallAccepted
| SystemMessage::CallEnded
| SystemMessage::Unknown => Ok(None),
}
}

View File

@@ -3,7 +3,7 @@
use std::collections::{BTreeSet, HashSet};
use std::io::Cursor;
use anyhow::{Context as _, Result, bail, ensure};
use anyhow::{Context as _, Result, anyhow, bail, ensure};
use base64::Engine as _;
use data_encoding::BASE32_NOPAD;
use deltachat_contact_tools::sanitize_bidi_characters;
@@ -1533,6 +1533,27 @@ impl MimeFactory {
.into(),
));
}
SystemMessage::OutgoingCall => {
headers.push((
"Chat-Content",
mail_builder::headers::raw::Raw::new("call").into(),
));
}
SystemMessage::IncomingCall => {
return Err(anyhow!("Unexpected incoming call rendering."));
}
SystemMessage::CallAccepted => {
headers.push((
"Chat-Content",
mail_builder::headers::raw::Raw::new("call-accepted").into(),
));
}
SystemMessage::CallEnded => {
headers.push((
"Chat-Content",
mail_builder::headers::raw::Raw::new("call-ended").into(),
));
}
_ => {}
}
@@ -1557,6 +1578,9 @@ impl MimeFactory {
"Chat-Content",
mail_builder::headers::raw::Raw::new("videochat-invitation").into(),
));
}
if msg.param.exists(Param::WebrtcRoom) {
headers.push((
"Chat-Webrtc-Room",
mail_builder::headers::raw::Raw::new(
@@ -1567,6 +1591,17 @@ impl MimeFactory {
)
.into(),
));
} else if msg.param.exists(Param::WebrtcAccepted) {
headers.push((
"Chat-Webrtc-Accepted",
mail_builder::headers::raw::Raw::new(
msg.param
.get(Param::WebrtcAccepted)
.unwrap_or_default()
.to_string(),
)
.into(),
));
}
if msg.viewtype == Viewtype::Voice

View File

@@ -216,6 +216,22 @@ pub enum SystemMessage {
/// "Messages are end-to-end encrypted."
ChatE2ee = 50,
/// This system message represents an outgoing call.
/// This message is visible to the user as an "info" message.
OutgoingCall = 60,
/// This system message represents an incoming call.
/// This message is visible to the user as an "info" message.
IncomingCall = 65,
/// Message indicating that a call was accepted.
/// While the 1:1 call may be established elsewhere,
/// the message is still needed for a multidevice setup, so that other devices stop ringing.
CallAccepted = 66,
/// Message indicating that a call was ended.
CallEnded = 67,
}
const MIME_AC_SETUP_FILE: &str = "application/autocrypt-setup";
@@ -676,6 +692,16 @@ impl MimeMessage {
self.is_system_message = SystemMessage::ChatProtectionDisabled;
} else if value == "group-avatar-changed" {
self.is_system_message = SystemMessage::GroupImageChanged;
} else if value == "call" {
self.is_system_message = if self.incoming {
SystemMessage::IncomingCall
} else {
SystemMessage::OutgoingCall
};
} else if value == "call-accepted" {
self.is_system_message = SystemMessage::CallAccepted;
} else if value == "call-ended" {
self.is_system_message = SystemMessage::CallEnded;
}
} else if self.get_header(HeaderDef::ChatGroupMemberRemoved).is_some() {
self.is_system_message = SystemMessage::MemberRemovedFromGroup;
@@ -698,16 +724,24 @@ impl MimeMessage {
}
fn parse_videochat_headers(&mut self) {
if let Some(value) = self.get_header(HeaderDef::ChatContent) {
if value == "videochat-invitation" {
let instance = self
.get_header(HeaderDef::ChatWebrtcRoom)
.map(|s| s.to_string());
if let Some(part) = self.parts.first_mut() {
let content = self
.get_header(HeaderDef::ChatContent)
.unwrap_or_default()
.to_string();
let room = self
.get_header(HeaderDef::ChatWebrtcRoom)
.map(|s| s.to_string());
let accepted = self
.get_header(HeaderDef::ChatWebrtcAccepted)
.map(|s| s.to_string());
if let Some(part) = self.parts.first_mut() {
if let Some(room) = room {
if content == "videochat-invitation" {
part.typ = Viewtype::VideochatInvitation;
part.param
.set(Param::WebrtcRoom, instance.unwrap_or_default());
}
part.param.set(Param::WebrtcRoom, room);
} else if let Some(accepted) = accepted {
part.param.set(Param::WebrtcAccepted, accepted);
}
}
}

View File

@@ -120,6 +120,9 @@ pub enum Param {
/// For Messages
WebrtcRoom = b'V',
/// For Messages
WebrtcAccepted = b'7',
/// For Messages: space-separated list of messaged IDs of forwarded copies.
///
/// This is used when a [crate::message::Message] is in the

View File

@@ -1000,12 +1000,16 @@ pub(crate) async fn receive_imf_inner(
}
}
if received_msg.hidden {
if mime_parser.is_system_message == SystemMessage::IncomingCall {
context.handle_call_msg(&mime_parser, insert_msg_id).await?;
} else if received_msg.hidden {
// No need to emit an event about the changed message
} else if let Some(replace_chat_id) = replace_chat_id {
context.emit_msgs_changed_without_msg_id(replace_chat_id);
} else if !chat_id.is_trash() {
let fresh = received_msg.state == MessageState::InFresh;
let fresh = received_msg.state == MessageState::InFresh
&& mime_parser.is_system_message != SystemMessage::CallAccepted
&& mime_parser.is_system_message != SystemMessage::CallEnded;
for msg_id in &received_msg.msg_ids {
chat_id.emit_msg_event(context, *msg_id, mime_parser.incoming && fresh);
}
@@ -1998,6 +2002,22 @@ async fn add_parts(
handle_edit_delete(context, mime_parser, from_id).await?;
if mime_parser.is_system_message == SystemMessage::CallAccepted
|| mime_parser.is_system_message == SystemMessage::CallEnded
{
if let Some(field) = mime_parser.get_header(HeaderDef::InReplyTo) {
if let Some(call) =
message::get_by_rfc724_mids(context, &parse_message_ids(field)).await?
{
context.handle_call_msg(mime_parser, call.get_id()).await?;
} else {
warn!(context, "Call: Cannot load parent.")
}
} else {
warn!(context, "Call: Not a reply.")
}
}
let hidden = mime_parser.parts.iter().all(|part| part.is_reaction);
let mut parts = mime_parser.parts.iter().peekable();
while let Some(part) = parts.next() {

View File

@@ -71,6 +71,9 @@ pub(crate) enum SyncData {
DeleteMessages {
msgs: Vec<String>, // RFC724 id (i.e. "Message-Id" header)
},
RejectIncomingCall {
msg: String, // RFC724 id (i.e. "Message-Id" header)
},
}
#[derive(Debug, Serialize, Deserialize)]
@@ -264,6 +267,7 @@ impl Context {
SyncData::Config { key, val } => self.sync_config(key, val).await,
SyncData::SaveMessage { src, dest } => self.save_message(src, dest).await,
SyncData::DeleteMessages { msgs } => self.sync_message_deletion(msgs).await,
SyncData::RejectIncomingCall { msg } => self.sync_call_rejection(msg).await,
},
SyncDataOrUnknown::Unknown(data) => {
warn!(self, "Ignored unknown sync item: {data}.");