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)