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