diff --git a/CHANGELOG.md b/CHANGELOG.md index f644d22f4..b00f5d95c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### API changes +- `dc_get_securejoin_qr_svg(chat_id)` added #2815 +- added stock-strings `DC_STR_SETUP_CONTACT_QR_DESC` and `DC_STR_SECURE_JOIN_GROUP_QR_DESC` + ## 1.66.0 ### API changes diff --git a/Cargo.lock b/Cargo.lock index 57d1e03f7..4e36d8155 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -706,7 +706,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37e58ac78573c40708d45522f0d80fa2f01cc4f9b4e2bf749807255454312002" dependencies = [ "bitflags", - "textwrap", + "textwrap 0.11.0", "unicode-width", ] @@ -1098,6 +1098,7 @@ dependencies = [ "pgp", "pretty_env_logger", "proptest", + "qrcodegen", "quick-xml", "r2d2", "r2d2_sqlite", @@ -1116,7 +1117,9 @@ dependencies = [ "strum", "strum_macros", "surf", + "tagger", "tempfile", + "textwrap 0.14.2", "thiserror", "toml", "url", @@ -2758,6 +2761,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "qrcodegen" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135e6754eed8ca897dd70584d895e72e36860b3e163b6bcedce48571cbaef343" + [[package]] name = "quick-error" version = "1.2.3" @@ -3381,6 +3390,12 @@ version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ecab6c735a6bb4139c0caafd0cc3635748bbb3acf4550e8138122099251f309" +[[package]] +name = "smawk" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f67ad224767faa3c7d8b6d91985b78e70a1324408abcb1cfcc2be4c06bc06043" + [[package]] name = "socket2" version = "0.3.19" @@ -3582,6 +3597,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "tagger" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c933e626196d509b053f49573e35ad237c0ff6a36c23c1aa61ffeab49251d24f" + [[package]] name = "tap" version = "1.0.1" @@ -3620,6 +3641,17 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "textwrap" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0066c8d12af8b5acd21e00547c3797fde4e8677254a7ee429176ccebbe93dd80" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width", +] + [[package]] name = "thiserror" version = "1.0.30" @@ -3817,6 +3849,15 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a01404663e3db436ed2746d9fefef640d868edae3cceb81c3b8d5732fda678f" +[[package]] +name = "unicode-linebreak" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a52dcaab0c48d931f7cc8ef826fa51690a08e1ea55117ef26f89864f532383f" +dependencies = [ + "regex", +] + [[package]] name = "unicode-normalization" version = "0.1.19" diff --git a/Cargo.toml b/Cargo.toml index 19050d93d..1c70ee92d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,6 +73,9 @@ url = "2" uuid = { version = "0.8", features = ["serde", "v4"] } fast-socks5 = "0.4" humansize = "1" +qrcodegen = "1.7.0" +tagger = "3.2.1" +textwrap = "0.14.2" [dev-dependencies] ansi_term = "0.12.0" diff --git a/assets/qrcode_logo_footer.svg b/assets/qrcode_logo_footer.svg new file mode 100644 index 000000000..b445e9dd9 --- /dev/null +++ b/assets/qrcode_logo_footer.svg @@ -0,0 +1,10 @@ +get.delta.chat + \ No newline at end of file diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index 65a6a88f2..23e849f86 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -2243,6 +2243,20 @@ dc_lot_t* dc_check_qr (dc_context_t* context, const char* char* dc_get_securejoin_qr (dc_context_t* context, uint32_t chat_id); +/** + * Get QR code image from the QR code text generated by dc_get_securejoin_qr(). + * See dc_get_securejoin_qr() for details about the contained QR code. + * + * @memberof dc_context_t + * @param context The context object. + * @param chat_id group-chat-id for secure-join or 0 for setup-contact, + * see dc_get_securejoin_qr() for details. + * @return SVG-Image with the QR code. + * On errors, an empty string is returned. + * The returned string must be released using dc_str_unref() after usage. + */ +char* dc_get_securejoin_qr_svg (dc_context_t* context, uint32_t chat_id); + /** * Continue a Setup-Contact or Verified-Group-Invite protocol * started on another device with dc_get_securejoin_qr(). @@ -6077,6 +6091,20 @@ void dc_event_unref(dc_event_t* event); /// `%1$s` will be replaced by the name of the inviter. #define DC_STR_SECURE_JOIN_REPLIES 118 +/// "Scan to chat with %1$s" +/// +/// Subtitle for verification qrcode svg image generated by the core. +/// +/// `%1$s` will be replaced by name and address of the inviter. +#define DC_STR_SETUP_CONTACT_QR_DESC 119 + +/// "Scan to join %1$s" +/// +/// Subtitle for group join qrcode svg image generated by the core. +/// +/// `%1$s` will be replaced with the group name. +#define DC_STR_SECURE_JOIN_GROUP_QR_DESC 120 + /** * @} */ diff --git a/deltachat-ffi/src/lib.rs b/deltachat-ffi/src/lib.rs index b8962af9a..3c5ad5447 100644 --- a/deltachat-ffi/src/lib.rs +++ b/deltachat-ffi/src/lib.rs @@ -25,6 +25,7 @@ use std::time::{Duration, SystemTime}; use anyhow::Context as _; use async_std::sync::RwLock; use async_std::task::{block_on, spawn}; +use deltachat::qr_code_generator::get_securejoin_qr_svg; use num_traits::{FromPrimitive, ToPrimitive}; use deltachat::chat::{ChatId, ChatVisibility, MuteDuration, ProtectionStatus}; @@ -2075,6 +2076,27 @@ pub unsafe extern "C" fn dc_get_securejoin_qr( .strdup() } +#[no_mangle] +pub unsafe extern "C" fn dc_get_securejoin_qr_svg( + context: *mut dc_context_t, + chat_id: u32, +) -> *mut libc::c_char { + if context.is_null() { + eprintln!("ignoring careless call to generate_verification_qr()"); + return "".strdup(); + } + let ctx = &*context; + let chat_id = if chat_id == 0 { + None + } else { + Some(ChatId::new(chat_id)) + }; + + block_on(get_securejoin_qr_svg(ctx, chat_id)) + .unwrap_or_else(|_| "".to_string()) + .strdup() +} + #[no_mangle] pub unsafe extern "C" fn dc_join_securejoin( context: *mut dc_context_t, diff --git a/examples/repl/cmdline.rs b/examples/repl/cmdline.rs index 1af8e0afb..f0d459eef 100644 --- a/examples/repl/cmdline.rs +++ b/examples/repl/cmdline.rs @@ -423,6 +423,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu listblocked\n\ ======================================Misc.==\n\ getqr []\n\ + getqrsvg []\n\ getbadqr\n\ checkqr \n\ joinqr \n\ diff --git a/examples/repl/main.rs b/examples/repl/main.rs index 452ee65c1..21ac12d0d 100644 --- a/examples/repl/main.rs +++ b/examples/repl/main.rs @@ -33,6 +33,8 @@ use rustyline::{ mod cmdline; use self::cmdline::*; +use deltachat::qr_code_generator::get_securejoin_qr_svg; +use std::fs; /// Event Handler fn receive_event(event: EventType) { @@ -224,8 +226,9 @@ const CONTACT_COMMANDS: [&str; 9] = [ "unblock", "listblocked", ]; -const MISC_COMMANDS: [&str; 11] = [ +const MISC_COMMANDS: [&str; 12] = [ "getqr", + "getqrsvg", "getbadqr", "checkqr", "joinqr", @@ -427,6 +430,20 @@ async fn handle_cmd( io::stderr().write_all(&output.stderr).unwrap(); } } + "getqrsvg" => { + ctx.start_io().await; + let group = arg1.parse::().ok().map(|id| ChatId::new(id)); + let file = dirs::home_dir().unwrap_or_default().join("qr.svg"); + match get_securejoin_qr_svg(&ctx, group).await { + Ok(svg) => { + fs::write(&file, svg)?; + println!("QR code svg written to: {:#?}", file); + } + Err(err) => { + bail!("Failed to get QR code svg: {}", err); + } + } + } "joinqr" => { ctx.start_io().await; if !arg0.is_empty() { diff --git a/src/color.rs b/src/color.rs index fd7f868cf..b59fa4a18 100644 --- a/src/color.rs +++ b/src/color.rs @@ -35,6 +35,10 @@ pub(crate) fn str_to_color(s: &str) -> u32 { rgb_to_u32(hsluv_to_rgb((str_to_angle(s), 100.0, 50.0))) } +pub fn color_int_to_hex_string(color: u32) -> String { + format!("{:#08x}", color).replace("0x", "#") +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/lib.rs b/src/lib.rs index 15f393da6..5a4102f0c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -77,6 +77,7 @@ pub mod peerstate; pub mod pgp; pub mod provider; pub mod qr; +pub mod qr_code_generator; pub mod quota; pub mod securejoin; mod simplify; diff --git a/src/qr_code_generator.rs b/src/qr_code_generator.rs new file mode 100644 index 000000000..ab1d94fa0 --- /dev/null +++ b/src/qr_code_generator.rs @@ -0,0 +1,262 @@ +use anyhow::Result; +use qrcodegen::{QrCode, QrCodeEcc}; + +use crate::{ + blob::BlobObject, + chat::{Chat, ChatId}, + color::color_int_to_hex_string, + config::Config, + constants::DC_CONTACT_ID_SELF, + contact::Contact, + context::Context, + securejoin, stock_str, +}; + +pub async fn get_securejoin_qr_svg(context: &Context, chat_id: Option) -> Result { + if let Some(chat_id) = chat_id { + generate_join_group_qr_code(context, chat_id).await + } else { + generate_verification_qr(context).await + } +} + +async fn generate_join_group_qr_code(context: &Context, chat_id: ChatId) -> Result { + let chat = Chat::load_from_db(context, chat_id).await?; + + let avatar = match chat.get_profile_image(context).await? { + Some(path) => { + let avatar_blob = BlobObject::from_path(context, &path)?; + Some(std::fs::read(avatar_blob.to_abs_path())?) + } + None => None, + }; + + inner_generate_secure_join_qr_code( + &stock_str::secure_join_group_qr_description(context, &chat).await, + &securejoin::dc_get_securejoin_qr(context, Some(chat_id)).await?, + &color_int_to_hex_string(chat.get_color(context).await?), + avatar, + chat.get_name().chars().next().unwrap_or('#'), + ) +} + +async fn generate_verification_qr(context: &Context) -> Result { + let contact = Contact::get_by_id(context, DC_CONTACT_ID_SELF).await?; + + let avatar = match contact.get_profile_image(context).await? { + Some(path) => { + let avatar_blob = BlobObject::from_path(context, &path)?; + Some(std::fs::read(avatar_blob.to_abs_path())?) + } + None => None, + }; + + let displayname = match context.get_config(Config::Displayname).await? { + Some(name) => name, + None => contact.get_addr().to_owned(), + }; + + inner_generate_secure_join_qr_code( + &stock_str::setup_contact_qr_description(context, &displayname, contact.get_addr()).await, + &securejoin::dc_get_securejoin_qr(context, None).await?, + &color_int_to_hex_string(contact.get_color()), + avatar, + displayname.chars().next().unwrap_or('#'), + ) +} + +fn inner_generate_secure_join_qr_code( + raw_qrcode_description: &str, + qrcode_content: &str, + color: &str, + avatar: Option>, + avatar_letter: char, +) -> Result { + let qrcode_description = &escaper::encode_minimal(raw_qrcode_description); + // config + let width = 515.0; + let height = 630.0; + let logo_offset = 28.0; + let qr_code_size = 400.0; + let qr_translate_up = 40.0; + let text_y_pos = ((height - qr_code_size) / 2.0) + qr_code_size; + let (text_font_size, max_text_width) = if qrcode_description.len() <= 75 { + (27.0, 32) + } else { + (19.0, 38) + }; + let avatar_border_size = 9.0; + let card_border_size = 2.0; + let card_roundness = 40.0; + + let qr = QrCode::encode_text(qrcode_content, QrCodeEcc::Medium)?; + let mut svg = String::with_capacity(28000); + let mut w = tagger::new(&mut svg); + + w.elem("svg", |d| { + d.attr("xmlns", "http://www.w3.org/2000/svg") + .attr("viewBox", format_args!("0 0 {} {}", width, height)); + }) + .build(|w| { + // White Background apears like a card + w.single("rect", |d| { + d.attr("x", card_border_size) + .attr("y", card_border_size) + .attr("rx", card_roundness) + .attr("stroke", "#c6c6c6") + .attr("stroke-width", card_border_size) + .attr("width", width - (card_border_size * 2.0)) + .attr("height", height - (card_border_size * 2.0)) + .attr("style", "fill:#f2f2f2"); + }); + // Qrcode + w.elem("g", |d| { + d.attr( + "transform", + format!( + "translate({},{})", + (width - qr_code_size) / 2.0, + ((height - qr_code_size) / 2.0) - qr_translate_up + ), + ); + // If the qr code should be in the wrong place, + // we could also translate and scale the points in the path already, + // but that would make the resulting svg way bigger in size and might bring up rounding issues, + // so better avoid doing it manually if possible + }) + .build(|w| { + w.single("path", |d| { + let mut path_data = String::with_capacity(0); + let scale = qr_code_size / qr.size() as f32; + + for y in 0..qr.size() { + for x in 0..qr.size() { + if qr.get_module(x, y) { + path_data += &format!("M{},{}h1v1h-1z", x, y); + } + } + } + + d.attr("style", "fill:#000000") + .attr("d", path_data) + .attr("transform", format!("scale({})", scale)); + }); + }); + // Text + for (count, line) in textwrap::fill(qrcode_description, max_text_width) + .split('\n') + .enumerate() + { + w.elem("text", |d| { + d.attr("y", (count as f32 * (text_font_size * 1.2)) + text_y_pos) + .attr("x", width / 2.0) + .attr("text-anchor", "middle") + .attr( + "style", + format!( + "font-family:sans-serif;\ + font-weight:bold;\ + font-size:{}px;\ + fill:#000000;\ + stroke:none", + text_font_size + ), + ); + }) + .build(|w| { + w.put_raw(line); + }); + } + // contact avatar in middle of qrcode + const LOGO_SIZE: f32 = 94.4; + const HALF_LOGO_SIZE: f32 = LOGO_SIZE / 2.0; + let logo_position_in_qr = (qr_code_size / 2.0) - HALF_LOGO_SIZE; + let logo_position_x = ((width - qr_code_size) / 2.0) + logo_position_in_qr; + let logo_position_y = + ((height - qr_code_size) / 2.0) - qr_translate_up + logo_position_in_qr; + + w.single("circle", |d| { + d.attr("cx", logo_position_x + HALF_LOGO_SIZE) + .attr("cy", logo_position_y + HALF_LOGO_SIZE) + .attr("r", HALF_LOGO_SIZE + avatar_border_size) + .attr("style", "fill:#f2f2f2"); + }); + + if let Some(img) = avatar { + w.elem("defs", |_| {}).build(|w| { + w.elem("clipPath", |d| { + d.attr("id", "avatar-cut"); + }) + .build(|w| { + w.single("circle", |d| { + d.attr("cx", logo_position_x + HALF_LOGO_SIZE) + .attr("cy", logo_position_y + HALF_LOGO_SIZE) + .attr("r", HALF_LOGO_SIZE); + }); + }); + }); + + w.single("image", |d| { + d.attr("x", logo_position_x) + .attr("y", logo_position_y) + .attr("width", HALF_LOGO_SIZE * 2.0) + .attr("height", HALF_LOGO_SIZE * 2.0) + .attr("preserveAspectRatio", "none") + .attr("clip-path", "url(#avatar-cut)") + .attr( + "href" /*might need xlink:href instead if it doesn't work on older devices?*/, + format!("data:image/jpeg;base64,{}", base64::encode(img)), + ); + }); + } else { + w.single("circle", |d| { + d.attr("cx", logo_position_x + HALF_LOGO_SIZE) + .attr("cy", logo_position_y + HALF_LOGO_SIZE) + .attr("r", HALF_LOGO_SIZE) + .attr("style", format!("fill:{}", &color)); + }); + + let avatar_font_size = LOGO_SIZE * 0.65; + let font_offset = avatar_font_size * 0.1; + w.elem("text", |d| { + d.attr("y", logo_position_y + HALF_LOGO_SIZE + font_offset) + .attr("x", logo_position_x + HALF_LOGO_SIZE) + .attr("text-anchor", "middle") + .attr("dominant-baseline", "central") + .attr("alignment-baseline", "middle") + .attr( + "style", + format!( + "font-family:sans-serif;\ + font-weight:400;\ + font-size:{}px;\ + fill:#ffffff;", + avatar_font_size + ), + ); + }) + .build(|w| { + w.put_raw(avatar_letter.to_uppercase()); + }); + } + + // Footer logo + const FOOTER_HEIGHT: f32 = 35.0; + const FOOTER_WIDTH: f32 = 198.0; + w.elem("g", |d| { + d.attr( + "transform", + format!( + "translate({},{})", + (width - FOOTER_WIDTH) / 2.0, + height - logo_offset - FOOTER_HEIGHT + ), + ); + }) + .build(|w| { + w.put_raw(include_str!("../assets/qrcode_logo_footer.svg")); + }); + }); + + Ok(svg) +} diff --git a/src/stock_str.rs b/src/stock_str.rs index 91fcee64a..658c0d818 100644 --- a/src/stock_str.rs +++ b/src/stock_str.rs @@ -8,7 +8,7 @@ use strum::EnumProperty; use strum_macros::EnumProperty; use crate::blob::BlobObject; -use crate::chat::{self, ChatId, ProtectionStatus}; +use crate::chat::{self, Chat, ChatId, ProtectionStatus}; use crate::config::Config; use crate::constants::{Viewtype, DC_CONTACT_ID_SELF}; use crate::contact::{Contact, Origin}; @@ -330,6 +330,12 @@ pub enum StockMessage { #[strum(props(fallback = "%1$s replied, waiting for being added to the group…"))] SecureJoinReplies = 118, + + #[strum(props(fallback = "Scan to chat with %1$s"))] + SetupContactQRDescription = 119, + + #[strum(props(fallback = "Scan to join group %1$s"))] + SecureJoinGroupQRDescription = 120, } impl StockMessage { @@ -614,6 +620,29 @@ pub(crate) async fn secure_join_replies(context: &Context, contact_id: u32) -> S } } +/// Stock string: `Scan to chat with %1$s`. +pub(crate) async fn setup_contact_qr_description( + context: &Context, + display_name: &str, + addr: &str, +) -> String { + let name = if display_name == addr { + addr.to_owned() + } else { + format!("{} ({})", display_name, addr) + }; + translated(context, StockMessage::SetupContactQRDescription) + .await + .replace1(name) +} + +/// Stock string: `Scan to join %1$s`. +pub(crate) async fn secure_join_group_qr_description(context: &Context, chat: &Chat) -> String { + translated(context, StockMessage::SecureJoinGroupQRDescription) + .await + .replace1(chat.get_name()) +} + /// Stock string: `%1$s verified.`. pub(crate) async fn contact_verified(context: &Context, contact_addr: impl AsRef) -> String { translated(context, StockMessage::ContactVerified)