From ee7bfb7937cd95dfeca94244f5e57f26d0adbaf4 Mon Sep 17 00:00:00 2001 From: "B. Petersen" Date: Mon, 10 Aug 2026 18:05:45 +0200 Subject: [PATCH] feat: client version information --- deltachat-jsonrpc/src/api.rs | 8 ++ .../src/api/types/appversions.rs | 47 ++++++ deltachat-jsonrpc/src/api/types/mod.rs | 1 + src/appversions.rs | 134 ++++++++++++++++++ src/imap.rs | 9 ++ src/lib.rs | 1 + 6 files changed, 200 insertions(+) create mode 100644 deltachat-jsonrpc/src/api/types/appversions.rs create mode 100644 src/appversions.rs diff --git a/deltachat-jsonrpc/src/api.rs b/deltachat-jsonrpc/src/api.rs index b070be94d..869791880 100644 --- a/deltachat-jsonrpc/src/api.rs +++ b/deltachat-jsonrpc/src/api.rs @@ -64,6 +64,7 @@ use self::types::{ JsonrpcMessageListItem, MessageNotificationInfo, MessageSearchResult, MessageViewtype, }, }; +use crate::api::types::appversions::JsonrpcAppVersionInfo; use crate::api::types::chat_list::{ChatListItemFetchResult, get_chat_list_item_by_id}; use crate::api::types::login_param::TransportListEntry; use crate::api::types::qr::{QrObject, SecurejoinSource, SecurejoinUiPath}; @@ -2789,6 +2790,13 @@ impl CommandApi { Err(anyhow!("chat with id {chat_id} doesn't have draft message")) } } + + /// Get version information of clients. + async fn get_app_versions(&self, account_id: u32) -> Result { + let ctx = self.get_context(account_id).await?; + let info = deltachat::appversions::get_app_versions(&ctx).await?; + JsonrpcAppVersionInfo::from_core_type(info) + } } // Helper functions (to prevent code duplication) diff --git a/deltachat-jsonrpc/src/api/types/appversions.rs b/deltachat-jsonrpc/src/api/types/appversions.rs new file mode 100644 index 000000000..18aa892c9 --- /dev/null +++ b/deltachat-jsonrpc/src/api/types/appversions.rs @@ -0,0 +1,47 @@ +use anyhow::Result; +use deltachat::appversions::AppVersionInfo; +use serde::{Deserialize, Serialize}; +use typescript_type_def::TypeDef; + +/// Version information of clients. +#[derive(Serialize, Deserialize, TypeDef, schemars::JsonSchema)] +#[serde(rename = "AppVersionInfo", rename_all = "camelCase")] +pub struct JsonrpcAppVersionInfo { + /// Array of clients with version information. + pub clients: Vec, +} + +/// Version information of a single client, eg. "deltachat" or "ubuntutouch". +#[derive(Serialize, Deserialize, TypeDef, schemars::JsonSchema)] +#[serde(rename = "AppClient", rename_all = "camelCase")] +pub struct JsonrpcAppClient { + /// ID how the client identifies itself, eg. "deltachat" or "ubuntutouch". + pub client_id: String, + + /// Array of sources for that client. + pub sources: Vec, +} + +/// Version information of a single source of a client, eg. "gplay" or "fdroid". +#[derive(Serialize, Deserialize, TypeDef, schemars::JsonSchema)] +#[serde(rename = "AppSource", rename_all = "camelCase")] +pub struct JsonrpcAppSource { + /// ID how the client identifies a source, eg. "gplay" or "fdroid". + pub app_id: String, + + /// Always increasing version number. + pub version_integer: u32, + + /// Any version string. + pub version_string: String, + + /// Where to download that version. + pub download_url: String, +} + +impl JsonrpcAppVersionInfo { + pub fn from_core_type(info: AppVersionInfo) -> Result { + let value = serde_json::to_value(info)?; + Ok(serde_json::from_value(value)?) + } +} diff --git a/deltachat-jsonrpc/src/api/types/mod.rs b/deltachat-jsonrpc/src/api/types/mod.rs index f39aa2e02..198180043 100644 --- a/deltachat-jsonrpc/src/api/types/mod.rs +++ b/deltachat-jsonrpc/src/api/types/mod.rs @@ -1,4 +1,5 @@ pub mod account; +pub mod appversions; pub mod calls; pub mod chat; pub mod chat_list; diff --git a/src/appversions.rs b/src/appversions.rs new file mode 100644 index 000000000..eaa1bec06 --- /dev/null +++ b/src/appversions.rs @@ -0,0 +1,134 @@ +//! Get version information of clients. +//! +//! Used by clients to inform about updates. +//! The version information comes in via IMAP METADATA, +//! (as JSON) and is parsed to `AppVersionInfo`. +use crate::context::Context; +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +/// Version information of clients as used on the wire. +#[derive(Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct AppVersionInfo { + /// Array clients with version information. + clients: Vec, +} + +/// Version infomation of a single client, eg. "deltachat" or "ubuntutouch". +#[derive(Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct AppClient { + /// ID how the client identifies itself, eg. "deltachat" or "ubuntutouch" + client_id: String, + + /// Array of sources for that client. + sources: Vec, +} + +/// Version information of a single source of a client, eg. "gplay" or "fdroid". +#[derive(Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct AppSource { + /// ID how the client identifies a source, eg. "gplay" or "fdroid" + app_id: String, + + /// Always increasing version number. + version_integer: u32, + + /// Any version string. + version_string: String, + + /// Where to download that version. + download_url: String, +} + +/// Get version information of clients. +/// +/// If no version information are available, `clients` is set to an empty array. +/// +/// The information is coming from the relay via IMAP METADATA and is not cached. +/// A call to `get_app_versions()` is cheap and does not involve network or database calls. +pub async fn get_app_versions(context: &Context) -> Result { + if let Some(metadata) = context.metadata.read().await.values().next() + && let Some(json) = &metadata.app_versions + { + let app_versions: AppVersionInfo = serde_json::from_str(json)?; + return Ok(app_versions); + } + Ok(AppVersionInfo::default()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::TestContextManager; + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_app_version_info_deserialize() -> Result<()> { + let json = r##"{ + "clients": [ + { + "clientId": "deltachat", + "sources": [ + { + "appId": "gplay", + "versionInteger": 754, + "versionString": "2.57.0", + "downloadUrl": "https://github.com/deltachat/deltachat-android/releases/download/v2.57.0/deltachat-gplay-release-2.57.0.apk" + } + ] + } + ] + }"##; + let versions: AppVersionInfo = serde_json::from_str(json)?; + assert_eq!(versions.clients.len(), 1); + assert_eq!(versions.clients[0].client_id, "deltachat"); + assert_eq!(versions.clients[0].sources.len(), 1); + assert_eq!(versions.clients[0].sources[0].app_id, "gplay"); + assert_eq!(versions.clients[0].sources[0].version_integer, 754); + assert_eq!(versions.clients[0].sources[0].version_string, "2.57.0"); + assert_eq!( + versions.clients[0].sources[0].download_url, + "https://github.com/deltachat/deltachat-android/releases/download/v2.57.0/deltachat-gplay-release-2.57.0.apk" + ); + + // missing fields are set to defaults, additional fields are ignored, errors are errors + let json = r##"{ + "clients": [ + { + "clientId": "deltachat", + "xsource": "bang" + } + ], + "foo": "bar" + }"##; + let versions: AppVersionInfo = serde_json::from_str(json)?; + assert_eq!(versions.clients.len(), 1); + assert_eq!(versions.clients[0].client_id, "deltachat"); + assert_eq!(versions.clients[0].sources.len(), 0); + + let json = "{}"; + let versions: AppVersionInfo = serde_json::from_str(json)?; + assert_eq!(versions.clients.len(), 0); + + let json = ""; + assert!(serde_json::from_str::(json).is_err()); + + let json = "bad json"; + assert!(serde_json::from_str::(json).is_err()); + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_get_app_versions() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + + let versions = get_app_versions(alice).await?; + assert!(versions.clients.is_empty()); + + Ok(()) + } +} diff --git a/src/imap.rs b/src/imap.rs index 6378137b1..7fce12349 100644 --- a/src/imap.rs +++ b/src/imap.rs @@ -140,6 +140,10 @@ pub(crate) struct ServerMetadata { /// should be fetched from the server /// to be ready for WebRTC calls. pub ice_servers_expiration_timestamp: i64, + + /// App versions, as raw JSON string. + /// Consumed by get_app_versions(). + pub app_versions: Option, } struct UidGrouper> { @@ -1342,6 +1346,7 @@ impl Session { let mut max_smtp_rcpt_to = None; let mut ice_servers = None; let mut ice_servers_expiration_timestamp = 0; + let mut app_versions = None; let mailbox = ""; let options = ""; @@ -1397,6 +1402,9 @@ impl Session { } } } + "/shared/vendor/deltachat/appversions" => { + app_versions = m.value; + } _ => {} } } @@ -1418,6 +1426,7 @@ impl Session { supports_push: max_smtp_rcpt_to.is_some() || self.capabilities.has_xdeltapush, ice_servers, ice_servers_expiration_timestamp, + app_versions, }, ); Ok(()) diff --git a/src/lib.rs b/src/lib.rs index f73fa7b8f..94e9e56d8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -54,6 +54,7 @@ pub(crate) mod events; pub use events::*; mod aheader; +pub mod appversions; mod automatic_relay_management; pub mod blob; pub mod calls;