twonly-app/rust/src/user_config.rs

623 lines
20 KiB
Rust

/*
* Copyright (c) 2026, Tobias Müller git@tsmr.eu
*
*/
use crate::context::Context;
use crate::error::{twonly_error, Result, TwonlyError};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use std::sync::{OnceLock, RwLock};
pub(crate) const MIN_USER_DISCOVERY_THRESHOLD: u8 = 3;
mod defaults {
pub fn true_value() -> bool {
true
}
pub fn subscription_plan() -> String {
"Free".into()
}
pub fn pre_key_index() -> i64 {
100_000
}
pub fn required_send_images() -> i64 {
4
}
pub fn user_discovery_threshold() -> u8 {
super::MIN_USER_DISCOVERY_THRESHOLD
}
pub fn passwordless_threshold() -> i64 {
2
}
}
mod optional_datetime {
use chrono::{DateTime, NaiveDateTime, Utc};
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S>(value: &Option<DateTime<Utc>>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match value {
Some(date) => serializer.serialize_some(&date.to_rfc3339()),
None => serializer.serialize_none(),
}
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<DateTime<Utc>>, D::Error>
where
D: Deserializer<'de>,
{
let Some(value) = Option::<String>::deserialize(deserializer)? else {
return Ok(None);
};
if let Ok(date) = DateTime::parse_from_rfc3339(&value) {
return Ok(Some(date.with_timezone(&Utc)));
}
NaiveDateTime::parse_from_str(&value, "%Y-%m-%dT%H:%M:%S%.f")
.map(|date| Some(date.and_utc()))
.map_err(serde::de::Error::custom)
}
}
fn config_lock() -> &'static RwLock<()> {
static LOCK: OnceLock<RwLock<()>> = OnceLock::new();
LOCK.get_or_init(|| RwLock::new(()))
}
fn merge_changed_fields(
current: &mut serde_json::Map<String, serde_json::Value>,
base: &serde_json::Map<String, serde_json::Value>,
updated: &serde_json::Map<String, serde_json::Value>,
) {
for key in base.keys().chain(updated.keys()) {
if base.get(key) == updated.get(key) {
continue;
}
match updated.get(key) {
Some(value) => {
current.insert(key.clone(), value.clone());
}
None => {
current.remove(key);
}
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize, Default, PartialEq)]
#[serde(rename_all = "camelCase")]
#[flutter_rust_bridge::frb(non_final)]
pub struct TwonlySafeBackup {
#[serde(default)]
#[frb(non_final)]
pub last_backup_size: i64,
#[serde(default)]
#[frb(non_final)]
pub backup_upload_state: LastBackupUploadState,
#[serde(default, with = "optional_datetime")]
#[frb(non_final)]
pub last_backup_done: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default)]
#[frb(non_final)]
pub backup_id: Vec<u8>,
#[serde(default)]
#[frb(non_final)]
pub encryption_key: Vec<u8>,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub enum LastBackupUploadState {
#[default]
None,
Pending,
Failed,
Success,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
#[flutter_rust_bridge::frb(non_final)]
pub struct PasswordlessRecoveryConfig {
#[frb(non_final)]
pub email: Option<String>,
#[serde(default = "defaults::passwordless_threshold")]
#[frb(non_final)]
pub threshold: i64,
#[frb(non_final)]
pub server_key_protection: Option<Vec<u8>>,
#[frb(non_final)]
pub pin_unlock_token: Option<Vec<u8>>,
#[serde(default, with = "optional_datetime")]
#[frb(non_final)]
pub last_server_heartbeat: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default, with = "optional_datetime")]
#[frb(non_final)]
pub last_contact_heartbeat: Option<chrono::DateTime<chrono::Utc>>,
#[frb(non_final)]
pub encrypted_server_key: Option<Vec<u8>>,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub enum SetupProfile {
#[default]
Standard,
Customized,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub enum ThemeMode {
#[default]
System,
Light,
Dark,
}
#[derive(Clone, Copy, Debug, Default, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub enum TwonlyScoreVisibility {
Nobody,
OnlyContacts,
#[default]
Everyone,
}
impl<'de> Deserialize<'de> for TwonlyScoreVisibility {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
match value {
serde_json::Value::String(value) => match value.as_str() {
"nobody" => Ok(Self::Nobody),
"onlyContacts" => Ok(Self::OnlyContacts),
"everyone" => Ok(Self::Everyone),
_ => Err(serde::de::Error::custom(format!(
"unknown twonly Score visibility: {value}"
))),
},
// Migrate the boolean setting used by the first version of this
// feature without changing an existing privacy choice.
serde_json::Value::Bool(true) => Ok(Self::Everyone),
serde_json::Value::Bool(false) => Ok(Self::Nobody),
_ => Err(serde::de::Error::custom(
"twonly Score visibility must be a visibility name",
)),
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize, Default, PartialEq)]
#[serde(rename_all = "camelCase")]
#[flutter_rust_bridge::frb(non_final)]
pub struct UserConfig {
#[frb(non_final)]
pub user_id: i64,
#[frb(non_final)]
pub username: String,
#[frb(non_final)]
pub display_name: String,
#[frb(non_final)]
pub avatar_svg: Option<String>,
#[serde(default)]
#[frb(non_final)]
pub app_version: i64,
#[serde(default)]
#[frb(non_final)]
pub avatar_counter: i64,
#[serde(default = "defaults::true_value")]
#[frb(non_final)]
pub video_stabilization_enabled: bool,
#[serde(default)]
#[frb(non_final)]
pub is_developer: bool,
#[serde(default)]
#[frb(non_final)]
pub device_id: i64,
#[serde(default)]
#[frb(non_final)]
pub setup_profile: SetupProfile,
#[serde(default = "defaults::subscription_plan")]
#[frb(non_final)]
pub subscription_plan: String,
#[frb(non_final)]
pub subscription_plan_id_store: Option<String>,
#[serde(default, with = "optional_datetime")]
#[frb(non_final)]
pub last_image_send: Option<chrono::DateTime<chrono::Utc>>,
#[frb(non_final)]
pub todays_image_counter: Option<i64>,
#[frb(non_final)]
pub last_plan_ballance: Option<String>,
#[frb(non_final)]
pub additional_user_invites: Option<String>,
#[serde(default)]
#[frb(non_final)]
pub theme_mode: ThemeMode,
#[frb(non_final)]
pub primary_color_value: Option<i64>,
/// Language code the app is shown in. `None` follows the device.
#[serde(default)]
#[frb(non_final)]
pub language: Option<String>,
#[frb(non_final)]
pub default_show_time: Option<i64>,
#[serde(default)]
#[frb(non_final)]
pub requested_audio_permission: bool,
#[serde(default)]
#[frb(non_final)]
pub enable_database_logging: bool,
#[serde(default)]
#[frb(non_final)]
pub automatically_mark_equal_media_files_as_opened: bool,
#[serde(default = "defaults::true_value")]
#[frb(non_final)]
pub show_news_shortcut: bool,
#[serde(default)]
#[frb(non_final)]
pub show_show_image_preview_when_sending: bool,
#[serde(default = "defaults::true_value")]
#[frb(non_final)]
pub start_with_camera_open: bool,
#[frb(non_final)]
pub pre_selected_emojies: Option<Vec<String>>,
#[frb(non_final)]
pub auto_download_options: Option<HashMap<String, Vec<String>>>,
#[serde(default = "defaults::true_value")]
#[frb(non_final)]
pub store_media_files_in_gallery: bool,
/// Adds the capture location to locally stored Memories and gallery
/// exports. Disabled by default and never added to outgoing media.
#[serde(default)]
#[frb(non_final)]
pub store_location_in_memories: bool,
#[serde(default)]
#[frb(non_final)]
pub auto_store_all_send_unlimited_media_files: bool,
#[serde(default = "defaults::true_value")]
#[frb(non_final)]
pub typing_indicators: bool,
/// Announce a contact's first story in 24 hours. Checked only on this
/// device, where the notification is created; senders always wake.
#[serde(default = "defaults::true_value")]
#[frb(non_final)]
pub story_notifications: bool,
#[serde(default = "defaults::true_value")]
#[frb(non_final)]
pub show_restore_flame: bool,
/// Unused: the best friend is derived from `total_media_counter` whenever
/// it is needed (`Group::best_friend_group_id`). Kept so configurations
/// written by older versions still deserialize.
#[frb(non_final)]
pub my_best_friend_group_id: Option<String>,
#[serde(default, with = "optional_datetime")]
#[frb(non_final)]
pub signal_last_signed_pre_key_updated: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default, with = "optional_datetime")]
#[frb(non_final)]
pub signal_last_pqc_pre_keys_uploaded: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default)]
#[frb(non_final)]
pub allow_error_tracking_via_sentry: bool,
#[serde(default)]
#[frb(non_final)]
pub screen_lock_enabled: bool,
#[serde(default)]
#[frb(non_final)]
pub is_cloud_backup_enabled: bool,
/// Controls which recipients receive the current twonly Score in sender metadata.
#[serde(default, alias = "shareTwonlyScore")]
#[frb(non_final)]
pub twonly_score_visibility: TwonlyScoreVisibility,
#[serde(default)]
#[frb(non_final)]
pub is_user_discovery_enabled: bool,
#[serde(default = "defaults::required_send_images")]
#[frb(non_final)]
pub required_send_images: i64,
#[serde(default = "defaults::user_discovery_threshold")]
#[frb(non_final)]
pub user_discovery_threshold: u8,
#[serde(default)]
#[frb(non_final)]
pub user_discovery_requires_manual_approval: bool,
#[serde(default = "defaults::true_value")]
#[frb(non_final)]
pub user_discovery_share_promotion: bool,
#[serde(default)]
#[frb(non_final)]
pub user_discovery_initialization_error: bool,
#[serde(default = "defaults::true_value")]
#[frb(non_final)]
pub ask_for_friend_promotions: bool,
#[serde(default = "defaults::pre_key_index")]
#[frb(non_final)]
pub current_pre_key_index_start: i64,
#[serde(default = "defaults::pre_key_index")]
#[frb(non_final)]
pub current_signed_pre_key_index_start: i64,
#[frb(non_final)]
pub last_change_log_hash: Option<Vec<u8>>,
#[serde(default = "defaults::true_value")]
#[frb(non_final)]
pub hide_change_log: bool,
/// Last in-app release note the user dismissed.
#[serde(default)]
#[frb(non_final)]
pub last_release_notes_version: Option<String>,
#[serde(default)]
#[frb(non_final)]
pub hide_memories_backup_promo: bool,
#[serde(default)]
#[frb(non_final)]
pub hide_widget_share_explainer: bool,
#[serde(default = "defaults::true_value")]
#[frb(non_final)]
pub update_fcm_token: bool,
#[serde(default = "defaults::true_value")]
#[frb(non_final)]
pub can_use_login_token_for_auth: bool,
#[frb(non_final)]
pub twonly_safe_backup: Option<TwonlySafeBackup>,
#[serde(default)]
#[frb(non_final)]
pub is_backup_enabled: bool,
#[frb(non_final)]
pub password_less_recovery: Option<PasswordlessRecoveryConfig>,
#[frb(non_final)]
pub fcm_token: Option<String>,
/// Unix seconds of the last opaque FCM/APNs wake-up that reached the native
/// notification worker. Recorded in Rust because Flutter is no longer
/// started for background delivery on either platform.
#[serde(default)]
#[frb(non_final)]
pub last_fcm_wakeup_at: Option<i64>,
/// Unix seconds of the last server message that was committed locally.
/// Kept next to the FCM wake-up timestamp so notification health can be
/// evaluated without a second secure-storage implementation in Dart.
#[serde(default)]
#[frb(non_final)]
pub last_server_message_at: Option<i64>,
#[frb(non_final)]
pub current_setup_page: Option<String>,
#[serde(default)]
#[frb(non_final)]
pub skip_setup_pages: bool,
#[serde(default)]
#[frb(non_final)]
pub has_zoomed: bool,
}
impl UserConfig {
fn normalize(&mut self) {
self.user_discovery_threshold = self
.user_discovery_threshold
.max(MIN_USER_DISCOVERY_THRESHOLD);
}
fn path(context: &Context) -> PathBuf {
PathBuf::from(&context.config.data_dir)
.join("keyvalue")
.join("user.json")
}
pub(crate) fn load_required_from(context: &Context) -> Result<Self> {
Self::load_from(context)?.ok_or_else(|| twonly_error!("user configuration is unavailable"))
}
pub(crate) fn load_from(context: &Context) -> Result<Option<Self>> {
let _guard = config_lock()
.read()
.map_err(|_| twonly_error!("user configuration lock was poisoned"))?;
Self::load_from_unlocked(context)
}
fn load_from_unlocked(context: &Context) -> Result<Option<Self>> {
let path = Self::path(context);
if !path.exists() {
return Ok(None);
}
let file = File::open(&path)?;
let mut config: Self = serde_json::from_reader(file).map_err(|error| {
TwonlyError::Generic(format!(
"invalid user configuration {}: {error}",
path.display()
))
})?;
config.normalize();
Ok(Some(config))
}
/// Validates and atomically persists the complete user configuration.
/// Rust is the only writer of `user.json`.
pub(crate) fn save_json(context: &Context, json: &str) -> Result<String> {
let _guard = config_lock()
.write()
.map_err(|_| twonly_error!("user configuration lock was poisoned"))?;
Self::save_json_unlocked(context, json)
}
/// Atomically updates the latest persisted configuration with a typed Rust
/// mutation. Unlike `update_json`, this does not need a caller snapshot:
/// loading, mutation, and saving all happen while holding the write lock.
pub(crate) fn update(context: &Context, mutate: impl FnOnce(&mut Self)) -> Result<Self> {
let _guard = config_lock()
.write()
.map_err(|_| twonly_error!("user configuration lock was poisoned"))?;
let mut config = Self::load_from_unlocked(context)?
.ok_or_else(|| twonly_error!("user configuration is unavailable"))?;
mutate(&mut config);
config.normalize();
Self::save_unlocked(context, &config)?;
Ok(config)
}
/// Applies only fields changed relative to the caller's original snapshot.
/// Concurrent updates from Flutter isolates or Rust therefore do not
/// overwrite unrelated fields with stale values.
pub(crate) fn update_json(
context: &Context,
base_json: &str,
updated_json: &str,
) -> Result<String> {
let base: serde_json::Map<String, serde_json::Value> = serde_json::from_str(base_json)?;
let updated: serde_json::Map<String, serde_json::Value> =
serde_json::from_str(updated_json)?;
let _guard = config_lock()
.write()
.map_err(|_| twonly_error!("user configuration lock was poisoned"))?;
let current = Self::load_from_unlocked(context)?
.ok_or_else(|| twonly_error!("user configuration is unavailable"))?;
let mut current: serde_json::Map<String, serde_json::Value> =
serde_json::from_value(serde_json::to_value(current)?)?;
merge_changed_fields(&mut current, &base, &updated);
Self::save_json_unlocked(context, &serde_json::to_string(&current)?)
}
fn save_json_unlocked(context: &Context, json: &str) -> Result<String> {
let mut config: Self = serde_json::from_str(json).map_err(|error| {
TwonlyError::Generic(format!("invalid user configuration update: {error}"))
})?;
config.normalize();
Self::save_unlocked(context, &config)
}
fn save_unlocked(context: &Context, config: &Self) -> Result<String> {
let mut config = config.clone();
config.normalize();
let normalized = serde_json::to_string(&config)?;
let path = Self::path(context);
let parent = path
.parent()
.ok_or_else(|| twonly_error!("user configuration path has no parent"))?;
std::fs::create_dir_all(parent)?;
let temporary = parent.join("user.json.tmp");
let mut file = File::create(&temporary)?;
file.write_all(normalized.as_bytes())?;
file.sync_all()?;
std::fs::rename(&temporary, &path)?;
Ok(normalized)
}
}
#[cfg(test)]
mod tests {
use super::{
merge_changed_fields, TwonlyScoreVisibility, UserConfig, MIN_USER_DISCOVERY_THRESHOLD,
};
#[test]
fn parses_only_rust_fields_and_applies_defaults() {
let config: UserConfig = serde_json::from_str(
r#"{
"userId": 42,
"username": "alice",
"displayName": "Alice",
"unrelatedFlutterSetting": {"ignored": true}
}"#,
)
.unwrap();
assert_eq!(config.user_id, 42);
assert_eq!(config.username, "alice");
assert_eq!(config.required_send_images, 4);
assert_eq!(
config.user_discovery_threshold,
MIN_USER_DISCOVERY_THRESHOLD
);
assert!(config.can_use_login_token_for_auth);
assert_eq!(
config.twonly_score_visibility,
TwonlyScoreVisibility::Everyone
);
assert!(!config.is_user_discovery_enabled);
assert_eq!(config.last_server_message_at, None);
assert_eq!(config.last_release_notes_version, None);
}
#[test]
fn migrates_legacy_twonly_score_boolean() {
let hidden: UserConfig = serde_json::from_str(
r#"{
"userId": 42,
"username": "alice",
"displayName": "Alice",
"shareTwonlyScore": false
}"#,
)
.unwrap();
assert_eq!(
hidden.twonly_score_visibility,
TwonlyScoreVisibility::Nobody
);
let shared: UserConfig = serde_json::from_str(
r#"{
"userId": 42,
"username": "alice",
"displayName": "Alice",
"shareTwonlyScore": true
}"#,
)
.unwrap();
assert_eq!(
shared.twonly_score_visibility,
TwonlyScoreVisibility::Everyone
);
}
#[test]
fn normalizes_legacy_user_discovery_threshold() {
let mut config: UserConfig = serde_json::from_str(
r#"{
"userId": 42,
"username": "alice",
"displayName": "Alice",
"userDiscoveryThreshold": 2
}"#,
)
.unwrap();
config.normalize();
assert_eq!(
config.user_discovery_threshold,
MIN_USER_DISCOVERY_THRESHOLD
);
}
#[test]
fn merges_only_fields_changed_by_a_stale_snapshot() {
let mut current = serde_json::from_str(r#"{"theme":"dark","counter":2}"#).unwrap();
let base = serde_json::from_str(r#"{"theme":"light","counter":1}"#).unwrap();
let updated = serde_json::from_str(r#"{"theme":"system","counter":1}"#).unwrap();
merge_changed_fields(&mut current, &base, &updated);
assert_eq!(current["theme"], "system");
assert_eq!(current["counter"], 2);
}
}