deltachat/
stats.rs

1//! Delta Chat has an advanced option
2//! "Send statistics to the developers of Delta Chat".
3//! If this is enabled, a JSON file with some anonymous statistics
4//! will be sent to a bot once a week.
5
6use std::collections::{BTreeMap, BTreeSet};
7
8use anyhow::{Context as _, Result};
9use deltachat_derive::FromSql;
10use num_traits::ToPrimitive;
11use pgp::types::PublicKeyTrait;
12use rusqlite::OptionalExtension;
13use serde::Serialize;
14
15use crate::chat::{self, ChatId, MuteDuration};
16use crate::config::Config;
17use crate::constants::Chattype;
18use crate::contact::{Contact, ContactId, Origin, import_vcard, mark_contact_id_as_verified};
19use crate::context::{Context, get_version_str};
20use crate::key::load_self_public_keyring;
21use crate::log::LogExt;
22use crate::message::{Message, Viewtype};
23use crate::securejoin::QrInvite;
24use crate::tools::{create_id, time};
25
26pub(crate) const STATISTICS_BOT_EMAIL: &str = "self_reporting@testrun.org";
27const STATISTICS_BOT_VCARD: &str = include_str!("../assets/statistics-bot.vcf");
28const SENDING_INTERVAL_SECONDS: i64 = 3600 * 24 * 7; // 1 week
29// const SENDING_INTERVAL_SECONDS: i64 = 60; // 1 minute (for testing)
30const MESSAGE_STATS_UPDATE_INTERVAL_SECONDS: i64 = 4 * 60; // 4 minutes (less than the lowest ephemeral messages timeout)
31
32#[derive(Serialize)]
33struct Statistics {
34    core_version: String,
35    key_create_timestamps: Vec<i64>,
36    stats_id: String,
37    is_chatmail: bool,
38    contact_stats: Vec<ContactStat>,
39    message_stats: BTreeMap<Chattype, MessageStats>,
40    securejoin_sources: SecurejoinSources,
41    securejoin_uipaths: SecurejoinUiPaths,
42    securejoin_invites: Vec<JoinedInvite>,
43    sending_enabled_timestamps: Vec<i64>,
44    sending_disabled_timestamps: Vec<i64>,
45}
46
47#[derive(Serialize, PartialEq)]
48enum VerifiedStatus {
49    Direct,
50    Transitive,
51    TransitiveViaBot,
52    Opportunistic,
53    Unencrypted,
54}
55
56#[derive(Serialize)]
57struct ContactStat {
58    #[serde(skip_serializing)]
59    id: ContactId,
60
61    verified: VerifiedStatus,
62
63    // If one of the boolean properties is false,
64    // we leave them away.
65    // This way, the Json file becomes a lot smaller.
66    #[serde(skip_serializing_if = "is_false")]
67    bot: bool,
68
69    #[serde(skip_serializing_if = "is_false")]
70    direct_chat: bool,
71
72    last_seen: u64,
73
74    #[serde(skip_serializing_if = "Option::is_none")]
75    transitive_chain: Option<u32>,
76
77    /// Whether the contact was established after stats-sending was enabled
78    #[serde(skip_serializing_if = "is_false")]
79    new: bool,
80}
81
82fn is_false(b: &bool) -> bool {
83    !b
84}
85
86#[derive(Serialize, Default)]
87struct MessageStats {
88    verified: u32,
89    unverified_encrypted: u32,
90    unencrypted: u32,
91    only_to_self: u32,
92}
93
94/// Where a securejoin invite link or QR code came from.
95/// This is only used if the user enabled StatsSending.
96#[repr(u32)]
97#[derive(
98    Debug, Clone, Copy, ToPrimitive, FromPrimitive, FromSql, PartialEq, Eq, PartialOrd, Ord,
99)]
100pub enum SecurejoinSource {
101    /// Because of some problem, it is unknown where the QR code came from.
102    Unknown = 0,
103    /// The user opened a link somewhere outside Delta Chat
104    ExternalLink = 1,
105    /// The user clicked on a link in a message inside Delta Chat
106    InternalLink = 2,
107    /// The user clicked "Paste from Clipboard" in the QR scan activity
108    Clipboard = 3,
109    /// The user clicked "Load QR code as image" in the QR scan activity
110    ImageLoaded = 4,
111    /// The user scanned a QR code
112    Scan = 5,
113}
114
115#[derive(Serialize)]
116struct SecurejoinSources {
117    unknown: u32,
118    external_link: u32,
119    internal_link: u32,
120    clipboard: u32,
121    image_loaded: u32,
122    scan: u32,
123}
124
125/// How the user opened the QR activity in order scan a QR code on Android.
126/// This is only used if the user enabled StatsSending.
127#[derive(
128    Debug, Clone, Copy, ToPrimitive, FromPrimitive, FromSql, PartialEq, Eq, PartialOrd, Ord,
129)]
130pub enum SecurejoinUiPath {
131    /// The UI path is unknown, or the user didn't open the QR code screen at all.
132    Unknown = 0,
133    /// The user directly clicked on the QR icon in the main screen
134    QrIcon = 1,
135    /// The user first clicked on the `+` button in the main screen,
136    /// and then on "New Contact"
137    NewContact = 2,
138}
139
140#[derive(Serialize)]
141struct SecurejoinUiPaths {
142    other: u32,
143    qr_icon: u32,
144    new_contact: u32,
145}
146
147/// Some information on an invite-joining event
148/// (i.e. a qr scan or a clicked link).
149#[derive(Serialize)]
150struct JoinedInvite {
151    /// Whether the contact already existed before.
152    /// If this is false, then a contact was newly created.
153    already_existed: bool,
154    /// If a contact already existed,
155    /// this tells us whether the contact was verified already.
156    already_verified: bool,
157    /// The type of the invite:
158    /// "contact" for 1:1 invites that setup a verified contact,
159    /// "group" for invites that invite to a group,
160    /// "broadcast" for invites that invite to a broadcast channel.
161    /// The invite also performs the contact verification 'along the way'.
162    typ: String,
163}
164
165pub(crate) async fn pre_sending_config_change(
166    context: &Context,
167    old_value: bool,
168    new_value: bool,
169) -> Result<()> {
170    // These functions are no-ops if they were called in the past already;
171    // just call them opportunistically:
172    ensure_last_old_contact_id(context).await?;
173    // Make sure that StatsId is available for the UI,
174    // in order to open the survey with the StatsId as a parameter:
175    stats_id(context).await?;
176
177    if old_value != new_value {
178        if new_value {
179            // Only count messages sent from now on:
180            set_last_counted_msg_id(context).await?;
181        } else {
182            // Update message stats one last time in case it's enabled again in the future:
183            update_message_stats(context).await?;
184        }
185
186        let sql_table = if new_value {
187            "stats_sending_enabled_events"
188        } else {
189            "stats_sending_disabled_events"
190        };
191
192        context
193            .sql
194            .execute(&format!("INSERT INTO {sql_table} VALUES(?)"), (time(),))
195            .await?;
196    }
197
198    Ok(())
199}
200
201/// Sends a message with statistics about the usage of Delta Chat,
202/// if the last time such a message was sent
203/// was more than a week ago.
204///
205/// On the other end, a bot will receive the message and make it available
206/// to Delta Chat's developers.
207pub async fn maybe_send_stats(context: &Context) -> Result<Option<ChatId>> {
208    if should_send_stats(context).await?
209        && time_has_passed(context, Config::StatsLastSent, SENDING_INTERVAL_SECONDS).await?
210    {
211        let chat_id = send_stats(context).await?;
212
213        return Ok(Some(chat_id));
214    }
215    Ok(None)
216}
217
218pub(crate) async fn maybe_update_message_stats(context: &Context) -> Result<()> {
219    if should_send_stats(context).await?
220        && time_has_passed(
221            context,
222            Config::StatsLastUpdate,
223            MESSAGE_STATS_UPDATE_INTERVAL_SECONDS,
224        )
225        .await?
226    {
227        update_message_stats(context).await?;
228    }
229
230    Ok(())
231}
232
233async fn time_has_passed(context: &Context, config: Config, seconds: i64) -> Result<bool> {
234    let last_time = context.get_config_i64(config).await?;
235    let next_time = last_time.saturating_add(seconds);
236
237    let res = if next_time <= time() {
238        // Already set the config to the current time.
239        // This prevents infinite loops in the (unlikely) case of an error:
240        context
241            .set_config_internal(config, Some(&time().to_string()))
242            .await?;
243        true
244    } else {
245        if time() < last_time {
246            // The clock was rewound.
247            // Reset the config, so that the statistics will be sent normally in a week,
248            // or be normally updated in a few minutes.
249            context
250                .set_config_internal(config, Some(&time().to_string()))
251                .await?;
252        }
253        false
254    };
255
256    Ok(res)
257}
258
259#[allow(clippy::unused_async, unused)]
260pub(crate) async fn should_send_stats(context: &Context) -> Result<bool> {
261    #[cfg(any(target_os = "android", test))]
262    {
263        context.get_config_bool(Config::StatsSending).await
264    }
265
266    // If the user enables statistics-sending on Android,
267    // and then transfers the account to e.g. Desktop,
268    // we should not send any statistics:
269    #[cfg(not(any(target_os = "android", test)))]
270    {
271        Ok(false)
272    }
273}
274
275async fn send_stats(context: &Context) -> Result<ChatId> {
276    info!(context, "Sending statistics.");
277
278    update_message_stats(context).await?;
279
280    let chat_id = get_stats_chat_id(context).await?;
281
282    let mut msg = Message::new(Viewtype::File);
283    msg.set_text(crate::stock_str::stats_msg_body(context).await);
284
285    let stats = get_stats(context).await?;
286
287    msg.set_file_from_bytes(
288        context,
289        "statistics.txt",
290        stats.as_bytes(),
291        Some("text/plain"),
292    )?;
293
294    chat::send_msg(context, chat_id, &mut msg)
295        .await
296        .context("Failed to send statistics message")
297        .log_err(context)
298        .ok();
299
300    Ok(chat_id)
301}
302
303async fn set_last_counted_msg_id(context: &Context) -> Result<()> {
304    context
305        .sql
306        .execute(
307            "UPDATE stats_msgs
308            SET last_counted_msg_id=(SELECT MAX(id) FROM msgs)",
309            (),
310        )
311        .await?;
312
313    Ok(())
314}
315
316async fn ensure_last_old_contact_id(context: &Context) -> Result<()> {
317    if context.config_exists(Config::StatsLastOldContactId).await? {
318        // The user had statistics-sending enabled already in the past,
319        // keep the 'last old contact id' as-is
320        return Ok(());
321    }
322
323    let last_contact_id: u64 = context
324        .sql
325        .query_get_value("SELECT MAX(id) FROM contacts", ())
326        .await?
327        .unwrap_or(0);
328
329    context
330        .sql
331        .set_raw_config(
332            Config::StatsLastOldContactId.as_ref(),
333            Some(&last_contact_id.to_string()),
334        )
335        .await?;
336
337    Ok(())
338}
339
340async fn get_stats(context: &Context) -> Result<String> {
341    // The Id of the last contact that already existed when the user enabled the setting.
342    // Newer contacts will get the `new` flag set.
343    let last_old_contact = context
344        .get_config_u32(Config::StatsLastOldContactId)
345        .await?;
346
347    let key_create_timestamps: Vec<i64> = load_self_public_keyring(context)
348        .await?
349        .iter()
350        .map(|k| k.created_at().timestamp())
351        .collect();
352
353    let sending_enabled_timestamps =
354        get_timestamps(context, "stats_sending_enabled_events").await?;
355    let sending_disabled_timestamps =
356        get_timestamps(context, "stats_sending_disabled_events").await?;
357
358    let stats = Statistics {
359        core_version: get_version_str().to_string(),
360        key_create_timestamps,
361        stats_id: stats_id(context).await?,
362        is_chatmail: context.is_chatmail().await?,
363        contact_stats: get_contact_stats(context, last_old_contact).await?,
364        message_stats: get_message_stats(context).await?,
365        securejoin_sources: get_securejoin_source_stats(context).await?,
366        securejoin_uipaths: get_securejoin_uipath_stats(context).await?,
367        securejoin_invites: get_securejoin_invite_stats(context).await?,
368        sending_enabled_timestamps,
369        sending_disabled_timestamps,
370    };
371
372    Ok(serde_json::to_string_pretty(&stats)?)
373}
374
375async fn get_timestamps(context: &Context, sql_table: &str) -> Result<Vec<i64>> {
376    context
377        .sql
378        .query_map_vec(
379            &format!("SELECT timestamp FROM {sql_table} LIMIT 1000"),
380            (),
381            |row| {
382                let timestamp: i64 = row.get(0)?;
383                Ok(timestamp)
384            },
385        )
386        .await
387}
388
389pub(crate) async fn stats_id(context: &Context) -> Result<String> {
390    Ok(match context.get_config(Config::StatsId).await? {
391        Some(id) => id,
392        None => {
393            let id = create_id();
394            context
395                .set_config_internal(Config::StatsId, Some(&id))
396                .await?;
397            id
398        }
399    })
400}
401
402async fn get_stats_chat_id(context: &Context) -> Result<ChatId, anyhow::Error> {
403    let contact_id: ContactId = *import_vcard(context, STATISTICS_BOT_VCARD)
404        .await?
405        .first()
406        .context("Statistics bot vCard does not contain a contact")?;
407    mark_contact_id_as_verified(context, contact_id, Some(ContactId::SELF)).await?;
408
409    let chat_id = if let Some(res) = ChatId::lookup_by_contact(context, contact_id).await? {
410        // Already exists, no need to create.
411        res
412    } else {
413        let chat_id = ChatId::get_for_contact(context, contact_id).await?;
414        chat::set_muted(context, chat_id, MuteDuration::Forever).await?;
415        chat_id
416    };
417
418    Ok(chat_id)
419}
420
421async fn get_contact_stats(context: &Context, last_old_contact: u32) -> Result<Vec<ContactStat>> {
422    let mut verified_by_map: BTreeMap<ContactId, ContactId> = BTreeMap::new();
423    let mut bot_ids: BTreeSet<ContactId> = BTreeSet::new();
424
425    let mut contacts = context
426        .sql
427        .query_map_vec(
428            "SELECT id, fingerprint<>'', verifier, last_seen, is_bot FROM contacts c
429            WHERE id>9 AND origin>? AND addr<>?",
430            (Origin::Hidden, STATISTICS_BOT_EMAIL),
431            |row| {
432                let id = row.get(0)?;
433                let is_encrypted: bool = row.get(1)?;
434                let verifier: ContactId = row.get(2)?;
435                let last_seen: u64 = row.get(3)?;
436                let bot: bool = row.get(4)?;
437
438                let verified = match (is_encrypted, verifier) {
439                    (true, ContactId::SELF) => VerifiedStatus::Direct,
440                    (true, ContactId::UNDEFINED) => VerifiedStatus::Opportunistic,
441                    (true, _) => VerifiedStatus::Transitive, // TransitiveViaBot will be filled later
442                    (false, _) => VerifiedStatus::Unencrypted,
443                };
444
445                if verifier != ContactId::UNDEFINED {
446                    verified_by_map.insert(id, verifier);
447                }
448
449                if bot {
450                    bot_ids.insert(id);
451                }
452
453                Ok(ContactStat {
454                    id,
455                    verified,
456                    bot,
457                    direct_chat: false, // will be filled later
458                    last_seen,
459                    transitive_chain: None, // will be filled later
460                    new: id.to_u32() > last_old_contact,
461                })
462            },
463        )
464        .await?;
465
466    // Fill TransitiveViaBot and transitive_chain
467    for contact in &mut contacts {
468        if contact.verified == VerifiedStatus::Transitive {
469            let mut transitive_chain: u32 = 0;
470            let mut has_bot = false;
471            let mut current_verifier_id = contact.id;
472
473            while current_verifier_id != ContactId::SELF && transitive_chain < 100 {
474                current_verifier_id = match verified_by_map.get(&current_verifier_id) {
475                    Some(id) => *id,
476                    None => {
477                        // The chain ends here, probably because some verification was done
478                        // before we started recording verifiers.
479                        // It's unclear how long the chain really is.
480                        transitive_chain = 0;
481                        break;
482                    }
483                };
484                if bot_ids.contains(&current_verifier_id) {
485                    has_bot = true;
486                }
487                transitive_chain = transitive_chain.saturating_add(1);
488            }
489
490            if transitive_chain > 0 {
491                contact.transitive_chain = Some(transitive_chain);
492            }
493
494            if has_bot {
495                contact.verified = VerifiedStatus::TransitiveViaBot;
496            }
497        }
498    }
499
500    // Fill direct_chat
501    for contact in &mut contacts {
502        let direct_chat = context
503            .sql
504            .exists(
505                "SELECT COUNT(*)
506                FROM chats_contacts cc INNER JOIN chats
507                WHERE cc.contact_id=? AND chats.type=?",
508                (contact.id, Chattype::Single),
509            )
510            .await?;
511        contact.direct_chat = direct_chat;
512    }
513
514    Ok(contacts)
515}
516
517/// - `last_msg_id`: The last msg_id that was already counted in the previous stats.
518///   Only messages newer than that will be counted.
519/// - `one_one_chats`: If true, only messages in 1:1 chats are counted.
520///   If false, only messages in other chats (groups and broadcast channels) are counted.
521async fn get_message_stats(context: &Context) -> Result<BTreeMap<Chattype, MessageStats>> {
522    let mut map: BTreeMap<Chattype, MessageStats> = context
523        .sql
524        .query_map_collect(
525            "SELECT chattype, verified, unverified_encrypted, unencrypted, only_to_self
526            FROM stats_msgs",
527            (),
528            |row| {
529                let chattype: Chattype = row.get(0)?;
530                let verified: u32 = row.get(1)?;
531                let unverified_encrypted: u32 = row.get(2)?;
532                let unencrypted: u32 = row.get(3)?;
533                let only_to_self: u32 = row.get(4)?;
534                let message_stats = MessageStats {
535                    verified,
536                    unverified_encrypted,
537                    unencrypted,
538                    only_to_self,
539                };
540                Ok((chattype, message_stats))
541            },
542        )
543        .await?;
544
545    // Fill zeroes if a chattype wasn't present:
546    for chattype in [Chattype::Group, Chattype::Single, Chattype::OutBroadcast] {
547        map.entry(chattype).or_default();
548    }
549
550    Ok(map)
551}
552
553pub(crate) async fn update_message_stats(context: &Context) -> Result<()> {
554    for chattype in [Chattype::Single, Chattype::Group, Chattype::OutBroadcast] {
555        update_message_stats_inner(context, chattype).await?;
556    }
557    context
558        .set_config_internal(Config::StatsLastUpdate, Some(&time().to_string()))
559        .await?;
560    Ok(())
561}
562
563async fn update_message_stats_inner(context: &Context, chattype: Chattype) -> Result<()> {
564    let stats_bot_chat_id = get_stats_chat_id(context).await?;
565
566    let trans_fn = |t: &mut rusqlite::Transaction| {
567        // The ID of the last msg that was already counted in the previously sent stats.
568        // Only newer messages will be counted in the current statistics.
569        let last_counted_msg_id: u32 = t
570            .query_row(
571                "SELECT last_counted_msg_id FROM stats_msgs WHERE chattype=?",
572                (chattype,),
573                |row| row.get(0),
574            )
575            .optional()?
576            .unwrap_or(0);
577        t.execute(
578            "UPDATE stats_msgs
579            SET last_counted_msg_id=(SELECT MAX(id) FROM msgs)
580            WHERE chattype=?",
581            (chattype,),
582        )?;
583
584        // This table will hold all empty chats,
585        // i.e. all chats that do not contain any members except for self.
586        // Messages in these chats are not actually sent out.
587        t.execute(
588            "CREATE TEMP TABLE temp.empty_chats (
589                id INTEGER PRIMARY KEY
590            ) STRICT",
591            (),
592        )?;
593
594        // id>9 because chat ids 0..9 are "special" chats like the trash chat,
595        // and contact ids 0..9 are "special" contact ids like the 'device'.
596        t.execute(
597            "INSERT INTO temp.empty_chats
598            SELECT id FROM chats
599            WHERE id>9 AND NOT EXISTS(
600                SELECT *
601                FROM contacts, chats_contacts
602                WHERE chats_contacts.contact_id=contacts.id AND chats_contacts.chat_id=chats.id
603				AND contacts.id>9
604            )",
605            (),
606        )?;
607
608        // This table will hold all verified chats,
609        // i.e. all chats that only contain verified contacts.
610        t.execute(
611            "CREATE TEMP TABLE temp.verified_chats (
612                id INTEGER PRIMARY KEY
613            ) STRICT",
614            (),
615        )?;
616
617        // Verified chats are chats that are not empty,
618        // and do not contain any unverified contacts
619        t.execute(
620            "INSERT INTO temp.verified_chats
621            SELECT id FROM chats
622            WHERE id>9
623            AND id NOT IN (SELECT id FROM temp.empty_chats)
624            AND NOT EXISTS(
625                SELECT *
626                FROM contacts, chats_contacts
627                WHERE chats_contacts.contact_id=contacts.id AND chats_contacts.chat_id=chats.id
628				AND contacts.id>9
629				AND contacts.verifier=0
630            )",
631            (),
632        )?;
633
634        // This table will hold all 1:1 chats.
635        t.execute(
636            "CREATE TEMP TABLE temp.chat_with_correct_type (
637                id INTEGER PRIMARY KEY
638            ) STRICT",
639            (),
640        )?;
641
642        t.execute(
643            "INSERT INTO temp.chat_with_correct_type
644            SELECT id FROM chats
645            WHERE type=?;",
646            (chattype,),
647        )?;
648
649        // - `from_id=?` is to count only outgoing messages.
650        // - `chat_id<>?` excludes the chat with the statistics bot itself,
651        // - `id>?` excludes messages that were already counted in the previously sent statistics, or messages sent before the config was enabled
652        // - `hidden=0` excludes hidden system messages, which are not actually shown to the user.
653        //   Note that reactions are also not counted as a message.
654        // - `chat_id>9` excludes messages in the 'Trash' chat, which is an internal chat assigned to messages that are not shown to the user
655        let general_requirements = "id>? AND from_id=? AND chat_id<>?
656            AND hidden=0 AND chat_id>9 AND chat_id IN temp.chat_with_correct_type"
657            .to_string();
658        let params = (last_counted_msg_id, ContactId::SELF, stats_bot_chat_id);
659
660        let verified: u32 = t.query_row(
661            &format!(
662                "SELECT COUNT(*) FROM msgs
663                WHERE chat_id IN temp.verified_chats
664                AND {general_requirements}"
665            ),
666            params,
667            |row| row.get(0),
668        )?;
669
670        let unverified_encrypted: u32 = t.query_row(
671            &format!(
672                // (param GLOB '*\nc=1*' OR param GLOB 'c=1*') matches all messages that are end-to-end encrypted
673                "SELECT COUNT(*) FROM msgs
674                WHERE chat_id NOT IN temp.verified_chats AND chat_id NOT IN temp.empty_chats
675                AND (param GLOB '*\nc=1*' OR param GLOB 'c=1*')
676                AND {general_requirements}"
677            ),
678            params,
679            |row| row.get(0),
680        )?;
681
682        let unencrypted: u32 = t.query_row(
683            &format!(
684                "SELECT COUNT(*) FROM msgs
685                WHERE chat_id NOT IN temp.verified_chats AND chat_id NOT IN temp.empty_chats
686                AND NOT (param GLOB '*\nc=1*' OR param GLOB 'c=1*')
687                AND {general_requirements}"
688            ),
689            params,
690            |row| row.get(0),
691        )?;
692
693        let only_to_self: u32 = t.query_row(
694            &format!(
695                "SELECT COUNT(*) FROM msgs
696                WHERE chat_id IN temp.empty_chats
697                AND {general_requirements}"
698            ),
699            params,
700            |row| row.get(0),
701        )?;
702
703        t.execute("DROP TABLE temp.verified_chats", ())?;
704        t.execute("DROP TABLE temp.empty_chats", ())?;
705        t.execute("DROP TABLE temp.chat_with_correct_type", ())?;
706
707        t.execute(
708            "INSERT INTO stats_msgs(chattype) VALUES (?)
709            ON CONFLICT(chattype) DO NOTHING",
710            (chattype,),
711        )?;
712        t.execute(
713            "UPDATE stats_msgs SET
714            verified=verified+?,
715            unverified_encrypted=unverified_encrypted+?,
716            unencrypted=unencrypted+?,
717            only_to_self=only_to_self+?
718            WHERE chattype=?",
719            (
720                verified,
721                unverified_encrypted,
722                unencrypted,
723                only_to_self,
724                chattype,
725            ),
726        )?;
727
728        Ok(())
729    };
730
731    context.sql.transaction(trans_fn).await?;
732
733    Ok(())
734}
735
736pub(crate) async fn count_securejoin_ux_info(
737    context: &Context,
738    source: Option<SecurejoinSource>,
739    uipath: Option<SecurejoinUiPath>,
740) -> Result<()> {
741    if !should_send_stats(context).await? {
742        return Ok(());
743    }
744
745    let source = source
746        .context("Missing securejoin source")
747        .log_err(context)
748        .unwrap_or(SecurejoinSource::Unknown);
749
750    // We only get a UI path if the source is a QR code scan,
751    // a loaded image, or a link pasted from the QR code,
752    // so, no need to log an error if `uipath` is None:
753    let uipath = uipath.unwrap_or(SecurejoinUiPath::Unknown);
754
755    context
756        .sql
757        .transaction(|conn| {
758            conn.execute(
759                "INSERT INTO stats_securejoin_sources VALUES (?, 1)
760                ON CONFLICT (source) DO UPDATE SET count=count+1;",
761                (source.to_u32(),),
762            )?;
763
764            conn.execute(
765                "INSERT INTO stats_securejoin_uipaths VALUES (?, 1)
766                ON CONFLICT (uipath) DO UPDATE SET count=count+1;",
767                (uipath.to_u32(),),
768            )?;
769            Ok(())
770        })
771        .await?;
772
773    Ok(())
774}
775
776async fn get_securejoin_source_stats(context: &Context) -> Result<SecurejoinSources> {
777    let map: BTreeMap<SecurejoinSource, u32> = context
778        .sql
779        .query_map_collect(
780            "SELECT source, count FROM stats_securejoin_sources",
781            (),
782            |row| {
783                let source: SecurejoinSource = row.get(0)?;
784                let count: u32 = row.get(1)?;
785                Ok((source, count))
786            },
787        )
788        .await?;
789
790    let stats = SecurejoinSources {
791        unknown: *map.get(&SecurejoinSource::Unknown).unwrap_or(&0),
792        external_link: *map.get(&SecurejoinSource::ExternalLink).unwrap_or(&0),
793        internal_link: *map.get(&SecurejoinSource::InternalLink).unwrap_or(&0),
794        clipboard: *map.get(&SecurejoinSource::Clipboard).unwrap_or(&0),
795        image_loaded: *map.get(&SecurejoinSource::ImageLoaded).unwrap_or(&0),
796        scan: *map.get(&SecurejoinSource::Scan).unwrap_or(&0),
797    };
798
799    Ok(stats)
800}
801
802async fn get_securejoin_uipath_stats(context: &Context) -> Result<SecurejoinUiPaths> {
803    let map: BTreeMap<SecurejoinUiPath, u32> = context
804        .sql
805        .query_map_collect(
806            "SELECT uipath, count FROM stats_securejoin_uipaths",
807            (),
808            |row| {
809                let uipath: SecurejoinUiPath = row.get(0)?;
810                let count: u32 = row.get(1)?;
811                Ok((uipath, count))
812            },
813        )
814        .await?;
815
816    let stats = SecurejoinUiPaths {
817        other: *map.get(&SecurejoinUiPath::Unknown).unwrap_or(&0),
818        qr_icon: *map.get(&SecurejoinUiPath::QrIcon).unwrap_or(&0),
819        new_contact: *map.get(&SecurejoinUiPath::NewContact).unwrap_or(&0),
820    };
821
822    Ok(stats)
823}
824
825pub(crate) async fn count_securejoin_invite(context: &Context, invite: &QrInvite) -> Result<()> {
826    if !should_send_stats(context).await? {
827        return Ok(());
828    }
829
830    let contact = Contact::get_by_id(context, invite.contact_id()).await?;
831
832    // If the contact was created just now by the QR code scan,
833    // (or if a contact existed in the database
834    // but it was not visible in the contacts list in the UI
835    // e.g. because it's a past contact of a group we're in),
836    // then its origin is UnhandledSecurejoinQrScan.
837    let already_existed = contact.origin > Origin::UnhandledSecurejoinQrScan;
838
839    // Check whether the contact was verified already before the QR scan.
840    let already_verified = contact.is_verified(context).await?;
841
842    let typ = match invite {
843        QrInvite::Contact { .. } => "contact",
844        QrInvite::Group { .. } => "group",
845        QrInvite::Broadcast { .. } => "broadcast",
846    };
847
848    context
849        .sql
850        .execute(
851            "INSERT INTO stats_securejoin_invites (already_existed, already_verified, type)
852            VALUES (?, ?, ?)",
853            (already_existed, already_verified, typ),
854        )
855        .await?;
856
857    Ok(())
858}
859
860async fn get_securejoin_invite_stats(context: &Context) -> Result<Vec<JoinedInvite>> {
861    context
862        .sql
863        .query_map_vec(
864            "SELECT already_existed, already_verified, type FROM stats_securejoin_invites",
865            (),
866            |row| {
867                let already_existed: bool = row.get(0)?;
868                let already_verified: bool = row.get(1)?;
869                let typ: String = row.get(2)?;
870
871                Ok(JoinedInvite {
872                    already_existed,
873                    already_verified,
874                    typ,
875                })
876            },
877        )
878        .await
879}
880
881#[cfg(test)]
882mod stats_tests;