diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index 31dc25b518..46c1a5c07a 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -1222,6 +1222,85 @@ 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, + * 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: + * - callee ends the call using dc_end_call(), caller receives #DC_EVENT_CALL_ENDED + * - caller ends the call using dc_end_call(), callee 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. + * @return ID of the system message announcing the call. + */ +uint32_t dc_place_outgoing_call (dc_context_t* context, uint32_t chat_id); + + +/** + * 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 equal to the ID of the corresponding info message. + * @return 1=success, 0=error + */ + int dc_accept_incoming_call (dc_context_t* context, uint32_t msg_id); + + + /** + * 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 an 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. * @@ -4489,6 +4568,8 @@ int dc_msg_is_info (const dc_msg_t* msg); * the UI should change the corresponding string using #DC_STR_INVALID_UNENCRYPTED_MAIL * 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_OUTGOING_CALL (50) - Info-message refers to an outgoing call + * - DC_INFO_INCOMING_CALL (55) - Info-message refers to an incoming call * * Even when you display an icon, * you should still display the text of the informational message using dc_msg_get_text() @@ -4517,6 +4598,8 @@ int dc_msg_get_info_type (const dc_msg_t* msg); #define DC_INFO_PROTECTION_DISABLED 12 #define DC_INFO_INVALID_UNENCRYPTED_MAIL 13 #define DC_INFO_WEBXDC_INFO_MESSAGE 32 +#define DC_INFO_OUTGOING_CALL 50 +#define DC_INFO_INCOMING_CALL 55 /** @@ -6514,6 +6597,53 @@ 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, + */ +#define DC_EVENT_INCOMING_CALL 2550 + +/** + * The callee accepted an incoming call on another 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. + */ + #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. + */ +#define DC_EVENT_OUTGOING_CALL_ACCEPTED 2570 + +/** + * An incoming or outgoing call was ended using dc_end_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. + */ +#define DC_EVENT_CALL_ENDED 2580 + + /** * @} */ diff --git a/deltachat-ffi/src/lib.rs b/deltachat-ffi/src/lib.rs index 8fedbc0622..09d5268b2e 100644 --- a/deltachat-ffi/src/lib.rs +++ b/deltachat-ffi/src/lib.rs @@ -565,6 +565,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"), @@ -628,7 +632,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 } @@ -680,6 +688,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, .. } @@ -777,6 +789,10 @@ pub unsafe extern "C" fn dc_event_get_data2_str(event: *mut dc_event_t) -> *mut | EventType::AccountsChanged | EventType::AccountsItemChanged | EventType::WebxdcRealtimeAdvertisementReceived { .. } + | EventType::IncomingCall { .. } + | EventType::IncomingCallAccepted { .. } + | EventType::OutgoingCallAccepted { .. } + | EventType::CallEnded { .. } | EventType::EventChannelOverflow { .. } => ptr::null_mut(), EventType::ConfigureProgress { comment, .. } => { if let Some(comment) = comment { @@ -1176,6 +1192,47 @@ 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) -> 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); + + block_on(ctx.place_outgoing_call(chat_id)) + .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, +) -> 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); + + block_on(ctx.accept_incoming_call(msg_id)).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)).is_ok() as libc::c_int +} + #[no_mangle] pub unsafe extern "C" fn dc_set_draft( context: *mut dc_context_t, diff --git a/deltachat-jsonrpc/src/api/types/events.rs b/deltachat-jsonrpc/src/api/types/events.rs index 7472b23025..8344823fa7 100644 --- a/deltachat-jsonrpc/src/api/types/events.rs +++ b/deltachat-jsonrpc/src/api/types/events.rs @@ -417,6 +417,19 @@ pub enum EventType { /// Number of events skipped. n: u64, }, + + /// Incoming call. + IncomingCall { msg_id: u32 }, + + /// Incoming call accepted. + /// This is esp. interesting to stop ringing on other devices. + IncomingCallAccepted { msg_id: u32 }, + + /// Outgoing call accepted. + OutgoingCallAccepted { msg_id: u32 }, + + /// Call ended. + CallEnded { msg_id: u32 }, } impl From for EventType { @@ -567,6 +580,18 @@ impl From for EventType { CoreEventType::EventChannelOverflow { n } => EventChannelOverflow { n }, CoreEventType::AccountsChanged => AccountsChanged, CoreEventType::AccountsItemChanged => AccountsItemChanged, + CoreEventType::IncomingCall { msg_id } => IncomingCall { + msg_id: msg_id.to_u32(), + }, + CoreEventType::IncomingCallAccepted { msg_id } => IncomingCallAccepted { + msg_id: msg_id.to_u32(), + }, + CoreEventType::OutgoingCallAccepted { msg_id } => OutgoingCallAccepted { + msg_id: msg_id.to_u32(), + }, + 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"), diff --git a/deltachat-jsonrpc/src/api/types/message.rs b/deltachat-jsonrpc/src/api/types/message.rs index a60ee15a5f..75dd2ce05a 100644 --- a/deltachat-jsonrpc/src/api/types/message.rs +++ b/deltachat-jsonrpc/src/api/types/message.rs @@ -429,6 +429,11 @@ pub enum SystemMessageType { /// This message contains a users iroh node address. IrohNodeAddr, + + OutgoingCall, + IncomingCall, + CallAccepted, + CallEnded, } impl From for SystemMessageType { @@ -454,6 +459,10 @@ impl From 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, } } } diff --git a/src/calls.rs b/src/calls.rs new file mode 100644 index 0000000000..10ff250a86 --- /dev/null +++ b/src/calls.rs @@ -0,0 +1,550 @@ +//! # Handle calls. +//! +//! Internally, calls a bound to the user-visible info message initializing the call. +//! This means, the "Call ID" is a "Message ID" currently - similar to webxdc. +//! So, no database changes are needed at this stage. +//! When it comes to relay calls over iroh, we may need a dedicated table, and this may change. +use crate::chat::{send_msg, Chat, ChatId}; +use crate::constants::Chattype; +use crate::context::Context; +use crate::events::EventType; +use crate::message::{self, rfc724_mid_exists, Message, MsgId, Viewtype}; +use crate::mimeparser::{MimeMessage, SystemMessage}; +use crate::param::Param; +use crate::sync::SyncData; +use crate::tools::time; +use anyhow::{ensure, Result}; +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 our outgoing call? + pub incoming: bool, + + /// Was an incoming call accepted on this device? + /// On other devices, this is never set and for outgoing calls, this is never set. + pub accepted: bool, + + /// 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) -> Result { + 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.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) -> Result<()> { + let call: CallInfo = self.load_call_by_root_id(call_id).await?; + ensure!(call.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.clone().mark_call_as_accepted(self).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.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, + }); + Ok(()) + } + + /// Cancel, reject for hangup an incoming or outgoing call. + pub async fn end_call(&self, call_id: MsgId) -> Result<()> { + let call: CallInfo = self.load_call_by_root_id(call_id).await?; + + if call.accepted || !call.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.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_root_id(call_id).await?; + if !call.accepted { + context.emit_event(EventType::CallEnded { + msg_id: call.msg.id, + }); + } + Ok(()) + } + + pub(crate) async fn handle_call_msg( + &self, + mime_message: &MimeMessage, + call_or_child_id: MsgId, + ) -> Result<()> { + match mime_message.is_system_message { + SystemMessage::IncomingCall => { + let call = self.load_call_by_root_id(call_or_child_id).await?; + if call.incoming { + if call.is_stale_call() { + call.update_text(self, "Missed call").await?; + self.emit_incoming_msg(call.msg.chat_id, call_or_child_id); + } else { + self.emit_msgs_changed(call.msg.chat_id, call_or_child_id); + self.emit_event(EventType::IncomingCall { + msg_id: call.msg.id, + }); + 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_or_child_id); + } + } + SystemMessage::CallAccepted => { + let call = self.load_call_by_root_id(call_or_child_id).await?; + self.emit_msgs_changed(call.msg.chat_id, call_or_child_id); + if call.incoming { + self.emit_event(EventType::IncomingCallAccepted { + msg_id: call.msg.id, + }); + } else { + call.msg.clone().mark_call_as_accepted(self).await?; + self.emit_event(EventType::OutgoingCallAccepted { + msg_id: call.msg.id, + }); + } + } + SystemMessage::CallEnded => { + let call = self.load_call_by_root_id(call_or_child_id).await?; + self.emit_msgs_changed(call.msg.chat_id, call_or_child_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? { + let call = self.load_call_by_root_id(msg_id).await?; + self.emit_event(EventType::CallEnded { + msg_id: call.msg.id, + }); + } + Ok(()) + } + + async fn load_call_by_root_id(&self, call_id: MsgId) -> Result { + 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 { + ensure!( + call.get_info_type() == SystemMessage::IncomingCall + || call.get_info_type() == SystemMessage::OutgoingCall + ); + + Ok(CallInfo { + incoming: call.get_info_type() == SystemMessage::IncomingCall, + accepted: call.is_call_accepted()?, + msg: call, + }) + } +} + +impl Message { + async fn mark_call_as_accepted(&mut self, context: &Context) -> Result<()> { + ensure!( + self.get_info_type() == SystemMessage::IncomingCall + || self.get_info_type() == SystemMessage::OutgoingCall + ); + self.param.set_int(Param::Arg, 1); + self.update_param(context).await?; + Ok(()) + } + + fn is_call_accepted(&self) -> Result { + 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 tests { + use super::*; + use crate::config::Config; + use crate::test_utils::{sync, TestContext, TestContextManager}; + + async fn setup_call() -> Result<( + TestContext, // Alice's 1st device + TestContext, // Alice's 2nd device + Message, // Call message from view of Alice + TestContext, // Bob's 1st device + TestContext, // Bob's 2nd device + Message, // Call message from view of Bob + Message, // Call message from view of Bob's 2nd device + )> { + 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).await?; + let sent1 = alice.pop_sent_msg().await; + let alice_call = Message::load_from_db(&alice, sent1.sender_msg_id).await?; + assert_eq!(sent1.sender_msg_id, test_msg_id); + assert!(alice_call.is_info()); + assert_eq!(alice_call.get_info_type(), SystemMessage::OutgoingCall); + let info = alice.load_call_by_root_id(alice_call.id).await?; + assert!(!info.accepted); + + let alice2_call = alice2.recv_msg(&sent1).await; + assert!(alice2_call.is_info()); + assert_eq!(alice2_call.get_info_type(), SystemMessage::OutgoingCall); + let info = alice2.load_call_by_root_id(alice2_call.id).await?; + assert!(!info.accepted); + + // 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; + bob.evtracker + .get_matching(|evt| matches!(evt, EventType::IncomingCall { .. })) + .await; + assert!(bob_call.is_info()); + assert_eq!(bob_call.get_info_type(), SystemMessage::IncomingCall); + + let bob2_call = bob2.recv_msg(&sent1).await; + assert!(bob2_call.is_info()); + assert_eq!(bob2_call.get_info_type(), SystemMessage::IncomingCall); + + Ok((alice, alice2, alice_call, bob, bob2, bob_call, bob2_call)) + } + + async fn accept_call() -> Result<( + TestContext, + TestContext, + Message, + TestContext, + TestContext, + Message, + )> { + let (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).await?; + bob.evtracker + .get_matching(|evt| matches!(evt, EventType::IncomingCallAccepted { .. })) + .await; + let sent2 = bob.pop_sent_msg().await; + let info = bob.load_call_by_root_id(bob_call.id).await?; + assert!(info.accepted); + + bob2.recv_msg(&sent2).await; + bob2.evtracker + .get_matching(|evt| matches!(evt, EventType::IncomingCallAccepted { .. })) + .await; + let info = bob2.load_call_by_root_id(bob2_call.id).await?; + assert!(!info.accepted); // "accepted" is only true on the device that does the call + + // Alice receives the acceptance message + alice.recv_msg(&sent2).await; + alice + .evtracker + .get_matching(|evt| matches!(evt, EventType::OutgoingCallAccepted { .. })) + .await; + + alice2.recv_msg(&sent2).await; + alice2 + .evtracker + .get_matching(|evt| matches!(evt, EventType::OutgoingCallAccepted { .. })) + .await; + Ok((alice, alice2, alice_call, bob, bob2, bob_call)) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_accept_call_callee_ends() -> Result<()> { + // Alice calls Bob, Bob accepts + let (alice, alice2, _alice_call, 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; + + bob2.recv_msg(&sent3).await; + bob2.evtracker + .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) + .await; + + // Alice receives the ending message + alice.recv_msg(&sent3).await; + alice + .evtracker + .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) + .await; + + alice2.recv_msg(&sent3).await; + 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 (alice, alice2, _alice_call, 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; + + alice2.recv_msg(&sent3).await; + alice2 + .evtracker + .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) + .await; + + // Bob receives the ending message + bob.recv_msg(&sent3).await; + bob.evtracker + .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) + .await; + + bob2.recv_msg(&sent3).await; + 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 (_alice, _alice2, _alice_call, bob, bob2, bob_call, _bob2_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 (alice, alice2, alice_call, bob, bob2, _bob_call, _bob2_call) = 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; + + alice2.recv_msg(&sent3).await; + alice2 + .evtracker + .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) + .await; + + // Bob receives the ending message + bob.recv_msg(&sent3).await; + bob.evtracker + .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) + .await; + + bob2.recv_msg(&sent3).await; + 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()); + assert_eq!(call_info.remaining_ring_seconds(), RINGING_SECONDS); + + // 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()); + assert_eq!(call_info.remaining_ring_seconds(), RINGING_SECONDS - 5); + + // 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 (alice, _alice2, alice_call, _bob, _bob2, _bob_call, _bob2_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).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 (alice, _alice2, alice_call, _bob, _bob2, _bob_call, _bob2_call) = setup_call().await?; + + let call_info = alice.load_call_by_root_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(()) + } +} diff --git a/src/events/payload.rs b/src/events/payload.rs index a5e2f99653..2bcd60bd76 100644 --- a/src/events/payload.rs +++ b/src/events/payload.rs @@ -376,6 +376,30 @@ 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, + }, + + /// Incoming call accepted. + IncomingCallAccepted { + /// ID of the message referring to the call. + msg_id: MsgId, + }, + + /// Outgoing call accepted. + OutgoingCallAccepted { + /// ID of the message referring to the call. + msg_id: MsgId, + }, + + /// 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, diff --git a/src/lib.rs b/src/lib.rs index 9e9cc49cca..a44cd525b6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -53,6 +53,7 @@ pub use events::*; mod aheader; pub mod blob; +pub mod calls; pub mod chat; pub mod chatlist; pub mod config; diff --git a/src/mimefactory.rs b/src/mimefactory.rs index df1d20f4f5..1811b5bb19 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -4,7 +4,7 @@ use std::collections::HashSet; use std::io::Cursor; use std::path::Path; -use anyhow::{bail, Context as _, Result}; +use anyhow::{anyhow, bail, Context as _, Result}; use base64::Engine as _; use chrono::TimeZone; use deltachat_contact_tools::sanitize_bidi_characters; @@ -1359,6 +1359,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(), + )); + } _ => {} } diff --git a/src/mimeparser.rs b/src/mimeparser.rs index b44166b481..2e190db981 100644 --- a/src/mimeparser.rs +++ b/src/mimeparser.rs @@ -214,6 +214,22 @@ pub enum SystemMessage { /// This message contains a users iroh node address. IrohNodeAddr = 40, + + /// This system message represents an outgoing call. + /// This message is visible to the user as an "info" message. + OutgoingCall = 50, + + /// This system message represents an incoming call. + /// This message is visible to the user as an "info" message. + IncomingCall = 55, + + /// 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 = 56, + + /// Message indicating that a call was ended. + CallEnded = 57, } const MIME_AC_SETUP_FILE: &str = "application/autocrypt-setup"; @@ -649,6 +665,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; diff --git a/src/receive_imf.rs b/src/receive_imf.rs index 945d440cc8..6355006b0f 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -624,7 +624,9 @@ 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); @@ -1498,6 +1500,21 @@ async fn add_parts( if handle_edit_delete(context, mime_parser, from_id).await? { chat_id = DC_CHAT_ID_TRASH; info!(context, "Message edits/deletes existing message (TRASH)."); + } else if mime_parser.is_system_message == SystemMessage::CallAccepted + || mime_parser.is_system_message == SystemMessage::CallEnded + { + // TODO: chat_id = DC_CHAT_ID_TRASH; + 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 mut parts = mime_parser.parts.iter().peekable(); diff --git a/src/sync.rs b/src/sync.rs index 0687f1778c..c15b3acf80 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -70,6 +70,9 @@ pub(crate) enum SyncData { DeleteMessages { msgs: Vec, // RFC724 id (i.e. "Message-Id" header) }, + RejectIncomingCall { + msg: String, // RFC724 id (i.e. "Message-Id" header) + }, } #[derive(Debug, Serialize, Deserialize)] @@ -263,6 +266,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}.");