Files
chatmail-core/src/calls.rs
2026-02-05 21:54:52 +01:00

807 lines
29 KiB
Rust

//! # Handle calls.
//!
//! Internally, calls are bound a user-visible message initializing the call.
//! This means, the "Call ID" is a "Message ID" - similar to Webxdc IDs.
use crate::chat::ChatIdBlocked;
use crate::chat::{Chat, ChatId, send_msg};
use crate::config::Config;
use crate::constants::{Blocked, Chattype};
use crate::contact::ContactId;
use crate::context::{Context, WeakContext};
use crate::events::EventType;
use crate::headerdef::HeaderDef;
use crate::log::warn;
use crate::message::{Message, MsgId, Viewtype};
use crate::mimeparser::{MimeMessage, SystemMessage};
use crate::net::dns::lookup_host_with_cache;
use crate::param::Param;
use crate::stock_str;
use crate::tools::{normalize_text, time};
use anyhow::{Context as _, Result, ensure};
use deltachat_derive::{FromSql, ToSql};
use num_traits::FromPrimitive;
use serde::Serialize;
use std::str::FromStr;
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 = 120;
// For persisting parameters in the call, we use Param::Arg*
const CALL_ACCEPTED_TIMESTAMP: Param = Param::Arg;
const CALL_ENDED_TIMESTAMP: Param = Param::Arg4;
const STUN_PORT: u16 = 3478;
/// Set if incoming call was ended explicitly
/// by the other side before we accepted it.
///
/// It is used to distinguish "ended" calls
/// that are rejected by us from the calls
/// canceled by the other side
/// immediately after ringing started.
const CALL_CANCELED_TIMESTAMP: Param = Param::Arg2;
/// Information about the status of a call.
#[derive(Debug, Default)]
pub struct CallInfo {
/// 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,
/// Message referring to the call.
/// Data are persisted along with the message using Param::Arg*
pub msg: Message,
}
impl CallInfo {
/// Returns true if the call is an incoming call.
pub fn is_incoming(&self) -> bool {
self.msg.from_id != ContactId::SELF
}
/// Returns true if the call should not ring anymore.
pub fn is_stale(&self) -> bool {
(self.is_incoming() || self.msg.timestamp_sent != 0) && 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, normalize_text(text), self.msg.id),
)
.await?;
Ok(())
}
async fn update_text_duration(&self, context: &Context) -> Result<()> {
let minutes = self.duration_seconds() / 60;
let duration = match minutes {
0 => "<1 minute".to_string(),
1 => "1 minute".to_string(),
n => format!("{n} minutes"),
};
if self.is_incoming() {
let incoming_call_str =
stock_str::incoming_call(context, self.has_video_initially()).await;
self.update_text(context, &format!("{incoming_call_str}\n{duration}"))
.await?;
} else {
let outgoing_call_str =
stock_str::outgoing_call(context, self.has_video_initially()).await;
self.update_text(context, &format!("{outgoing_call_str}\n{duration}"))
.await?;
}
Ok(())
}
/// Mark calls as accepted.
/// This is needed for all devices where a stale-timer runs, to prevent accepted calls being terminated as stale.
async fn mark_as_accepted(&mut self, context: &Context) -> Result<()> {
self.msg.param.set_i64(CALL_ACCEPTED_TIMESTAMP, time());
self.msg.update_param(context).await?;
Ok(())
}
/// Returns true if the call is accepted.
pub fn is_accepted(&self) -> bool {
self.msg.param.exists(CALL_ACCEPTED_TIMESTAMP)
}
/// Returns true if the call is started as a video call.
pub fn has_video_initially(&self) -> bool {
self.msg
.param
.get_bool(Param::WebrtcHasVideoInitially)
.unwrap_or(false)
}
/// Returns true if the call is missed
/// because the caller canceled it
/// explicitly before ringing stopped.
///
/// For outgoing calls this means
/// the receiver has rejected the call
/// explicitly.
pub fn is_canceled(&self) -> bool {
self.msg.param.exists(CALL_CANCELED_TIMESTAMP)
}
async fn mark_as_ended(&mut self, context: &Context) -> Result<()> {
self.msg.param.set_i64(CALL_ENDED_TIMESTAMP, time());
self.msg.update_param(context).await?;
Ok(())
}
/// Explicitly mark the call as canceled.
///
/// For incoming calls this should be called
/// when "call ended" message is received
/// from the caller before we picked up the call.
/// In this case the call becomes "missed" early
/// before the ringing timeout.
async fn mark_as_canceled(&mut self, context: &Context) -> Result<()> {
let now = time();
self.msg.param.set_i64(CALL_ENDED_TIMESTAMP, now);
self.msg.param.set_i64(CALL_CANCELED_TIMESTAMP, now);
self.msg.update_param(context).await?;
Ok(())
}
/// Returns true if the call is ended.
pub fn is_ended(&self) -> bool {
self.msg.param.exists(CALL_ENDED_TIMESTAMP)
}
/// Returns call duration in seconds.
pub fn duration_seconds(&self) -> i64 {
if let (Some(start), Some(end)) = (
self.msg.param.get_i64(CALL_ACCEPTED_TIMESTAMP),
self.msg.param.get_i64(CALL_ENDED_TIMESTAMP),
) {
let seconds = end - start;
if seconds <= 0 {
return 1;
}
return seconds;
}
0
}
}
impl Context {
/// Start an outgoing call.
pub async fn place_outgoing_call(
&self,
chat_id: ChatId,
place_call_info: String,
has_video_initially: bool,
) -> Result<MsgId> {
let chat = Chat::load_from_db(self, chat_id).await?;
ensure!(
chat.typ == Chattype::Single,
"Can only place calls in 1:1 chats"
);
ensure!(!chat.is_self_talk(), "Cannot call self");
let outgoing_call_str = stock_str::outgoing_call(self, has_video_initially).await;
let mut call = Message {
viewtype: Viewtype::Call,
text: outgoing_call_str,
..Default::default()
};
call.param.set(Param::WebrtcRoom, &place_call_info);
call.param
.set_int(Param::WebrtcHasVideoInitially, has_video_initially.into());
call.id = send_msg(self, chat_id, &mut call).await?;
let wait = RINGING_SECONDS;
let context = self.get_weak_context();
task::spawn(Context::emit_end_call_if_unaccepted(
context,
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?.with_context(|| {
format!("accept_incoming_call is called with {call_id} which does not refer to a call")
})?;
ensure!(call.is_incoming());
if call.is_accepted() || call.is_ended() {
info!(self, "Call already accepted/ended");
return Ok(());
}
call.mark_as_accepted(self).await?;
let chat = Chat::load_from_db(self, call.msg.chat_id).await?;
if chat.is_contact_request() {
chat.id.accept(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.hidden = true;
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,
chat_id: call.msg.chat_id,
});
self.emit_msgs_changed(call.msg.chat_id, call_id);
Ok(())
}
/// Cancel, decline or hangup an incoming or outgoing call.
pub async fn end_call(&self, call_id: MsgId) -> Result<()> {
let mut call: CallInfo = self.load_call_by_id(call_id).await?.with_context(|| {
format!("end_call is called with {call_id} which does not refer to a call")
})?;
if call.is_ended() {
info!(self, "Call already ended");
return Ok(());
}
if !call.is_accepted() {
if call.is_incoming() {
call.mark_as_ended(self).await?;
let declined_call_str = stock_str::declined_call(self).await;
call.update_text(self, &declined_call_str).await?;
} else {
call.mark_as_canceled(self).await?;
let canceled_call_str = stock_str::canceled_call(self).await;
call.update_text(self, &canceled_call_str).await?;
}
} else {
call.mark_as_ended(self).await?;
call.update_text_duration(self).await?;
}
let mut msg = Message {
viewtype: Viewtype::Text,
text: "[Call ended]".into(),
..Default::default()
};
msg.param.set_cmd(SystemMessage::CallEnded);
msg.hidden = true;
msg.set_quote(self, Some(&call.msg)).await?;
msg.id = send_msg(self, call.msg.chat_id, &mut msg).await?;
self.emit_event(EventType::CallEnded {
msg_id: call.msg.id,
chat_id: call.msg.chat_id,
});
self.emit_msgs_changed(call.msg.chat_id, call_id);
Ok(())
}
async fn emit_end_call_if_unaccepted(
context: WeakContext,
wait: u64,
call_id: MsgId,
) -> 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."
);
return Ok(());
};
if !call.is_accepted() && !call.is_ended() {
if call.is_incoming() {
call.mark_as_canceled(&context).await?;
let missed_call_str = stock_str::missed_call(&context).await;
call.update_text(&context, &missed_call_str).await?;
} else {
call.mark_as_ended(&context).await?;
let canceled_call_str = stock_str::canceled_call(&context).await;
call.update_text(&context, &canceled_call_str).await?;
}
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(())
}
pub(crate) async fn handle_call_msg(
&self,
call_id: MsgId,
mime_message: &MimeMessage,
from_id: ContactId,
) -> Result<()> {
if mime_message.is_call() {
let Some(call) = self.load_call_by_id(call_id).await? else {
warn!(self, "{call_id} does not refer to a call message");
return Ok(());
};
if call.is_incoming() {
if call.is_stale() {
let missed_call_str = stock_str::missed_call(self).await;
call.update_text(self, &missed_call_str).await?;
self.emit_incoming_msg(call.msg.chat_id, call_id); // notify missed call
} else {
let incoming_call_str =
stock_str::incoming_call(self, call.has_video_initially()).await;
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(),
});
}
let wait = call.remaining_ring_seconds();
let context = self.get_weak_context();
task::spawn(Context::emit_end_call_if_unaccepted(
context,
wait.try_into()?,
call.msg.id,
));
}
} else {
let outgoing_call_str =
stock_str::outgoing_call(self, call.has_video_initially()).await;
call.update_text(self, &outgoing_call_str).await?;
self.emit_msgs_changed(call.msg.chat_id, call_id);
}
} else {
match mime_message.is_system_message {
SystemMessage::CallAccepted => {
let Some(mut call) = self.load_call_by_id(call_id).await? else {
warn!(self, "{call_id} does not refer to a call message");
return Ok(());
};
if call.is_ended() || call.is_accepted() {
info!(self, "CallAccepted received for accepted/ended call");
return Ok(());
}
call.mark_as_accepted(self).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,
chat_id: call.msg.chat_id,
});
} else {
let accept_call_info = mime_message
.get_header(HeaderDef::ChatWebrtcAccepted)
.unwrap_or_default();
self.emit_event(EventType::OutgoingCallAccepted {
msg_id: call.msg.id,
chat_id: call.msg.chat_id,
accept_call_info: accept_call_info.to_string(),
});
}
}
SystemMessage::CallEnded => {
let Some(mut call) = self.load_call_by_id(call_id).await? else {
warn!(self, "{call_id} does not refer to a call message");
return Ok(());
};
if call.is_ended() {
// may happen eg. if a a message is missed
info!(self, "CallEnded received for ended call");
return Ok(());
}
if !call.is_accepted() {
if call.is_incoming() {
if from_id == ContactId::SELF {
call.mark_as_ended(self).await?;
let declined_call_str = stock_str::declined_call(self).await;
call.update_text(self, &declined_call_str).await?;
} else {
call.mark_as_canceled(self).await?;
let missed_call_str = stock_str::missed_call(self).await;
call.update_text(self, &missed_call_str).await?;
}
} else {
// outgoing
if from_id == ContactId::SELF {
call.mark_as_canceled(self).await?;
let canceled_call_str = stock_str::canceled_call(self).await;
call.update_text(self, &canceled_call_str).await?;
} else {
call.mark_as_ended(self).await?;
let declined_call_str = stock_str::declined_call(self).await;
call.update_text(self, &declined_call_str).await?;
}
}
} else {
call.mark_as_ended(self).await?;
call.update_text_duration(self).await?;
}
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,
});
}
_ => {}
}
}
Ok(())
}
/// Loads information about the call given its ID.
///
/// If the message referred to by ID is
/// not a call message, returns `None`.
pub async fn load_call_by_id(&self, call_id: MsgId) -> Result<Option<CallInfo>> {
let call = Message::load_from_db(self, call_id).await?;
Ok(self.load_call_by_message(call))
}
// Loads information about the call given the `Message`.
//
// If the `Message` is not a call message, returns `None`
fn load_call_by_message(&self, call: Message) -> Option<CallInfo> {
if call.viewtype != Viewtype::Call {
// This can happen e.g. if a "call accepted"
// or "call ended" message is received
// with `In-Reply-To` referring to non-call message.
return None;
}
Some(CallInfo {
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,
})
}
}
/// State of the call for display in the message bubble.
#[derive(Debug, PartialEq, Eq)]
pub enum CallState {
/// Fresh incoming or outgoing call that is still ringing.
///
/// There is no separate state for outgoing call
/// that has been dialled but not ringing on the other side yet
/// as we don't know whether the other side received our call.
Alerting,
/// Active call.
Active,
/// Completed call that was once active
/// and then was terminated for any reason.
Completed {
/// Call duration in seconds.
duration: i64,
},
/// Incoming call that was not picked up within a timeout
/// or was explicitly ended by the caller before we picked up.
Missed,
/// Incoming call that was explicitly ended on our side
/// before picking up or outgoing call
/// that was declined before the timeout.
Declined,
/// Outgoing call that has been canceled on our side
/// before receiving a response.
///
/// Incoming calls cannot be canceled,
/// on the receiver side canceled calls
/// usually result in missed calls.
Canceled,
}
/// Returns call state given the message ID.
///
/// Returns an error if the message is not a call message.
pub async fn call_state(context: &Context, msg_id: MsgId) -> Result<CallState> {
let call = context
.load_call_by_id(msg_id)
.await?
.with_context(|| format!("{msg_id} is not a call message"))?;
let state = if call.is_incoming() {
if call.is_accepted() {
if call.is_ended() {
CallState::Completed {
duration: call.duration_seconds(),
}
} else {
CallState::Active
}
} else if call.is_canceled() {
// Call was explicitly canceled
// by the caller before we picked it up.
CallState::Missed
} else if call.is_ended() {
CallState::Declined
} else if call.is_stale() {
CallState::Missed
} else {
CallState::Alerting
}
} else if call.is_accepted() {
if call.is_ended() {
CallState::Completed {
duration: call.duration_seconds(),
}
} else {
CallState::Active
}
} else if call.is_canceled() {
CallState::Canceled
} else if call.is_ended() || call.is_stale() {
CallState::Declined
} else {
CallState::Alerting
};
Ok(state)
}
/// ICE server for JSON serialization.
#[derive(Serialize, Debug, Clone, PartialEq)]
struct IceServer {
/// STUN or TURN URLs.
pub urls: Vec<String>,
/// Username for TURN server authentication.
pub username: Option<String>,
/// Password for logging into the server.
pub credential: Option<String>,
}
/// Creates ICE servers from a line received over IMAP METADATA.
///
/// IMAP METADATA returns a line such as
/// `example.com:3478:1758650868:8Dqkyyu11MVESBqjbIylmB06rv8=`
///
/// 1758650868 is the username and expiration timestamp
/// at the same time,
/// while `8Dqkyyu11MVESBqjbIylmB06rv8=`
/// is the password.
pub(crate) async fn create_ice_servers_from_metadata(
metadata: &str,
) -> Result<(i64, Vec<UnresolvedIceServer>)> {
let (hostname, rest) = metadata.split_once(':').context("Missing hostname")?;
let (port, rest) = rest.split_once(':').context("Missing port")?;
let port = u16::from_str(port).context("Failed to parse the port")?;
let (ts, password) = rest.split_once(':').context("Missing timestamp")?;
let expiration_timestamp = i64::from_str(ts).context("Failed to parse the timestamp")?;
let ice_servers = vec![UnresolvedIceServer::Turn {
hostname: hostname.to_string(),
port,
username: ts.to_string(),
credential: password.to_string(),
}];
Ok((expiration_timestamp, ice_servers))
}
/// STUN or TURN server with unresolved DNS name.
#[derive(Debug, Clone)]
pub(crate) enum UnresolvedIceServer {
/// STUN server.
Stun { hostname: String, port: u16 },
/// TURN server with the username and password.
Turn {
hostname: String,
port: u16,
username: String,
credential: String,
},
}
/// Resolves domain names of ICE servers.
///
/// On failure to resolve, logs the error
/// and skips the server, but does not fail.
pub(crate) async fn resolve_ice_servers(
context: &Context,
unresolved_ice_servers: Vec<UnresolvedIceServer>,
) -> Result<String> {
let mut result: Vec<IceServer> = Vec::new();
// Do not use cache because there is no TLS.
let load_cache = false;
for unresolved_ice_server in unresolved_ice_servers {
match unresolved_ice_server {
UnresolvedIceServer::Stun { hostname, port } => {
match lookup_host_with_cache(context, &hostname, port, "", load_cache).await {
Ok(addrs) => {
let urls: Vec<String> = addrs
.into_iter()
.map(|addr| format!("stun:{addr}"))
.collect();
let stun_server = IceServer {
urls,
username: None,
credential: None,
};
result.push(stun_server);
}
Err(err) => {
warn!(
context,
"Failed to resolve STUN {hostname}:{port}: {err:#}."
);
}
}
}
UnresolvedIceServer::Turn {
hostname,
port,
username,
credential,
} => match lookup_host_with_cache(context, &hostname, port, "", load_cache).await {
Ok(addrs) => {
let urls: Vec<String> = addrs
.into_iter()
.map(|addr| format!("turn:{addr}"))
.collect();
let turn_server = IceServer {
urls,
username: Some(username),
credential: Some(credential),
};
result.push(turn_server);
}
Err(err) => {
warn!(
context,
"Failed to resolve TURN {hostname}:{port}: {err:#}."
);
}
},
}
}
let json = serde_json::to_string(&result)?;
Ok(json)
}
/// Creates JSON with ICE servers when no TURN servers are known.
pub(crate) fn create_fallback_ice_servers() -> Vec<UnresolvedIceServer> {
// Do not use public STUN server from https://stunprotocol.org/.
// It changes the hostname every year
// (e.g. stunserver2025.stunprotocol.org
// which was previously stunserver2024.stunprotocol.org)
// because of bandwidth costs:
// <https://github.com/jselbie/stunserver/issues/50>
vec![
UnresolvedIceServer::Stun {
hostname: "nine.testrun.org".to_string(),
port: STUN_PORT,
},
UnresolvedIceServer::Turn {
hostname: "turn.delta.chat".to_string(),
port: STUN_PORT,
username: "public".to_string(),
credential: "o4tR7yG4rG2slhXqRUf9zgmHz".to_string(),
},
]
}
/// Returns JSON with ICE servers.
///
/// <https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/RTCPeerConnection#iceservers>
///
/// All returned servers are resolved to their IP addresses.
/// The primary point of DNS lookup is that Delta Chat Desktop
/// relies on the servers being specified by IP,
/// because it itself cannot utilize DNS. See
/// <https://github.com/deltachat/deltachat-desktop/issues/5447>.
pub async fn ice_servers(context: &Context) -> Result<String> {
if let Some(ref metadata) = *context.metadata.read().await {
let ice_servers = resolve_ice_servers(context, metadata.ice_servers.clone()).await?;
Ok(ice_servers)
} else {
Ok("[]".to_string())
}
}
/// "Who can call me" config options.
#[derive(
Debug, Default, Display, Clone, Copy, PartialEq, Eq, FromPrimitive, ToPrimitive, FromSql, ToSql,
)]
#[repr(u8)]
pub enum WhoCanCallMe {
/// Everybody can call me if they are not blocked.
///
/// This includes contact requests.
Everybody = 0,
/// Every contact who is not blocked and not a contact request, can call.
#[default]
Contacts = 1,
/// Nobody can call me.
Nobody = 2,
}
/// Returns currently configuration of the "who can call me" option.
async fn who_can_call_me(context: &Context) -> Result<WhoCanCallMe> {
let who_can_call_me =
WhoCanCallMe::from_i32(context.get_config_int(Config::WhoCanCallMe).await?)
.unwrap_or_default();
Ok(who_can_call_me)
}
#[cfg(test)]
mod calls_tests;