From a6a9defb955244f7737cad3ea49ba0d093395424 Mon Sep 17 00:00:00 2001 From: Kevin Boos Date: Thu, 9 Jul 2026 15:06:29 -0700 Subject: [PATCH 1/4] Fix Android TLS issue caused by SDK change On Android, matrix-sdk's reqwest config now defaults to `rustls-platform-verifier`, which panics by default because it wasn't being initialized. Instead we now just directly use Mozilla's `webpki` roots directly (Android only). This commit also dedups the client builder stuff so that we don't mess it up across different callsites (first-time login vs. session restore). Another important change: use the platform-canonical cache dir as the location for the SDK's event and media caches. This allows the user to clear them easily using OS-provided mechanisms. --- Cargo.lock | 1 + Cargo.toml | 5 ++ src/persistence/matrix_state.rs | 99 ++++++++++++++++++--------------- src/sliding_sync.rs | 74 +++++++++++++++--------- 4 files changed, 109 insertions(+), 70 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fedd980d8..107c8adf0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5121,6 +5121,7 @@ dependencies = [ "tsp_sdk", "unicode-segmentation 1.12.0 (registry+https://github.com/rust-lang/crates.io-index)", "url", + "webpki-root-certs", "winresource", ] diff --git a/Cargo.toml b/Cargo.toml index 8a7fc4cec..1ad69c844 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -114,6 +114,11 @@ reqwest = { version = "0.12", default-features = false, optional = true, feature [target.'cfg(target_os = "ios")'.dependencies] robius-web-auth-session = { git = "https://github.com/project-robius/robius" } +## On Android, the matrix-sdk no longer sets up TLS roots for rustls by default, +## so we have to do it manually. +[target.'cfg(target_os = "android")'.dependencies] +webpki-root-certs = "1" + [features] default = [] diff --git a/src/persistence/matrix_state.rs b/src/persistence/matrix_state.rs index 53bca52dc..220cc70d1 100644 --- a/src/persistence/matrix_state.rs +++ b/src/persistence/matrix_state.rs @@ -13,6 +13,7 @@ use serde::{Deserialize, Serialize}; use crate::{ app_data_dir, + cache_dir, login::login_screen::LoginAction, }; @@ -142,17 +143,22 @@ fn resolve_db_path(stored: PathBuf) -> PathBuf { app_data_dir().join(name) } -/// Returns the set of `db` paths referenced by any saved session file. +/// Returns the set of `db` paths referenced by any saved session file, or `None` if the +/// data dir can't be read (so callers don't mistake "couldn't scan" for "no sessions"). /// /// This basically scans every saved user session dir, not just the most recent one, /// to help ensure that db dirs don't get orphaned on the filesystem forever. -async fn collect_referenced_db_paths() -> std::collections::HashSet { +async fn collect_referenced_db_paths() -> Option> { use std::collections::HashSet; let mut paths = HashSet::new(); let data_dir = app_data_dir(); - let Ok(mut entries) = tokio::fs::read_dir(data_dir).await else { - return paths; + let mut entries = match tokio::fs::read_dir(data_dir).await { + Ok(entries) => entries, + Err(e) => { + log!("collect_referenced_db_paths: could not read data dir {}: {e}", data_dir.display()); + return None; + } }; while let Ok(Some(entry)) = entries.next_entry().await { @@ -179,39 +185,39 @@ async fn collect_referenced_db_paths() -> std::collections::HashSet { paths.insert(resolve_db_path(session.client_session.db_path)); } - paths + Some(paths) } -/// Deletes `db_*` subdirs not referenced by any saved session. Only touches -/// entries that match the `db_*` prefix and that came from -/// `read_dir(app_data_dir())`, so it can't escape the data dir even with a -/// malicious session file. -pub async fn cleanup_orphan_db_dirs() { - let data_dir = app_data_dir(); - let active = collect_referenced_db_paths().await; - - let mut entries = match tokio::fs::read_dir(data_dir).await { +/// Deletes old database files that start with `"db_"` within the given `dir` and its subdirectories. +/// +/// Only deletes database files that are inactive, i.e., where `is_active` returns false. +async fn prune_orphan_db_dirs(dir: &std::path::Path, is_active: impl Fn(&std::ffi::OsStr) -> bool) { + let mut entries = match tokio::fs::read_dir(dir).await { Ok(e) => e, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return, Err(e) => { - log!("cleanup_orphan_db_dirs: could not read data dir {}: {e}", data_dir.display()); + log!("prune_orphan_db_dirs: could not read {}: {e}", dir.display()); return; } }; - let mut deleted = 0usize; - let mut bytes_freed = 0u64; - let mut kept = 0usize; + let mut deleted: usize = 0; + let mut bytes_freed: u64 = 0; + let mut kept: usize = 0; + while let Ok(Some(entry)) = entries.next_entry().await { let path = entry.path(); - let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + let Some(name) = path.file_name() else { + continue; + }; + let Some(name_str) = name.to_str() else { continue; }; - if !name.starts_with("db_") { + if !name_str.starts_with("db_") { continue; } - if active.contains(&path) { + if is_active(name) { kept += 1; - // log!("cleanup_orphan_db_dirs: preserving referenced db dir: {}", path.display()); continue; } let size = dir_size_bytes(&path).await.unwrap_or(0); @@ -219,28 +225,41 @@ pub async fn cleanup_orphan_db_dirs() { Ok(()) => { deleted += 1; bytes_freed += size; - log!( - "cleanup_orphan_db_dirs: deleted orphaned db dir ({} bytes): {}", - size, - path.display(), - ); + // log!( + // "prune_orphan_db_dirs: deleted orphaned dir ({size} bytes): {}", + // path.display(), + // ); } Err(e) => { - log!( - "cleanup_orphan_db_dirs: failed to delete {}: {e}", - path.display(), - ); + log!("prune_orphan_db_dirs: failed to delete {}: {e}", path.display()); } } } if deleted > 0 || kept > 0 { - log!( - "cleanup_orphan_db_dirs: deleted {deleted} orphan(s), freed {bytes_freed} bytes; kept {kept} active referenced", - ); + log!("prune_orphan_db_dirs ({}): deleted {deleted} orphan(s), freed {bytes_freed} bytes; kept {kept} active", dir.display()); } } +/// Deletes orphaned (no longer used) database and cache directories. +pub async fn cleanup_orphan_db_dirs() { + use std::collections::HashSet; + use std::ffi::OsString; + + // If we couldn't read the data directory, we can't know which ones are active, so skip pruning. + let Some(active) = collect_referenced_db_paths().await else { + return; + }; + let active_names: HashSet = active + .iter() + .filter_map(|p| p.file_name().map(ToOwned::to_owned)) + .collect(); + + let data_dir = app_data_dir(); + prune_orphan_db_dirs(data_dir, |name| active.contains(&data_dir.join(name))).await; + prune_orphan_db_dirs(cache_dir(), |name| active_names.contains(name)).await; +} + /// Recursive size sum, best-effort. Just for the cleanup log line. async fn dir_size_bytes(path: &std::path::Path) -> Option { let mut total = 0u64; @@ -328,19 +347,11 @@ pub async fn restore_session( db_path.display(), original_stored.display(), ); - let store_config = crate::sliding_sync::build_sqlite_store_config(&db_path, &client_session.passphrase); - // Build the client with the previous settings from the session. - let client = Client::builder() + let client = crate::sliding_sync::base_client_builder(&db_path, &client_session.passphrase) .homeserver_url(client_session.homeserver) - .sqlite_store_with_config_and_cache_path(store_config, None::<&std::path::Path>) - .with_threading_support(matrix_sdk::ThreadingSupport::Enabled { - with_subscriptions: true, - }) - .handle_refresh_tokens() .build() .await?; - let sliding_sync_version = sliding_sync_version.into(); - client.set_sliding_sync_version(sliding_sync_version); + client.set_sliding_sync_version(sliding_sync_version.into()); let status_str = format!("Authenticating previous login session for {}...", user_session.meta.user_id); log!("{status_str}"); Cx::post_action(LoginAction::Status { diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 80bc62bd5..40a668f69 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -35,7 +35,7 @@ use std::{borrow::Cow, cmp::{max, min}, future::Future, hash::{BuildHasherDefaul use std::io; use hashbrown::{HashMap, HashSet}; use crate::{ - app::AppStateAction, app_data_dir, avatar_cache::AvatarUpdate, event_preview::{BeforeText, TextPreview, text_preview_of_raw_timeline_event, text_preview_of_timeline_item}, home::{ + app::AppStateAction, app_data_dir, cache_dir, avatar_cache::AvatarUpdate, event_preview::{BeforeText, TextPreview, text_preview_of_raw_timeline_event, text_preview_of_timeline_item}, home::{ add_room::KnockResultAction, invite_screen::{JoinRoomResultAction, LeaveRoomResultAction}, link_preview::{LinkPreviewData, LinkPreviewDataNonNumeric, LinkPreviewRateLimitResponse}, room_screen::{InviteResultAction, TimelineUpdate}, rooms_list::{self, InvitedRoomInfo, InviterInfo, JoinedRoomInfo, RoomsListUpdate, enqueue_rooms_list_update}, rooms_list_header::RoomsListHeaderAction, tombstone_footer::SuccessorRoomDetails }, login::login_screen::LoginAction, logout::{logout_confirm_modal::LogoutAction, logout_state_machine::{LogoutConfig, is_logout_in_progress, logout_with_state_machine}}, media_cache::{MediaCacheEntry, MediaCacheEntryRef}, persistence::{self, ClientSessionPersisted, load_app_state}, profile::{ user_profile::UserProfile, @@ -109,6 +109,51 @@ pub fn build_sqlite_store_config( .passphrase(Some(passphrase)) } +/// Fix the SDK to use standard webpki TLS root certificates, only relevant on Android. +/// +/// This is necessary because of: . +fn use_android_tls_roots(builder: matrix_sdk::ClientBuilder) -> matrix_sdk::ClientBuilder { + #[cfg(target_os = "android")] + let builder = { + let roots = webpki_root_certs::TLS_SERVER_ROOT_CERTS + .iter() + .filter_map(|der| matrix_sdk::reqwest::Certificate::from_der(der.as_ref()).ok()) + .collect(); + builder + .disable_built_in_root_certificates() + .add_root_certificates(roots) + }; + builder +} + +/// Creates and returns a `ClientBuilder` configured with every setting Robrix needs. +pub(crate) fn base_client_builder(db_path: &Path, passphrase: &str) -> matrix_sdk::ClientBuilder { + let store_config = build_sqlite_store_config(db_path, passphrase); + // Store event and media cache content in the platform's intended cache dir. + let cache_path = db_path.file_name().map(|name| cache_dir().join(name)); + + let builder = Client::builder() + .sqlite_store_with_config_and_cache_path(store_config, cache_path) + .with_threading_support(matrix_sdk::ThreadingSupport::Enabled { + with_subscriptions: true, + }) + .with_decryption_settings(DecryptionSettings { + sender_device_trust_requirement: TrustRequirement::Untrusted, + }) + .with_encryption_settings(EncryptionSettings { + auto_enable_cross_signing: true, + backup_download_strategy: matrix_sdk::encryption::BackupDownloadStrategy::OneShot, + auto_enable_backups: true, + }) + .with_enable_share_history_on_invite(true) + .handle_refresh_tokens() + // Use a 60 second timeout for all requests to the homeserver. + // Yes, this is a long timeout, but the standard matrix homeserver is often very slow. + .request_config(RequestConfig::new().timeout(std::time::Duration::from_secs(60))); + + use_android_tls_roots(builder) +} + /// Build a new client. async fn build_client( cli: &Cli, @@ -144,38 +189,15 @@ async fn build_client( .unwrap_or("https://matrix-client.matrix.org/"); // .unwrap_or("https://matrix.org/"); - let store_config = build_sqlite_store_config(&db_path, &passphrase); - - let mut builder = Client::builder() + let mut builder = base_client_builder(&db_path, &passphrase) .server_name_or_homeserver_url(homeserver_url) - // Use a sqlite database to persist the client's encryption setup. - .sqlite_store_with_config_and_cache_path(store_config, None::<&std::path::Path>) - .with_threading_support(matrix_sdk::ThreadingSupport::Enabled { - with_subscriptions: true, - }) // The sliding sync proxy has now been deprecated in favor of native sliding sync. - .sliding_sync_version_builder(VersionBuilder::DiscoverNative) - .with_decryption_settings(DecryptionSettings { - sender_device_trust_requirement: TrustRequirement::Untrusted, - }) - .with_encryption_settings(EncryptionSettings { - auto_enable_cross_signing: true, - backup_download_strategy: matrix_sdk::encryption::BackupDownloadStrategy::OneShot, - auto_enable_backups: true, - }) - .with_enable_share_history_on_invite(true) - .handle_refresh_tokens(); + .sliding_sync_version_builder(VersionBuilder::DiscoverNative); if let Some(proxy) = cli.proxy.as_ref() { builder = builder.proxy(proxy.clone()); } - // Use a 60 second timeout for all requests to the homeserver. - // Yes, this is a long timeout, but the standard matrix homeserver is often very slow. - builder = builder.request_config( - RequestConfig::new() - .timeout(std::time::Duration::from_secs(60)) - ); let client = builder.build().await?; let homeserver_url = client.homeserver().to_string(); Ok(( From 34f7d25ea31b5f9fb8785ddca8813fc9215d856e Mon Sep 17 00:00:00 2001 From: Kevin Boos Date: Thu, 9 Jul 2026 15:14:26 -0700 Subject: [PATCH 2/4] add lib.rs changes, oops --- src/lib.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 5aaf15763..24bbaa3d5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -99,3 +99,7 @@ pub fn project_dir() -> &'static ProjectDirs { pub fn app_data_dir() -> &'static Path { project_dir().data_dir() } + +pub fn cache_dir() -> &'static Path { + project_dir().cache_dir() +} From 961f9197ab63dcba8e1f498f86bb5a5d794230d7 Mon Sep 17 00:00:00 2001 From: Kevin Boos Date: Thu, 9 Jul 2026 15:16:46 -0700 Subject: [PATCH 3/4] clarify doc comment --- src/persistence/matrix_state.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/persistence/matrix_state.rs b/src/persistence/matrix_state.rs index 220cc70d1..1bb605ccf 100644 --- a/src/persistence/matrix_state.rs +++ b/src/persistence/matrix_state.rs @@ -143,11 +143,13 @@ fn resolve_db_path(stored: PathBuf) -> PathBuf { app_data_dir().join(name) } -/// Returns the set of `db` paths referenced by any saved session file, or `None` if the -/// data dir can't be read (so callers don't mistake "couldn't scan" for "no sessions"). +/// Returns the set of `db` paths referenced by any saved session file. /// /// This basically scans every saved user session dir, not just the most recent one, /// to help ensure that db dirs don't get orphaned on the filesystem forever. +/// +/// Returns `None` if the app data directory can't be accessed, +/// which means that nothing should be considered as eligible for deletion. async fn collect_referenced_db_paths() -> Option> { use std::collections::HashSet; let mut paths = HashSet::new(); From 436c8d9df724a495d6859e5017239ea85af969d6 Mon Sep 17 00:00:00 2001 From: Kevin Boos Date: Thu, 9 Jul 2026 15:33:04 -0700 Subject: [PATCH 4/4] clippy fixes --- src/home/room_screen.rs | 4 ++-- src/room/typing_notice.rs | 2 +- src/sliding_sync.rs | 2 +- src/tsp/mod.rs | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index caf20bdab..8a35fca8a 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -3718,11 +3718,11 @@ fn populate_message_view( Cow::from(&fb.body), Some(FormattedBody { format: fb.format.clone(), - body: format!("* {} {}", &username, &fb.body), + body: format!("* {} {}", username, fb.body), }) ) } else { - (Cow::from(format!("* {} {}", &username, body)), None) + (Cow::from(format!("* {} {}", username, body)), None) }; let html_or_plaintext_ref = item.html_or_plaintext(cx, ids!(content.message)); diff --git a/src/room/typing_notice.rs b/src/room/typing_notice.rs index 55fad31bd..ba76a80f4 100644 --- a/src/room/typing_notice.rs +++ b/src/room/typing_notice.rs @@ -94,7 +94,7 @@ impl TypingNotice { [user1, user2] => format!("{user1} and {user2} are typing "), [user1, user2, others @ ..] => { if others.len() > 1 { - format!("{user1}, {user2}, and {} are typing ", &others[0]) + format!("{user1}, {user2}, and {} are typing ", others[0]) } else { format!( "{user1}, {user2}, and {} others are typing ", diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 40a668f69..2608afa77 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -4935,7 +4935,7 @@ async fn spawn_sso_server( enqueue_rooms_list_update(RoomsListUpdate::Status { status: format!( "Logged in as {:?}.\n → Loading rooms...", - &identity_provider_res.user_id + identity_provider_res.user_id ), }); } diff --git a/src/tsp/mod.rs b/src/tsp/mod.rs index 17335d889..1c534de03 100644 --- a/src/tsp/mod.rs +++ b/src/tsp/mod.rs @@ -905,7 +905,7 @@ async fn create_did_web( let transport = Url::parse( &format!("https://{}/endpoint/{}", server, - &did.replace("%", "%25") + did.replace("%", "%25") ) ).map_err(|e| anyhow!("Invalid transport URL: {e}"))?;