diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index cac7c6e1a..a5da32c53 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -5959,6 +5959,21 @@ void dc_event_unref(dc_event_t* event); /// `%1$s` will be replaced by human-readable date and time. #define DC_STR_DOWNLOAD_AVAILABILITY 100 +/// "Multi Device Synchronization" +/// +/// Used in subjects of outgoing sync messages. +#define DC_STR_SYNC_MSG_SUBJECT 101 + +/// "This message is used to synchronize data between your devices." +/// +/// +/// Used as message text of outgoing sync messages. +/// The text is visible in non-dc-muas or in outdated Delta Chat versions, +/// the default text therefore adds the following hint: +/// "If you see this message in Delta Chat, +/// please update your Delta Chat apps on all devices." +#define DC_STR_SYNC_MSG_BODY 102 + /// "Incoming Messages" /// /// Used as a headline in the connectivity view. diff --git a/examples/repl/cmdline.rs b/examples/repl/cmdline.rs index c6c07d6b7..8e4ba82ca 100644 --- a/examples/repl/cmdline.rs +++ b/examples/repl/cmdline.rs @@ -386,6 +386,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu sendsticker []\n\ sendfile []\n\ sendhtml []\n\ + sendsyncmsg\n\ videochat\n\ draft []\n\ devicemsg \n\ @@ -895,6 +896,10 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu })); chat::send_msg(&context, sel_chat.as_ref().unwrap().get_id(), &mut msg).await?; } + "sendsyncmsg" => match context.send_sync_msg().await? { + Some(msg_id) => println!("sync message sent as {}.", msg_id), + None => println!("sync message not needed."), + }, "videochat" => { ensure!(sel_chat.is_some(), "No chat selected."); chat::send_videochat_invitation(&context, sel_chat.as_ref().unwrap().get_id()).await?; diff --git a/examples/repl/main.rs b/examples/repl/main.rs index da6f4e957..433e4a1e0 100644 --- a/examples/repl/main.rs +++ b/examples/repl/main.rs @@ -167,7 +167,7 @@ const DB_COMMANDS: [&str; 10] = [ "housekeeping", ]; -const CHAT_COMMANDS: [&str; 34] = [ +const CHAT_COMMANDS: [&str; 35] = [ "listchats", "listarchived", "chat", @@ -188,6 +188,7 @@ const CHAT_COMMANDS: [&str; 34] = [ "sendimage", "sendfile", "sendhtml", + "sendsyncmsg", "videochat", "draft", "listmedia", diff --git a/src/chat.rs b/src/chat.rs index d0491bffc..c88656be7 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -171,6 +171,17 @@ impl ChatId { /// This should be used when **a user action** creates a chat 1:1, it ensures the chat /// exists and is unblocked and scales the [`Contact`]'s origin. pub async fn create_for_contact(context: &Context, contact_id: u32) -> Result { + ChatId::create_for_contact_with_blocked(context, contact_id, Blocked::Not).await + } + + /// Same as `create_for_contact()` with an additional `create_blocked` parameter + /// that is used in case the chat does not exist. + /// If the chat exists already, `create_blocked` is ignored. + pub(crate) async fn create_for_contact_with_blocked( + context: &Context, + contact_id: u32, + create_blocked: Blocked, + ) -> Result { let chat_id = match ChatIdBlocked::lookup_by_contact(context, contact_id).await? { Some(chat) => { if chat.blocked != Blocked::Not { @@ -182,7 +193,10 @@ impl ChatId { if Contact::real_exists_by_id(context, contact_id).await? || contact_id == DC_CONTACT_ID_SELF { - let chat_id = ChatId::get_for_contact(context, contact_id).await?; + let chat_id = + ChatIdBlocked::get_for_contact(context, contact_id, create_blocked) + .await + .map(|chat| chat.id)?; Contact::scaleup_origin_by_id(context, contact_id, Origin::CreateChat).await?; chat_id } else { @@ -1191,6 +1205,10 @@ impl Chat { msg.param.set_int(Param::AttachGroupImage, 1); self.param.remove(Param::Unpromoted); self.update_param(context).await?; + // send_sync_msg() is called (usually) a moment later at Job::send_msg_to_smtp() + // when the group-creation message is actually sent though SMTP - + // this makes sure, the other devices are aware of grpid that is used in the sync-message. + context.sync_qr_code_tokens(Some(self.id)).await?; } // reset encrypt error state eg. for forwarding @@ -2383,6 +2401,8 @@ pub(crate) async fn add_contact_to_chat_ex( if from_handshake && chat.param.get_int(Param::Unpromoted).unwrap_or_default() == 1 { chat.param.remove(Param::Unpromoted); chat.update_param(context).await?; + context.sync_qr_code_tokens(Some(chat_id)).await?; + context.send_sync_msg().await?; } let self_addr = context .get_config(Config::ConfiguredAddr) diff --git a/src/config.rs b/src/config.rs index f7ae28cfb..a0cd9d76b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -175,6 +175,11 @@ pub enum Config { /// 0 = no limit. #[strum(props(default = "0"))] DownloadLimit, + + /// Send sync messages, requires `BccSelf` to be set as well. + /// In a future versions, this switch may be removed. + #[strum(props(default = "0"))] + SendSyncMsgs, } impl Context { diff --git a/src/context.rs b/src/context.rs index 35c1787f1..a9b542886 100644 --- a/src/context.rs +++ b/src/context.rs @@ -303,6 +303,7 @@ impl Context { 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 send_sync_msgs = self.get_config_int(Config::SendSyncMsgs).await?; let prv_key_cnt = self .sql @@ -392,6 +393,7 @@ impl Context { self.get_config_int(Config::KeyGenType).await?.to_string(), ); res.insert("bcc_self", bcc_self.to_string()); + res.insert("send_sync_msgs", send_sync_msgs.to_string()); res.insert("private_key_count", prv_key_cnt.to_string()); res.insert("public_key_count", pub_key_cnt.to_string()); res.insert("fingerprint", fingerprint_str); diff --git a/src/dc_receive_imf.rs b/src/dc_receive_imf.rs index 165b58a29..bdaf5e026 100644 --- a/src/dc_receive_imf.rs +++ b/src/dc_receive_imf.rs @@ -233,6 +233,20 @@ pub(crate) async fn dc_receive_imf_inner( .await; } + if let Some(ref sync_items) = mime_parser.sync_items { + if from_id == DC_CONTACT_ID_SELF { + if mime_parser.was_encrypted() { + if let Err(err) = context.execute_sync_items(sync_items).await { + warn!(context, "receive_imf cannot execute sync items: {}", err); + } + } else { + warn!(context, "sync items are not encrypted."); + } + } else { + warn!(context, "sync items not sent by self."); + } + } + if let Some(avatar_action) = &mime_parser.user_avatar { if from_id != 0 && context @@ -692,6 +706,10 @@ async fn add_parts( state = MessageState::OutDelivered; to_id = to_ids.get_index(0).cloned().unwrap_or_default(); + let self_sent = from_id == DC_CONTACT_ID_SELF + && to_ids.len() == 1 + && to_ids.contains(&DC_CONTACT_ID_SELF); + // handshake may mark contacts as verified and must be processed before chats are created if mime_parser.get_header(HeaderDef::SecureJoin).is_some() { is_dc_message = MessengerMessage::Yes; // avoid discarding by show_emails setting @@ -710,6 +728,10 @@ async fn add_parts( return Ok(DC_CHAT_ID_TRASH); } } + } else if mime_parser.sync_items.is_some() && self_sent { + is_dc_message = MessengerMessage::Yes; + allow_creation = true; + *hidden = true; } // If the message is outgoing AND there is no Received header AND it's not in the sentbox, @@ -775,7 +797,11 @@ async fn add_parts( } if chat_id.is_none() && allow_creation { let create_blocked = if !Contact::is_blocked_load(context, to_id).await? { - Blocked::Not + if self_sent && *hidden { + Blocked::Manually + } else { + Blocked::Not + } } else { Blocked::Request }; @@ -794,9 +820,6 @@ async fn add_parts( } } } - let self_sent = from_id == DC_CONTACT_ID_SELF - && to_ids.len() == 1 - && to_ids.contains(&DC_CONTACT_ID_SELF); if chat_id.is_none() && self_sent { // from_id==to_id==DC_CONTACT_ID_SELF - this is a self-sent messages, diff --git a/src/job.rs b/src/job.rs index 778729ceb..1dbc44a3d 100644 --- a/src/job.rs +++ b/src/job.rs @@ -433,6 +433,13 @@ impl Job { } // now also delete the generated file dc_delete_file(context, filename).await; + + // finally, create another send-job if there are items to be synced. + // triggering sync-job after msg-send-job guarantees, the recipient has grpid etc. + // once the sync message arrives. + // if there are no items to sync, this function returns fast. + context.send_sync_msg().await?; + Ok(()) } }) @@ -1001,6 +1008,12 @@ pub async fn send_msg_job(context: &Context, msg_id: MsgId) -> Result { references: String, req_mdn: bool, last_added_location_id: u32, + + /// If the created mime-structure contains sync-items, + /// the IDs of these items are listed here. + /// The IDs are returned via `RenderedEmail` + /// and must be deleted if the message is actually queued for sending. + sync_ids_to_delete: Option, + attach_selfavatar: bool, } @@ -80,6 +87,12 @@ pub struct RenderedEmail { pub is_gossiped: bool, pub last_added_location_id: u32, + /// A comma-separated string of sync-IDs that are used by the rendered email + /// and must be deleted once the message is actually queued for sending + /// (deletion must be done by `delete_sync_ids()`). + /// If the rendered email is not queued for sending, the IDs must not be deleted. + pub sync_ids_to_delete: Option, + /// Message ID (Message in the sense of Email) pub rfc724_mid: String, pub subject: String, @@ -205,6 +218,7 @@ impl<'a> MimeFactory<'a> { references, req_mdn, last_added_location_id: 0, + sync_ids_to_delete: None, attach_selfavatar, }; Ok(factory) @@ -249,6 +263,7 @@ impl<'a> MimeFactory<'a> { references: String::default(), req_mdn: false, last_added_location_id: 0, + sync_ids_to_delete: None, attach_selfavatar: false, }; @@ -603,12 +618,20 @@ impl<'a> MimeFactory<'a> { main_part } else { // Multiple parts, render as multipart. - parts.into_iter().fold( - PartBuilder::new() - .message_type(MimeMultipartType::Mixed) - .child(main_part.build()), - |message, part| message.child(part.build()), - ) + let part_holder = if self.msg.param.get_cmd() == SystemMessage::MultiDeviceSync { + PartBuilder::new().header(( + "Content-Type".to_string(), + "multipart/report; report-type=multi-device-sync".to_string(), + )) + } else { + PartBuilder::new().message_type(MimeMultipartType::Mixed) + }; + + parts + .into_iter() + .fold(part_holder.child(main_part.build()), |message, part| { + message.child(part.build()) + }) }; let outer_message = if is_encrypted { @@ -729,6 +752,7 @@ impl<'a> MimeFactory<'a> { is_encrypted, is_gossiped, last_added_location_id, + sync_ids_to_delete: self.sync_ids_to_delete, rfc724_mid, subject: subject_str, }) @@ -873,7 +897,7 @@ impl<'a> MimeFactory<'a> { "ephemeral-timer-changed".to_string(), )); } - SystemMessage::LocationOnly => { + SystemMessage::LocationOnly | SystemMessage::MultiDeviceSync => { // This should prevent automatic replies, // such as non-delivery reports. // @@ -1103,6 +1127,15 @@ impl<'a> MimeFactory<'a> { } } + // we do not piggyback sync-files to other self-sent-messages + // to not risk files becoming too larger and being skipped by download-on-demand. + if command == SystemMessage::MultiDeviceSync && self.is_e2ee_guaranteed() { + let json = self.msg.param.get(Param::Arg).unwrap_or_default(); + let ids = self.msg.param.get(Param::Arg2).unwrap_or_default(); + parts.push(context.build_sync_part(json.to_string()).await); + self.sync_ids_to_delete = Some(ids.to_string()); + } + if self.attach_selfavatar { match context.get_config(Config::Selfavatar).await? { Some(path) => match build_selfavatar_file(context, &path) { diff --git a/src/mimeparser.rs b/src/mimeparser.rs index 9b527c7bc..377235c0f 100644 --- a/src/mimeparser.rs +++ b/src/mimeparser.rs @@ -28,6 +28,7 @@ use crate::param::{Param, Params}; use crate::peerstate::Peerstate; use crate::simplify::simplify; use crate::stock_str; +use crate::sync::SyncItems; /// A parsed MIME message. /// @@ -61,6 +62,7 @@ pub struct MimeMessage { pub is_system_message: SystemMessage, pub location_kml: Option, pub message_kml: Option, + pub(crate) sync_items: Option, pub(crate) user_avatar: Option, pub(crate) group_avatar: Option, pub(crate) mdn_reports: Vec, @@ -124,6 +126,10 @@ pub enum SystemMessage { // Chat protection state changed ChatProtectionEnabled = 11, ChatProtectionDisabled = 12, + + /// Self-sent-message that contains only json used for multi-device-sync; + /// if possible, we attach that to other messages as for locations. + MultiDeviceSync = 20, } impl Default for SystemMessage { @@ -279,6 +285,7 @@ impl MimeMessage { is_system_message: SystemMessage::Unknown, location_kml: None, message_kml: None, + sync_items: None, user_avatar: None, group_avatar: None, failure_report: None, @@ -813,6 +820,12 @@ impl MimeMessage { } } } + Some("multi-device-sync") => { + if let Some(second) = mail.subparts.get(1) { + self.add_single_part_if_known(context, second, is_related) + .await?; + } + } Some(_) => { if let Some(first) = mail.subparts.get(0) { any_part_added = self @@ -999,7 +1012,20 @@ impl MimeMessage { } return; } + } else if filename == "multi-device-sync.json" { + let serialized = String::from_utf8_lossy(decoded_data) + .parse() + .unwrap_or_default(); + self.sync_items = context + .parse_sync_items(serialized) + .await + .map_err(|err| { + warn!(context, "failed to parse sync data: {}", err); + }) + .ok(); + return; } + /* we have a regular file attachment, write decoded data to new blob object */ diff --git a/src/qr.rs b/src/qr.rs index ea4be6cfe..595b3f563 100644 --- a/src/qr.rs +++ b/src/qr.rs @@ -391,6 +391,10 @@ pub async fn set_config_from_qr(context: &Context, qr: &str) -> Result<()> { } => { token::delete(context, token::Namespace::InviteNumber, &invitenumber).await?; token::delete(context, token::Namespace::Auth, &authcode).await?; + context + .sync_qr_code_token_deletion(invitenumber, authcode) + .await?; + context.send_sync_msg().await?; } Qr::WithdrawVerifyGroup { invitenumber, @@ -399,6 +403,10 @@ pub async fn set_config_from_qr(context: &Context, qr: &str) -> Result<()> { } => { token::delete(context, token::Namespace::InviteNumber, &invitenumber).await?; token::delete(context, token::Namespace::Auth, &authcode).await?; + context + .sync_qr_code_token_deletion(invitenumber, authcode) + .await?; + context.send_sync_msg().await?; } Qr::ReviveVerifyContact { invitenumber, @@ -407,6 +415,8 @@ pub async fn set_config_from_qr(context: &Context, qr: &str) -> Result<()> { } => { token::save(context, token::Namespace::InviteNumber, None, &invitenumber).await?; token::save(context, token::Namespace::Auth, None, &authcode).await?; + context.sync_qr_code_tokens(None).await?; + context.send_sync_msg().await?; } Qr::ReviveVerifyGroup { invitenumber, @@ -425,6 +435,8 @@ pub async fn set_config_from_qr(context: &Context, qr: &str) -> Result<()> { ) .await?; token::save(context, token::Namespace::Auth, chat_id, &authcode).await?; + context.sync_qr_code_tokens(chat_id).await?; + context.send_sync_msg().await?; } _ => bail!("qr code {:?} does not contain config", qr), } diff --git a/src/securejoin.rs b/src/securejoin.rs index c1d5d36eb..522dc7282 100644 --- a/src/securejoin.rs +++ b/src/securejoin.rs @@ -30,6 +30,7 @@ use crate::token; mod bobstate; mod qrinvite; +use crate::token::Namespace; use bobstate::{BobHandshakeStage, BobState, BobStateHandle}; use qrinvite::QrInvite; @@ -171,8 +172,11 @@ pub async fn dc_get_securejoin_qr(context: &Context, group: Option) -> R // invitenumber will be used to allow starting the handshake, // auth will be used to verify the fingerprint - let invitenumber = token::lookup_or_new(context, token::Namespace::InviteNumber, group).await; - let auth = token::lookup_or_new(context, token::Namespace::Auth, group).await; + let sync_token = token::lookup(context, Namespace::InviteNumber, group) + .await? + .is_none(); + let invitenumber = token::lookup_or_new(context, Namespace::InviteNumber, group).await; + let auth = token::lookup_or_new(context, Namespace::Auth, group).await; let self_addr = match context.get_config(Config::ConfiguredAddr).await { Ok(Some(addr)) => addr, Ok(None) => { @@ -208,7 +212,9 @@ pub async fn dc_get_securejoin_qr(context: &Context, group: Option) -> R let chat = Chat::load_from_db(context, group).await?; let group_name = chat.get_name(); let group_name_urlencoded = utf8_percent_encode(group_name, NON_ALPHANUMERIC).to_string(); - + if sync_token { + context.sync_qr_code_tokens(Some(chat.id)).await?; + } format!( "OPENPGP4FPR:{}#a={}&g={}&x={}&i={}&s={}", fingerprint.hex(), @@ -220,6 +226,9 @@ pub async fn dc_get_securejoin_qr(context: &Context, group: Option) -> R ) } else { // parameters used: a=n=i=s= + if sync_token { + context.sync_qr_code_tokens(None).await?; + } format!( "OPENPGP4FPR:{}#a={}&n={}&i={}&s={}", fingerprint.hex(), diff --git a/src/sql/migrations.rs b/src/sql/migrations.rs index 8c3450524..b74330577 100644 --- a/src/sql/migrations.rs +++ b/src/sql/migrations.rs @@ -487,6 +487,16 @@ paramsv![] ) .await?; } + if dbversion < 80 { + info!(context, "[migration] v80"); + sql.execute_migration( + r#"CREATE TABLE multi_device_sync ( +id INTEGER PRIMARY KEY AUTOINCREMENT, +item TEXT DEFAULT '');"#, + 80, + ) + .await?; + } Ok(( recalc_fingerprints, diff --git a/src/stock_str.rs b/src/stock_str.rs index 4d554613b..eeb019e26 100644 --- a/src/stock_str.rs +++ b/src/stock_str.rs @@ -276,6 +276,15 @@ pub enum StockMessage { #[strum(props(fallback = "Download maximum available until %1$s"))] DownloadAvailability = 100, + #[strum(props(fallback = "Multi Device Synchronization"))] + SyncMsgSubject = 101, + + #[strum(props( + fallback = "This message is used to synchronize data between your devices.\n\n\ + 👉 If you see this message in Delta Chat, please update your Delta Chat apps on all devices." + ))] + SyncMsgBody = 102, + #[strum(props(fallback = "Incoming Messages"))] IncomingMessages = 103, @@ -624,6 +633,16 @@ pub(crate) async fn ac_setup_msg_body(context: &Context) -> String { translated(context, StockMessage::AcSetupMsgBody).await } +/// Stock string: `Multi Device Synchronization`. +pub(crate) async fn sync_msg_subject(context: &Context) -> String { + translated(context, StockMessage::SyncMsgSubject).await +} + +/// Stock string: `This message is used to synchronize data betweeen your devices.`. +pub(crate) async fn sync_msg_body(context: &Context) -> String { + translated(context, StockMessage::SyncMsgBody).await +} + /// Stock string: `Cannot login as \"%1$s\". Please check...`. pub(crate) async fn cannot_login(context: &Context, user: impl AsRef) -> String { translated(context, StockMessage::CannotLogin) diff --git a/src/sync.rs b/src/sync.rs new file mode 100644 index 000000000..1f8f26908 --- /dev/null +++ b/src/sync.rs @@ -0,0 +1,509 @@ +//! # Synchronize items between devices. + +use crate::chat::{Chat, ChatId}; +use crate::config::Config; +use crate::constants::{Blocked, Viewtype, DC_CONTACT_ID_SELF}; +use crate::context::Context; +use crate::dc_tools::time; +use crate::message::{Message, MsgId}; +use crate::mimeparser::SystemMessage; +use crate::param::Param; +use crate::sync::SyncData::{AddQrToken, DeleteQrToken}; +use crate::token::Namespace; +use crate::{chat, stock_str, token}; +use anyhow::Result; +use itertools::Itertools; +use lettre_email::mime::{self}; +use lettre_email::PartBuilder; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct QrTokenData { + pub(crate) invitenumber: String, + pub(crate) auth: String, + pub(crate) grpid: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) enum SyncData { + AddQrToken(QrTokenData), + DeleteQrToken(QrTokenData), +} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct SyncItem { + timestamp: i64, + data: SyncData, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct SyncItems { + items: Vec, +} + +impl Context { + /// Checks if sync messages shall be sent. + /// Receiving sync messages is currently always enabled; + /// the messages are force-encrypted anyway. + async fn is_sync_sending_enabled(&self) -> Result { + self.get_config_bool(Config::SendSyncMsgs).await + } + + /// Adds an item to the list of items that should be synchronized to other devices. + pub(crate) async fn add_sync_item(&self, data: SyncData) -> Result<()> { + self.add_sync_item_with_timestamp(data, time()).await + } + + /// Adds item and timestamp to the list of items that should be synchronized to other devices. + /// If device synchronization is disabled, the function does nothing. + async fn add_sync_item_with_timestamp(&self, data: SyncData, timestamp: i64) -> Result<()> { + if !self.is_sync_sending_enabled().await? { + return Ok(()); + } + + let item = SyncItem { timestamp, data }; + let item = serde_json::to_string(&item)?; + self.sql + .execute( + "INSERT INTO multi_device_sync (item) VALUES(?);", + paramsv![item], + ) + .await?; + + Ok(()) + } + + /// Adds most recent qr-code tokens for a given chat to the list of items to be synced. + /// If device synchronization is disabled, + /// no tokens exist or the chat is unpromoted, the function does nothing. + pub(crate) async fn sync_qr_code_tokens(&self, chat_id: Option) -> Result<()> { + if !self.is_sync_sending_enabled().await? { + return Ok(()); + } + + if let (Some(invitenumber), Some(auth)) = ( + token::lookup(self, Namespace::InviteNumber, chat_id).await?, + token::lookup(self, Namespace::Auth, chat_id).await?, + ) { + let grpid = if let Some(chat_id) = chat_id { + let chat = Chat::load_from_db(self, chat_id).await?; + if !chat.is_promoted() { + info!( + self, + "group '{}' not yet promoted, do not sync tokens yet.", chat.grpid + ); + return Ok(()); + } + Some(chat.grpid) + } else { + None + }; + self.add_sync_item(SyncData::AddQrToken(QrTokenData { + invitenumber, + auth, + grpid, + })) + .await?; + } + Ok(()) + } + + // Add deleted qr-code token to the list of items to be synced + // so that the token also gets deleted on the other devices. + pub(crate) async fn sync_qr_code_token_deletion( + &self, + invitenumber: String, + auth: String, + ) -> Result<()> { + self.add_sync_item(SyncData::DeleteQrToken(QrTokenData { + invitenumber, + auth, + grpid: None, + })) + .await + } + + /// Sends out a self-sent message with items to be synchronized, if any. + pub async fn send_sync_msg(&self) -> Result> { + if let Some((json, ids)) = self.build_sync_json().await? { + let chat_id = ChatId::create_for_contact_with_blocked( + self, + DC_CONTACT_ID_SELF, + Blocked::Manually, + ) + .await?; + let mut msg = Message { + chat_id, + viewtype: Viewtype::Text, + text: Some(stock_str::sync_msg_body(self).await), + hidden: true, + subject: stock_str::sync_msg_subject(self).await, + ..Default::default() + }; + msg.param.set_cmd(SystemMessage::MultiDeviceSync); + msg.param.set(Param::Arg, json); + msg.param.set(Param::Arg2, ids); + msg.param.set_int(Param::GuaranteeE2ee, 1); + Ok(Some(chat::send_msg(self, chat_id, &mut msg).await?)) + } else { + Ok(None) + } + } + + /// Copies all sync items to a JSON string and clears the sync-table. + /// Returns the JSON string and a comma-separated string of the IDs used. + pub(crate) async fn build_sync_json(&self) -> Result> { + let (ids, serialized) = self + .sql + .query_map( + "SELECT id, item FROM multi_device_sync ORDER BY id;", + paramsv![], + |row| Ok((row.get::<_, u32>(0)?, row.get::<_, String>(1)?)), + |rows| { + let mut ids = vec![]; + let mut serialized = String::default(); + for row in rows { + let (id, item) = row?; + ids.push(id); + if !serialized.is_empty() { + serialized.push_str(",\n"); + } + serialized.push_str(&item); + } + Ok((ids, serialized)) + }, + ) + .await?; + + if ids.is_empty() { + Ok(None) + } else { + Ok(Some(( + format!("{{\"items\":[\n{}\n]}}", serialized), + ids.iter().map(|x| x.to_string()).join(","), + ))) + } + } + + pub(crate) async fn build_sync_part(&self, json: String) -> PartBuilder { + PartBuilder::new() + .content_type(&"application/json".parse::().unwrap()) + .header(( + "Content-Disposition", + "attachment; filename=\"multi-device-sync.json\"", + )) + .body(json) + } + + /// Deletes IDs as returned by `build_sync_json()`. + pub(crate) async fn delete_sync_ids(&self, ids: String) -> Result<()> { + self.sql + .execute( + format!("DELETE FROM multi_device_sync WHERE id IN ({});", ids), + paramsv![], + ) + .await?; + Ok(()) + } + + /// Takes a JSON string created by `build_sync_json()` + /// and construct `SyncItems` from it. + pub(crate) async fn parse_sync_items(&self, serialized: String) -> Result { + let sync_items: SyncItems = serde_json::from_str(&serialized)?; + Ok(sync_items) + } + + /// Execute sync items. + /// + /// CAVE: When changing the code to handle other sync items, + /// take care that does not result in calls to `add_sync_item()` + /// as otherwise we would add in a dead-loop between two devices + /// sending message back and forth. + /// + /// If an error is returned, the caller shall not try over. + /// Therefore, errors should only be returned on database errors or so. + /// If eg. just an item cannot be deleted, + /// that should not hold off the other items to be executed. + pub(crate) async fn execute_sync_items(&self, items: &SyncItems) -> Result<()> { + info!(self, "executing {} sync item(s)", items.items.len()); + for item in &items.items { + match &item.data { + AddQrToken(token) => { + let chat_id = if let Some(grpid) = &token.grpid { + if let Some((chat_id, _, _)) = + chat::get_chat_id_by_grpid(self, grpid).await? + { + Some(chat_id) + } else { + warn!( + self, + "Ignoring token for nonexistent/deleted group '{}'.", grpid + ); + continue; + } + } else { + None + }; + token::save(self, Namespace::InviteNumber, chat_id, &token.invitenumber) + .await?; + token::save(self, Namespace::Auth, chat_id, &token.auth).await?; + } + DeleteQrToken(token) => { + token::delete(self, Namespace::InviteNumber, &token.invitenumber).await?; + token::delete(self, Namespace::Auth, &token.auth).await?; + } + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::chat::Chat; + use crate::chatlist::Chatlist; + use crate::test_utils::TestContext; + use crate::token::Namespace; + use anyhow::bail; + + #[async_std::test] + async fn test_is_sync_sending_enabled() -> Result<()> { + let t = TestContext::new_alice().await; + assert!(!t.is_sync_sending_enabled().await?); + t.set_config_bool(Config::SendSyncMsgs, true).await?; + assert!(t.is_sync_sending_enabled().await?); + t.set_config_bool(Config::SendSyncMsgs, false).await?; + assert!(!t.is_sync_sending_enabled().await?); + Ok(()) + } + + #[async_std::test] + async fn test_build_sync_json() -> Result<()> { + let t = TestContext::new_alice().await; + t.set_config_bool(Config::SendSyncMsgs, true).await?; + + assert!(t.build_sync_json().await?.is_none()); + + t.add_sync_item_with_timestamp( + SyncData::AddQrToken(QrTokenData { + invitenumber: "testinvite".to_string(), + auth: "testauth".to_string(), + grpid: Some("group123".to_string()), + }), + 1631781316, + ) + .await?; + t.add_sync_item_with_timestamp( + SyncData::DeleteQrToken(QrTokenData { + invitenumber: "123!?\":.;{}".to_string(), + auth: "456".to_string(), + grpid: None, + }), + 1631781317, + ) + .await?; + + let (serialized, ids) = t.build_sync_json().await?.unwrap(); + assert_eq!( + serialized, + r#"{"items":[ +{"timestamp":1631781316,"data":{"AddQrToken":{"invitenumber":"testinvite","auth":"testauth","grpid":"group123"}}}, +{"timestamp":1631781317,"data":{"DeleteQrToken":{"invitenumber":"123!?\":.;{}","auth":"456","grpid":null}}} +]}"# + ); + + assert!(t.build_sync_json().await?.is_some()); + t.delete_sync_ids(ids).await?; + assert!(t.build_sync_json().await?.is_none()); + + let sync_items = t.parse_sync_items(serialized).await?; + assert_eq!(sync_items.items.len(), 2); + + Ok(()) + } + + #[async_std::test] + async fn test_build_sync_json_sync_msgs_off() -> Result<()> { + let t = TestContext::new_alice().await; + t.set_config_bool(Config::SendSyncMsgs, false).await?; + t.add_sync_item(SyncData::AddQrToken(QrTokenData { + invitenumber: "testinvite".to_string(), + auth: "testauth".to_string(), + grpid: Some("group123".to_string()), + })) + .await?; + assert!(t.build_sync_json().await?.is_none()); + Ok(()) + } + + #[async_std::test] + async fn test_parse_sync_items() -> Result<()> { + let t = TestContext::new_alice().await; + + assert!(t + .parse_sync_items(r#"{bad json}"#.to_string()) + .await + .is_err()); + + assert!(t + .parse_sync_items(r#"{"badname":[]}"#.to_string()) + .await + .is_err()); + + assert!(t.parse_sync_items( + r#"{"items":[{"timestamp":1631781316,"data":{"BadItem":{"invitenumber":"in","auth":"a","grpid":null}}}]}"# + .to_string(), + ) + .await.is_err()); + + assert!(t.parse_sync_items( + r#"{"items":[{"timestamp":1631781316,"data":{"AddQrToken":{"invitenumber":"in","auth":123}}}]}"#.to_string(), + ) + .await + .is_err()); // `123` is invalid for `String` + + assert!(t.parse_sync_items( + r#"{"items":[{"timestamp":1631781316,"data":{"AddQrToken":{"invitenumber":"in","auth":true}}}]}"#.to_string(), + ) + .await + .is_err()); // `true` is invalid for `String` + + assert!(t.parse_sync_items( + r#"{"items":[{"timestamp":1631781316,"data":{"AddQrToken":{"invitenumber":"in","auth":[]}}}]}"#.to_string(), + ) + .await + .is_err()); // `[]` is invalid for `String` + + assert!(t.parse_sync_items( + r#"{"items":[{"timestamp":1631781316,"data":{"AddQrToken":{"invitenumber":"in","auth":{}}}}]}"#.to_string(), + ) + .await + .is_err()); // `{}` is invalid for `String` + + assert!(t.parse_sync_items( + r#"{"items":[{"timestamp":1631781316,"data":{"AddQrToken":{"invitenumber":"in","grpid":null}}}]}"#.to_string(), + ) + .await + .is_err()); // missing field + + // empty item list is okay + assert_eq!( + t.parse_sync_items(r#"{"items":[]}"#.to_string()) + .await? + .items + .len(), + 0 + ); + + // to allow forward compatibility, additional fields should not break parsing + let sync_items = t + .parse_sync_items( + r#"{"items":[ +{"timestamp":1631781316,"data":{"DeleteQrToken":{"invitenumber":"in","auth":"yip","grpid":null}}}, +{"timestamp":1631781316,"data":{"AddQrToken":{"invitenumber":"in","auth":"yip","additional":123,"grpid":null}}} +]}"# + .to_string(), + ) + .await?; + assert_eq!(sync_items.items.len(), 2); + + let sync_items = t + .parse_sync_items( + r#"{"items":[ +{"timestamp":1631781318,"data":{"AddQrToken":{"invitenumber":"in","auth":"yip","grpid":null}}} +],"additional":"field"}"# + .to_string(), + ) + .await?; + + assert_eq!(sync_items.items.len(), 1); + if let AddQrToken(token) = &sync_items.items.get(0).unwrap().data { + assert_eq!(token.invitenumber, "in"); + assert_eq!(token.auth, "yip"); + assert_eq!(token.grpid, None); + } else { + bail!("bad item"); + } + + // to allow backward compatibility, missing `Option<>` should not break parsing + let sync_items = t.parse_sync_items( + r#"{"items":[{"timestamp":1631781319,"data":{"AddQrToken":{"invitenumber":"in","auth":"a"}}}]}"#.to_string(), + ) + .await?; + assert_eq!(sync_items.items.len(), 1); + + Ok(()) + } + + #[async_std::test] + async fn test_execute_sync_items() -> Result<()> { + let t = TestContext::new_alice().await; + + assert!(!token::exists(&t, Namespace::Auth, "yip-auth").await); + + let sync_items = t + .parse_sync_items( + r#"{"items":[ +{"timestamp":1631781316,"data":{"AddQrToken":{"invitenumber":"yip-in","auth":"a"}}}, +{"timestamp":1631781316,"data":{"DeleteQrToken":{"invitenumber":"in","auth":"delete unexistant, shall continue"}}}, +{"timestamp":1631781316,"data":{"AddQrToken":{"invitenumber":"in","auth":"yip-auth"}}}, +{"timestamp":1631781316,"data":{"AddQrToken":{"invitenumber":"in","auth":"foo","grpid":"non-existant"}}}, +{"timestamp":1631781316,"data":{"AddQrToken":{"invitenumber":"in","auth":"directly deleted"}}}, +{"timestamp":1631781316,"data":{"DeleteQrToken":{"invitenumber":"in","auth":"directly deleted"}}} +]}"# + .to_string(), + ) + .await?; + t.execute_sync_items(&sync_items).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-existant").await); + assert!(!token::exists(&t, Namespace::Auth, "directly deleted").await); + + Ok(()) + } + + #[async_std::test] + async fn test_send_sync_msg() -> Result<()> { + let alice = TestContext::new_alice().await; + alice.set_config_bool(Config::SendSyncMsgs, true).await?; + alice + .add_sync_item(SyncData::AddQrToken(QrTokenData { + invitenumber: "in".to_string(), + auth: "testtoken".to_string(), + grpid: None, + })) + .await?; + let msg_id = alice.send_sync_msg().await?.unwrap(); + let msg = Message::load_from_db(&alice, msg_id).await?; + let chat = Chat::load_from_db(&alice, msg.chat_id).await?; + assert!(chat.is_self_talk()); + + // check that the used self-talk is not visible to the user + // but that creation will still work (in this case, the chat is empty) + assert_eq!(Chatlist::try_load(&alice, 0, None, None).await?.len(), 0); + let chat_id = ChatId::create_for_contact(&alice, DC_CONTACT_ID_SELF).await?; + let chat = Chat::load_from_db(&alice, chat_id).await?; + assert!(chat.is_self_talk()); + assert_eq!(Chatlist::try_load(&alice, 0, None, None).await?.len(), 1); + let msgs = chat::get_chat_msgs(&alice, chat_id, 0, None).await?; + assert_eq!(msgs.len(), 0); + + // let alice's other device receive and execute the sync message, + // also here, self-talk should stay hidden + let sent_msg = alice.pop_sent_msg().await; + let alice2 = TestContext::new_alice().await; + alice2.recv_msg(&sent_msg).await; + assert!(token::exists(&alice2, token::Namespace::Auth, "testtoken").await); + assert_eq!(Chatlist::try_load(&alice2, 0, None, None).await?.len(), 0); + + // the same sync message sent to bob must not be executed + let bob = TestContext::new_bob().await; + bob.recv_msg(&sent_msg).await; + assert!(!token::exists(&bob, token::Namespace::Auth, "testtoken").await); + + Ok(()) + } +}