diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index d8579bab7..3cee74820 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -6701,6 +6701,16 @@ void dc_event_unref(dc_event_t* event); */ #define DC_EVENT_CALL_ENDED 2580 +/** + * An incoming call was missed. Only emitted if the caller is allowed to call us. This happens when: + * - A call timed out (not accepted by us on time). + * - A call was canceled by the caller. + * - A stale call message was received, i.e. it is older than the timeout. + * + * This should trigger a UI notification. + */ +#define DC_EVENT_CALL_MISSED 2590 + /** * Transport relay added/deleted or default has changed. * UI should update the list. diff --git a/deltachat-ffi/src/lib.rs b/deltachat-ffi/src/lib.rs index 7f319e669..57812c0f5 100644 --- a/deltachat-ffi/src/lib.rs +++ b/deltachat-ffi/src/lib.rs @@ -556,6 +556,7 @@ pub unsafe extern "C" fn dc_event_get_id(event: *mut dc_event_t) -> libc::c_int EventType::IncomingCallAccepted { .. } => 2560, EventType::OutgoingCallAccepted { .. } => 2570, EventType::CallEnded { .. } => 2580, + EventType::CallMissed { .. } => 2590, EventType::TransportsModified => 2600, #[allow(unreachable_patterns)] #[cfg(test)] @@ -626,6 +627,7 @@ pub unsafe extern "C" fn dc_event_get_data1_int(event: *mut dc_event_t) -> libc: | EventType::IncomingCallAccepted { msg_id, .. } | EventType::OutgoingCallAccepted { msg_id, .. } | EventType::CallEnded { msg_id, .. } => msg_id.to_u32() as libc::c_int, + EventType::CallMissed { 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 } @@ -679,6 +681,7 @@ pub unsafe extern "C" fn dc_event_get_data2_int(event: *mut dc_event_t) -> libc: | EventType::WebxdcRealtimeAdvertisementReceived { .. } | EventType::OutgoingCallAccepted { .. } | EventType::CallEnded { .. } + | EventType::CallMissed { .. } | EventType::EventChannelOverflow { .. } | EventType::TransportsModified => 0, EventType::MsgsChanged { msg_id, .. } @@ -796,7 +799,9 @@ pub unsafe extern "C" fn dc_event_get_data2_str(event: *mut dc_event_t) -> *mut let data2 = accept_call_info.to_c_string().unwrap_or_default(); data2.into_raw() } - EventType::CallEnded { .. } | EventType::EventChannelOverflow { .. } => ptr::null_mut(), + EventType::CallEnded { .. } + | EventType::CallMissed { .. } + | EventType::EventChannelOverflow { .. } => ptr::null_mut(), EventType::ConfigureProgress { comment, .. } => { if let Some(comment) = comment { comment.to_c_string().unwrap_or_default().into_raw() diff --git a/deltachat-jsonrpc/src/api/types/events.rs b/deltachat-jsonrpc/src/api/types/events.rs index 85751c363..9fd6178f9 100644 --- a/deltachat-jsonrpc/src/api/types/events.rs +++ b/deltachat-jsonrpc/src/api/types/events.rs @@ -463,6 +463,14 @@ pub enum EventType { chat_id: u32, }, + /// Call missed. + CallMissed { + /// ID of the info message referring to the call. + msg_id: u32, + /// ID of the chat which the message belongs to. + chat_id: u32, + }, + /// One or more transports has changed. /// /// UI should update the list. @@ -658,6 +666,10 @@ impl From for EventType { msg_id: msg_id.to_u32(), chat_id: chat_id.to_u32(), }, + CoreEventType::CallMissed { msg_id, chat_id } => CallMissed { + msg_id: msg_id.to_u32(), + chat_id: chat_id.to_u32(), + }, CoreEventType::TransportsModified => TransportsModified, #[allow(unreachable_patterns)] diff --git a/src/calls.rs b/src/calls.rs index a934ddc76..5afa400b0 100644 --- a/src/calls.rs +++ b/src/calls.rs @@ -218,10 +218,11 @@ impl Context { let wait = RINGING_SECONDS; let context = self.get_weak_context(); - task::spawn(Context::emit_end_call_if_unaccepted( + task::spawn(Context::finalize_call_if_unaccepted( context, wait.try_into()?, call.id, + true, // Doesn't matter for outgoing calls )); Ok(call.id) @@ -314,39 +315,67 @@ impl Context { Ok(()) } - async fn emit_end_call_if_unaccepted( + async fn finalize_call_if_unaccepted( context: WeakContext, wait: u64, call_id: MsgId, + can_call_me: bool, ) -> Result<()> { sleep(Duration::from_secs(wait)).await; let context = context.upgrade()?; let Some(mut call) = context.load_call_by_id(call_id).await? else { warn!( context, - "emit_end_call_if_unaccepted is called with {call_id} which does not refer to a call." + "finalize_call_if_unaccepted is called with {call_id} which does not refer to a call." ); return Ok(()); }; if !call.is_accepted() && !call.is_ended() { + let (msg_id, chat_id) = (call_id, call.msg.chat_id); if call.is_incoming() { call.mark_as_canceled(&context).await?; let missed_call_str = stock_str::missed_call(&context); call.update_text(&context, &missed_call_str).await?; + if can_call_me { + context.emit_event(EventType::CallMissed { msg_id, chat_id }); + } } else { call.mark_as_ended(&context).await?; let canceled_call_str = stock_str::canceled_call(&context); call.update_text(&context, &canceled_call_str).await?; } + if can_call_me { + context.emit_event(EventType::CallEnded { msg_id, chat_id }); + } context.emit_msgs_changed(call.msg.chat_id, call_id); - context.emit_event(EventType::CallEnded { - msg_id: call.msg.id, - chat_id: call.msg.chat_id, - }); } Ok(()) } + async fn can_call_me(&self, from_id: ContactId) -> Result { + Ok(match who_can_call_me(self).await? { + WhoCanCallMe::Contacts => ChatIdBlocked::lookup_by_contact(self, from_id) + .await? + .is_some_and(|chat_id_blocked| { + match chat_id_blocked.blocked { + Blocked::Not => true, + Blocked::Yes | Blocked::Request => { + // Do not notify about incoming calls + // from contact requests and blocked contacts. + // + // User can still access the call and accept it + // via the chat in case of contact requests. + false + } + } + }), + WhoCanCallMe::Everybody => ChatIdBlocked::lookup_by_contact(self, from_id) + .await? + .is_none_or(|chat_id_blocked| chat_id_blocked.blocked != Blocked::Yes), + WhoCanCallMe::Nobody => false, + }) + } + pub(crate) async fn handle_call_msg( &self, call_id: MsgId, @@ -360,50 +389,33 @@ impl Context { }; if call.is_incoming() { - if call.is_stale() { - let missed_call_str = stock_str::missed_call(self); - call.update_text(self, &missed_call_str).await?; - self.emit_incoming_msg(call.msg.chat_id, call_id); // notify missed call + let call_str = match call.is_stale() { + true => stock_str::missed_call(self), + false => stock_str::incoming_call(self, call.has_video_initially()), + }; + call.update_text(self, &call_str).await?; + let (msg_id, chat_id) = (call_id, call.msg.chat_id); + let can_call_me = self.can_call_me(from_id).await?; + if !can_call_me { + } else if call.is_stale() { + self.emit_event(EventType::CallMissed { msg_id, chat_id }); } else { - let incoming_call_str = - stock_str::incoming_call(self, call.has_video_initially()); - call.update_text(self, &incoming_call_str).await?; - self.emit_msgs_changed(call.msg.chat_id, call_id); // ringing calls are not additionally notified - let can_call_me = match who_can_call_me(self).await? { - WhoCanCallMe::Contacts => ChatIdBlocked::lookup_by_contact(self, from_id) - .await? - .is_some_and(|chat_id_blocked| { - match chat_id_blocked.blocked { - Blocked::Not => true, - Blocked::Yes | Blocked::Request => { - // Do not notify about incoming calls - // from contact requests and blocked contacts. - // - // User can still access the call and accept it - // via the chat in case of contact requests. - false - } - } - }), - WhoCanCallMe::Everybody => ChatIdBlocked::lookup_by_contact(self, from_id) - .await? - .is_none_or(|chat_id_blocked| chat_id_blocked.blocked != Blocked::Yes), - WhoCanCallMe::Nobody => false, - }; - if can_call_me { - self.emit_event(EventType::IncomingCall { - msg_id: call.msg.id, - chat_id: call.msg.chat_id, - place_call_info: call.place_call_info.to_string(), - has_video: call.has_video_initially(), - }); - } + self.emit_event(EventType::IncomingCall { + msg_id, + chat_id, + place_call_info: call.place_call_info.to_string(), + has_video: call.has_video_initially(), + }); + } + self.emit_msgs_changed(chat_id, msg_id); + if !call.is_stale() { let wait = call.remaining_ring_seconds(); let context = self.get_weak_context(); - task::spawn(Context::emit_end_call_if_unaccepted( + task::spawn(Context::finalize_call_if_unaccepted( context, wait.try_into()?, call.msg.id, + can_call_me, )); } } else { @@ -455,6 +467,7 @@ impl Context { return Ok(()); } + let (msg_id, chat_id) = (call_id, call.msg.chat_id); if !call.is_accepted() { if call.is_incoming() { if from_id == ContactId::SELF { @@ -465,6 +478,9 @@ impl Context { call.mark_as_canceled(self).await?; let missed_call_str = stock_str::missed_call(self); call.update_text(self, &missed_call_str).await?; + if self.can_call_me(from_id).await? { + self.emit_event(EventType::CallMissed { msg_id, chat_id }); + } } } else { // outgoing @@ -482,12 +498,8 @@ impl Context { call.mark_as_ended(self).await?; call.update_text_duration(self).await?; } - + self.emit_event(EventType::CallEnded { msg_id, chat_id }); self.emit_msgs_changed(call.msg.chat_id, call_id); - self.emit_event(EventType::CallEnded { - msg_id: call.msg.id, - chat_id: call.msg.chat_id, - }); } _ => {} } diff --git a/src/calls/calls_tests.rs b/src/calls/calls_tests.rs index 946cd6aeb..a96ec3c1c 100644 --- a/src/calls/calls_tests.rs +++ b/src/calls/calls_tests.rs @@ -5,6 +5,7 @@ use crate::constants::DC_CHAT_ID_TRASH; use crate::message::MessageState; use crate::receive_imf::receive_imf; use crate::test_utils::{TestContext, TestContextManager}; +use crate::tools::SystemTime; struct CallSetup { pub alice: TestContext, @@ -490,6 +491,9 @@ async fn test_caller_cancels_call() -> Result<()> { // Bob receives the ending message bob.recv_msg_trash(&sent3).await; assert_text(&bob, bob_call.id, "Missed call").await?; + bob.evtracker + .get_matching(|evt| matches!(evt, EventType::CallMissed { .. })) + .await; bob.evtracker .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) .await; @@ -502,6 +506,9 @@ async fn test_caller_cancels_call() -> Result<()> { bob2.recv_msg_trash(&sent3).await; assert_text(&bob2, bob2_call.id, "Missed call").await?; + bob2.evtracker + .get_matching(|evt| matches!(evt, EventType::CallMissed { .. })) + .await; bob2.evtracker .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) .await; @@ -510,6 +517,95 @@ async fn test_caller_cancels_call() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_stale_call() -> Result<()> { + let mut tcm = TestContextManager::new(); + for accepted in [false, true] { + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; + + info!(bob, "Alice is accepted: {accepted}."); + if accepted { + bob.create_chat(alice).await; + } + let alice_chat = alice.create_chat(bob).await; + alice + .place_outgoing_call(alice_chat.id, PLACE_INFO.to_string(), true) + .await?; + let sent1 = alice.pop_sent_msg().await; + + SystemTime::shift(Duration::from_secs(3600)); + let bob_call = bob.recv_msg(&sent1).await; + let EventType::MsgsChanged { msg_id, chat_id } = bob + .evtracker + .get_matching(|evt| { + matches!( + evt, + EventType::MsgsChanged { .. } + | EventType::CallMissed { .. } + | EventType::CallEnded { .. } + ) + }) + .await + else { + unreachable!(); + }; + assert_eq!(chat_id, bob_call.chat_id); + let msg = Message::load_from_db(bob, msg_id).await?; + assert_eq!(msg.text, stock_str::messages_e2ee_info_msg(bob)); + if accepted { + let EventType::CallMissed { msg_id, chat_id } = bob + .evtracker + .get_matching(|evt| { + matches!( + evt, + EventType::CallMissed { .. } | EventType::CallEnded { .. } + ) + }) + .await + else { + unreachable!(); + }; + assert_eq!(msg_id, bob_call.id); + assert_eq!(chat_id, bob_call.chat_id); + } + let EventType::MsgsChanged { msg_id, chat_id } = bob + .evtracker + .get_matching(|evt| { + matches!( + evt, + EventType::MsgsChanged { .. } + | EventType::CallMissed { .. } + | EventType::CallEnded { .. } + ) + }) + .await + else { + unreachable!(); + }; + assert_eq!(msg_id, bob_call.id); + assert_eq!(chat_id, bob_call.chat_id); + let evt = bob + .evtracker + .get_matching_opt(bob, |evt| { + matches!( + evt, + EventType::CallMissed { .. } | EventType::CallEnded { .. } + ) + }) + .await; + assert!(evt.is_none()); + assert_text(bob, bob_call.id, "Missed call").await?; + assert_eq!(call_state(bob, bob_call.id).await?, CallState::Missed); + + // Test that message summary says it is a missed call. + let bob_call_msg = Message::load_from_db(bob, bob_call.id).await?; + let summary = bob_call_msg.get_summary(bob, None).await?; + assert_eq!(summary.text, "🎥 Missed call"); + } + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_is_stale_call() -> Result<()> { // a call started now is not stale diff --git a/src/events/payload.rs b/src/events/payload.rs index ee7761f24..587f1abc6 100644 --- a/src/events/payload.rs +++ b/src/events/payload.rs @@ -419,6 +419,14 @@ pub enum EventType { chat_id: ChatId, }, + /// Call missed. + CallMissed { + /// ID of the message referring to the call. + msg_id: MsgId, + /// ID of the chat which the message belongs to. + chat_id: ChatId, + }, + /// One or more transports has changed or another transport is primary now. /// /// UI should update the list.