Files
chatmail-core/src/stats.rs
Hocuri 8c56b63f21 feat: Add number_of_keys to statistics (#8297)
As of 3b29469102, all public keys except
for the currently-used one are ignored. But Delta Chat still tries to
use all _private_ keys for decryption.

This PR adds the information how many keys there are in the database. If
there is more than one, we know that the user imported their own key
back when that was possible. This info is interesting both for ourselves
and for the Cispa researchers that want to write a paper about Delta
Chat.

An alternative to this PR would be to make `key_create_timestamps`
actually contain the full list of key creation timestamps. I'm not sure
if that's worth the effort, though.

Follow-up to #8293
2026-06-03 12:36:31 +02:00

902 lines
29 KiB
Rust

//! Delta Chat has an advanced option
//! "Send statistics to the developers of Delta Chat".
//! If this is enabled, a JSON file with some anonymous statistics
//! will be sent to a bot once a week.
use std::collections::{BTreeMap, BTreeSet};
use anyhow::{Context as _, Result};
use deltachat_derive::FromSql;
use num_traits::ToPrimitive;
use pgp::types::KeyDetails as _;
use rand::distr::SampleString as _;
use rusqlite::OptionalExtension;
use serde::Serialize;
use crate::chat::{self, ChatId, MuteDuration};
use crate::config::Config;
use crate::constants::{Chattype, DC_VERSION_STR};
use crate::contact::{Contact, ContactId, Origin, import_vcard, mark_contact_id_as_verified};
use crate::context::Context;
use crate::key::{DcKey, load_self_public_key};
use crate::log::LogExt;
use crate::message::{Message, Viewtype};
use crate::securejoin::QrInvite;
use crate::tools::time;
pub(crate) const STATISTICS_BOT_EMAIL: &str = "self_reporting@testrun.org";
const STATISTICS_BOT_VCARD: &str = include_str!("../assets/statistics-bot.vcf");
const SENDING_INTERVAL_SECONDS: i64 = 3600 * 24 * 7; // 1 week
// const SENDING_INTERVAL_SECONDS: i64 = 60; // 1 minute (for testing)
const MESSAGE_STATS_UPDATE_INTERVAL_SECONDS: i64 = 4 * 60; // 4 minutes (less than the lowest ephemeral messages timeout)
#[derive(Serialize)]
struct Statistics {
core_version: String,
number_of_transports: usize,
key_create_timestamps: Vec<u32>,
number_of_keys: u32,
/// OpenPGP version of the key.
key_version: u8,
key_algorithm: String,
/// Size of the public key in bytes (encoded in binary, not base64).
pubkey_size: usize,
stats_id: String,
is_chatmail: bool,
contact_stats: Vec<ContactStat>,
message_stats: BTreeMap<Chattype, MessageStats>,
securejoin_sources: SecurejoinSources,
securejoin_uipaths: SecurejoinUiPaths,
securejoin_invites: Vec<JoinedInvite>,
sending_enabled_timestamps: Vec<i64>,
sending_disabled_timestamps: Vec<i64>,
}
#[derive(Serialize, PartialEq)]
enum VerifiedStatus {
Direct,
Transitive,
TransitiveViaBot,
Opportunistic,
Unencrypted,
}
#[derive(Serialize)]
struct ContactStat {
#[serde(skip_serializing)]
id: ContactId,
verified: VerifiedStatus,
// If one of the boolean properties is false,
// we leave them away.
// This way, the Json file becomes a lot smaller.
#[serde(skip_serializing_if = "is_false")]
bot: bool,
#[serde(skip_serializing_if = "is_false")]
direct_chat: bool,
last_seen: u64,
#[serde(skip_serializing_if = "Option::is_none")]
transitive_chain: Option<u32>,
/// Whether the contact was established after stats-sending was enabled
#[serde(skip_serializing_if = "is_false")]
new: bool,
}
fn is_false(b: &bool) -> bool {
!b
}
#[derive(Serialize, Default)]
struct MessageStats {
verified: u32,
unverified_encrypted: u32,
unencrypted: u32,
only_to_self: u32,
}
/// Where a securejoin invite link or QR code came from.
/// This is only used if the user enabled StatsSending.
#[repr(u32)]
#[derive(
Debug, Clone, Copy, ToPrimitive, FromPrimitive, FromSql, PartialEq, Eq, PartialOrd, Ord,
)]
pub enum SecurejoinSource {
/// Because of some problem, it is unknown where the QR code came from.
Unknown = 0,
/// The user opened a link somewhere outside Delta Chat
ExternalLink = 1,
/// The user clicked on a link in a message inside Delta Chat
InternalLink = 2,
/// The user clicked "Paste from Clipboard" in the QR scan activity
Clipboard = 3,
/// The user clicked "Load QR code as image" in the QR scan activity
ImageLoaded = 4,
/// The user scanned a QR code
Scan = 5,
}
#[derive(Serialize)]
struct SecurejoinSources {
unknown: u32,
external_link: u32,
internal_link: u32,
clipboard: u32,
image_loaded: u32,
scan: u32,
}
/// How the user opened the QR activity in order scan a QR code on Android.
/// This is only used if the user enabled StatsSending.
#[derive(
Debug, Clone, Copy, ToPrimitive, FromPrimitive, FromSql, PartialEq, Eq, PartialOrd, Ord,
)]
pub enum SecurejoinUiPath {
/// The UI path is unknown, or the user didn't open the QR code screen at all.
Unknown = 0,
/// The user directly clicked on the QR icon in the main screen
QrIcon = 1,
/// The user first clicked on the `+` button in the main screen,
/// and then on "New Contact"
NewContact = 2,
}
#[derive(Serialize)]
struct SecurejoinUiPaths {
other: u32,
qr_icon: u32,
new_contact: u32,
}
/// Some information on an invite-joining event
/// (i.e. a qr scan or a clicked link).
#[derive(Serialize)]
struct JoinedInvite {
/// Whether the contact already existed before.
/// If this is false, then a contact was newly created.
already_existed: bool,
/// If a contact already existed,
/// this tells us whether the contact was verified already.
already_verified: bool,
/// The type of the invite:
/// "contact" for 1:1 invites that setup a verified contact,
/// "group" for invites that invite to a group,
/// "broadcast" for invites that invite to a broadcast channel.
/// The invite also performs the contact verification 'along the way'.
typ: String,
}
pub(crate) async fn pre_sending_config_change(
context: &Context,
old_value: bool,
new_value: bool,
) -> Result<()> {
// These functions are no-ops if they were called in the past already;
// just call them opportunistically:
ensure_last_old_contact_id(context).await?;
// Make sure that StatsId is available for the UI,
// in order to open the survey with the StatsId as a parameter:
stats_id(context).await?;
if old_value != new_value {
if new_value {
// Only count messages sent from now on:
set_last_counted_msg_id(context).await?;
} else {
// Update message stats one last time in case it's enabled again in the future:
update_message_stats(context).await?;
}
let sql_table = if new_value {
"stats_sending_enabled_events"
} else {
"stats_sending_disabled_events"
};
context
.sql
.execute(&format!("INSERT INTO {sql_table} VALUES(?)"), (time(),))
.await?;
}
Ok(())
}
/// Sends a message with statistics about the usage of Delta Chat,
/// if the last time such a message was sent
/// was more than a week ago.
///
/// On the other end, a bot will receive the message and make it available
/// to Delta Chat's developers.
pub async fn maybe_send_stats(context: &Context) -> Result<Option<ChatId>> {
if should_send_stats(context).await?
&& time_has_passed(context, Config::StatsLastSent, SENDING_INTERVAL_SECONDS).await?
{
let chat_id = send_stats(context).await?;
return Ok(Some(chat_id));
}
Ok(None)
}
pub(crate) async fn maybe_update_message_stats(context: &Context) -> Result<()> {
if should_send_stats(context).await?
&& time_has_passed(
context,
Config::StatsLastUpdate,
MESSAGE_STATS_UPDATE_INTERVAL_SECONDS,
)
.await?
{
update_message_stats(context).await?;
}
Ok(())
}
async fn time_has_passed(context: &Context, config: Config, seconds: i64) -> Result<bool> {
let last_time = context.get_config_i64(config).await?;
let next_time = last_time.saturating_add(seconds);
let res = if next_time <= time() {
// Already set the config to the current time.
// This prevents infinite loops in the (unlikely) case of an error:
context
.set_config_internal(config, Some(&time().to_string()))
.await?;
true
} else {
if time() < last_time {
// The clock was rewound.
// Reset the config, so that the statistics will be sent normally in a week,
// or be normally updated in a few minutes.
context
.set_config_internal(config, Some(&time().to_string()))
.await?;
}
false
};
Ok(res)
}
#[allow(clippy::unused_async, unused)]
pub(crate) async fn should_send_stats(context: &Context) -> Result<bool> {
#[cfg(any(target_os = "android", test))]
{
context.get_config_bool(Config::StatsSending).await
}
// If the user enables statistics-sending on Android,
// and then transfers the account to e.g. Desktop,
// we should not send any statistics:
#[cfg(not(any(target_os = "android", test)))]
{
Ok(false)
}
}
async fn send_stats(context: &Context) -> Result<ChatId> {
info!(context, "Sending statistics.");
update_message_stats(context).await?;
let chat_id = get_stats_chat_id(context).await?;
let mut msg = Message::new(Viewtype::File);
msg.set_text(crate::stock_str::stats_msg_body(context));
let stats = get_stats(context).await?;
msg.set_file_from_bytes(
context,
"statistics.txt",
stats.as_bytes(),
Some("text/plain"),
)?;
chat::send_msg(context, chat_id, &mut msg)
.await
.context("Failed to send statistics message")
.log_err(context)
.ok();
Ok(chat_id)
}
async fn set_last_counted_msg_id(context: &Context) -> Result<()> {
context
.sql
.execute(
"UPDATE stats_msgs
SET last_counted_msg_id=(SELECT MAX(id) FROM msgs)",
(),
)
.await?;
Ok(())
}
async fn ensure_last_old_contact_id(context: &Context) -> Result<()> {
if context.config_exists(Config::StatsLastOldContactId).await? {
// The user had statistics-sending enabled already in the past,
// keep the 'last old contact id' as-is
return Ok(());
}
let last_contact_id: u64 = context
.sql
.query_get_value("SELECT MAX(id) FROM contacts", ())
.await?
.unwrap_or(0);
context
.sql
.set_raw_config(
Config::StatsLastOldContactId.as_ref(),
Some(&last_contact_id.to_string()),
)
.await?;
Ok(())
}
async fn get_stats(context: &Context) -> Result<String> {
// The Id of the last contact that already existed when the user enabled the setting.
// Newer contacts will get the `new` flag set.
let last_old_contact = context
.get_config_u32(Config::StatsLastOldContactId)
.await?;
let self_public_key = load_self_public_key(context).await?;
// `key_create_timestamps` is a `Vec` for historical reasons,
// support for using multiple keys is being phased out.
let key_create_timestamps: Vec<u32> = vec![self_public_key.created_at().as_secs()];
let number_of_keys: u32 = context
.sql
.query_get_value("SELECT COUNT(*) FROM keypairs", ())
.await?
.unwrap_or(0);
let sending_enabled_timestamps =
get_timestamps(context, "stats_sending_enabled_events").await?;
let sending_disabled_timestamps =
get_timestamps(context, "stats_sending_disabled_events").await?;
let stats = Statistics {
core_version: DC_VERSION_STR.to_string(),
number_of_transports: context.count_transports().await?,
key_create_timestamps,
number_of_keys,
key_version: self_public_key.primary_key.version().into(),
key_algorithm: format!("{:?}", self_public_key.algorithm()),
pubkey_size: DcKey::to_bytes(&self_public_key).len(),
stats_id: stats_id(context).await?,
is_chatmail: context.is_chatmail().await?,
contact_stats: get_contact_stats(context, last_old_contact).await?,
message_stats: get_message_stats(context).await?,
securejoin_sources: get_securejoin_source_stats(context).await?,
securejoin_uipaths: get_securejoin_uipath_stats(context).await?,
securejoin_invites: get_securejoin_invite_stats(context).await?,
sending_enabled_timestamps,
sending_disabled_timestamps,
};
Ok(serde_json::to_string_pretty(&stats)?)
}
async fn get_timestamps(context: &Context, sql_table: &str) -> Result<Vec<i64>> {
context
.sql
.query_map_vec(
&format!("SELECT timestamp FROM {sql_table} LIMIT 1000"),
(),
|row| {
let timestamp: i64 = row.get(0)?;
Ok(timestamp)
},
)
.await
}
pub(crate) async fn stats_id(context: &Context) -> Result<String> {
Ok(match context.get_config(Config::StatsId).await? {
Some(id) => id,
None => {
let id = rand::distr::Alphabetic
.sample_string(&mut rand::rng(), 25)
.to_lowercase();
context
.set_config_internal(Config::StatsId, Some(&id))
.await?;
id
}
})
}
async fn get_stats_chat_id(context: &Context) -> Result<ChatId, anyhow::Error> {
let contact_id: ContactId = *import_vcard(context, STATISTICS_BOT_VCARD)
.await?
.first()
.context("Statistics bot vCard does not contain a contact")?;
mark_contact_id_as_verified(context, contact_id, Some(ContactId::SELF)).await?;
let chat_id = if let Some(res) = ChatId::lookup_by_contact(context, contact_id).await? {
// Already exists, no need to create.
res
} else {
let chat_id = ChatId::get_for_contact(context, contact_id).await?;
chat::set_muted(context, chat_id, MuteDuration::Forever).await?;
chat_id
};
Ok(chat_id)
}
async fn get_contact_stats(context: &Context, last_old_contact: u32) -> Result<Vec<ContactStat>> {
let mut verified_by_map: BTreeMap<ContactId, ContactId> = BTreeMap::new();
let mut bot_ids: BTreeSet<ContactId> = BTreeSet::new();
let mut contacts = context
.sql
.query_map_vec(
"SELECT id, fingerprint<>'', verifier, last_seen, is_bot FROM contacts c
WHERE id>9 AND origin>? AND addr<>?",
(Origin::Hidden, STATISTICS_BOT_EMAIL),
|row| {
let id = row.get(0)?;
let is_encrypted: bool = row.get(1)?;
let verifier: ContactId = row.get(2)?;
let last_seen: u64 = row.get(3)?;
let bot: bool = row.get(4)?;
let verified = match (is_encrypted, verifier) {
(true, ContactId::SELF) => VerifiedStatus::Direct,
(true, ContactId::UNDEFINED) => VerifiedStatus::Opportunistic,
(true, _) => VerifiedStatus::Transitive, // TransitiveViaBot will be filled later
(false, _) => VerifiedStatus::Unencrypted,
};
if verifier != ContactId::UNDEFINED {
verified_by_map.insert(id, verifier);
}
if bot {
bot_ids.insert(id);
}
Ok(ContactStat {
id,
verified,
bot,
direct_chat: false, // will be filled later
last_seen,
transitive_chain: None, // will be filled later
new: id.to_u32() > last_old_contact,
})
},
)
.await?;
// Fill TransitiveViaBot and transitive_chain
for contact in &mut contacts {
if contact.verified == VerifiedStatus::Transitive {
let mut transitive_chain: u32 = 0;
let mut has_bot = false;
let mut current_verifier_id = contact.id;
while current_verifier_id != ContactId::SELF && transitive_chain < 100 {
current_verifier_id = match verified_by_map.get(&current_verifier_id) {
Some(id) => *id,
None => {
// The chain ends here, probably because some verification was done
// before we started recording verifiers.
// It's unclear how long the chain really is.
transitive_chain = 0;
break;
}
};
if bot_ids.contains(&current_verifier_id) {
has_bot = true;
}
transitive_chain = transitive_chain.saturating_add(1);
}
if transitive_chain > 0 {
contact.transitive_chain = Some(transitive_chain);
}
if has_bot {
contact.verified = VerifiedStatus::TransitiveViaBot;
}
}
}
// Fill direct_chat
for contact in &mut contacts {
let direct_chat = context
.sql
.exists(
"SELECT COUNT(*)
FROM chats_contacts cc INNER JOIN chats
WHERE cc.contact_id=? AND chats.type=?",
(contact.id, Chattype::Single),
)
.await?;
contact.direct_chat = direct_chat;
}
Ok(contacts)
}
/// - `last_msg_id`: The last msg_id that was already counted in the previous stats.
/// Only messages newer than that will be counted.
/// - `one_one_chats`: If true, only messages in 1:1 chats are counted.
/// If false, only messages in other chats (groups and broadcast channels) are counted.
async fn get_message_stats(context: &Context) -> Result<BTreeMap<Chattype, MessageStats>> {
let mut map: BTreeMap<Chattype, MessageStats> = context
.sql
.query_map_collect(
"SELECT chattype, verified, unverified_encrypted, unencrypted, only_to_self
FROM stats_msgs",
(),
|row| {
let chattype: Chattype = row.get(0)?;
let verified: u32 = row.get(1)?;
let unverified_encrypted: u32 = row.get(2)?;
let unencrypted: u32 = row.get(3)?;
let only_to_self: u32 = row.get(4)?;
let message_stats = MessageStats {
verified,
unverified_encrypted,
unencrypted,
only_to_self,
};
Ok((chattype, message_stats))
},
)
.await?;
// Fill zeroes if a chattype wasn't present:
for chattype in [Chattype::Group, Chattype::Single, Chattype::OutBroadcast] {
map.entry(chattype).or_default();
}
Ok(map)
}
pub(crate) async fn update_message_stats(context: &Context) -> Result<()> {
for chattype in [Chattype::Single, Chattype::Group, Chattype::OutBroadcast] {
update_message_stats_inner(context, chattype).await?;
}
context
.set_config_internal(Config::StatsLastUpdate, Some(&time().to_string()))
.await?;
Ok(())
}
async fn update_message_stats_inner(context: &Context, chattype: Chattype) -> Result<()> {
let stats_bot_chat_id = get_stats_chat_id(context).await?;
let trans_fn = |t: &mut rusqlite::Transaction| {
// The ID of the last msg that was already counted in the previously sent stats.
// Only newer messages will be counted in the current statistics.
let last_counted_msg_id: u32 = t
.query_row(
"SELECT last_counted_msg_id FROM stats_msgs WHERE chattype=?",
(chattype,),
|row| row.get(0),
)
.optional()?
.unwrap_or(0);
t.execute(
"UPDATE stats_msgs
SET last_counted_msg_id=(SELECT MAX(id) FROM msgs)
WHERE chattype=?",
(chattype,),
)?;
// This table will hold all empty chats,
// i.e. all chats that do not contain any members except for self.
// Messages in these chats are not actually sent out.
t.execute(
"CREATE TEMP TABLE temp.empty_chats (
id INTEGER PRIMARY KEY
) STRICT",
(),
)?;
// id>9 because chat ids 0..9 are "special" chats like the trash chat,
// and contact ids 0..9 are "special" contact ids like the 'device'.
t.execute(
"INSERT INTO temp.empty_chats
SELECT id FROM chats
WHERE id>9 AND NOT EXISTS(
SELECT *
FROM contacts, chats_contacts
WHERE chats_contacts.contact_id=contacts.id AND chats_contacts.chat_id=chats.id
AND contacts.id>9
)",
(),
)?;
// This table will hold all verified chats,
// i.e. all chats that only contain verified contacts.
t.execute(
"CREATE TEMP TABLE temp.verified_chats (
id INTEGER PRIMARY KEY
) STRICT",
(),
)?;
// Verified chats are chats that are not empty,
// and do not contain any unverified contacts
t.execute(
"INSERT INTO temp.verified_chats
SELECT id FROM chats
WHERE id>9
AND id NOT IN (SELECT id FROM temp.empty_chats)
AND NOT EXISTS(
SELECT *
FROM contacts, chats_contacts
WHERE chats_contacts.contact_id=contacts.id AND chats_contacts.chat_id=chats.id
AND contacts.id>9
AND contacts.verifier=0
)",
(),
)?;
// This table will hold all 1:1 chats.
t.execute(
"CREATE TEMP TABLE temp.chat_with_correct_type (
id INTEGER PRIMARY KEY
) STRICT",
(),
)?;
t.execute(
"INSERT INTO temp.chat_with_correct_type
SELECT id FROM chats
WHERE type=?;",
(chattype,),
)?;
// - `from_id=?` is to count only outgoing messages.
// - `chat_id<>?` excludes the chat with the statistics bot itself,
// - `id>?` excludes messages that were already counted in the previously sent statistics, or messages sent before the config was enabled
// - `hidden=0` excludes hidden system messages, which are not actually shown to the user.
// Note that reactions are also not counted as a message.
// - `chat_id>9` excludes messages in the 'Trash' chat, which is an internal chat assigned to messages that are not shown to the user
let general_requirements = "id>? AND from_id=? AND chat_id<>?
AND hidden=0 AND chat_id>9 AND chat_id IN temp.chat_with_correct_type"
.to_string();
let params = (last_counted_msg_id, ContactId::SELF, stats_bot_chat_id);
let verified: u32 = t.query_row(
&format!(
"SELECT COUNT(*) FROM msgs
WHERE chat_id IN temp.verified_chats
AND {general_requirements}"
),
params,
|row| row.get(0),
)?;
let unverified_encrypted: u32 = t.query_row(
&format!(
// (param GLOB '*\nc=1*' OR param GLOB 'c=1*') matches all messages that are end-to-end encrypted
"SELECT COUNT(*) FROM msgs
WHERE chat_id NOT IN temp.verified_chats AND chat_id NOT IN temp.empty_chats
AND (param GLOB '*\nc=1*' OR param GLOB 'c=1*')
AND {general_requirements}"
),
params,
|row| row.get(0),
)?;
let unencrypted: u32 = t.query_row(
&format!(
"SELECT COUNT(*) FROM msgs
WHERE chat_id NOT IN temp.verified_chats AND chat_id NOT IN temp.empty_chats
AND NOT (param GLOB '*\nc=1*' OR param GLOB 'c=1*')
AND {general_requirements}"
),
params,
|row| row.get(0),
)?;
let only_to_self: u32 = t.query_row(
&format!(
"SELECT COUNT(*) FROM msgs
WHERE chat_id IN temp.empty_chats
AND {general_requirements}"
),
params,
|row| row.get(0),
)?;
t.execute("DROP TABLE temp.verified_chats", ())?;
t.execute("DROP TABLE temp.empty_chats", ())?;
t.execute("DROP TABLE temp.chat_with_correct_type", ())?;
t.execute(
"INSERT INTO stats_msgs(chattype) VALUES (?)
ON CONFLICT(chattype) DO NOTHING",
(chattype,),
)?;
t.execute(
"UPDATE stats_msgs SET
verified=verified+?,
unverified_encrypted=unverified_encrypted+?,
unencrypted=unencrypted+?,
only_to_self=only_to_self+?
WHERE chattype=?",
(
verified,
unverified_encrypted,
unencrypted,
only_to_self,
chattype,
),
)?;
Ok(())
};
context.sql.transaction(trans_fn).await?;
Ok(())
}
pub(crate) async fn count_securejoin_ux_info(
context: &Context,
source: Option<SecurejoinSource>,
uipath: Option<SecurejoinUiPath>,
) -> Result<()> {
if !should_send_stats(context).await? {
return Ok(());
}
let source = source
.context("Missing securejoin source")
.log_err(context)
.unwrap_or(SecurejoinSource::Unknown);
// We only get a UI path if the source is a QR code scan,
// a loaded image, or a link pasted from the QR code,
// so, no need to log an error if `uipath` is None:
let uipath = uipath.unwrap_or(SecurejoinUiPath::Unknown);
context
.sql
.transaction(|conn| {
conn.execute(
"INSERT INTO stats_securejoin_sources VALUES (?, 1)
ON CONFLICT (source) DO UPDATE SET count=count+1;",
(source.to_u32(),),
)?;
conn.execute(
"INSERT INTO stats_securejoin_uipaths VALUES (?, 1)
ON CONFLICT (uipath) DO UPDATE SET count=count+1;",
(uipath.to_u32(),),
)?;
Ok(())
})
.await?;
Ok(())
}
async fn get_securejoin_source_stats(context: &Context) -> Result<SecurejoinSources> {
let map: BTreeMap<SecurejoinSource, u32> = context
.sql
.query_map_collect(
"SELECT source, count FROM stats_securejoin_sources",
(),
|row| {
let source: SecurejoinSource = row.get(0)?;
let count: u32 = row.get(1)?;
Ok((source, count))
},
)
.await?;
let stats = SecurejoinSources {
unknown: *map.get(&SecurejoinSource::Unknown).unwrap_or(&0),
external_link: *map.get(&SecurejoinSource::ExternalLink).unwrap_or(&0),
internal_link: *map.get(&SecurejoinSource::InternalLink).unwrap_or(&0),
clipboard: *map.get(&SecurejoinSource::Clipboard).unwrap_or(&0),
image_loaded: *map.get(&SecurejoinSource::ImageLoaded).unwrap_or(&0),
scan: *map.get(&SecurejoinSource::Scan).unwrap_or(&0),
};
Ok(stats)
}
async fn get_securejoin_uipath_stats(context: &Context) -> Result<SecurejoinUiPaths> {
let map: BTreeMap<SecurejoinUiPath, u32> = context
.sql
.query_map_collect(
"SELECT uipath, count FROM stats_securejoin_uipaths",
(),
|row| {
let uipath: SecurejoinUiPath = row.get(0)?;
let count: u32 = row.get(1)?;
Ok((uipath, count))
},
)
.await?;
let stats = SecurejoinUiPaths {
other: *map.get(&SecurejoinUiPath::Unknown).unwrap_or(&0),
qr_icon: *map.get(&SecurejoinUiPath::QrIcon).unwrap_or(&0),
new_contact: *map.get(&SecurejoinUiPath::NewContact).unwrap_or(&0),
};
Ok(stats)
}
pub(crate) async fn count_securejoin_invite(context: &Context, invite: &QrInvite) -> Result<()> {
if !should_send_stats(context).await? {
return Ok(());
}
let contact = Contact::get_by_id(context, invite.contact_id()).await?;
// If the contact was created just now by the QR code scan,
// (or if a contact existed in the database
// but it was not visible in the contacts list in the UI
// e.g. because it's a past contact of a group we're in),
// then its origin is UnhandledSecurejoinQrScan.
let already_existed = contact.origin > Origin::UnhandledSecurejoinQrScan;
// Check whether the contact was verified already before the QR scan.
let already_verified = contact.is_verified(context).await?;
let typ = match invite {
QrInvite::Contact { .. } => "contact",
QrInvite::Group { .. } => "group",
QrInvite::Broadcast { .. } => "broadcast",
};
context
.sql
.execute(
"INSERT INTO stats_securejoin_invites (already_existed, already_verified, type)
VALUES (?, ?, ?)",
(already_existed, already_verified, typ),
)
.await?;
Ok(())
}
async fn get_securejoin_invite_stats(context: &Context) -> Result<Vec<JoinedInvite>> {
context
.sql
.query_map_vec(
"SELECT already_existed, already_verified, type FROM stats_securejoin_invites",
(),
|row| {
let already_existed: bool = row.get(0)?;
let already_verified: bool = row.get(1)?;
let typ: String = row.get(2)?;
Ok(JoinedInvite {
already_existed,
already_verified,
typ,
})
},
)
.await
}
#[cfg(test)]
mod stats_tests;