add multi-device sync (#2669)

* add basic multi-device-sync functions

* generate json

* add context.parse_sync_items()

* add context.execute_sync_items()

* piggyback sync-commands message, add body for human-readable part

* avoid double json renderings

* mimeparser parses incoming .json sync-files

* do not piggyback sync-files

* execute sync items

* return status of send_sync_msg()

* send sync messages as multipart/report

* add a per-item-timestamp and also allow adding other per-item-fields in the future

* if the self-chat does not exist, create it blocked/hidden

* create tokens closer to real qr-code needs

* respect bcc_self setting, add test for that

* sync qr code tokens after promoting groups

* send sync-messages only if an experimental switch is set

* trigger send_sync_msg() after sending messages and after creating/redraw/revive qr-code

* add DC_STR_* constants to deltachat.h

* adapt to refactored qr module as of #2729

* tweak test

* use SendSyncMsgs config name instead of SendExperimentalSyncMsgs - we can remove or rename the config nevertheless, but have the option to keep it without renaming

* tweak docs

* remove currently unused effective timestamp calculation

* clarify when send_sync_msg() is called

* make sure, sync-messages are encrypted and are sent by SELF

* tweak docs, fix typos
This commit is contained in:
bjoern
2021-10-25 12:40:32 +02:00
committed by GitHub
parent bb97d842df
commit 65f09c238b
16 changed files with 719 additions and 16 deletions

View File

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

View File

@@ -386,6 +386,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
sendsticker <file> [<text>]\n\
sendfile <file> [<text>]\n\
sendhtml <file for html-part> [<text for plain-part>]\n\
sendsyncmsg\n\
videochat\n\
draft [<text>]\n\
devicemsg <text>\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?;

View File

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

View File

@@ -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<Self> {
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<Self> {
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)

View File

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

View File

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

View File

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

View File

@@ -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<Option<Job
}
}
if let Some(sync_ids) = rendered_msg.sync_ids_to_delete {
if let Err(err) = context.delete_sync_ids(sync_ids).await {
error!(context, "Failed to delete sync ids: {:?}", err);
}
}
if attach_selfavatar {
if let Err(err) = msg.chat_id.set_selfavatar_timestamp(context, time()).await {
error!(context, "Failed to set selfavatar timestamp: {:?}", err);

View File

@@ -82,6 +82,7 @@ pub mod securejoin;
mod simplify;
mod smtp;
pub mod stock_str;
mod sync;
mod token;
mod update_helper;
#[macro_use]

View File

@@ -68,6 +68,13 @@ pub struct MimeFactory<'a> {
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<String>,
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<String>,
/// 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) {

View File

@@ -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<location::Kml>,
pub message_kml: Option<location::Kml>,
pub(crate) sync_items: Option<SyncItems>,
pub(crate) user_avatar: Option<AvatarAction>,
pub(crate) group_avatar: Option<AvatarAction>,
pub(crate) mdn_reports: Vec<Report>,
@@ -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 */

View File

@@ -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),
}

View File

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

View File

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

View File

@@ -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<str>) -> String {
translated(context, StockMessage::CannotLogin)

509
src/sync.rs Normal file
View File

@@ -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<String>,
}
#[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<SyncItem>,
}
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<bool> {
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<ChatId>) -> 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<Option<MsgId>> {
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<Option<(String, String)>> {
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::<mime::Mime>().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<SyncItems> {
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(())
}
}