From 16ed021f96c9a044e02175c46e726704ef248bbe Mon Sep 17 00:00:00 2001 From: link2xt Date: Fri, 23 Feb 2024 10:01:33 +0000 Subject: [PATCH] api: dc_accounts_set_push_device_token and dc_get_push_state APIs --- deltachat-ffi/deltachat.h | 28 ++++++++++ deltachat-ffi/src/lib.rs | 35 +++++++++++- src/accounts.rs | 18 +++++- src/context.rs | 77 +++++++++++++++++++++---- src/imap.rs | 32 +++++++++++ src/imap/capabilities.rs | 7 +++ src/imap/client.rs | 1 + src/imap/session.rs | 4 ++ src/lib.rs | 1 + src/push.rs | 115 ++++++++++++++++++++++++++++++++++++++ src/scheduler.rs | 4 ++ 11 files changed, 308 insertions(+), 14 deletions(-) create mode 100644 src/push.rs diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index 683081151e..82fc2c6e18 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -686,6 +686,24 @@ int dc_get_connectivity (dc_context_t* context); char* dc_get_connectivity_html (dc_context_t* context); +#define DC_PUSH_NOT_CONNECTED 0 +#define DC_PUSH_HEARTBEAT 1 +#define DC_PUSH_CONNECTED 2 + +/** + * Get the current push notification state. + * One of: + * - DC_PUSH_NOT_CONNECTED + * - DC_PUSH_HEARTBEAT + * - DC_PUSH_CONNECTED + * + * @memberof dc_context_t + * @param context The context object. + * @return Push notification state. + */ +int dc_get_push_state (dc_context_t* context); + + /** * Standalone version of dc_accounts_all_work_done(). * Only used by the python tests. @@ -3165,6 +3183,16 @@ void dc_accounts_maybe_network_lost (dc_accounts_t* accounts); */ int dc_accounts_background_fetch (dc_accounts_t* accounts, uint64_t timeout); + +/** + * Sets device token for Apple Push Notification service. + * Returns immediately. + * + * @memberof dc_accounts_t + * @param token Hexadecimal device token + */ +void dc_accounts_set_push_device_token (dc_accounts_t* accounts, const char *token); + /** * Create the event emitter that is used to receive events. * diff --git a/deltachat-ffi/src/lib.rs b/deltachat-ffi/src/lib.rs index 277588d90b..037ddb2d3b 100644 --- a/deltachat-ffi/src/lib.rs +++ b/deltachat-ffi/src/lib.rs @@ -384,7 +384,7 @@ pub unsafe extern "C" fn dc_get_connectivity(context: *const dc_context_t) -> li return 0; } let ctx = &*context; - block_on(async move { ctx.get_connectivity().await as u32 as libc::c_int }) + block_on(ctx.get_connectivity()) as u32 as libc::c_int } #[no_mangle] @@ -407,6 +407,16 @@ pub unsafe extern "C" fn dc_get_connectivity_html( }) } +#[no_mangle] +pub unsafe extern "C" fn dc_get_push_state(context: *const dc_context_t) -> libc::c_int { + if context.is_null() { + eprintln!("ignoring careless call to dc_get_push_state()"); + return 0; + } + let ctx = &*context; + block_on(ctx.push_state()) as libc::c_int +} + #[no_mangle] pub unsafe extern "C" fn dc_all_work_done(context: *mut dc_context_t) -> libc::c_int { if context.is_null() { @@ -4919,6 +4929,29 @@ pub unsafe extern "C" fn dc_accounts_background_fetch( 1 } +#[no_mangle] +pub unsafe extern "C" fn dc_accounts_set_push_device_token( + accounts: *mut dc_accounts_t, + token: *const libc::c_char, +) { + if accounts.is_null() { + eprintln!("ignoring careless call to dc_accounts_set_push_device_token()"); + return; + } + + let accounts = &*accounts; + let token = to_string_lossy(token); + + block_on(async move { + let mut accounts = accounts.write().await; + if let Err(err) = accounts.set_push_device_token(&token).await { + accounts.emit_event(EventType::Error(format!( + "Failed to set notify token: {err:#}." + ))); + } + }) +} + #[no_mangle] pub unsafe extern "C" fn dc_accounts_get_event_emitter( accounts: *mut dc_accounts_t, diff --git a/src/accounts.rs b/src/accounts.rs index 197bd960a6..ccd4d64365 100644 --- a/src/accounts.rs +++ b/src/accounts.rs @@ -19,6 +19,7 @@ use tokio::time::{sleep, Duration}; use crate::context::{Context, ContextBuilder}; use crate::events::{Event, EventEmitter, EventType, Events}; +use crate::push::PushSubscriber; use crate::stock_str::StockStrings; /// Account manager, that can handle multiple accounts in a single place. @@ -37,6 +38,9 @@ pub struct Accounts { /// This way changing a translation for one context automatically /// changes it for all other contexts. pub(crate) stockstrings: StockStrings, + + /// Push notification subscriber shared between accounts. + push_subscriber: PushSubscriber, } impl Accounts { @@ -73,8 +77,9 @@ impl Accounts { .context("failed to load accounts config")?; let events = Events::new(); let stockstrings = StockStrings::new(); + let push_subscriber = PushSubscriber::new(); let accounts = config - .load_accounts(&events, &stockstrings, &dir) + .load_accounts(&events, &stockstrings, push_subscriber.clone(), &dir) .await .context("failed to load accounts")?; @@ -84,6 +89,7 @@ impl Accounts { accounts, events, stockstrings, + push_subscriber, }) } @@ -124,6 +130,7 @@ impl Accounts { .with_id(account_config.id) .with_events(self.events.clone()) .with_stock_strings(self.stockstrings.clone()) + .with_push_subscriber(self.push_subscriber.clone()) .build() .await?; // Try to open without a passphrase, @@ -144,6 +151,7 @@ impl Accounts { .with_id(account_config.id) .with_events(self.events.clone()) .with_stock_strings(self.stockstrings.clone()) + .with_push_subscriber(self.push_subscriber.clone()) .build() .await?; self.accounts.insert(account_config.id, ctx); @@ -340,6 +348,12 @@ impl Accounts { pub fn get_event_emitter(&self) -> EventEmitter { self.events.get_emitter() } + + /// Sets notification token for Apple Push Notification service. + pub async fn set_push_device_token(&mut self, token: &str) -> Result<()> { + self.push_subscriber.set_device_token(token).await; + Ok(()) + } } /// Configuration file name. @@ -525,6 +539,7 @@ impl Config { &self, events: &Events, stockstrings: &StockStrings, + push_subscriber: PushSubscriber, dir: &Path, ) -> Result> { let mut accounts = BTreeMap::new(); @@ -535,6 +550,7 @@ impl Config { .with_id(account_config.id) .with_events(events.clone()) .with_stock_strings(stockstrings.clone()) + .with_push_subscriber(push_subscriber.clone()) .build() .await .with_context(|| format!("failed to create context from file {:?}", &dbfile))?; diff --git a/src/context.rs b/src/context.rs index 3b372dfa1c..feac1418fd 100644 --- a/src/context.rs +++ b/src/context.rs @@ -30,6 +30,7 @@ use crate::login_param::LoginParam; use crate::message::{self, Message, MessageState, MsgId, Viewtype}; use crate::param::{Param, Params}; use crate::peerstate::Peerstate; +use crate::push::PushSubscriber; use crate::quota::QuotaInfo; use crate::scheduler::{convert_folder_meaning, SchedulerState}; use crate::sql::Sql; @@ -86,6 +87,8 @@ pub struct ContextBuilder { events: Events, stock_strings: StockStrings, password: Option, + + push_subscriber: Option, } impl ContextBuilder { @@ -101,6 +104,7 @@ impl ContextBuilder { events: Events::new(), stock_strings: StockStrings::new(), password: None, + push_subscriber: None, } } @@ -155,10 +159,23 @@ impl ContextBuilder { self } + /// Sets push subscriber. + pub(crate) fn with_push_subscriber(mut self, push_subscriber: PushSubscriber) -> Self { + self.push_subscriber = Some(push_subscriber); + self + } + /// Builds the [`Context`] without opening it. pub async fn build(self) -> Result { - let context = - Context::new_closed(&self.dbfile, self.id, self.events, self.stock_strings).await?; + let push_subscriber = self.push_subscriber.unwrap_or_default(); + let context = Context::new_closed( + &self.dbfile, + self.id, + self.events, + self.stock_strings, + push_subscriber, + ) + .await?; Ok(context) } @@ -263,6 +280,13 @@ pub struct InnerContext { /// Standard RwLock instead of [`tokio::sync::RwLock`] is used /// because the lock is used from synchronous [`Context::emit_event`]. pub(crate) debug_logging: std::sync::RwLock>, + + /// Push subscriber to store device token + /// and register for heartbeat notifications. + pub(crate) push_subscriber: PushSubscriber, + + /// True if account has subscribed to push notifications via IMAP. + pub(crate) push_subscribed: AtomicBool, } /// The state of ongoing process. @@ -308,7 +332,8 @@ impl Context { events: Events, stock_strings: StockStrings, ) -> Result { - let context = Self::new_closed(dbfile, id, events, stock_strings).await?; + let context = + Self::new_closed(dbfile, id, events, stock_strings, Default::default()).await?; // Open the database if is not encrypted. if context.check_passphrase("".to_string()).await? { @@ -323,6 +348,7 @@ impl Context { id: u32, events: Events, stockstrings: StockStrings, + push_subscriber: PushSubscriber, ) -> Result { let mut blob_fname = OsString::new(); blob_fname.push(dbfile.file_name().unwrap_or_default()); @@ -331,7 +357,14 @@ impl Context { if !blobdir.exists() { tokio::fs::create_dir_all(&blobdir).await?; } - let context = Context::with_blobdir(dbfile.into(), blobdir, id, events, stockstrings)?; + let context = Context::with_blobdir( + dbfile.into(), + blobdir, + id, + events, + stockstrings, + push_subscriber, + )?; Ok(context) } @@ -374,6 +407,7 @@ impl Context { id: u32, events: Events, stockstrings: StockStrings, + push_subscriber: PushSubscriber, ) -> Result { ensure!( blobdir.is_dir(), @@ -408,6 +442,8 @@ impl Context { last_full_folder_scan: Mutex::new(None), last_error: std::sync::RwLock::new("".to_string()), debug_logging: std::sync::RwLock::new(None), + push_subscriber, + push_subscribed: AtomicBool::new(false), }; let ctx = Context { @@ -1509,7 +1545,14 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let dbfile = tmp.path().join("db.sqlite"); let blobdir = PathBuf::new(); - let res = Context::with_blobdir(dbfile, blobdir, 1, Events::new(), StockStrings::new()); + let res = Context::with_blobdir( + dbfile, + blobdir, + 1, + Events::new(), + StockStrings::new(), + Default::default(), + ); assert!(res.is_err()); } @@ -1518,7 +1561,14 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let dbfile = tmp.path().join("db.sqlite"); let blobdir = tmp.path().join("blobs"); - let res = Context::with_blobdir(dbfile, blobdir, 1, Events::new(), StockStrings::new()); + let res = Context::with_blobdir( + dbfile, + blobdir, + 1, + Events::new(), + StockStrings::new(), + Default::default(), + ); assert!(res.is_err()); } @@ -1741,16 +1791,18 @@ mod tests { let dir = tempdir()?; let dbfile = dir.path().join("db.sqlite"); - let id = 1; - let context = Context::new_closed(&dbfile, id, Events::new(), StockStrings::new()) + let context = ContextBuilder::new(dbfile.clone()) + .with_id(1) + .build() .await .context("failed to create context")?; assert_eq!(context.open("foo".to_string()).await?, true); assert_eq!(context.is_open().await, true); drop(context); - let id = 2; - let context = Context::new(&dbfile, id, Events::new(), StockStrings::new()) + let context = ContextBuilder::new(dbfile) + .with_id(2) + .build() .await .context("failed to create context")?; assert_eq!(context.is_open().await, false); @@ -1766,8 +1818,9 @@ mod tests { let dir = tempdir()?; let dbfile = dir.path().join("db.sqlite"); - let id = 1; - let context = Context::new_closed(&dbfile, id, Events::new(), StockStrings::new()) + let context = ContextBuilder::new(dbfile) + .with_id(1) + .build() .await .context("failed to create context")?; assert_eq!(context.open("foo".to_string()).await?, true); diff --git a/src/imap.rs b/src/imap.rs index 83432bae57..61bde65f0f 100644 --- a/src/imap.rs +++ b/src/imap.rs @@ -8,6 +8,7 @@ use std::{ collections::{BTreeMap, BTreeSet, HashMap}, iter::Peekable, mem::take, + sync::atomic::Ordering, time::Duration, }; @@ -1445,6 +1446,37 @@ impl Session { *lock = Some(ServerMetadata { comment, admin }); Ok(()) } + + /// Stores device token into /private/devicetoken IMAP METADATA of the Inbox. + pub(crate) async fn register_token(&mut self, context: &Context) -> Result<()> { + if context.push_subscribed.load(Ordering::Relaxed) { + return Ok(()); + } + + let Some(device_token) = context.push_subscriber.device_token().await else { + return Ok(()); + }; + + if self.can_metadata() && self.can_push() { + let folder = context + .get_config(Config::ConfiguredInboxFolder) + .await? + .context("INBOX is not configured")?; + + self.run_command_and_check_ok(format!( + "SETMETADATA \"{folder}\" (/private/devicetoken \"{device_token}\")" + )) + .await + .context("SETMETADATA command failed")?; + context.push_subscribed.store(true, Ordering::Relaxed); + } else if !context.push_subscriber.heartbeat_subscribed().await { + let context = context.clone(); + // Subscribe for heartbeat notifications. + tokio::spawn(async move { context.push_subscriber.subscribe().await }); + } + + Ok(()) + } } impl Session { diff --git a/src/imap/capabilities.rs b/src/imap/capabilities.rs index ced5def49c..af1996baa2 100644 --- a/src/imap/capabilities.rs +++ b/src/imap/capabilities.rs @@ -25,6 +25,13 @@ pub(crate) struct Capabilities { /// pub can_metadata: bool, + /// True if the server supports XDELTAPUSH capability. + /// This capability means setting /private/devicetoken IMAP METADATA + /// on the INBOX results in new mail notifications + /// via notifications.delta.chat service. + /// This is supported by + pub can_push: bool, + /// Server ID if the server supports ID capability. pub server_id: Option>, } diff --git a/src/imap/client.rs b/src/imap/client.rs index 5d728e5fba..aff54f5e59 100644 --- a/src/imap/client.rs +++ b/src/imap/client.rs @@ -60,6 +60,7 @@ async fn determine_capabilities( can_check_quota: caps.has_str("QUOTA"), can_condstore: caps.has_str("CONDSTORE"), can_metadata: caps.has_str("METADATA"), + can_push: caps.has_str("XDELTAPUSH"), server_id, }; Ok(capabilities) diff --git a/src/imap/session.rs b/src/imap/session.rs index c3a5bb778c..1c541aaf09 100644 --- a/src/imap/session.rs +++ b/src/imap/session.rs @@ -90,6 +90,10 @@ impl Session { self.capabilities.can_metadata } + pub fn can_push(&self) -> bool { + self.capabilities.can_push + } + /// Returns the names of all folders on the IMAP server. pub async fn list_folders(&mut self) -> Result> { let list = self.list(Some(""), Some("*")).await?.try_collect().await?; diff --git a/src/lib.rs b/src/lib.rs index 20f15972c5..ed3f6860d0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -98,6 +98,7 @@ mod color; pub mod html; pub mod net; pub mod plaintext; +mod push; pub mod summary; mod debug_logging; diff --git a/src/push.rs b/src/push.rs new file mode 100644 index 0000000000..9196ab8555 --- /dev/null +++ b/src/push.rs @@ -0,0 +1,115 @@ +use std::sync::atomic::Ordering; +use std::sync::Arc; + +use anyhow::Result; +use tokio::sync::RwLock; + +use crate::context::Context; +use crate::net::http; + +/// Manages subscription to Apple Push Notification services. +/// +/// This structure is created by account manager and is shared between accounts. +/// To enable notifications, application should request the device token as described in +/// +/// and give it to the account manager, which will forward the token in this structure. +/// +/// Each account (context) can then retrieve device token +/// from this structure and give it to the email server. +/// If email server does not support push notifications, +/// account can call `subscribe` method +/// to register device token with the heartbeat +/// notification provider server as a fallback. +#[derive(Debug, Clone, Default)] +pub struct PushSubscriber { + inner: Arc>, +} + +impl PushSubscriber { + /// Creates new push notification subscriber. + pub(crate) fn new() -> Self { + Default::default() + } + + /// Sets device token for Apple Push Notification service. + pub(crate) async fn set_device_token(&mut self, token: &str) { + self.inner.write().await.device_token = Some(token.to_string()); + } + + /// Retrieves device token. + /// + /// Token may be not available if application is not running on Apple platform, + /// failed to register for remote notifications or is in the process of registering. + /// + /// IMAP loop should periodically check if device token is available + /// and send the token to the email server if it supports push notifications. + pub(crate) async fn device_token(&self) -> Option { + self.inner.read().await.device_token.clone() + } + + /// Subscribes for heartbeat notifications with previously set device token. + pub(crate) async fn subscribe(&self) -> Result<()> { + let mut state = self.inner.write().await; + + if state.heartbeat_subscribed { + return Ok(()); + } + + let Some(ref token) = state.device_token else { + return Ok(()); + }; + + let socks5_config = None; + let response = http::get_client(socks5_config)? + .post("https://notifications.delta.chat/register") + .body(format!("{{\"token\":\"{token}\"}}")) + .send() + .await?; + + let response_status = response.status(); + if response_status.is_success() { + state.heartbeat_subscribed = true; + } + Ok(()) + } + + pub(crate) async fn heartbeat_subscribed(&self) -> bool { + self.inner.read().await.heartbeat_subscribed + } +} + +#[derive(Debug, Default)] +pub(crate) struct PushSubscriberState { + /// Device token. + device_token: Option, + + /// If subscribed to heartbeat push notifications. + heartbeat_subscribed: bool, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[repr(i8)] +pub enum NotifyState { + /// Not subscribed to push notifications. + #[default] + NotConnected = 0, + + /// Subscribed to heartbeat push notifications. + Heartbeat = 1, + + /// Subscribed to push notifications for new messages. + Connected = 2, +} + +impl Context { + /// Returns push notification subscriber state. + pub async fn push_state(&self) -> NotifyState { + if self.push_subscribed.load(Ordering::Relaxed) { + NotifyState::Connected + } else if self.push_subscriber.heartbeat_subscribed().await { + NotifyState::Heartbeat + } else { + NotifyState::NotConnected + } + } +} diff --git a/src/scheduler.rs b/src/scheduler.rs index c4a065af8b..948ac152bf 100644 --- a/src/scheduler.rs +++ b/src/scheduler.rs @@ -525,6 +525,10 @@ async fn inbox_fetch_idle(ctx: &Context, imap: &mut Imap, mut session: Session) .fetch_metadata(ctx) .await .context("Failed to fetch metadata")?; + session + .register_token(ctx) + .await + .context("Failed to register push token")?; let session = fetch_idle(ctx, imap, session, FolderMeaning::Inbox).await?; Ok(session)