From 1e37cb8c3cdd189e23ed52cff223e94a7e459df1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Sep 2025 15:09:58 +0000 Subject: [PATCH 01/15] chore(cargo): bump nu-ansi-term from 0.46.0 to 0.50.1 Bumps [nu-ansi-term](https://github.com/nushell/nu-ansi-term) from 0.46.0 to 0.50.1. - [Release notes](https://github.com/nushell/nu-ansi-term/releases) - [Changelog](https://github.com/nushell/nu-ansi-term/blob/main/CHANGELOG.md) - [Commits](https://github.com/nushell/nu-ansi-term/compare/v0.46.0...v0.50.1) --- updated-dependencies: - dependency-name: nu-ansi-term dependency-version: 0.50.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Cargo.lock | 22 +++------------------- Cargo.toml | 2 +- deny.toml | 1 - 3 files changed, 4 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a4ca86b31..792a125fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1337,7 +1337,7 @@ dependencies = [ "mail-builder", "mailparse", "mime", - "nu-ansi-term 0.46.0", + "nu-ansi-term", "num-derive", "num-traits", "num_cpus", @@ -1434,7 +1434,7 @@ dependencies = [ "deltachat", "dirs", "log", - "nu-ansi-term 0.46.0", + "nu-ansi-term", "qr2term", "rusqlite", "rustyline", @@ -3729,16 +3729,6 @@ dependencies = [ "serde", ] -[[package]] -name = "nu-ansi-term" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" -dependencies = [ - "overload", - "winapi", -] - [[package]] name = "nu-ansi-term" version = "0.50.1" @@ -3977,12 +3967,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "overload" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" - [[package]] name = "p256" version = "0.13.2" @@ -6335,7 +6319,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" dependencies = [ "matchers", - "nu-ansi-term 0.50.1", + "nu-ansi-term", "once_cell", "regex-automata", "sharded-slab", diff --git a/Cargo.toml b/Cargo.toml index a3591b6bf..adbca045b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -186,7 +186,7 @@ futures-lite = "2.6.1" libc = "0.2" log = "0.4" mailparse = "0.16.1" -nu-ansi-term = "0.46" +nu-ansi-term = "0.50" num-traits = "0.2" rand = "0.8" regex = "1.10" diff --git a/deny.toml b/deny.toml index d46630e0a..42d5ab5c5 100644 --- a/deny.toml +++ b/deny.toml @@ -33,7 +33,6 @@ skip = [ { name = "lru", version = "0.12.3" }, { name = "netlink-packet-route", version = "0.17.1" }, { name = "nom", version = "7.1.3" }, - { name = "nu-ansi-term", version = "0.46.0" }, { name = "rand_chacha", version = "0.3.1" }, { name = "rand_core", version = "0.6.4" }, { name = "rand", version = "0.8.5" }, From ba827283be5783ed97e7f97de7e3070ac79a4dd4 Mon Sep 17 00:00:00 2001 From: link2xt Date: Tue, 2 Sep 2025 19:00:40 +0000 Subject: [PATCH 02/15] docs(STYLE.md): prefer BTreeMap and BTreeSet over hash variants --- STYLE.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/STYLE.md b/STYLE.md index 08a01f792..c67f77102 100644 --- a/STYLE.md +++ b/STYLE.md @@ -112,6 +112,18 @@ Follow for `.expect` message style. +## BTreeMap vs HashMap + +Prefer [BTreeMap](https://doc.rust-lang.org/std/collections/struct.BTreeMap.html) +over [HashMap](https://doc.rust-lang.org/std/collections/struct.HashMap.html) +and [BTreeSet](https://doc.rust-lang.org/std/collections/struct.BTreeSet.html) +over [HashSet](https://doc.rust-lang.org/std/collections/struct.HashSet.html) +as iterating over these structures returns items in deterministic order. + +Non-deterministic code may result in difficult to reproduce bugs, +flaky tests, regression tests that miss bugs +or different behavior on different devices when processing the same messages. + ## Logging For logging, use `info!`, `warn!` and `error!` macros. From 9870725d1f7e0ed25037b132fd2c250609b9f9e2 Mon Sep 17 00:00:00 2001 From: link2xt Date: Thu, 4 Sep 2025 05:34:59 +0000 Subject: [PATCH 03/15] refactor: remove unused EncryptPreference::Reset --- src/aheader.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/aheader.rs b/src/aheader.rs index c0e26a3ec..3b69e0907 100644 --- a/src/aheader.rs +++ b/src/aheader.rs @@ -17,7 +17,6 @@ pub enum EncryptPreference { #[default] NoPreference = 0, Mutual = 1, - Reset = 20, } impl fmt::Display for EncryptPreference { @@ -25,7 +24,6 @@ impl fmt::Display for EncryptPreference { match *self { EncryptPreference::Mutual => write!(fmt, "mutual"), EncryptPreference::NoPreference => write!(fmt, "nopreference"), - EncryptPreference::Reset => write!(fmt, "reset"), } } } @@ -155,7 +153,7 @@ mod tests { Ok(()) } - // EncryptPreference::Reset is an internal value, parser should never return it + // Non-standard values of prefer-encrypt such as `reset` are treated as no preference. #[test] fn test_from_str_reset() -> Result<()> { let raw = format!("addr=reset@example.com; prefer-encrypt=reset; keydata={RAWKEY}"); From 838eed94bc8ed1c5311b22a5c47dd5fe873d89e8 Mon Sep 17 00:00:00 2001 From: link2xt Date: Thu, 4 Sep 2025 13:36:50 +0000 Subject: [PATCH 04/15] chore: update provider database --- scripts/update-provider-database.sh | 2 +- src/provider/data.rs | 46 +++++++++++++---------------- 2 files changed, 22 insertions(+), 26 deletions(-) diff --git a/scripts/update-provider-database.sh b/scripts/update-provider-database.sh index a9d4e7268..eaed2d97f 100755 --- a/scripts/update-provider-database.sh +++ b/scripts/update-provider-database.sh @@ -6,7 +6,7 @@ set -euo pipefail export TZ=UTC # Provider database revision. -REV=77cbf92a8565fdf1bcaba10fa93c1455c750a1e9 +REV=1cce91c1f1065b47e4f307d6fe2f4cca68c74d2e CORE_ROOT="$PWD" TMP="$(mktemp -d)" diff --git a/src/provider/data.rs b/src/provider/data.rs index 9979be93d..1e328b9ff 100644 --- a/src/provider/data.rs +++ b/src/provider/data.rs @@ -13,8 +13,8 @@ use std::sync::LazyLock; // 163.md: 163.com static P_163: Provider = Provider { id: "163", - status: Status::Ok, - before_login_hint: "", + status: Status::Preparation, + before_login_hint: "Enable \"POP3/SMTP/IMAP\" on the website, add a third-party auth code and use that as the login password", after_login_hint: "", overview_page: "https://providers.delta.chat/163", server: &[ @@ -98,7 +98,7 @@ static P_ALIYUN: Provider = Provider { static P_AOL: Provider = Provider { id: "aol", status: Status::Preparation, - before_login_hint: "To log in to AOL with Delta Chat, you need to set up an app password in the AOL web interface.", + before_login_hint: "To log in to AOL, you need to set up an app password in the AOL web interface.", after_login_hint: "", overview_page: "https://providers.delta.chat/aol", server: &[ @@ -432,7 +432,7 @@ static P_EXAMPLE_COM: Provider = Provider { id: "example.com", status: Status::Broken, before_login_hint: "Hush this provider doesn't exist!", - after_login_hint: "This provider doesn't really exist, so you can't use it :/ If you need an email provider for Delta Chat, take a look at providers.delta.chat!", + after_login_hint: "This provider doesn't really exist, so you can't use it :/ If you need an email provider, take a look at providers.delta.chat!", overview_page: "https://providers.delta.chat/example-com", server: &[ Server { @@ -459,7 +459,7 @@ static P_EXAMPLE_COM: Provider = Provider { static P_FASTMAIL: Provider = Provider { id: "fastmail", status: Status::Preparation, - before_login_hint: "You must create an app-specific password for Delta Chat before you can log in.", + before_login_hint: "You must create an app-specific password before you can log in.", after_login_hint: "", overview_page: "https://providers.delta.chat/fastmail", server: &[ @@ -526,7 +526,7 @@ static P_FIVE_CHAT: Provider = Provider { static P_FREENET_DE: Provider = Provider { id: "freenet.de", status: Status::Preparation, - before_login_hint: "Um deine freenet.de E-Mail-Adresse mit Delta Chat zu benutzen, musst du erst auf der freenet.de-Webseite \"POP3/IMAP/SMTP\" aktivieren.", + before_login_hint: "Um deine freenet.de E-Mail-Adresse zu benutzen, musst du erst auf der freenet.de-Webseite \"POP3/IMAP/SMTP\" aktivieren.", after_login_hint: "", overview_page: "https://providers.delta.chat/freenet-de", server: &[ @@ -647,10 +647,6 @@ static P_HERMES_RADIO: Provider = Provider { key: Config::MdnsEnabled, value: "0", }, - ConfigDefault { - key: Config::E2eeEnabled, - value: "0", - }, ConfigDefault { key: Config::ShowEmails, value: "2", @@ -663,7 +659,7 @@ static P_HERMES_RADIO: Provider = Provider { static P_HEY_COM: Provider = Provider { id: "hey.com", status: Status::Broken, - before_login_hint: "hey.com does not offer the standard IMAP e-mail protocol, so you cannot log in with Delta Chat to hey.com.", + before_login_hint: "hey.com does not offer the standard IMAP e-mail protocol, so you cannot log in to hey.com.", after_login_hint: "", overview_page: "https://providers.delta.chat/hey-com", server: &[], @@ -702,7 +698,7 @@ static P_I3_NET: Provider = Provider { static P_ICLOUD: Provider = Provider { id: "icloud", status: Status::Preparation, - before_login_hint: "You must create an app-specific password for Delta Chat before login.", + before_login_hint: "You must create an app-specific password before login.", after_login_hint: "", overview_page: "https://providers.delta.chat/icloud", server: &[ @@ -787,7 +783,7 @@ static P_KONTENT_COM: Provider = Provider { static P_MAIL_COM: Provider = Provider { id: "mail.com", status: Status::Preparation, - before_login_hint: "To log in with Delta Chat, you first need to activate POP3/IMAP in your mail.com settings. Note that this is a mail.com Premium feature only.", + before_login_hint: "To log in, you first need to activate POP3/IMAP in your mail.com settings. Note that this is a mail.com Premium feature only.", after_login_hint: "", overview_page: "https://providers.delta.chat/mail-com", server: &[], @@ -828,7 +824,7 @@ static P_MAIL_DE: Provider = Provider { static P_MAIL_RU: Provider = Provider { id: "mail.ru", status: Status::Preparation, - before_login_hint: "Вам необходимо сгенерировать \"пароль для внешнего приложения\" в веб-интерфейсе mail.ru, чтобы mail.ru работал с Delta Chat.", + before_login_hint: "Вам необходимо сгенерировать \"пароль для внешнего приложения\" в веб-интерфейсе mail.ru, чтобы mail.ru работал с chatmail.", after_login_hint: "", overview_page: "https://providers.delta.chat/mail-ru", server: &[ @@ -1222,8 +1218,8 @@ static P_NUBO_COOP: Provider = Provider { // outlook.com.md: hotmail.com, outlook.com, office365.com, outlook.com.tr, live.com, outlook.de static P_OUTLOOK_COM: Provider = Provider { id: "outlook.com", - status: Status::Ok, - before_login_hint: "", + status: Status::Broken, + before_login_hint: "Unfortunately, Outlook does not allow using passwords anymore, per-app-passwords are currently not working.", after_login_hint: "", overview_page: "https://providers.delta.chat/outlook-com", server: &[ @@ -1321,8 +1317,8 @@ static P_POSTEO: Provider = Provider { static P_PROTONMAIL: Provider = Provider { id: "protonmail", status: Status::Broken, - before_login_hint: "Protonmail does not offer the standard IMAP e-mail protocol, so you cannot log in with Delta Chat to Protonmail.", - after_login_hint: "To use Delta Chat with Protonmail, the IMAP bridge must be running in the background. If you have connectivity issues, double check whether it works as expected.", + before_login_hint: "Protonmail does not offer the standard IMAP e-mail protocol, so you cannot log in with to Protonmail.", + after_login_hint: "To use Protonmail, the IMAP bridge must be running in the background. If you have connectivity issues, double check whether it works as expected.", overview_page: "https://providers.delta.chat/protonmail", server: &[], opt: ProviderOptions::new(), @@ -1362,7 +1358,7 @@ static P_PURELYMAIL_COM: Provider = Provider { static P_QQ: Provider = Provider { id: "qq", status: Status::Preparation, - before_login_hint: "Manually enabling IMAP/SMTP and creating an app-specific password for Delta Chat are required.", + before_login_hint: "Manually enabling IMAP/SMTP and creating an app-specific password are required.", after_login_hint: "", overview_page: "https://providers.delta.chat/qq", server: &[ @@ -1390,7 +1386,7 @@ static P_QQ: Provider = Provider { static P_RAMBLER_RU: Provider = Provider { id: "rambler.ru", status: Status::Preparation, - before_login_hint: "Чтобы войти в Рамблер/почта через Delta Chat, необходимо предварительно включить доступ с помощью почтовых клиентов на сайте mail.rambler.ru", + before_login_hint: "Чтобы войти в Рамблер/почта, необходимо предварительно включить доступ с помощью почтовых клиентов на сайте mail.rambler.ru", after_login_hint: "", overview_page: "https://providers.delta.chat/rambler-ru", server: &[ @@ -1566,7 +1562,7 @@ static P_SYSTEMLI_ORG: Provider = Provider { static P_T_ONLINE: Provider = Provider { id: "t-online", status: Status::Preparation, - before_login_hint: "To use Delta Chat with a T-Online email address, you need to create an app password in the web interface.", + before_login_hint: "To use a T-Online email address, you need to create an app password in the web interface.", after_login_hint: "", overview_page: "https://providers.delta.chat/t-online", server: &[ @@ -1677,7 +1673,7 @@ static P_TISCALI_IT: Provider = Provider { static P_TUTANOTA: Provider = Provider { id: "tutanota", status: Status::Broken, - before_login_hint: "Tutanota does not offer the standard IMAP e-mail protocol, so you cannot log in with Delta Chat to Tutanota.", + before_login_hint: "Tutanota does not offer the standard IMAP e-mail protocol, so you cannot log in to Tutanota.", after_login_hint: "", overview_page: "https://providers.delta.chat/tutanota", server: &[], @@ -1787,7 +1783,7 @@ static P_VIVALDI: Provider = Provider { static P_VK_COM: Provider = Provider { id: "vk.com", status: Status::Preparation, - before_login_hint: "Вам необходимо сгенерировать \"пароль для внешнего приложения\" в веб-интерфейсе mail.ru https://account.mail.ru/user/2-step-auth/passwords/ чтобы vk.com работал с Delta Chat.", + before_login_hint: "Вам необходимо сгенерировать \"пароль для внешнего приложения\" в веб-интерфейсе mail.ru https://account.mail.ru/user/2-step-auth/passwords/ чтобы vk.com работал с chatmail.", after_login_hint: "", overview_page: "https://providers.delta.chat/vk-com", server: &[ @@ -1906,7 +1902,7 @@ static P_WKPB_DE: Provider = Provider { static P_YAHOO: Provider = Provider { id: "yahoo", status: Status::Preparation, - before_login_hint: "To use Delta Chat with your Yahoo email address you have to create an \"App-Password\" in the account security screen.", + before_login_hint: "To use your Yahoo email address you have to create an \"App-Password\" in the account security screen.", after_login_hint: "", overview_page: "https://providers.delta.chat/yahoo", server: &[ @@ -2662,4 +2658,4 @@ pub(crate) static PROVIDER_IDS: LazyLock = - LazyLock::new(|| chrono::NaiveDate::from_ymd_opt(2024, 9, 13).unwrap()); + LazyLock::new(|| chrono::NaiveDate::from_ymd_opt(2025, 9, 4).unwrap()); From d4704977bce7296d57113414ea19d6fa0dd68f2e Mon Sep 17 00:00:00 2001 From: link2xt Date: Thu, 4 Sep 2025 05:28:43 +0000 Subject: [PATCH 05/15] api!: remove e2ee_enabled preference The setting is already removed from the UIs, but users who had it disabled previously have no way to enable it. After this change encryption is effectively always preferred. --- deltachat-ffi/deltachat.h | 1 - src/config.rs | 5 ----- src/context.rs | 2 -- src/e2ee.rs | 6 +----- src/imex.rs | 26 +------------------------- src/imex/key_transfer.rs | 5 +---- src/key.rs | 32 +++++++------------------------- src/mimeparser.rs | 2 +- src/push.rs | 2 +- src/test_utils.rs | 28 +++++++++++----------------- 10 files changed, 23 insertions(+), 86 deletions(-) diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index dee7ded8a..cf8ff0b90 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -415,7 +415,6 @@ char* dc_get_blobdir (const dc_context_t* context); * As for `displayname` and `selfstatus`, also the avatar is sent to the recipients. * To save traffic, however, the avatar is attached only as needed * and also recoded to a reasonable size. - * - `e2ee_enabled` = 0=no end-to-end-encryption, 1=prefer end-to-end-encryption (default) * - `mdns_enabled` = 0=do not send or request read receipts, * 1=send and request read receipts * default=send and request read receipts, only send but not request if `bot` is set diff --git a/src/config.rs b/src/config.rs index b16382cd7..02ad57374 100644 --- a/src/config.rs +++ b/src/config.rs @@ -151,10 +151,6 @@ pub enum Config { /// setting up a second device, or receiving a sync message. BccSelf, - /// True if encryption is preferred according to Autocrypt standard. - #[strum(props(default = "1"))] - E2eeEnabled, - /// True if Message Delivery Notifications (read receipts) should /// be sent and requested. #[strum(props(default = "1"))] @@ -705,7 +701,6 @@ impl Context { Config::Socks5Enabled | Config::ProxyEnabled | Config::BccSelf - | Config::E2eeEnabled | Config::MdnsEnabled | Config::SentboxWatch | Config::MvboxMove diff --git a/src/context.rs b/src/context.rs index ba085f63e..c94780a8d 100644 --- a/src/context.rs +++ b/src/context.rs @@ -833,7 +833,6 @@ impl Context { .query_get_value("PRAGMA journal_mode;", ()) .await? .unwrap_or_else(|| "unknown".to_string()); - let e2ee_enabled = self.get_config_int(Config::E2eeEnabled).await?; let mdns_enabled = self.get_config_int(Config::MdnsEnabled).await?; let bcc_self = self.get_config_int(Config::BccSelf).await?; let sync_msgs = self.get_config_int(Config::SyncMsgs).await?; @@ -967,7 +966,6 @@ impl Context { res.insert("configured_mvbox_folder", configured_mvbox_folder); res.insert("configured_trash_folder", configured_trash_folder); res.insert("mdns_enabled", mdns_enabled.to_string()); - res.insert("e2ee_enabled", e2ee_enabled.to_string()); res.insert("bcc_self", bcc_self.to_string()); res.insert("sync_msgs", sync_msgs.to_string()); res.insert("disable_idle", disable_idle.to_string()); diff --git a/src/e2ee.rs b/src/e2ee.rs index 9968c2245..b68361347 100644 --- a/src/e2ee.rs +++ b/src/e2ee.rs @@ -4,10 +4,8 @@ use std::io::Cursor; use anyhow::Result; use mail_builder::mime::MimePart; -use num_traits::FromPrimitive; use crate::aheader::{Aheader, EncryptPreference}; -use crate::config::Config; use crate::context::Context; use crate::key::{SignedPublicKey, load_self_public_key, load_self_secret_key}; use crate::pgp; @@ -21,9 +19,7 @@ pub struct EncryptHelper { impl EncryptHelper { pub async fn new(context: &Context) -> Result { - let prefer_encrypt = - EncryptPreference::from_i32(context.get_config_int(Config::E2eeEnabled).await?) - .unwrap_or_default(); + let prefer_encrypt = EncryptPreference::Mutual; let addr = context.get_primary_self_addr().await?; let public_key = load_self_public_key(context).await?; diff --git a/src/imex.rs b/src/imex.rs index 3ccdc4615..56f018acb 100644 --- a/src/imex.rs +++ b/src/imex.rs @@ -140,32 +140,8 @@ pub async fn has_backup(_context: &Context, dir_name: &Path) -> Result { } async fn set_self_key(context: &Context, armored: &str) -> Result<()> { - // try hard to only modify key-state - let (private_key, header) = SignedSecretKey::from_asc(armored)?; + let private_key = SignedSecretKey::from_asc(armored)?; let public_key = private_key.split_public_key()?; - if let Some(preferencrypt) = header.get("Autocrypt-Prefer-Encrypt") { - let e2ee_enabled = match preferencrypt.as_str() { - "nopreference" => 0, - "mutual" => 1, - _ => { - bail!("invalid Autocrypt-Prefer-Encrypt header: {:?}", header); - } - }; - context - .sql - .set_raw_config_int("e2ee_enabled", e2ee_enabled) - .await?; - } else { - // `Autocrypt-Prefer-Encrypt` is not included - // in keys exported to file. - // - // `Autocrypt-Prefer-Encrypt` also SHOULD be sent - // in Autocrypt Setup Message according to Autocrypt specification, - // but K-9 6.802 does not include this header. - // - // We keep current setting in this case. - info!(context, "No Autocrypt-Prefer-Encrypt header."); - }; let keypair = pgp::KeyPair { public: public_key, diff --git a/src/imex/key_transfer.rs b/src/imex/key_transfer.rs index 2df464391..6d1f9f418 100644 --- a/src/imex/key_transfer.rs +++ b/src/imex/key_transfer.rs @@ -93,10 +93,7 @@ pub async fn render_setup_file(context: &Context, passphrase: &str) -> Result None, - true => Some(("Autocrypt-Prefer-Encrypt", "mutual")), - }; + let ac_headers = Some(("Autocrypt-Prefer-Encrypt", "mutual")); let private_key_asc = private_key.to_asc(ac_headers); let encr = pgp::symm_encrypt(passphrase, private_key_asc.into_bytes()) .await? diff --git a/src/key.rs b/src/key.rs index 7d01812d2..e9ca6c595 100644 --- a/src/key.rs +++ b/src/key.rs @@ -71,31 +71,17 @@ pub(crate) trait DcKey: Serialize + Deserializable + Clone { } /// Create a key from an ASCII-armored string. - /// - /// Returns the key and a map of any headers which might have been set in - /// the ASCII-armored representation. - fn from_asc(data: &str) -> Result<(Self, BTreeMap)> { + fn from_asc(data: &str) -> Result { let bytes = data.as_bytes(); let res = Self::from_armor_single(Cursor::new(bytes)); - let (key, headers) = match res { + let (key, _headers) = match res { Err(pgp::errors::Error::NoMatchingPacket { .. }) => match Self::is_private() { true => bail!("No private key packet found"), false => bail!("No public key packet found"), }, _ => res.context("rPGP error")?, }; - let headers = headers - .into_iter() - .map(|(key, values)| { - ( - key.trim().to_lowercase(), - values - .last() - .map_or_else(String::new, |s| s.trim().to_string()), - ) - }) - .collect(); - Ok((key, headers)) + Ok(key) } /// Serialise the key as bytes. @@ -446,7 +432,7 @@ pub(crate) async fn store_self_keypair(context: &Context, keypair: &KeyPair) -> /// to avoid generating the key in tests. /// Use import/export APIs instead. pub async fn preconfigure_keypair(context: &Context, secret_data: &str) -> Result<()> { - let secret = SignedSecretKey::from_asc(secret_data)?.0; + let secret = SignedSecretKey::from_asc(secret_data)?; let public = secret.split_public_key()?; let keypair = KeyPair { public, secret }; store_self_keypair(context, &keypair).await?; @@ -532,7 +518,7 @@ mod tests { #[test] fn test_from_armored_string() { - let (private_key, _) = SignedSecretKey::from_asc( + let private_key = SignedSecretKey::from_asc( "-----BEGIN PGP PRIVATE KEY BLOCK----- xcLYBF0fgz4BCADnRUV52V4xhSsU56ZaAn3+3oG86MZhXy4X8w14WZZDf0VJGeTh @@ -600,17 +586,13 @@ i8pcjGO+IZffvyZJVRWfVooBJmWWbPB1pueo3tx8w3+fcuzpxz+RLFKaPyqXO+dD fn test_asc_roundtrip() { let key = KEYPAIR.public.clone(); let asc = key.to_asc(Some(("spam", "ham"))); - let (key2, hdrs) = SignedPublicKey::from_asc(&asc).unwrap(); + let key2 = SignedPublicKey::from_asc(&asc).unwrap(); assert_eq!(key, key2); - assert_eq!(hdrs.len(), 1); - assert_eq!(hdrs.get("spam"), Some(&String::from("ham"))); let key = KEYPAIR.secret.clone(); let asc = key.to_asc(Some(("spam", "ham"))); - let (key2, hdrs) = SignedSecretKey::from_asc(&asc).unwrap(); + let key2 = SignedSecretKey::from_asc(&asc).unwrap(); assert_eq!(key, key2); - assert_eq!(hdrs.len(), 1); - assert_eq!(hdrs.get("spam"), Some(&String::from("ham"))); } #[test] diff --git a/src/mimeparser.rs b/src/mimeparser.rs index e42647680..467a82b33 100644 --- a/src/mimeparser.rs +++ b/src/mimeparser.rs @@ -1519,7 +1519,7 @@ impl MimeMessage { ); return Ok(false); } - Ok((key, _)) => key, + Ok(key) => key, }; if let Err(err) = key.verify() { warn!(context, "Attached PGP key verification failed: {err:#}."); diff --git a/src/push.rs b/src/push.rs index ffd31cd9c..8b4bcc3ca 100644 --- a/src/push.rs +++ b/src/push.rs @@ -74,7 +74,7 @@ fn pad_device_token(s: &str) -> String { /// /// The result is base64-encoded and not ASCII armored to avoid dealing with newlines. pub(crate) fn encrypt_device_token(device_token: &str) -> Result { - let public_key = pgp::composed::SignedPublicKey::from_asc(NOTIFIERS_PUBLIC_KEY)?.0; + let public_key = pgp::composed::SignedPublicKey::from_asc(NOTIFIERS_PUBLIC_KEY)?; let encryption_subkey = public_key .public_subkeys .first() diff --git a/src/test_utils.rs b/src/test_utils.rs index 0534a66fc..083f0b5c5 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -1245,9 +1245,8 @@ impl SentMessage<'_> { /// /// The keypair was created using the crate::key::tests::gen_key test. pub fn alice_keypair() -> KeyPair { - let secret = key::SignedSecretKey::from_asc(include_str!("../test-data/key/alice-secret.asc")) - .unwrap() - .0; + let secret = + key::SignedSecretKey::from_asc(include_str!("../test-data/key/alice-secret.asc")).unwrap(); let public = secret.split_public_key().unwrap(); KeyPair { public, secret } } @@ -1256,9 +1255,8 @@ pub fn alice_keypair() -> KeyPair { /// /// Like [alice_keypair] but a different key and identity. pub fn bob_keypair() -> KeyPair { - let secret = key::SignedSecretKey::from_asc(include_str!("../test-data/key/bob-secret.asc")) - .unwrap() - .0; + let secret = + key::SignedSecretKey::from_asc(include_str!("../test-data/key/bob-secret.asc")).unwrap(); let public = secret.split_public_key().unwrap(); KeyPair { public, secret } } @@ -1269,8 +1267,7 @@ pub fn bob_keypair() -> KeyPair { pub fn charlie_keypair() -> KeyPair { let secret = key::SignedSecretKey::from_asc(include_str!("../test-data/key/charlie-secret.asc")) - .unwrap() - .0; + .unwrap(); let public = secret.split_public_key().unwrap(); KeyPair { public, secret } } @@ -1279,9 +1276,8 @@ pub fn charlie_keypair() -> KeyPair { /// /// Like [alice_keypair] but a different key and identity. pub fn dom_keypair() -> KeyPair { - let secret = key::SignedSecretKey::from_asc(include_str!("../test-data/key/dom-secret.asc")) - .unwrap() - .0; + let secret = + key::SignedSecretKey::from_asc(include_str!("../test-data/key/dom-secret.asc")).unwrap(); let public = secret.split_public_key().unwrap(); KeyPair { public, secret } } @@ -1290,9 +1286,8 @@ pub fn dom_keypair() -> KeyPair { /// /// Like [alice_keypair] but a different key and identity. pub fn elena_keypair() -> KeyPair { - let secret = key::SignedSecretKey::from_asc(include_str!("../test-data/key/elena-secret.asc")) - .unwrap() - .0; + let secret = + key::SignedSecretKey::from_asc(include_str!("../test-data/key/elena-secret.asc")).unwrap(); let public = secret.split_public_key().unwrap(); KeyPair { public, secret } } @@ -1301,9 +1296,8 @@ pub fn elena_keypair() -> KeyPair { /// /// Like [alice_keypair] but a different key and identity. pub fn fiona_keypair() -> KeyPair { - let secret = key::SignedSecretKey::from_asc(include_str!("../test-data/key/fiona-secret.asc")) - .unwrap() - .0; + let secret = + key::SignedSecretKey::from_asc(include_str!("../test-data/key/fiona-secret.asc")).unwrap(); let public = secret.split_public_key().unwrap(); KeyPair { public, secret } } From bed1623dcb7c698cdf47b530340181fb8a30f235 Mon Sep 17 00:00:00 2001 From: bjoern Date: Thu, 4 Sep 2025 16:51:51 +0200 Subject: [PATCH 06/15] feat: use dedicated 'call' viewtype (#7174) a dedicated viewtype allows the UI to show a more advanced UI, but even when using the defaults, it has the advantage that incoming/outgoing and the date are directly visible. successor of https://github.com/chatmail/core/pull/6650 --- deltachat-ffi/deltachat.h | 10 +- deltachat-jsonrpc/src/api/types/message.rs | 9 +- src/calls.rs | 114 ++++++++++----------- src/calls/calls_tests.rs | 10 +- src/chat.rs | 6 +- src/message.rs | 6 +- src/mimefactory.rs | 16 ++- src/mimeparser.rs | 30 +++--- src/receive_imf.rs | 2 +- src/summary.rs | 12 ++- 10 files changed, 110 insertions(+), 105 deletions(-) diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index cf8ff0b90..94b932fa3 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -4620,8 +4620,6 @@ 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. @@ -4678,8 +4676,6 @@ 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 /** @@ -5716,6 +5712,12 @@ int64_t dc_lot_get_timestamp (const dc_lot_t* lot); #define DC_MSG_VIDEOCHAT_INVITATION 70 +/** + * Message indicating an incoming or outgoing call. + */ +#define DC_MSG_CALL 71 + + /** * The message is a webxdc instance. * diff --git a/deltachat-jsonrpc/src/api/types/message.rs b/deltachat-jsonrpc/src/api/types/message.rs index 398a6b018..0369c43e3 100644 --- a/deltachat-jsonrpc/src/api/types/message.rs +++ b/deltachat-jsonrpc/src/api/types/message.rs @@ -324,6 +324,9 @@ pub enum MessageViewtype { /// Message is an invitation to a videochat. VideochatInvitation, + /// Message is a call. + Call, + /// Message is an webxdc instance. Webxdc, @@ -346,6 +349,7 @@ impl From for MessageViewtype { Viewtype::Video => MessageViewtype::Video, Viewtype::File => MessageViewtype::File, Viewtype::VideochatInvitation => MessageViewtype::VideochatInvitation, + Viewtype::Call => MessageViewtype::Call, Viewtype::Webxdc => MessageViewtype::Webxdc, Viewtype::Vcard => MessageViewtype::Vcard, } @@ -365,6 +369,7 @@ impl From for Viewtype { MessageViewtype::Video => Viewtype::Video, MessageViewtype::File => Viewtype::File, MessageViewtype::VideochatInvitation => Viewtype::VideochatInvitation, + MessageViewtype::Call => Viewtype::Call, MessageViewtype::Webxdc => Viewtype::Webxdc, MessageViewtype::Vcard => Viewtype::Vcard, } @@ -438,8 +443,6 @@ pub enum SystemMessageType { /// This message contains a users iroh node address. IrohNodeAddr, - OutgoingCall, - IncomingCall, CallAccepted, CallEnded, } @@ -468,8 +471,6 @@ 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 index b59740e34..a968ccfdf 100644 --- a/src/calls.rs +++ b/src/calls.rs @@ -4,6 +4,7 @@ //! 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::contact::ContactId; use crate::context::Context; use crate::events::EventType; use crate::headerdef::HeaderDef; @@ -82,11 +83,10 @@ impl Context { ensure!(chat.typ == Chattype::Single && !chat.is_self_talk()); let mut call = Message { - viewtype: Viewtype::Text, + viewtype: Viewtype::Call, 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?; @@ -184,60 +184,61 @@ impl Context { 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, - )); - } + if mime_message.is_call() { + 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); - } - } - 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 { + self.emit_event(EventType::IncomingCall { msg_id: call.msg.id, - accept_call_info: call.accept_call_info, + place_call_info: call.place_call_info.to_string(), }); - } 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 { + 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); + } + } else { + match mime_message.is_system_message { + 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, - 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(()) } @@ -255,13 +256,10 @@ impl Context { } fn load_call_by_message(&self, call: Message) -> Result { - ensure!( - call.get_info_type() == SystemMessage::IncomingCall - || call.get_info_type() == SystemMessage::OutgoingCall - ); + ensure!(call.viewtype == Viewtype::Call); Ok(CallInfo { - is_incoming: call.get_info_type() == SystemMessage::IncomingCall, + is_incoming: call.get_from_id() != ContactId::SELF, is_accepted: call.is_call_accepted()?, place_call_info: call .param @@ -284,10 +282,7 @@ impl Message { context: &Context, accept_call_info: String, ) -> Result<()> { - ensure!( - self.get_info_type() == SystemMessage::IncomingCall - || self.get_info_type() == SystemMessage::OutgoingCall - ); + ensure!(self.viewtype == Viewtype::Call); self.param.set_int(Param::Arg, 1); self.param.set(Param::WebrtcAccepted, accept_call_info); self.update_param(context).await?; @@ -295,10 +290,7 @@ impl Message { } fn is_call_accepted(&self) -> Result { - ensure!( - self.get_info_type() == SystemMessage::IncomingCall - || self.get_info_type() == SystemMessage::OutgoingCall - ); + ensure!(self.viewtype == Viewtype::Call); Ok(self.param.get_int(Param::Arg) == Some(1)) } } diff --git a/src/calls/calls_tests.rs b/src/calls/calls_tests.rs index 44a76a29d..0e5eb1418 100644 --- a/src/calls/calls_tests.rs +++ b/src/calls/calls_tests.rs @@ -33,9 +33,10 @@ async fn setup_call() -> Result { 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); + assert!(!m.is_info()); + assert_eq!(m.viewtype, Viewtype::Call); let info = t.load_call_by_id(m.id).await?; + assert!(!info.is_incoming); assert!(!info.is_accepted); assert_eq!(info.place_call_info, "place_info"); } @@ -45,12 +46,13 @@ async fn setup_call() -> Result { 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); + assert!(!m.is_info()); + assert_eq!(m.viewtype, Viewtype::Call); t.evtracker .get_matching(|evt| matches!(evt, EventType::IncomingCall { .. })) .await; let info = t.load_call_by_id(m.id).await?; + assert!(info.is_incoming); assert!(!info.is_accepted); assert_eq!(info.place_call_info, "place_info"); } diff --git a/src/chat.rs b/src/chat.rs index f0ebdb0c6..2086e774f 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -2691,7 +2691,10 @@ impl ChatIdBlocked { } async fn prepare_msg_blob(context: &Context, msg: &mut Message) -> Result<()> { - if msg.viewtype == Viewtype::Text || msg.viewtype == Viewtype::VideochatInvitation { + if msg.viewtype == Viewtype::Text + || msg.viewtype == Viewtype::VideochatInvitation + || msg.viewtype == Viewtype::Call + { // the caller should check if the message text is empty } else if msg.viewtype.has_file() { let viewtype_orig = msg.viewtype; @@ -3167,6 +3170,7 @@ pub async fn send_edit_request(context: &Context, msg_id: MsgId, new_text: Strin original_msg.viewtype != Viewtype::VideochatInvitation, "Cannot edit videochat invitations" ); + ensure!(original_msg.viewtype != Viewtype::Call, "Cannot edit calls"); ensure!( !original_msg.text.is_empty(), // avoid complexity in UI element changes. focus is typos and rewordings "Cannot add text" diff --git a/src/message.rs b/src/message.rs index cf4b84304..4d3aef3a6 100644 --- a/src/message.rs +++ b/src/message.rs @@ -973,8 +973,6 @@ impl Message { | SystemMessage::WebxdcStatusUpdate | SystemMessage::WebxdcInfoMessage | SystemMessage::IrohNodeAddr - | SystemMessage::OutgoingCall - | SystemMessage::IncomingCall | SystemMessage::CallAccepted | SystemMessage::CallEnded | SystemMessage::Unknown => Ok(None), @@ -2280,6 +2278,9 @@ pub enum Viewtype { /// Message is an invitation to a videochat. VideochatInvitation = 70, + /// Message is an incoming or outgoing call. + Call = 71, + /// Message is an webxdc instance. Webxdc = 80, @@ -2303,6 +2304,7 @@ impl Viewtype { Viewtype::Video => true, Viewtype::File => true, Viewtype::VideochatInvitation => false, + Viewtype::Call => false, Viewtype::Webxdc => true, Viewtype::Vcard => true, } diff --git a/src/mimefactory.rs b/src/mimefactory.rs index a16420e52..c4c64c33d 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -3,7 +3,7 @@ use std::collections::{BTreeSet, HashSet}; use std::io::Cursor; -use anyhow::{Context as _, Result, anyhow, bail, ensure}; +use anyhow::{Context as _, Result, bail, ensure}; use base64::Engine as _; use data_encoding::BASE32_NOPAD; use deltachat_contact_tools::sanitize_bidi_characters; @@ -1533,15 +1533,6 @@ 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", @@ -1578,6 +1569,11 @@ impl MimeFactory { "Chat-Content", mail_builder::headers::raw::Raw::new("videochat-invitation").into(), )); + } else if msg.viewtype == Viewtype::Call { + headers.push(( + "Chat-Content", + mail_builder::headers::raw::Raw::new("call").into(), + )); } if msg.param.exists(Param::WebrtcRoom) { diff --git a/src/mimeparser.rs b/src/mimeparser.rs index 467a82b33..1d92a377c 100644 --- a/src/mimeparser.rs +++ b/src/mimeparser.rs @@ -217,17 +217,7 @@ 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. @@ -692,12 +682,6 @@ 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" { @@ -738,6 +722,8 @@ impl MimeMessage { if let Some(room) = room { if content == "videochat-invitation" { part.typ = Viewtype::VideochatInvitation; + } else if content == "call" { + part.typ = Viewtype::Call } part.param.set(Param::WebrtcRoom, room); } else if let Some(accepted) = accepted { @@ -767,7 +753,10 @@ impl MimeMessage { | Viewtype::Vcard | Viewtype::File | Viewtype::Webxdc => true, - Viewtype::Unknown | Viewtype::Text | Viewtype::VideochatInvitation => false, + Viewtype::Unknown + | Viewtype::Text + | Viewtype::VideochatInvitation + | Viewtype::Call => false, }) { let mut parts = std::mem::take(&mut self.parts); @@ -1582,6 +1571,13 @@ impl MimeMessage { } } + /// Check if a message is a call. + pub(crate) fn is_call(&self) -> bool { + self.parts + .first() + .is_some_and(|part| part.typ == Viewtype::Call) + } + pub(crate) fn get_rfc724_mid(&self) -> Option { self.get_header(HeaderDef::MessageId) .and_then(|msgid| parse_message_id(msgid).ok()) diff --git a/src/receive_imf.rs b/src/receive_imf.rs index c228a1b18..2e1d2b603 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -1000,7 +1000,7 @@ pub(crate) async fn receive_imf_inner( } } - if mime_parser.is_system_message == SystemMessage::IncomingCall { + if mime_parser.is_call() { 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 diff --git a/src/summary.rs b/src/summary.rs index 6601e8982..7392b395e 100644 --- a/src/summary.rs +++ b/src/summary.rs @@ -97,7 +97,7 @@ impl Summary { let prefix = if msg.state == MessageState::OutDraft { Some(SummaryPrefix::Draft(stock_str::draft(context).await)) } else if msg.from_id == ContactId::SELF { - if msg.is_info() { + if msg.is_info() || msg.viewtype == Viewtype::Call { None } else { Some(SummaryPrefix::Me(stock_str::self_msg(context).await)) @@ -233,6 +233,16 @@ impl Message { type_file = self.param.get(Param::Summary1).map(|s| s.to_string()); append_text = true; } + Viewtype::Call => { + emoji = Some("📞"); + type_name = Some(if self.from_id == ContactId::SELF { + "Outgoing call".to_string() + } else { + "Incoming call".to_string() + }); + type_file = None; + append_text = false + } Viewtype::Text | Viewtype::Unknown => { emoji = None; if self.param.get_cmd() == SystemMessage::LocationOnly { From 4033566b4abf01fb7460c1899bb8ea5ea23f95cd Mon Sep 17 00:00:00 2001 From: link2xt Date: Tue, 2 Sep 2025 19:15:48 +0000 Subject: [PATCH 07/15] refactor: remove Aheader::new --- src/aheader.rs | 45 +++++++++++++++------------------------------ src/e2ee.rs | 8 +++++--- src/mimefactory.rs | 10 +++++----- 3 files changed, 25 insertions(+), 38 deletions(-) diff --git a/src/aheader.rs b/src/aheader.rs index 3b69e0907..c5da72e80 100644 --- a/src/aheader.rs +++ b/src/aheader.rs @@ -48,21 +48,6 @@ pub struct Aheader { pub prefer_encrypt: EncryptPreference, } -impl Aheader { - /// Creates new autocrypt header - pub fn new( - addr: String, - public_key: SignedPublicKey, - prefer_encrypt: EncryptPreference, - ) -> Self { - Aheader { - addr, - public_key, - prefer_encrypt, - } - } -} - impl fmt::Display for Aheader { fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { write!(fmt, "addr={};", self.addr.to_lowercase())?; @@ -243,11 +228,11 @@ mod tests { assert!( format!( "{}", - Aheader::new( - "test@example.com".to_string(), - SignedPublicKey::from_base64(RAWKEY).unwrap(), - EncryptPreference::Mutual - ) + Aheader { + addr: "test@example.com".to_string(), + public_key: SignedPublicKey::from_base64(RAWKEY).unwrap(), + prefer_encrypt: EncryptPreference::Mutual + } ) .contains("prefer-encrypt=mutual;") ); @@ -258,11 +243,11 @@ mod tests { assert!( !format!( "{}", - Aheader::new( - "test@example.com".to_string(), - SignedPublicKey::from_base64(RAWKEY).unwrap(), - EncryptPreference::NoPreference - ) + Aheader { + addr: "test@example.com".to_string(), + public_key: SignedPublicKey::from_base64(RAWKEY).unwrap(), + prefer_encrypt: EncryptPreference::NoPreference + } ) .contains("prefer-encrypt") ); @@ -271,11 +256,11 @@ mod tests { assert!( format!( "{}", - Aheader::new( - "TeSt@eXaMpLe.cOm".to_string(), - SignedPublicKey::from_base64(RAWKEY).unwrap(), - EncryptPreference::Mutual - ) + Aheader { + addr: "TeSt@eXaMpLe.cOm".to_string(), + public_key: SignedPublicKey::from_base64(RAWKEY).unwrap(), + prefer_encrypt: EncryptPreference::Mutual + } ) .contains("test@example.com") ); diff --git a/src/e2ee.rs b/src/e2ee.rs index b68361347..63b4d7dc0 100644 --- a/src/e2ee.rs +++ b/src/e2ee.rs @@ -31,9 +31,11 @@ impl EncryptHelper { } pub fn get_aheader(&self) -> Aheader { - let pk = self.public_key.clone(); - let addr = self.addr.to_string(); - Aheader::new(addr, pk, self.prefer_encrypt) + Aheader { + addr: self.addr.clone(), + public_key: self.public_key.clone(), + prefer_encrypt: self.prefer_encrypt, + } } /// Tries to encrypt the passed in `mail`. diff --git a/src/mimefactory.rs b/src/mimefactory.rs index c4c64c33d..972854d9d 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -1092,13 +1092,13 @@ impl MimeFactory { continue; } - let header = Aheader::new( - addr.clone(), - key.clone(), + let header = Aheader { + addr: addr.clone(), + public_key: key.clone(), // Autocrypt 1.1.0 specification says that // `prefer-encrypt` attribute SHOULD NOT be included. - EncryptPreference::NoPreference, - ) + prefer_encrypt: EncryptPreference::NoPreference, + } .to_string(); message = message.header( From 53a3e519200f4a36efbb446cb05ae9d152dc4e06 Mon Sep 17 00:00:00 2001 From: link2xt Date: Fri, 15 Aug 2025 22:16:07 +0000 Subject: [PATCH 08/15] feat: support receiving Autocrypt-Gossip with `_verified` attribute This commit is a preparation for sending Autocrypt-Gossip with `_verified` attribute instead of `Chat-Verified` header. --- src/aheader.rs | 36 +++++++++++++++++++++++++++--- src/e2ee.rs | 1 + src/mimefactory.rs | 1 + src/mimeparser.rs | 26 ++++++++++++++++----- src/receive_imf.rs | 34 +++++++++++++++++++++------- src/securejoin.rs | 6 +++-- src/securejoin/securejoin_tests.rs | 6 ++++- 7 files changed, 91 insertions(+), 19 deletions(-) diff --git a/src/aheader.rs b/src/aheader.rs index c5da72e80..86941fbeb 100644 --- a/src/aheader.rs +++ b/src/aheader.rs @@ -46,6 +46,13 @@ pub struct Aheader { pub addr: String, pub public_key: SignedPublicKey, pub prefer_encrypt: EncryptPreference, + + // Whether `_verified` attribute is present. + // + // `_verified` attribute is an extension to `Autocrypt-Gossip` + // header that is used to tell that the sender + // marked this key as verified. + pub verified: bool, } impl fmt::Display for Aheader { @@ -54,6 +61,9 @@ impl fmt::Display for Aheader { if self.prefer_encrypt == EncryptPreference::Mutual { write!(fmt, " prefer-encrypt=mutual;")?; } + if self.verified { + write!(fmt, " _verified=1;")?; + } // adds a whitespace every 78 characters, this allows // email crate to wrap the lines according to RFC 5322 @@ -108,6 +118,8 @@ impl FromStr for Aheader { .and_then(|raw| raw.parse().ok()) .unwrap_or_default(); + let verified = attributes.remove("_verified").is_some(); + // Autocrypt-Level0: unknown attributes starting with an underscore can be safely ignored // Autocrypt-Level0: unknown attribute, treat the header as invalid if attributes.keys().any(|k| !k.starts_with('_')) { @@ -118,6 +130,7 @@ impl FromStr for Aheader { addr, public_key, prefer_encrypt, + verified, }) } } @@ -135,6 +148,7 @@ mod tests { assert_eq!(h.addr, "me@mail.com"); assert_eq!(h.prefer_encrypt, EncryptPreference::Mutual); + assert_eq!(h.verified, false); Ok(()) } @@ -231,7 +245,8 @@ mod tests { Aheader { addr: "test@example.com".to_string(), public_key: SignedPublicKey::from_base64(RAWKEY).unwrap(), - prefer_encrypt: EncryptPreference::Mutual + prefer_encrypt: EncryptPreference::Mutual, + verified: false } ) .contains("prefer-encrypt=mutual;") @@ -246,7 +261,8 @@ mod tests { Aheader { addr: "test@example.com".to_string(), public_key: SignedPublicKey::from_base64(RAWKEY).unwrap(), - prefer_encrypt: EncryptPreference::NoPreference + prefer_encrypt: EncryptPreference::NoPreference, + verified: false } ) .contains("prefer-encrypt") @@ -259,10 +275,24 @@ mod tests { Aheader { addr: "TeSt@eXaMpLe.cOm".to_string(), public_key: SignedPublicKey::from_base64(RAWKEY).unwrap(), - prefer_encrypt: EncryptPreference::Mutual + prefer_encrypt: EncryptPreference::Mutual, + verified: false } ) .contains("test@example.com") ); + + assert!( + format!( + "{}", + Aheader { + addr: "test@example.com".to_string(), + public_key: SignedPublicKey::from_base64(RAWKEY).unwrap(), + prefer_encrypt: EncryptPreference::NoPreference, + verified: true + } + ) + .contains("_verified") + ); } } diff --git a/src/e2ee.rs b/src/e2ee.rs index 63b4d7dc0..1eabbb4e1 100644 --- a/src/e2ee.rs +++ b/src/e2ee.rs @@ -35,6 +35,7 @@ impl EncryptHelper { addr: self.addr.clone(), public_key: self.public_key.clone(), prefer_encrypt: self.prefer_encrypt, + verified: false, } } diff --git a/src/mimefactory.rs b/src/mimefactory.rs index 972854d9d..d5c4420ee 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -1098,6 +1098,7 @@ impl MimeFactory { // Autocrypt 1.1.0 specification says that // `prefer-encrypt` attribute SHOULD NOT be included. prefer_encrypt: EncryptPreference::NoPreference, + verified: false, } .to_string(); diff --git a/src/mimeparser.rs b/src/mimeparser.rs index 1d92a377c..cae9357ca 100644 --- a/src/mimeparser.rs +++ b/src/mimeparser.rs @@ -1,7 +1,7 @@ //! # MIME message parsing module. use std::cmp::min; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::Path; use std::str; use std::str::FromStr; @@ -36,6 +36,17 @@ use crate::tools::{ }; use crate::{chatlist_events, location, stock_str, tools}; +/// Public key extracted from `Autocrypt-Gossip` +/// header with associated information. +#[derive(Debug)] +pub struct GossipedKey { + /// Public key extracted from `keydata` attribute. + pub public_key: SignedPublicKey, + + /// True if `Autocrypt-Gossip` has a `_verified` attribute. + pub verified: bool, +} + /// A parsed MIME message. /// /// This represents the relevant information of a parsed MIME message @@ -85,7 +96,7 @@ pub(crate) struct MimeMessage { /// The addresses for which there was a gossip header /// and their respective gossiped keys. - pub gossiped_keys: HashMap, + pub gossiped_keys: BTreeMap, /// Fingerprint of the key in the Autocrypt header. /// @@ -1963,9 +1974,9 @@ async fn parse_gossip_headers( from: &str, recipients: &[SingleInfo], gossip_headers: Vec, -) -> Result> { +) -> Result> { // XXX split the parsing from the modification part - let mut gossiped_keys: HashMap = Default::default(); + let mut gossiped_keys: BTreeMap = Default::default(); for value in &gossip_headers { let header = match value.parse::() { @@ -2007,7 +2018,12 @@ async fn parse_gossip_headers( ) .await?; - gossiped_keys.insert(header.addr.to_lowercase(), header.public_key); + let gossiped_key = GossipedKey { + public_key: header.public_key, + + verified: header.verified, + }; + gossiped_keys.insert(header.addr.to_lowercase(), gossiped_key); } Ok(gossiped_keys) diff --git a/src/receive_imf.rs b/src/receive_imf.rs index 2e1d2b603..6331aa937 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -1,6 +1,6 @@ //! Internet Message Format reception pipeline. -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashSet}; use std::iter; use std::sync::LazyLock; @@ -28,14 +28,14 @@ use crate::events::EventType; use crate::headerdef::{HeaderDef, HeaderDefMap}; use crate::imap::{GENERATED_PREFIX, markseen_on_imap_table}; use crate::key::self_fingerprint_opt; -use crate::key::{DcKey, Fingerprint, SignedPublicKey}; +use crate::key::{DcKey, Fingerprint}; use crate::log::LogExt; use crate::log::{info, warn}; use crate::logged_debug_assert; use crate::message::{ self, Message, MessageState, MessengerMessage, MsgId, Viewtype, rfc724_mid_exists, }; -use crate::mimeparser::{AvatarAction, MimeMessage, SystemMessage, parse_message_ids}; +use crate::mimeparser::{AvatarAction, GossipedKey, MimeMessage, SystemMessage, parse_message_ids}; use crate::param::{Param, Params}; use crate::peer_channels::{add_gossip_peer_from_header, insert_topic_stub}; use crate::reaction::{Reaction, set_msg_reaction}; @@ -835,7 +835,7 @@ pub(crate) async fn receive_imf_inner( context .sql .transaction(move |transaction| { - let fingerprint = gossiped_key.dc_fingerprint().hex(); + let fingerprint = gossiped_key.public_key.dc_fingerprint().hex(); transaction.execute( "INSERT INTO gossip_timestamp (chat_id, fingerprint, timestamp) VALUES (?, ?, ?) @@ -2917,7 +2917,7 @@ async fn apply_group_changes( // highest `add_timestamp` to disambiguate. // The result of the error is that info message // may contain display name of the wrong contact. - let fingerprint = key.dc_fingerprint().hex(); + let fingerprint = key.public_key.dc_fingerprint().hex(); if let Some(contact_id) = lookup_key_contact_by_fingerprint(context, &fingerprint).await? { @@ -3659,10 +3659,28 @@ async fn mark_recipients_as_verified( to_ids: &[Option], mimeparser: &MimeMessage, ) -> Result<()> { + let verifier_id = Some(from_id).filter(|&id| id != ContactId::SELF); + for gossiped_key in mimeparser + .gossiped_keys + .values() + .filter(|gossiped_key| gossiped_key.verified) + { + let fingerprint = gossiped_key.public_key.dc_fingerprint().hex(); + let Some(to_id) = lookup_key_contact_by_fingerprint(context, &fingerprint).await? else { + continue; + }; + + if to_id == ContactId::SELF || to_id == from_id { + continue; + } + + mark_contact_id_as_verified(context, to_id, verifier_id).await?; + ChatId::set_protection_for_contact(context, to_id, mimeparser.timestamp_sent).await?; + } + if mimeparser.get_header(HeaderDef::ChatVerified).is_none() { return Ok(()); } - let verifier_id = Some(from_id).filter(|&id| id != ContactId::SELF); for to_id in to_ids.iter().filter_map(|&x| x) { if to_id == ContactId::SELF || to_id == from_id { continue; @@ -3755,7 +3773,7 @@ async fn add_or_lookup_contacts_by_address_list( async fn add_or_lookup_key_contacts( context: &Context, address_list: &[SingleInfo], - gossiped_keys: &HashMap, + gossiped_keys: &BTreeMap, fingerprints: &[Fingerprint], origin: Origin, ) -> Result>> { @@ -3771,7 +3789,7 @@ async fn add_or_lookup_key_contacts( // Iterator has not ran out of fingerprints yet. fp.hex() } else if let Some(key) = gossiped_keys.get(addr) { - key.dc_fingerprint().hex() + key.public_key.dc_fingerprint().hex() } else if context.is_self_addr(addr).await? { contact_ids.push(Some(ContactId::SELF)); continue; diff --git a/src/securejoin.rs b/src/securejoin.rs index 7813c9de5..888fe0556 100644 --- a/src/securejoin.rs +++ b/src/securejoin.rs @@ -272,7 +272,9 @@ pub(crate) async fn handle_securejoin_handshake( let mut self_found = false; let self_fingerprint = load_self_public_key(context).await?.dc_fingerprint(); for (addr, key) in &mime_message.gossiped_keys { - if key.dc_fingerprint() == self_fingerprint && context.is_self_addr(addr).await? { + if key.public_key.dc_fingerprint() == self_fingerprint + && context.is_self_addr(addr).await? + { self_found = true; break; } @@ -542,7 +544,7 @@ pub(crate) async fn observe_securejoin_on_other_device( return Ok(HandshakeMessage::Ignore); }; - if key.dc_fingerprint() != contact_fingerprint { + if key.public_key.dc_fingerprint() != contact_fingerprint { // Fingerprint does not match, ignore. warn!(context, "Fingerprint does not match."); return Ok(HandshakeMessage::Ignore); diff --git a/src/securejoin/securejoin_tests.rs b/src/securejoin/securejoin_tests.rs index e245e7d8e..981640640 100644 --- a/src/securejoin/securejoin_tests.rs +++ b/src/securejoin/securejoin_tests.rs @@ -5,6 +5,7 @@ use crate::chat::{CantSendReason, remove_contact_from_chat}; use crate::chatlist::Chatlist; use crate::constants::Chattype; use crate::key::self_fingerprint; +use crate::mimeparser::GossipedKey; use crate::receive_imf::receive_imf; use crate::stock_str::{self, messages_e2e_encrypted}; use crate::test_utils::{ @@ -185,7 +186,10 @@ async fn test_setup_contact_ex(case: SetupContactCase) { ); if case == SetupContactCase::WrongAliceGossip { - let wrong_pubkey = load_self_public_key(&bob).await.unwrap(); + let wrong_pubkey = GossipedKey { + public_key: load_self_public_key(&bob).await.unwrap(), + verified: false, + }; let alice_pubkey = msg .gossiped_keys .insert(alice_addr.to_string(), wrong_pubkey) From b6ab13f1de796b760e6c2981225b48e49f924b77 Mon Sep 17 00:00:00 2001 From: bjoern Date: Fri, 5 Sep 2025 08:52:15 +0200 Subject: [PATCH 09/15] feat: hide call status change messages (#7175) this PR uses the initial "call messages" (that has a separate viewtype since #7174) to show all call status. this is what most other messengers are doing as well. additional "info messages" after a call are no longer needed. on the wire, as we cannot pickpack on visible info messages, we use hidden messages, similar to eg. webxdc status updates. in future PR, it is planned to allow getting call state as a json, so that UI can render nicely. it is then decided if we want to translate the strings in the core. IMG_0150 successor of https://github.com/chatmail/core/pull/6650 --- src/calls.rs | 16 +++++++- src/calls/calls_tests.rs | 83 ++++++++++++++++++++++++---------------- src/receive_imf.rs | 5 +++ 3 files changed, 69 insertions(+), 35 deletions(-) diff --git a/src/calls.rs b/src/calls.rs index a968ccfdf..18155d9d4 100644 --- a/src/calls.rs +++ b/src/calls.rs @@ -114,6 +114,7 @@ impl Context { chat.id.accept(self).await?; } + call.update_text(self, "Call accepted").await?; call.msg .mark_call_as_accepted(self, accept_call_info.to_string()) .await?; @@ -125,6 +126,7 @@ impl Context { ..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?; @@ -133,6 +135,7 @@ impl Context { msg_id: call.msg.id, accept_call_info, }); + self.emit_msgs_changed(call.msg.chat_id, call_id); Ok(()) } @@ -140,6 +143,8 @@ impl Context { pub async fn end_call(&self, call_id: MsgId) -> Result<()> { let call: CallInfo = self.load_call_by_id(call_id).await?; + call.update_text(self, "Call ended").await?; + if call.is_accepted || !call.is_incoming { let mut msg = Message { viewtype: Viewtype::Text, @@ -147,6 +152,7 @@ impl Context { ..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?; } else if call.is_incoming { @@ -161,6 +167,7 @@ impl Context { self.emit_event(EventType::CallEnded { msg_id: call.msg.id, }); + self.emit_msgs_changed(call.msg.chat_id, call_id); Ok(()) } @@ -189,9 +196,9 @@ impl Context { 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); + self.emit_incoming_msg(call.msg.chat_id, call_id); // notify missed call } else { - self.emit_msgs_changed(call.msg.chat_id, call_id); + self.emit_msgs_changed(call.msg.chat_id, call_id); // ringing calls are not additionally notified self.emit_event(EventType::IncomingCall { msg_id: call.msg.id, place_call_info: call.place_call_info.to_string(), @@ -210,6 +217,7 @@ impl Context { match mime_message.is_system_message { SystemMessage::CallAccepted => { let call = self.load_call_by_id(call_id).await?; + call.update_text(self, "Call accepted").await?; self.emit_msgs_changed(call.msg.chat_id, call_id); if call.is_incoming { self.emit_event(EventType::IncomingCallAccepted { @@ -232,6 +240,7 @@ impl Context { } SystemMessage::CallEnded => { let call = self.load_call_by_id(call_id).await?; + call.update_text(self, "Call ended").await?; self.emit_msgs_changed(call.msg.chat_id, call_id); self.emit_event(EventType::CallEnded { msg_id: call.msg.id, @@ -245,7 +254,10 @@ impl Context { 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_id(msg_id).await?; + call.update_text(self, "Call ended").await?; self.emit_event(EventType::CallEnded { msg_id }); + self.emit_msgs_changed(call.msg.chat_id, msg_id); } Ok(()) } diff --git a/src/calls/calls_tests.rs b/src/calls/calls_tests.rs index 0e5eb1418..4c15666b9 100644 --- a/src/calls/calls_tests.rs +++ b/src/calls/calls_tests.rs @@ -6,6 +6,7 @@ struct CallSetup { pub alice: TestContext, pub alice2: TestContext, pub alice_call: Message, + pub alice2_call: Message, pub bob: TestContext, pub bob2: TestContext, pub bob_call: Message, @@ -61,6 +62,7 @@ async fn setup_call() -> Result { alice, alice2, alice_call, + alice2_call, bob, bob2, bob_call, @@ -73,13 +75,14 @@ async fn accept_call() -> Result { alice, alice2, alice_call, + alice2_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 accepts the incoming call bob.accept_incoming_call(bob_call.id, "accepted_info".to_string()) .await?; bob.evtracker @@ -91,9 +94,11 @@ async fn accept_call() -> Result { 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.recv_msg_trash(&sent2).await; + assert_eq!( + Message::load_from_db(&bob, bob_call.id).await?.text, + "Call accepted" + ); bob2.evtracker .get_matching(|evt| matches!(evt, EventType::IncomingCallAccepted { .. })) .await; @@ -101,11 +106,10 @@ async fn accept_call() -> Result { 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()); + alice.recv_msg_trash(&sent2).await; assert_eq!( - alice_accept_msg.get_info_type(), - SystemMessage::CallAccepted + Message::load_from_db(&alice, alice_call.id).await?.text, + "Call accepted" ); alice .evtracker @@ -116,11 +120,10 @@ async fn accept_call() -> Result { 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()); + alice2.recv_msg_trash(&sent2).await; assert_eq!( - alice2_accept_msg.get_info_type(), - SystemMessage::CallAccepted + Message::load_from_db(&alice2, alice2_call.id).await?.text, + "Call accepted" ); alice2 .evtracker @@ -131,6 +134,7 @@ async fn accept_call() -> Result { alice, alice2, alice_call, + alice2_call, bob, bob2, bob_call, @@ -138,9 +142,9 @@ async fn accept_call() -> Result { }) } -fn assert_is_call_ended_info_msg(msg: Message) { - assert!(msg.is_info()); - assert_eq!(msg.get_info_type(), SystemMessage::CallEnded); +async fn assert_is_call_ended(t: &TestContext, call_id: MsgId) -> Result<()> { + assert_eq!(Message::load_from_db(t, call_id).await?.text, "Call ended"); + Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -148,36 +152,40 @@ async fn test_accept_call_callee_ends() -> Result<()> { // Alice calls Bob, Bob accepts let CallSetup { alice, + alice_call, alice2, + alice2_call, bob, bob2, bob_call, + bob2_call, .. } = accept_call().await?; // Bob has accepted the call and also ends it bob.end_call(bob_call.id).await?; + assert_is_call_ended(&bob, 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.recv_msg_trash(&sent3).await; + assert_is_call_ended(&bob2, bob2_call.id).await?; 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.recv_msg_trash(&sent3).await; + assert_is_call_ended(&alice, alice_call.id).await?; 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.recv_msg_trash(&sent3).await; + assert_is_call_ended(&alice2, alice2_call.id).await?; alice2 .evtracker .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) @@ -192,9 +200,11 @@ async fn test_accept_call_caller_ends() -> Result<()> { let CallSetup { alice, alice2, + alice2_call, bob, bob2, bob_call, + bob2_call, .. } = accept_call().await?; @@ -206,22 +216,22 @@ async fn test_accept_call_caller_ends() -> Result<()> { .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.recv_msg_trash(&sent3).await; + assert_is_call_ended(&alice2, alice2_call.id).await?; 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.recv_msg_trash(&sent3).await; + assert_is_call_ended(&bob, bob_call.id).await?; 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.recv_msg_trash(&sent3).await; + assert_is_call_ended(&bob2, bob2_call.id).await?; bob2.evtracker .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) .await; @@ -236,6 +246,7 @@ async fn test_callee_rejects_call() -> Result<()> { bob, bob2, bob_call, + bob2_call, .. } = setup_call().await?; @@ -243,11 +254,13 @@ async fn test_callee_rejects_call() -> Result<()> { // 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?; + assert_is_call_ended(&bob, bob_call.id).await?; bob.evtracker .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) .await; sync(&bob, &bob2).await; + assert_is_call_ended(&bob2, bob2_call.id).await?; bob2.evtracker .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) .await; @@ -262,35 +275,39 @@ async fn test_caller_cancels_call() -> Result<()> { alice, alice2, alice_call, + alice2_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?; + assert_is_call_ended(&alice, 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.recv_msg_trash(&sent3).await; + assert_is_call_ended(&alice2, alice2_call.id).await?; 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.recv_msg_trash(&sent3).await; + assert_is_call_ended(&bob, bob_call.id).await?; 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.recv_msg_trash(&sent3).await; + assert_is_call_ended(&bob2, bob2_call.id).await?; bob2.evtracker .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) .await; diff --git a/src/receive_imf.rs b/src/receive_imf.rs index 6331aa937..d247d1123 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -1153,6 +1153,11 @@ async fn decide_chat_assignment( { info!(context, "Chat edit/delete/iroh/sync message (TRASH)."); true + } else if mime_parser.is_system_message == SystemMessage::CallAccepted + || mime_parser.is_system_message == SystemMessage::CallEnded + { + info!(context, "Call state changed (TRASH)."); + true } else if mime_parser.decrypting_failed && !mime_parser.incoming { // Outgoing undecryptable message. let last_time = context From ab8aedf06e5ac408db0bb6db1780572963f15a78 Mon Sep 17 00:00:00 2001 From: bjoern Date: Mon, 8 Sep 2025 15:48:35 +0200 Subject: [PATCH 10/15] refine call states (#7179) - sync declined calls from callee to caller, as usual in all larger messengers - introduce the call states "Missed call", "Declined call" and "Cancelled all" ("Ended call" is gone) - allow calling end_call()/accept_call() for already ended/accepted calls, in practise, handling all cornercases is tricky in UI - and the state needs anyways to be tracked. - track and show the call duration the duration calculation depends on local time, but it is displayed only coarse and is not needed for any state. this can be improved as needed, timestamps of the corresponding messages are probably better at some point. or ending device sends its view of the time around. but for the first throw, it seems good enough if we finally want that set of states, it can be exposed to a json-info in a subsequent call, so that the UI can render it more nicely. fallback strings as follows will stay for now to make adaption in other UI easy, and for debugging: IMG_0154 successor of https://github.com/chatmail/core/pull/6650 --- deltachat-ffi/deltachat.h | 46 ++-- deltachat-ffi/src/lib.rs | 6 +- deltachat-jsonrpc/src/api/types/events.rs | 8 +- src/calls.rs | 244 ++++++++++++++-------- src/calls/calls_tests.rs | 154 ++++++++------ src/events/payload.rs | 2 - src/mimefactory.rs | 3 + src/receive_imf.rs | 8 +- src/sync.rs | 4 - 9 files changed, 276 insertions(+), 199 deletions(-) diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index 94b932fa3..7e0d811b6 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -1216,21 +1216,21 @@ uint32_t dc_init_webxdc_integration (dc_context_t* context, uint32_t c /** * Start an outgoing call. - * This sends a message with all relevant information to the callee, + * This sends a message of type #DC_MSG_CALL 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 receives #DC_EVENT_CALL_ENDED and has a "Missed Call" * * - 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 declines using dc_end_call(): + * caller receives #DC_EVENT_CALL_ENDED and has a "Declinced Call". + * callee's other devices receive #DC_EVENT_CALL_ENDED and have a "Cancelled Call", * * - callee is already in a call: * in this case, UI may decide to show a notification instead of ringing. @@ -1240,7 +1240,9 @@ uint32_t dc_init_webxdc_integration (dc_context_t* context, uint32_t c * 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 + * in case caller got offline without being able to send cancellation message. + * for caller, this is a "Cancelled Call"; + * for callee, this is a "Missed Call" * * Actions during the call: * @@ -1276,13 +1278,15 @@ uint32_t dc_place_outgoing_call (dc_context_t* context, uint32_t ch * All affected devices will receive * either #DC_EVENT_OUTGOING_CALL_ACCEPTED or #DC_EVENT_INCOMING_CALL_ACCEPTED. * + * If the call is already accepted or ended, nothing happens. + * * @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. + * in #DC_EVENT_OUTGOING_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); @@ -1291,17 +1295,13 @@ uint32_t dc_place_outgoing_call (dc_context_t* context, uint32_t ch /** * End incoming or outgoing call. * - * From the view of the caller, a "cancellation", - * from the view of callee, a "rejection". + * For unaccepted calls ended by the caller, this is a "cancellation". + * Unaccepted calls ended by the callee are a "decline". * 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. + * All participant devices get informed about the ended call via #DC_EVENT_CALL_ENDED. * - * 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. + * If the call is already ended, nothing happens. * * @memberof dc_context_t * @param context The context object. @@ -6724,29 +6724,27 @@ void dc_event_unref(dc_event_t* event); * 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. + * a message of type #DC_MSG_CALL is added to the corresponding chat; + * this message is announced and updated by the usual even as #DC_EVENT_MSGS_CHANGED. * * 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 data1 (int) msg_id ID of the 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 callee accepted an incoming call on this or 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() + * @param data1 (int) msg_id ID of the message referring to the call */ #define DC_EVENT_INCOMING_CALL_ACCEPTED 2560 @@ -6756,7 +6754,7 @@ void dc_event_unref(dc_event_t* event); * 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 data1 (int) msg_id ID of the 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 @@ -6768,7 +6766,7 @@ void dc_event_unref(dc_event_t* event); * 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 data1 (int) msg_id ID of the message referring to the call */ #define DC_EVENT_CALL_ENDED 2580 diff --git a/deltachat-ffi/src/lib.rs b/deltachat-ffi/src/lib.rs index ddae6de52..9dc2d0680 100644 --- a/deltachat-ffi/src/lib.rs +++ b/deltachat-ffi/src/lib.rs @@ -779,6 +779,7 @@ pub unsafe extern "C" fn dc_event_get_data2_str(event: *mut dc_event_t) -> *mut | EventType::ChatlistChanged | EventType::AccountsChanged | EventType::AccountsItemChanged + | EventType::IncomingCallAccepted { .. } | EventType::WebxdcRealtimeAdvertisementReceived { .. } => ptr::null_mut(), EventType::IncomingCall { place_call_info, .. @@ -786,10 +787,7 @@ pub unsafe extern "C" fn dc_event_get_data2_str(event: *mut dc_event_t) -> *mut let data2 = place_call_info.to_c_string().unwrap_or_default(); data2.into_raw() } - EventType::IncomingCallAccepted { - accept_call_info, .. - } - | EventType::OutgoingCallAccepted { + EventType::OutgoingCallAccepted { accept_call_info, .. } => { let data2 = accept_call_info.to_c_string().unwrap_or_default(); diff --git a/deltachat-jsonrpc/src/api/types/events.rs b/deltachat-jsonrpc/src/api/types/events.rs index 2113a9d7a..746eee72f 100644 --- a/deltachat-jsonrpc/src/api/types/events.rs +++ b/deltachat-jsonrpc/src/api/types/events.rs @@ -430,8 +430,6 @@ pub enum EventType { 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. @@ -604,12 +602,8 @@ impl From for EventType { msg_id: msg_id.to_u32(), place_call_info, }, - CoreEventType::IncomingCallAccepted { - msg_id, - accept_call_info, - } => IncomingCallAccepted { + CoreEventType::IncomingCallAccepted { msg_id } => IncomingCallAccepted { msg_id: msg_id.to_u32(), - accept_call_info, }, CoreEventType::OutgoingCallAccepted { msg_id, diff --git a/src/calls.rs b/src/calls.rs index 18155d9d4..5b14fa408 100644 --- a/src/calls.rs +++ b/src/calls.rs @@ -1,17 +1,17 @@ //! # 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. +//! 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::{Chat, ChatId, send_msg}; use crate::constants::Chattype; use crate::contact::ContactId; use crate::context::Context; use crate::events::EventType; use crate::headerdef::HeaderDef; -use crate::message::{self, Message, MsgId, Viewtype, rfc724_mid_exists}; +use crate::log::info; +use crate::message::{self, Message, MsgId, Viewtype}; 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; @@ -29,29 +29,30 @@ use tokio::time::sleep; /// as the callee won't start the call afterwards. const RINGING_SECONDS: i64 = 60; +/// For persisting parameters in the call, we use Param::Arg* +const CALL_ACCEPTED_TIMESTAMP: Param = Param::Arg; +const CALL_ENDED_TIMESTAMP: Param = Param::Arg4; + /// 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. + /// Message referring to the call. + /// Data are persisted along with the message using Param::Arg* pub msg: Message, } impl CallInfo { - fn is_stale_call(&self) -> bool { + fn is_incoming(&self) -> bool { + self.msg.from_id != ContactId::SELF + } + + fn is_stale(&self) -> bool { self.remaining_ring_seconds() <= 0 } @@ -70,6 +71,60 @@ impl CallInfo { .await?; Ok(()) } + + async fn update_text_duration(&self, context: &Context) -> Result<()> { + let minutes = self.get_duration_seconds() / 60; + let duration = match minutes { + 0 => "<1 minute".to_string(), + 1 => "1 minute".to_string(), + n => format!("{} minutes", n), + }; + + if self.is_incoming() { + self.update_text(context, &format!("Incoming call\n{duration}")) + .await?; + } else { + self.update_text(context, &format!("Outgoing call\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(()) + } + + fn is_accepted(&self) -> bool { + self.msg.param.exists(CALL_ACCEPTED_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(()) + } + + fn is_ended(&self) -> bool { + self.msg.param.exists(CALL_ENDED_TIMESTAMP) + } + + fn get_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 { @@ -84,7 +139,7 @@ impl Context { let mut call = Message { viewtype: Viewtype::Call, - text: "Calling...".into(), + text: "Outgoing call".into(), ..Default::default() }; call.param.set(Param::WebrtcRoom, &place_call_info); @@ -107,22 +162,22 @@ impl Context { accept_call_info: String, ) -> Result<()> { let mut call: CallInfo = self.load_call_by_id(call_id).await?; - ensure!(call.is_incoming); + 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?; } - call.update_text(self, "Call accepted").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(), + text: "[Call accepted]".into(), ..Default::default() }; msg.param.set_cmd(SystemMessage::CallAccepted); @@ -133,36 +188,39 @@ impl Context { 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, }); self.emit_msgs_changed(call.msg.chat_id, call_id); Ok(()) } - /// Cancel, reject or hangup an incoming or outgoing call. + /// Cancel, decline 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?; - - call.update_text(self, "Call ended").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.hidden = true; - 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; + let mut call: CallInfo = self.load_call_by_id(call_id).await?; + if call.is_ended() { + info!(self, "Call already ended"); + return Ok(()); } + call.mark_as_ended(self).await?; + + if !call.is_accepted() { + if call.is_incoming() { + call.update_text(self, "Declined call").await?; + } else { + call.update_text(self, "Cancelled call").await?; + } + } else { + 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, @@ -177,8 +235,15 @@ impl Context { call_id: MsgId, ) -> Result<()> { sleep(Duration::from_secs(wait)).await; - let call = context.load_call_by_id(call_id).await?; - if !call.is_accepted { + let mut call = context.load_call_by_id(call_id).await?; + if !call.is_accepted() && !call.is_ended() { + call.mark_as_ended(&context).await?; + if call.is_incoming() { + call.update_text(&context, "Missed call").await?; + } else { + call.update_text(&context, "Cancelled call").await?; + } + context.emit_msgs_changed(call.msg.chat_id, call_id); context.emit_event(EventType::CallEnded { msg_id: call.msg.id, }); @@ -188,16 +253,18 @@ impl Context { pub(crate) async fn handle_call_msg( &self, - mime_message: &MimeMessage, call_id: MsgId, + mime_message: &MimeMessage, + from_id: ContactId, ) -> Result<()> { if mime_message.is_call() { let call = self.load_call_by_id(call_id).await?; - if call.is_incoming { - if call.is_stale_call() { + if call.is_incoming() { + if call.is_stale() { call.update_text(self, "Missed call").await?; self.emit_incoming_msg(call.msg.chat_id, call_id); // notify missed call } else { + call.update_text(self, "Incoming call").await?; self.emit_msgs_changed(call.msg.chat_id, call_id); // ringing calls are not additionally notified self.emit_event(EventType::IncomingCall { msg_id: call.msg.id, @@ -211,27 +278,28 @@ impl Context { )); } } else { + call.update_text(self, "Outgoing call").await?; self.emit_msgs_changed(call.msg.chat_id, call_id); } } else { match mime_message.is_system_message { SystemMessage::CallAccepted => { - let call = self.load_call_by_id(call_id).await?; - call.update_text(self, "Call accepted").await?; + let mut call = self.load_call_by_id(call_id).await?; + 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 { + 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(), @@ -239,8 +307,33 @@ impl Context { } } SystemMessage::CallEnded => { - let call = self.load_call_by_id(call_id).await?; - call.update_text(self, "Call ended").await?; + let mut call = self.load_call_by_id(call_id).await?; + if call.is_ended() { + // may happen eg. if a a message is missed + info!(self, "CallEnded received for ended call"); + return Ok(()); + } + + call.mark_as_ended(self).await?; + if !call.is_accepted() { + if call.is_incoming() { + if from_id == ContactId::SELF { + call.update_text(self, "Declined call").await?; + } else { + call.update_text(self, "Missed call").await?; + } + } else { + // outgoing + if from_id == ContactId::SELF { + call.update_text(self, "Cancelled call").await?; + } else { + call.update_text(self, "Declined call").await?; + } + } + } else { + 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, @@ -252,16 +345,6 @@ impl Context { 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_id(msg_id).await?; - call.update_text(self, "Call ended").await?; - self.emit_event(EventType::CallEnded { msg_id }); - self.emit_msgs_changed(call.msg.chat_id, msg_id); - } - Ok(()) - } - async fn load_call_by_id(&self, call_id: MsgId) -> Result { let call = Message::load_from_db(self, call_id).await?; self.load_call_by_message(call) @@ -271,8 +354,6 @@ impl Context { ensure!(call.viewtype == Viewtype::Call); Ok(CallInfo { - is_incoming: call.get_from_id() != ContactId::SELF, - is_accepted: call.is_call_accepted()?, place_call_info: call .param .get(Param::WebrtcRoom) @@ -288,24 +369,5 @@ impl Context { } } -impl Message { - async fn mark_call_as_accepted( - &mut self, - context: &Context, - accept_call_info: String, - ) -> Result<()> { - ensure!(self.viewtype == Viewtype::Call); - 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 { - ensure!(self.viewtype == Viewtype::Call); - Ok(self.param.get_int(Param::Arg) == Some(1)) - } -} - #[cfg(test)] mod calls_tests; diff --git a/src/calls/calls_tests.rs b/src/calls/calls_tests.rs index 4c15666b9..d51f2ce28 100644 --- a/src/calls/calls_tests.rs +++ b/src/calls/calls_tests.rs @@ -1,6 +1,6 @@ use super::*; use crate::config::Config; -use crate::test_utils::{TestContext, TestContextManager, sync}; +use crate::test_utils::{TestContext, TestContextManager}; struct CallSetup { pub alice: TestContext, @@ -13,6 +13,11 @@ struct CallSetup { pub bob2_call: Message, } +async fn assert_text(t: &TestContext, call_id: MsgId, text: &str) -> Result<()> { + assert_eq!(Message::load_from_db(t, call_id).await?.text, text); + Ok(()) +} + async fn setup_call() -> Result { let mut tcm = TestContextManager::new(); let alice = tcm.alice().await; @@ -27,7 +32,7 @@ async fn setup_call() -> Result { // 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()) + .place_outgoing_call(alice_chat.id, "place-info-123".to_string()) .await?; let sent1 = alice.pop_sent_msg().await; assert_eq!(sent1.sender_msg_id, test_msg_id); @@ -37,9 +42,10 @@ async fn setup_call() -> Result { assert!(!m.is_info()); assert_eq!(m.viewtype, Viewtype::Call); let info = t.load_call_by_id(m.id).await?; - assert!(!info.is_incoming); - assert!(!info.is_accepted); - assert_eq!(info.place_call_info, "place_info"); + assert!(!info.is_incoming()); + assert!(!info.is_accepted()); + assert_eq!(info.place_call_info, "place-info-123"); + assert_text(t, m.id, "Outgoing call").await?; } // Bob receives the message referring to the call on two devices; @@ -53,9 +59,10 @@ async fn setup_call() -> Result { .get_matching(|evt| matches!(evt, EventType::IncomingCall { .. })) .await; let info = t.load_call_by_id(m.id).await?; - assert!(info.is_incoming); - assert!(!info.is_accepted); - assert_eq!(info.place_call_info, "place_info"); + assert!(info.is_incoming()); + assert!(!info.is_accepted()); + assert_eq!(info.place_call_info, "place-info-123"); + assert_text(t, m.id, "Incoming call").await?; } Ok(CallSetup { @@ -83,48 +90,45 @@ async fn accept_call() -> Result { } = setup_call().await?; // Bob accepts the incoming call - bob.accept_incoming_call(bob_call.id, "accepted_info".to_string()) + bob.accept_incoming_call(bob_call.id, "accept-info-456".to_string()) .await?; + assert_text(&bob, bob_call.id, "Incoming call").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"); + assert!(info.is_accepted()); + assert_eq!(info.place_call_info, "place-info-123"); bob2.recv_msg_trash(&sent2).await; - assert_eq!( - Message::load_from_db(&bob, bob_call.id).await?.text, - "Call accepted" - ); + assert_text(&bob, bob_call.id, "Incoming call").await?; 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 + assert!(info.is_accepted()); // Alice receives the acceptance message alice.recv_msg_trash(&sent2).await; - assert_eq!( - Message::load_from_db(&alice, alice_call.id).await?.text, - "Call accepted" - ); - alice + assert_text(&alice, alice_call.id, "Outgoing call").await?; + let ev = alice .evtracker .get_matching(|evt| matches!(evt, EventType::OutgoingCallAccepted { .. })) .await; + assert_eq!( + ev, + EventType::OutgoingCallAccepted { + msg_id: alice2_call.id, + accept_call_info: "accept-info-456".to_string() + } + ); 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"); + assert!(info.is_accepted()); + assert_eq!(info.place_call_info, "place-info-123"); alice2.recv_msg_trash(&sent2).await; - assert_eq!( - Message::load_from_db(&alice2, alice2_call.id).await?.text, - "Call accepted" - ); + assert_text(&alice2, alice2_call.id, "Outgoing call").await?; alice2 .evtracker .get_matching(|evt| matches!(evt, EventType::OutgoingCallAccepted { .. })) @@ -142,11 +146,6 @@ async fn accept_call() -> Result { }) } -async fn assert_is_call_ended(t: &TestContext, call_id: MsgId) -> Result<()> { - assert_eq!(Message::load_from_db(t, call_id).await?.text, "Call ended"); - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_accept_call_callee_ends() -> Result<()> { // Alice calls Bob, Bob accepts @@ -164,28 +163,28 @@ async fn test_accept_call_callee_ends() -> Result<()> { // Bob has accepted the call and also ends it bob.end_call(bob_call.id).await?; - assert_is_call_ended(&bob, bob_call.id).await?; + assert_text(&bob, bob_call.id, "Incoming call\n<1 minute").await?; bob.evtracker .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) .await; let sent3 = bob.pop_sent_msg().await; bob2.recv_msg_trash(&sent3).await; - assert_is_call_ended(&bob2, bob2_call.id).await?; + assert_text(&bob2, bob2_call.id, "Incoming call\n<1 minute").await?; bob2.evtracker .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) .await; // Alice receives the ending message alice.recv_msg_trash(&sent3).await; - assert_is_call_ended(&alice, alice_call.id).await?; + assert_text(&alice, alice_call.id, "Outgoing call\n<1 minute").await?; alice .evtracker .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) .await; alice2.recv_msg_trash(&sent3).await; - assert_is_call_ended(&alice2, alice2_call.id).await?; + assert_text(&alice2, alice2_call.id, "Outgoing call\n<1 minute").await?; alice2 .evtracker .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) @@ -199,6 +198,7 @@ async fn test_accept_call_caller_ends() -> Result<()> { // Alice calls Bob, Bob accepts let CallSetup { alice, + alice_call, alice2, alice2_call, bob, @@ -209,7 +209,8 @@ async fn test_accept_call_caller_ends() -> Result<()> { } = accept_call().await?; // Bob has accepted the call but Alice ends it - alice.end_call(bob_call.id).await?; + alice.end_call(alice_call.id).await?; + assert_text(&alice, alice_call.id, "Outgoing call\n<1 minute").await?; alice .evtracker .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) @@ -217,7 +218,7 @@ async fn test_accept_call_caller_ends() -> Result<()> { let sent3 = alice.pop_sent_msg().await; alice2.recv_msg_trash(&sent3).await; - assert_is_call_ended(&alice2, alice2_call.id).await?; + assert_text(&alice2, alice2_call.id, "Outgoing call\n<1 minute").await?; alice2 .evtracker .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) @@ -225,13 +226,13 @@ async fn test_accept_call_caller_ends() -> Result<()> { // Bob receives the ending message bob.recv_msg_trash(&sent3).await; - assert_is_call_ended(&bob, bob_call.id).await?; + assert_text(&bob, bob_call.id, "Incoming call\n<1 minute").await?; bob.evtracker .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) .await; bob2.recv_msg_trash(&sent3).await; - assert_is_call_ended(&bob2, bob2_call.id).await?; + assert_text(&bob2, bob2_call.id, "Incoming call\n<1 minute").await?; bob2.evtracker .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) .await; @@ -243,6 +244,10 @@ async fn test_accept_call_caller_ends() -> Result<()> { async fn test_callee_rejects_call() -> Result<()> { // Alice calls Bob let CallSetup { + alice, + alice2, + alice_call, + alice2_call, bob, bob2, bob_call, @@ -250,21 +255,36 @@ async fn test_callee_rejects_call() -> Result<()> { .. } = 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 has accepted Alice before, but does not want to talk with Alice + bob_call.chat_id.accept(&bob).await?; bob.end_call(bob_call.id).await?; - assert_is_call_ended(&bob, bob_call.id).await?; + assert_text(&bob, bob_call.id, "Declined call").await?; bob.evtracker .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) .await; + let sent3 = bob.pop_sent_msg().await; - sync(&bob, &bob2).await; - assert_is_call_ended(&bob2, bob2_call.id).await?; + bob2.recv_msg_trash(&sent3).await; + assert_text(&bob2, bob2_call.id, "Declined call").await?; bob2.evtracker .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) .await; + // Alice receives decline message + alice.recv_msg_trash(&sent3).await; + assert_text(&alice, alice_call.id, "Declined call").await?; + alice + .evtracker + .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) + .await; + + alice2.recv_msg_trash(&sent3).await; + assert_text(&alice2, alice2_call.id, "Declined call").await?; + alice2 + .evtracker + .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) + .await; + Ok(()) } @@ -285,7 +305,7 @@ async fn test_caller_cancels_call() -> Result<()> { // Alice changes their mind before Bob picks up alice.end_call(alice_call.id).await?; - assert_is_call_ended(&alice, alice_call.id).await?; + assert_text(&alice, alice_call.id, "Cancelled call").await?; alice .evtracker .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) @@ -293,7 +313,7 @@ async fn test_caller_cancels_call() -> Result<()> { let sent3 = alice.pop_sent_msg().await; alice2.recv_msg_trash(&sent3).await; - assert_is_call_ended(&alice2, alice2_call.id).await?; + assert_text(&alice2, alice2_call.id, "Cancelled call").await?; alice2 .evtracker .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) @@ -301,13 +321,13 @@ async fn test_caller_cancels_call() -> Result<()> { // Bob receives the ending message bob.recv_msg_trash(&sent3).await; - assert_is_call_ended(&bob, bob_call.id).await?; + assert_text(&bob, bob_call.id, "Missed call").await?; bob.evtracker .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) .await; bob2.recv_msg_trash(&sent3).await; - assert_is_call_ended(&bob2, bob2_call.id).await?; + assert_text(&bob2, bob2_call.id, "Missed call").await?; bob2.evtracker .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) .await; @@ -325,7 +345,7 @@ async fn test_is_stale_call() -> Result<()> { }, ..Default::default() }; - assert!(!call_info.is_stale_call()); + assert!(!call_info.is_stale()); let remaining_seconds = call_info.remaining_ring_seconds(); assert!(remaining_seconds == RINGING_SECONDS || remaining_seconds == RINGING_SECONDS - 1); @@ -337,7 +357,7 @@ async fn test_is_stale_call() -> Result<()> { }, ..Default::default() }; - assert!(!call_info.is_stale_call()); + assert!(!call_info.is_stale()); let remaining_seconds = call_info.remaining_ring_seconds(); assert!(remaining_seconds == RINGING_SECONDS - 5 || remaining_seconds == RINGING_SECONDS - 6); @@ -349,28 +369,32 @@ async fn test_is_stale_call() -> Result<()> { }, ..Default::default() }; - assert!(call_info.is_stale_call()); + assert!(call_info.is_stale()); 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<()> { +async fn test_mark_calls() -> 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 mut call_info: CallInfo = alice.load_call_by_id(alice_call.id).await?; + assert!(!call_info.is_accepted()); + assert!(!call_info.is_ended()); + call_info.mark_as_accepted(&alice).await?; + assert!(call_info.is_accepted()); + assert!(!call_info.is_ended()); - let alice_call = Message::load_from_db(&alice, alice_call.id).await?; - assert!(alice_call.is_call_accepted()?); + let mut call_info: CallInfo = alice.load_call_by_id(alice_call.id).await?; + assert!(call_info.is_accepted()); + assert!(!call_info.is_ended()); + + call_info.mark_as_ended(&alice).await?; + assert!(call_info.is_accepted()); + assert!(call_info.is_ended()); Ok(()) } diff --git a/src/events/payload.rs b/src/events/payload.rs index fb0d972a3..6bddadf79 100644 --- a/src/events/payload.rs +++ b/src/events/payload.rs @@ -388,8 +388,6 @@ pub enum EventType { 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. diff --git a/src/mimefactory.rs b/src/mimefactory.rs index d5c4420ee..5ccbbe9c7 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -1575,6 +1575,9 @@ impl MimeFactory { "Chat-Content", mail_builder::headers::raw::Raw::new("call").into(), )); + placeholdertext = Some( + "[This is a 'Call'. The sender uses an experiment not supported on your version yet]".to_string(), + ); } if msg.param.exists(Param::WebrtcRoom) { diff --git a/src/receive_imf.rs b/src/receive_imf.rs index d247d1123..fac627db5 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -1001,7 +1001,9 @@ pub(crate) async fn receive_imf_inner( } if mime_parser.is_call() { - context.handle_call_msg(&mime_parser, insert_msg_id).await?; + context + .handle_call_msg(insert_msg_id, &mime_parser, from_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 { @@ -1989,7 +1991,9 @@ async fn add_parts( 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?; + context + .handle_call_msg(call.get_id(), mime_parser, from_id) + .await?; } else { warn!(context, "Call: Cannot load parent.") } diff --git a/src/sync.rs b/src/sync.rs index b5a42130b..90e302f06 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -71,9 +71,6 @@ 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)] @@ -267,7 +264,6 @@ 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}."); From 307a2eb6ecdc81473b2b707d1d12a9f156db1fab Mon Sep 17 00:00:00 2001 From: link2xt Date: Wed, 3 Sep 2025 03:56:33 +0000 Subject: [PATCH 11/15] feat: withdraw all QR codes when one is withdrawn This is a preparation for expiring authentication tokens. If we make authentication token expire, we need to generate new authentication tokens each time QR code screen is opened in the UI, so authentication token is fresh. We however don't want to completely invalidate old authentication codes at the same time, e.g. they should still be valid for joining groups, just not result in a verification on the inviter side. Since a group now can have a lot of authentication tokens, it is easy to lose track of them without any way to remove them as they are not displayed anywhere in the UI. As a solution, we now remove all tokens corresponding to a group ID when one token is withdrawn, or all non-group tokens when a single non-group token is withdrawn. "Reset QR code" option already present in the UI which works by resetting current QR code will work without any UI changes, but will now result in invalidation of all previously created QR codes and invite links. --- src/qr.rs | 7 ++--- src/qr/qr_tests.rs | 73 +++++++++++++++++++++++++++++++++++++++++++++- src/sync.rs | 15 +++++++--- src/token.rs | 11 +++---- 4 files changed, 92 insertions(+), 14 deletions(-) diff --git a/src/qr.rs b/src/qr.rs index 645318803..553a2a924 100644 --- a/src/qr.rs +++ b/src/qr.rs @@ -766,19 +766,18 @@ pub async fn set_config_from_qr(context: &Context, qr: &str) -> Result<()> { authcode, .. } => { - token::delete(context, token::Namespace::InviteNumber, &invitenumber).await?; - token::delete(context, token::Namespace::Auth, &authcode).await?; + token::delete(context, "").await?; context .sync_qr_code_token_deletion(invitenumber, authcode) .await?; } Qr::WithdrawVerifyGroup { + grpid, invitenumber, authcode, .. } => { - token::delete(context, token::Namespace::InviteNumber, &invitenumber).await?; - token::delete(context, token::Namespace::Auth, &authcode).await?; + token::delete(context, &grpid).await?; context .sync_qr_code_token_deletion(invitenumber, authcode) .await?; diff --git a/src/qr/qr_tests.rs b/src/qr/qr_tests.rs index 01cb0edbe..307cbbf5d 100644 --- a/src/qr/qr_tests.rs +++ b/src/qr/qr_tests.rs @@ -2,7 +2,7 @@ use super::*; use crate::chat::{ProtectionStatus, create_group_chat}; use crate::config::Config; use crate::securejoin::get_securejoin_qr; -use crate::test_utils::{TestContext, TestContextManager}; +use crate::test_utils::{TestContext, TestContextManager, sync}; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_decode_http() -> Result<()> { @@ -509,6 +509,77 @@ async fn test_withdraw_verifygroup() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_withdraw_multidevice() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let alice2 = &tcm.alice().await; + + alice.set_config_bool(Config::SyncMsgs, true).await?; + alice2.set_config_bool(Config::SyncMsgs, true).await?; + + // Alice creates two QR codes on the first device: + // group QR code and contact QR code. + let chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, "Group").await?; + let chat2_id = create_group_chat(alice, ProtectionStatus::Unprotected, "Group 2").await?; + let contact_qr = get_securejoin_qr(alice, None).await?; + let group_qr = get_securejoin_qr(alice, Some(chat_id)).await?; + let group2_qr = get_securejoin_qr(alice, Some(chat2_id)).await?; + + assert!(matches!( + check_qr(alice, &contact_qr).await?, + Qr::WithdrawVerifyContact { .. } + )); + assert!(matches!( + check_qr(alice, &group_qr).await?, + Qr::WithdrawVerifyGroup { .. } + )); + + // Sync group QR codes. + sync(alice, alice2).await; + assert!(matches!( + check_qr(alice2, &group_qr).await?, + Qr::WithdrawVerifyGroup { .. } + )); + assert!(matches!( + check_qr(alice2, &group2_qr).await?, + Qr::WithdrawVerifyGroup { .. } + )); + + // Alice creates a contact QR code on second device + // and withdraws it. + let contact_qr2 = get_securejoin_qr(alice2, None).await?; + set_config_from_qr(alice2, &contact_qr2).await?; + assert!(matches!( + check_qr(alice2, &contact_qr2).await?, + Qr::ReviveVerifyContact { .. } + )); + + // Alice also withdraws second group QR code on second device. + set_config_from_qr(alice2, &group2_qr).await?; + + // Sync messages are sent from Alice's second device to first device. + sync(alice2, alice).await; + + // Now first device has reset all contact QR codes + // and second group QR code, + // but first group QR code is still valid. + assert!(matches!( + check_qr(alice, &contact_qr2).await?, + Qr::ReviveVerifyContact { .. } + )); + assert!(matches!( + check_qr(alice, &group_qr).await?, + Qr::WithdrawVerifyGroup { .. } + )); + assert!(matches!( + check_qr(alice, &group2_qr).await?, + Qr::ReviveVerifyGroup { .. } + )); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_decode_and_apply_dclogin() -> Result<()> { let ctx = TestContext::new().await; diff --git a/src/sync.rs b/src/sync.rs index 90e302f06..1f17ff48c 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -292,8 +292,15 @@ impl Context { } async fn delete_qr_token(&self, token: &QrTokenData) -> Result<()> { - token::delete(self, Namespace::InviteNumber, &token.invitenumber).await?; - token::delete(self, Namespace::Auth, &token.auth).await?; + self.sql + .execute( + "DELETE FROM tokens + WHERE foreign_key IN + (SELECT foreign_key FROM tokens + WHERE token=? OR token=?)", + (&token.invitenumber, &token.auth), + ) + .await?; Ok(()) } @@ -564,8 +571,8 @@ mod tests { .await? .is_none() ); - assert!(token::exists(&t, Namespace::InviteNumber, "yip-in").await?); - assert!(token::exists(&t, Namespace::Auth, "yip-auth").await?); + assert!(!token::exists(&t, Namespace::InviteNumber, "yip-in").await?); + assert!(!token::exists(&t, Namespace::Auth, "yip-auth").await?); assert!(!token::exists(&t, Namespace::Auth, "non-existent").await?); assert!(!token::exists(&t, Namespace::Auth, "directly deleted").await?); diff --git a/src/token.rs b/src/token.rs index a5bdfc068..7846e1e59 100644 --- a/src/token.rs +++ b/src/token.rs @@ -104,13 +104,14 @@ pub async fn auth_foreign_key(context: &Context, token: &str) -> Result Result<()> { +/// Resets all tokens corresponding to the `foreign_key`. +/// +/// `foreign_key` is a group ID to reset all group tokens +/// or empty string to reset all setup contact tokens. +pub async fn delete(context: &Context, foreign_key: &str) -> Result<()> { context .sql - .execute( - "DELETE FROM tokens WHERE namespc=? AND token=?;", - (namespace, token), - ) + .execute("DELETE FROM tokens WHERE foreign_key=?", (foreign_key,)) .await?; Ok(()) } From e047184ede86981653404e74d8308af8e439587f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Sep 2025 21:27:59 +0000 Subject: [PATCH 12/15] chore(deps): bump actions/setup-python from 5 to 6 Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 4 ++-- .github/workflows/deltachat-rpc-server.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1564c7a44..815e7ef0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -256,7 +256,7 @@ jobs: path: target/debug - name: Install python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python }} @@ -303,7 +303,7 @@ jobs: persist-credentials: false - name: Install python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python }} diff --git a/.github/workflows/deltachat-rpc-server.yml b/.github/workflows/deltachat-rpc-server.yml index 981c0c391..365f3d3e1 100644 --- a/.github/workflows/deltachat-rpc-server.yml +++ b/.github/workflows/deltachat-rpc-server.yml @@ -224,7 +224,7 @@ jobs: # Python 3.11 is needed for tomllib used in scripts/wheel-rpc-server.py - name: Install python 3.12 - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: 3.12 @@ -289,7 +289,7 @@ jobs: with: show-progress: false persist-credentials: false - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: "3.11" From 5e1d945198b5d3ed43dcaa6717e1d4e941aa7b55 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Sep 2025 21:25:05 +0000 Subject: [PATCH 13/15] chore(deps): bump actions/setup-node from 4 to 5 Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 5. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/deltachat-rpc-server.yml | 2 +- .github/workflows/jsonrpc-client-npm-package.yml | 2 +- .github/workflows/jsonrpc.yml | 2 +- .github/workflows/upload-docs.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deltachat-rpc-server.yml b/.github/workflows/deltachat-rpc-server.yml index 365f3d3e1..b3e175f60 100644 --- a/.github/workflows/deltachat-rpc-server.yml +++ b/.github/workflows/deltachat-rpc-server.yml @@ -401,7 +401,7 @@ jobs: deltachat-rpc-server/npm-package/*.tgz # Configure Node.js for publishing. - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version: 20 registry-url: "https://registry.npmjs.org" diff --git a/.github/workflows/jsonrpc-client-npm-package.yml b/.github/workflows/jsonrpc-client-npm-package.yml index 0c43dbb7b..2aecdc9d3 100644 --- a/.github/workflows/jsonrpc-client-npm-package.yml +++ b/.github/workflows/jsonrpc-client-npm-package.yml @@ -19,7 +19,7 @@ jobs: show-progress: false persist-credentials: false - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version: 20 registry-url: "https://registry.npmjs.org" diff --git a/.github/workflows/jsonrpc.yml b/.github/workflows/jsonrpc.yml index edccae690..45db64efa 100644 --- a/.github/workflows/jsonrpc.yml +++ b/.github/workflows/jsonrpc.yml @@ -21,7 +21,7 @@ jobs: show-progress: false persist-credentials: false - name: Use Node.js 18.x - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: 18.x - name: Add Rust cache diff --git a/.github/workflows/upload-docs.yml b/.github/workflows/upload-docs.yml index 2615914e0..62e9412d4 100644 --- a/.github/workflows/upload-docs.yml +++ b/.github/workflows/upload-docs.yml @@ -78,7 +78,7 @@ jobs: persist-credentials: false fetch-depth: 0 # Fetch history to calculate VCS version number. - name: Use Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: '18' - name: npm install From 75bcf8660bca5b80bea7835bd85df11518a0f7e4 Mon Sep 17 00:00:00 2001 From: link2xt Date: Tue, 9 Sep 2025 05:46:13 +0000 Subject: [PATCH 14/15] chore(release): prepare for 2.13.0 --- CHANGELOG.md | 46 +++++++++++++++++++ Cargo.lock | 10 ++-- Cargo.toml | 2 +- deltachat-ffi/Cargo.toml | 2 +- deltachat-jsonrpc/Cargo.toml | 2 +- deltachat-jsonrpc/typescript/package.json | 2 +- deltachat-repl/Cargo.toml | 2 +- deltachat-rpc-client/pyproject.toml | 2 +- deltachat-rpc-server/Cargo.toml | 2 +- deltachat-rpc-server/npm-package/package.json | 2 +- python/pyproject.toml | 2 +- release-date.in | 2 +- 12 files changed, 61 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13fff2b5e..099e1d05f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,51 @@ # Changelog +## [2.13.0] - 2025-09-09 + +### API-Changes + +- [**breaking**] Remove `is_profile_verified` APIs. +- [**breaking**] Remove deprecated `is_protection_broken`. +- [**breaking**] Remove `e2ee_enabled` preference. + +### Features / Changes + +- Add call ringing API ([#6650](https://github.com/chatmail/core/pull/6650), [#7174](https://github.com/chatmail/core/pull/7174), [#7175](https://github.com/chatmail/core/pull/7175), [#7179](https://github.com/chatmail/core/pull/7179)) +- Warn for outdated versions after 6 months instead of 1 year ([#7144](https://github.com/chatmail/core/pull/7144)). +- Do not set "unknown sender for this chat" error. +- Do not replace messages with an error on verification failure. +- Support receiving Autocrypt-Gossip with `_verified` attribute. +- Withdraw all QR codes when one is withdrawn. + +### Fixes + +- Don't reverify contacts by SELF on receipt of a message from another device. +- Don't verify contacts by others having an unknown verifier. +- Update verifier_id if it's "unknown" and new verifier has known verifier. +- Mark message as failed if it can't be sent ([#7143](https://github.com/chatmail/core/pull/7143)). +- Add "Messages are end-to-end encrypted." to non-protected groups. + +### Documentation + +- Fix for SecurejoinInviterProgress with progress == 600. +- STYLE.md: Prefer BTreeMap and BTreeSet over hash variants. + +### Miscellaneous Tasks + +- Update provider database. +- Update dependencies. + +### Refactor + +- Check that verifier is verified in turn. +- Remove unused `EncryptPreference::Reset`. +- Remove `Aheader::new`. + +### Tests + +- Add another TimeShiftFalsePositiveNote ([#7142](https://github.com/chatmail/core/pull/7142)). +- Add TestContext.create_chat_id. + ## [2.12.0] - 2025-08-26 ### API-Changes diff --git a/Cargo.lock b/Cargo.lock index 792a125fe..2b64fcf4c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1296,7 +1296,7 @@ dependencies = [ [[package]] name = "deltachat" -version = "2.12.0" +version = "2.13.0" dependencies = [ "anyhow", "async-broadcast", @@ -1406,7 +1406,7 @@ dependencies = [ [[package]] name = "deltachat-jsonrpc" -version = "2.12.0" +version = "2.13.0" dependencies = [ "anyhow", "async-channel 2.5.0", @@ -1428,7 +1428,7 @@ dependencies = [ [[package]] name = "deltachat-repl" -version = "2.12.0" +version = "2.13.0" dependencies = [ "anyhow", "deltachat", @@ -1444,7 +1444,7 @@ dependencies = [ [[package]] name = "deltachat-rpc-server" -version = "2.12.0" +version = "2.13.0" dependencies = [ "anyhow", "deltachat", @@ -1473,7 +1473,7 @@ dependencies = [ [[package]] name = "deltachat_ffi" -version = "2.12.0" +version = "2.13.0" dependencies = [ "anyhow", "deltachat", diff --git a/Cargo.toml b/Cargo.toml index adbca045b..574f3b839 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "deltachat" -version = "2.12.0" +version = "2.13.0" edition = "2024" license = "MPL-2.0" rust-version = "1.85" diff --git a/deltachat-ffi/Cargo.toml b/deltachat-ffi/Cargo.toml index a8aa0fc56..e49faeeb6 100644 --- a/deltachat-ffi/Cargo.toml +++ b/deltachat-ffi/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "deltachat_ffi" -version = "2.12.0" +version = "2.13.0" description = "Deltachat FFI" edition = "2018" readme = "README.md" diff --git a/deltachat-jsonrpc/Cargo.toml b/deltachat-jsonrpc/Cargo.toml index 3133c6b08..0e77b6b54 100644 --- a/deltachat-jsonrpc/Cargo.toml +++ b/deltachat-jsonrpc/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "deltachat-jsonrpc" -version = "2.12.0" +version = "2.13.0" description = "DeltaChat JSON-RPC API" edition = "2021" license = "MPL-2.0" diff --git a/deltachat-jsonrpc/typescript/package.json b/deltachat-jsonrpc/typescript/package.json index 865365282..56ca32738 100644 --- a/deltachat-jsonrpc/typescript/package.json +++ b/deltachat-jsonrpc/typescript/package.json @@ -54,5 +54,5 @@ }, "type": "module", "types": "dist/deltachat.d.ts", - "version": "2.12.0" + "version": "2.13.0" } diff --git a/deltachat-repl/Cargo.toml b/deltachat-repl/Cargo.toml index 4b578702f..0f8950a83 100644 --- a/deltachat-repl/Cargo.toml +++ b/deltachat-repl/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "deltachat-repl" -version = "2.12.0" +version = "2.13.0" license = "MPL-2.0" edition = "2021" repository = "https://github.com/chatmail/core" diff --git a/deltachat-rpc-client/pyproject.toml b/deltachat-rpc-client/pyproject.toml index 4e06b5479..87ba827fb 100644 --- a/deltachat-rpc-client/pyproject.toml +++ b/deltachat-rpc-client/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "deltachat-rpc-client" -version = "2.12.0" +version = "2.13.0" description = "Python client for Delta Chat core JSON-RPC interface" classifiers = [ "Development Status :: 5 - Production/Stable", diff --git a/deltachat-rpc-server/Cargo.toml b/deltachat-rpc-server/Cargo.toml index 48e19b920..4893b95c5 100644 --- a/deltachat-rpc-server/Cargo.toml +++ b/deltachat-rpc-server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "deltachat-rpc-server" -version = "2.12.0" +version = "2.13.0" description = "DeltaChat JSON-RPC server" edition = "2021" readme = "README.md" diff --git a/deltachat-rpc-server/npm-package/package.json b/deltachat-rpc-server/npm-package/package.json index b1611b8e0..f4c07b041 100644 --- a/deltachat-rpc-server/npm-package/package.json +++ b/deltachat-rpc-server/npm-package/package.json @@ -15,5 +15,5 @@ }, "type": "module", "types": "index.d.ts", - "version": "2.12.0" + "version": "2.13.0" } diff --git a/python/pyproject.toml b/python/pyproject.toml index 8049bec07..702c763a2 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "deltachat" -version = "2.12.0" +version = "2.13.0" description = "Python bindings for the Delta Chat Core library using CFFI against the Rust-implemented libdeltachat" readme = "README.rst" requires-python = ">=3.8" diff --git a/release-date.in b/release-date.in index fd0ed26ad..72764f116 100644 --- a/release-date.in +++ b/release-date.in @@ -1 +1 @@ -2025-08-26 \ No newline at end of file +2025-09-09 \ No newline at end of file From 82bc1bf0b19b51f01be0ee7fbc09aec762e7e702 Mon Sep 17 00:00:00 2001 From: link2xt Date: Tue, 9 Sep 2025 04:43:20 +0000 Subject: [PATCH 15/15] refactor: use recv_msg_trash() instead of recv_msg_opt() --- src/test_utils.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/test_utils.rs b/src/test_utils.rs index 083f0b5c5..0bca60bdc 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -1433,8 +1433,7 @@ pub(crate) async fn mark_as_verified(this: &TestContext, other: &TestContext) { pub(crate) async fn sync(alice0: &TestContext, alice1: &TestContext) { alice0.send_sync_msg().await.unwrap(); let sync_msg = alice0.pop_sent_sync_msg().await; - let no_msg = alice1.recv_msg_opt(&sync_msg).await; - assert!(no_msg.is_none()); + alice1.recv_msg_trash(&sync_msg).await; } /// Pretty-print an event to stdout