1use anyhow::{Result, bail};
4use base64::Engine as _;
5use qrcodegen::{QrCode, QrCodeEcc};
6
7use crate::blob::BlobObject;
8use crate::chat::{Chat, ChatId};
9use crate::color::color_int_to_hex_string;
10use crate::config::Config;
11use crate::contact::{Contact, ContactId};
12use crate::context::Context;
13use crate::qr::{self, Qr};
14use crate::securejoin;
15use crate::stock_str::{self, backup_transfer_qr};
16
17pub fn create_qr_svg(qrcode_content: &str) -> Result<String> {
19 let all_size = 512.0;
20 let qr_code_size = 416.0;
21 let logo_size = 96.0;
22
23 let qr = QrCode::encode_text(qrcode_content, QrCodeEcc::Medium)?;
24 let mut svg = String::with_capacity(28000);
25 let mut w = tagger::new(&mut svg);
26
27 w.elem("svg", |d| {
28 d.attr("xmlns", "http://www.w3.org/2000/svg")?;
29 d.attr("viewBox", format_args!("0 0 {all_size} {all_size}"))?;
30 d.attr("xmlns:xlink", "http://www.w3.org/1999/xlink")?; Ok(())
32 })?
33 .build(|w| {
34 w.single("rect", |d| {
36 d.attr("x", 0)?;
37 d.attr("y", 0)?;
38 d.attr("width", all_size)?;
39 d.attr("height", all_size)?;
40 d.attr("style", "fill:#ffffff")?;
41 Ok(())
42 })?;
43 w.elem("g", |d| {
45 d.attr(
46 "transform",
47 format!(
48 "translate({},{})",
49 (all_size - qr_code_size) / 2.0,
50 ((all_size - qr_code_size) / 2.0)
51 ),
52 )
53 })?
54 .build(|w| {
55 w.single("path", |d| {
56 let mut path_data = String::with_capacity(0);
57 let scale = qr_code_size / qr.size() as f32;
58
59 for y in 0..qr.size() {
60 for x in 0..qr.size() {
61 if qr.get_module(x, y) {
62 path_data += &format!("M{x},{y}h1v1h-1z");
63 }
64 }
65 }
66
67 d.attr("style", "fill:#000000")?;
68 d.attr("d", path_data)?;
69 d.attr("transform", format!("scale({scale})"))
70 })
71 })?;
72 w.elem("g", |d| {
73 d.attr(
74 "transform",
75 format!(
76 "translate({},{}) scale(2)", (all_size - logo_size) / 2.0,
78 (all_size - logo_size) / 2.0
79 ),
80 )
81 })?
82 .build(|w| w.put_raw_escapable(include_str!("../assets/qr_overlay_delta.svg-part")))
83 })?;
84
85 Ok(svg)
86}
87
88pub async fn get_securejoin_qr_svg(context: &Context, chat_id: Option<ChatId>) -> Result<String> {
93 if let Some(chat_id) = chat_id {
94 generate_join_group_qr_code(context, chat_id).await
95 } else {
96 generate_verification_qr(context).await
97 }
98}
99
100async fn generate_join_group_qr_code(context: &Context, chat_id: ChatId) -> Result<String> {
101 let chat = Chat::load_from_db(context, chat_id).await?;
102
103 let avatar = match chat.get_profile_image(context).await? {
104 Some(path) => {
105 let avatar_blob = BlobObject::from_path(context, &path)?;
106 Some(tokio::fs::read(avatar_blob.to_abs_path()).await?)
107 }
108 None => None,
109 };
110
111 let qrcode_description = match chat.typ {
112 crate::constants::Chattype::Group => {
113 stock_str::secure_join_group_qr_description(context, &chat).await
114 }
115 crate::constants::Chattype::OutBroadcast => {
116 stock_str::secure_join_broadcast_qr_description(context, &chat).await
117 }
118 _ => bail!("Unexpected chat type {}", chat.typ),
119 };
120
121 inner_generate_secure_join_qr_code(
122 &qrcode_description,
123 &securejoin::get_securejoin_qr(context, Some(chat_id)).await?,
124 &color_int_to_hex_string(chat.get_color(context).await?),
125 avatar,
126 chat.get_name().chars().next().unwrap_or('#'),
127 )
128}
129
130async fn generate_verification_qr(context: &Context) -> Result<String> {
131 let (avatar, displayname, addr, color) = self_info(context).await?;
132
133 inner_generate_secure_join_qr_code(
134 &stock_str::setup_contact_qr_description(context, &displayname, &addr).await,
135 &securejoin::get_securejoin_qr(context, None).await?,
136 &color,
137 avatar,
138 displayname.chars().next().unwrap_or('#'),
139 )
140}
141
142pub async fn generate_backup_qr(context: &Context, qr: &Qr) -> Result<String> {
144 let content = qr::format_backup(qr)?;
145 let (avatar, displayname, _addr, color) = self_info(context).await?;
146 let description = backup_transfer_qr(context).await?;
147
148 inner_generate_secure_join_qr_code(
149 &description,
150 &content,
151 &color,
152 avatar,
153 displayname.chars().next().unwrap_or('#'),
154 )
155}
156
157async fn self_info(context: &Context) -> Result<(Option<Vec<u8>>, String, String, String)> {
159 let contact = Contact::get_by_id(context, ContactId::SELF).await?;
160
161 let avatar = match contact.get_profile_image(context).await? {
162 Some(path) => {
163 let avatar_blob = BlobObject::from_path(context, &path)?;
164 Some(tokio::fs::read(avatar_blob.to_abs_path()).await?)
165 }
166 None => None,
167 };
168
169 let displayname = match context.get_config(Config::Displayname).await? {
170 Some(name) => name,
171 None => contact.get_addr().to_string(),
172 };
173 let addr = contact.get_addr().to_string();
174 let color = color_int_to_hex_string(contact.get_color());
175 Ok((avatar, displayname, addr, color))
176}
177
178fn inner_generate_secure_join_qr_code(
179 qrcode_description: &str,
180 qrcode_content: &str,
181 color: &str,
182 avatar: Option<Vec<u8>>,
183 avatar_letter: char,
184) -> Result<String> {
185 let width = 515.0;
187 let height = 630.0;
188 let logo_offset = 28.0;
189 let qr_code_size = 400.0;
190 let qr_translate_up = 40.0;
191 let text_y_pos = ((height - qr_code_size) / 2.0) + qr_code_size;
192 let avatar_border_size = 9.0;
193 let card_border_size = 2.0;
194 let card_roundness = 40.0;
195
196 let qr = QrCode::encode_text(qrcode_content, QrCodeEcc::Medium)?;
197 let mut svg = String::with_capacity(28000);
198 let mut w = tagger::new(&mut svg);
199
200 w.elem("svg", |d| {
201 d.attr("xmlns", "http://www.w3.org/2000/svg")?;
202 d.attr("viewBox", format_args!("0 0 {width} {height}"))?;
203 d.attr("xmlns:xlink", "http://www.w3.org/1999/xlink")?; Ok(())
205 })?
206 .build(|w| {
207 w.single("rect", |d| {
209 d.attr("x", card_border_size)?;
210 d.attr("y", card_border_size)?;
211 d.attr("rx", card_roundness)?;
212 d.attr("stroke", "#c6c6c6")?;
213 d.attr("stroke-width", card_border_size)?;
214 d.attr("width", width - (card_border_size * 2.0))?;
215 d.attr("height", height - (card_border_size * 2.0))?;
216 d.attr("style", "fill:#f2f2f2")?;
217 Ok(())
218 })?;
219 w.elem("g", |d| {
221 d.attr(
222 "transform",
223 format!(
224 "translate({},{})",
225 (width - qr_code_size) / 2.0,
226 ((height - qr_code_size) / 2.0) - qr_translate_up
227 ),
228 )
229 })?
234 .build(|w| {
235 w.single("path", |d| {
236 let mut path_data = String::with_capacity(0);
237 let scale = qr_code_size / qr.size() as f32;
238
239 for y in 0..qr.size() {
240 for x in 0..qr.size() {
241 if qr.get_module(x, y) {
242 path_data += &format!("M{x},{y}h1v1h-1z");
243 }
244 }
245 }
246
247 d.attr("style", "fill:#000000")?;
248 d.attr("d", path_data)?;
249 d.attr("transform", format!("scale({scale})"))
250 })
251 })?;
252
253 const BIG_TEXT_CHARS_PER_LINE: usize = 32;
255 const SMALL_TEXT_CHARS_PER_LINE: usize = 38;
256 let chars_per_line = if qrcode_description.len() > SMALL_TEXT_CHARS_PER_LINE * 2 {
257 SMALL_TEXT_CHARS_PER_LINE
258 } else {
259 BIG_TEXT_CHARS_PER_LINE
260 };
261 let lines = textwrap::fill(qrcode_description, chars_per_line);
262 let (text_font_size, text_y_shift) = if lines.split('\n').count() <= 2 {
263 (27.0, 0.0)
264 } else {
265 (19.0, -10.0)
266 };
267 for (count, line) in lines.split('\n').enumerate() {
268 w.elem("text", |d| {
269 d.attr(
270 "y",
271 (count as f32 * (text_font_size * 1.2)) + text_y_pos + text_y_shift,
272 )?;
273 d.attr("x", width / 2.0)?;
274 d.attr("text-anchor", "middle")?;
275 d.attr(
276 "style",
277 format!(
278 "font-family:sans-serif;\
279 font-weight:bold;\
280 font-size:{text_font_size}px;\
281 fill:#000000;\
282 stroke:none"
283 ),
284 )
285 })?
286 .build(|w| w.put_raw(line))?;
287 }
288 const LOGO_SIZE: f32 = 94.4;
290 const HALF_LOGO_SIZE: f32 = LOGO_SIZE / 2.0;
291 let logo_position_in_qr = (qr_code_size / 2.0) - HALF_LOGO_SIZE;
292 let logo_position_x = ((width - qr_code_size) / 2.0) + logo_position_in_qr;
293 let logo_position_y =
294 ((height - qr_code_size) / 2.0) - qr_translate_up + logo_position_in_qr;
295
296 w.single("circle", |d| {
297 d.attr("cx", logo_position_x + HALF_LOGO_SIZE)?;
298 d.attr("cy", logo_position_y + HALF_LOGO_SIZE)?;
299 d.attr("r", HALF_LOGO_SIZE + avatar_border_size)?;
300 d.attr("style", "fill:#f2f2f2")
301 })?;
302
303 if let Some(img) = avatar {
304 w.elem("defs", tagger::no_attr())?.build(|w| {
305 w.elem("clipPath", |d| d.attr("id", "avatar-cut"))?
306 .build(|w| {
307 w.single("circle", |d| {
308 d.attr("cx", logo_position_x + HALF_LOGO_SIZE)?;
309 d.attr("cy", logo_position_y + HALF_LOGO_SIZE)?;
310 d.attr("r", HALF_LOGO_SIZE)
311 })
312 })
313 })?;
314
315 w.single("image", |d| {
316 d.attr("x", logo_position_x)?;
317 d.attr("y", logo_position_y)?;
318 d.attr("width", HALF_LOGO_SIZE * 2.0)?;
319 d.attr("height", HALF_LOGO_SIZE * 2.0)?;
320 d.attr("preserveAspectRatio", "none")?;
321 d.attr("clip-path", "url(#avatar-cut)")?;
322 d.attr(
323 "xlink:href", format!(
325 "data:image/jpeg;base64,{}",
326 base64::engine::general_purpose::STANDARD.encode(img)
327 ),
328 )
329 })?;
330 } else {
331 w.single("circle", |d| {
332 d.attr("cx", logo_position_x + HALF_LOGO_SIZE)?;
333 d.attr("cy", logo_position_y + HALF_LOGO_SIZE)?;
334 d.attr("r", HALF_LOGO_SIZE)?;
335 d.attr("style", format!("fill:{}", &color))
336 })?;
337
338 let avatar_font_size = LOGO_SIZE * 0.65;
339 let font_offset = avatar_font_size * 0.1;
340 w.elem("text", |d| {
341 d.attr("y", logo_position_y + HALF_LOGO_SIZE + font_offset)?;
342 d.attr("x", logo_position_x + HALF_LOGO_SIZE)?;
343 d.attr("text-anchor", "middle")?;
344 d.attr("dominant-baseline", "central")?;
345 d.attr("alignment-baseline", "middle")?;
346 d.attr(
347 "style",
348 format!(
349 "font-family:sans-serif;\
350 font-weight:400;\
351 font-size:{avatar_font_size}px;\
352 fill:#ffffff;"
353 ),
354 )
355 })?
356 .build(|w| w.put_raw(avatar_letter.to_uppercase()))?;
357 }
358
359 const FOOTER_HEIGHT: f32 = 35.0;
361 const FOOTER_WIDTH: f32 = 198.0;
362 w.elem("g", |d| {
363 d.attr(
364 "transform",
365 format!(
366 "translate({},{})",
367 (width - FOOTER_WIDTH) / 2.0,
368 height - logo_offset - FOOTER_HEIGHT - text_y_shift
369 ),
370 )
371 })?
372 .build(|w| w.put_raw(include_str!("../assets/qrcode_logo_footer.svg")))
373 })?;
374
375 Ok(svg)
376}
377
378#[cfg(test)]
379mod tests {
380 use testdir::testdir;
381
382 use crate::imex::BackupProvider;
383 use crate::qr::format_backup;
384 use crate::test_utils::TestContextManager;
385
386 use super::*;
387
388 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
389 async fn test_create_qr_svg() -> Result<()> {
390 let svg = create_qr_svg("this is a test QR code \" < > &")?;
391 assert!(svg.contains("<svg"));
392 assert!(svg.contains("</svg>"));
393 Ok(())
394 }
395
396 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
397 async fn test_svg_escaping() {
398 let svg = inner_generate_secure_join_qr_code(
399 "descr123 \" < > &",
400 "qr-code-content",
401 "#000000",
402 None,
403 'X',
404 )
405 .unwrap();
406 assert!(svg.contains("descr123 " < > &"))
407 }
408
409 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
410 async fn test_generate_backup_qr() {
411 let dir = testdir!();
412 let mut tcm = TestContextManager::new();
413 let ctx = tcm.alice().await;
414 let provider = BackupProvider::prepare(&ctx).await.unwrap();
415 let qr = provider.qr();
416
417 println!("{}", format_backup(&qr).unwrap());
418 let rendered = generate_backup_qr(&ctx, &qr).await.unwrap();
419 tokio::fs::write(dir.join("qr.svg"), &rendered)
420 .await
421 .unwrap();
422 assert_eq!(rendered.get(..4), Some("<svg"));
423 }
424}