feat: Add EventType::CallMissed and emit it for missed calls (#7840)

Before, only `CallEnded` was emitted for missed calls, or, if a call arrives already being stale,
`IncomingMsg`. Now:
- `CallMissed` is emitted in addition to `CallEnded`.
- `IncomingMsg` is replaced with `CallMissed` for stale calls.
Having only one event type for missed calls should simplify handling them in the apps.

This doesn't emit `CallMissed` for those who aren't allowed to call us. Also, don't emit `CallEnded`
if the caller isn't allowed to call us and the call wasn't accepted, as there's no previous
`IncomingCall` event in this case.
This commit is contained in:
iequidoo
2026-03-04 20:12:47 -03:00
parent ef718bb869
commit 79c45e338c
6 changed files with 194 additions and 51 deletions

View File

@@ -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.

View File

@@ -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()

View File

@@ -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<CoreEventType> 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)]

View File

@@ -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<bool> {
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,
});
}
_ => {}
}

View File

@@ -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

View File

@@ -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.