api: dc_accounts_set_push_device_token and dc_get_push_state APIs

This commit is contained in:
link2xt
2024-02-23 10:01:33 +00:00
parent 863a386d0f
commit 7502234686
11 changed files with 308 additions and 14 deletions

View File

@@ -686,6 +686,24 @@ int dc_get_connectivity (dc_context_t* context);
char* dc_get_connectivity_html (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(). * Standalone version of dc_accounts_all_work_done().
* Only used by the python tests. * 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); 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. * Create the event emitter that is used to receive events.
* *

View File

@@ -384,7 +384,7 @@ pub unsafe extern "C" fn dc_get_connectivity(context: *const dc_context_t) -> li
return 0; return 0;
} }
let ctx = &*context; 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] #[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] #[no_mangle]
pub unsafe extern "C" fn dc_all_work_done(context: *mut dc_context_t) -> libc::c_int { pub unsafe extern "C" fn dc_all_work_done(context: *mut dc_context_t) -> libc::c_int {
if context.is_null() { if context.is_null() {
@@ -4919,6 +4929,29 @@ pub unsafe extern "C" fn dc_accounts_background_fetch(
1 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] #[no_mangle]
pub unsafe extern "C" fn dc_accounts_get_event_emitter( pub unsafe extern "C" fn dc_accounts_get_event_emitter(
accounts: *mut dc_accounts_t, accounts: *mut dc_accounts_t,

View File

@@ -19,6 +19,7 @@ use tokio::time::{sleep, Duration};
use crate::context::{Context, ContextBuilder}; use crate::context::{Context, ContextBuilder};
use crate::events::{Event, EventEmitter, EventType, Events}; use crate::events::{Event, EventEmitter, EventType, Events};
use crate::push::PushSubscriber;
use crate::stock_str::StockStrings; use crate::stock_str::StockStrings;
/// Account manager, that can handle multiple accounts in a single place. /// 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 /// This way changing a translation for one context automatically
/// changes it for all other contexts. /// changes it for all other contexts.
pub(crate) stockstrings: StockStrings, pub(crate) stockstrings: StockStrings,
/// Push notification subscriber shared between accounts.
push_subscriber: PushSubscriber,
} }
impl Accounts { impl Accounts {
@@ -73,8 +77,9 @@ impl Accounts {
.context("failed to load accounts config")?; .context("failed to load accounts config")?;
let events = Events::new(); let events = Events::new();
let stockstrings = StockStrings::new(); let stockstrings = StockStrings::new();
let push_subscriber = PushSubscriber::new();
let accounts = config let accounts = config
.load_accounts(&events, &stockstrings, &dir) .load_accounts(&events, &stockstrings, push_subscriber.clone(), &dir)
.await .await
.context("failed to load accounts")?; .context("failed to load accounts")?;
@@ -84,6 +89,7 @@ impl Accounts {
accounts, accounts,
events, events,
stockstrings, stockstrings,
push_subscriber,
}) })
} }
@@ -124,6 +130,7 @@ impl Accounts {
.with_id(account_config.id) .with_id(account_config.id)
.with_events(self.events.clone()) .with_events(self.events.clone())
.with_stock_strings(self.stockstrings.clone()) .with_stock_strings(self.stockstrings.clone())
.with_push_subscriber(self.push_subscriber.clone())
.build() .build()
.await?; .await?;
// Try to open without a passphrase, // Try to open without a passphrase,
@@ -144,6 +151,7 @@ impl Accounts {
.with_id(account_config.id) .with_id(account_config.id)
.with_events(self.events.clone()) .with_events(self.events.clone())
.with_stock_strings(self.stockstrings.clone()) .with_stock_strings(self.stockstrings.clone())
.with_push_subscriber(self.push_subscriber.clone())
.build() .build()
.await?; .await?;
self.accounts.insert(account_config.id, ctx); self.accounts.insert(account_config.id, ctx);
@@ -340,6 +348,12 @@ impl Accounts {
pub fn get_event_emitter(&self) -> EventEmitter { pub fn get_event_emitter(&self) -> EventEmitter {
self.events.get_emitter() 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. /// Configuration file name.
@@ -525,6 +539,7 @@ impl Config {
&self, &self,
events: &Events, events: &Events,
stockstrings: &StockStrings, stockstrings: &StockStrings,
push_subscriber: PushSubscriber,
dir: &Path, dir: &Path,
) -> Result<BTreeMap<u32, Context>> { ) -> Result<BTreeMap<u32, Context>> {
let mut accounts = BTreeMap::new(); let mut accounts = BTreeMap::new();
@@ -535,6 +550,7 @@ impl Config {
.with_id(account_config.id) .with_id(account_config.id)
.with_events(events.clone()) .with_events(events.clone())
.with_stock_strings(stockstrings.clone()) .with_stock_strings(stockstrings.clone())
.with_push_subscriber(push_subscriber.clone())
.build() .build()
.await .await
.with_context(|| format!("failed to create context from file {:?}", &dbfile))?; .with_context(|| format!("failed to create context from file {:?}", &dbfile))?;

View File

@@ -30,6 +30,7 @@ use crate::login_param::LoginParam;
use crate::message::{self, Message, MessageState, MsgId, Viewtype}; use crate::message::{self, Message, MessageState, MsgId, Viewtype};
use crate::param::{Param, Params}; use crate::param::{Param, Params};
use crate::peerstate::Peerstate; use crate::peerstate::Peerstate;
use crate::push::PushSubscriber;
use crate::quota::QuotaInfo; use crate::quota::QuotaInfo;
use crate::scheduler::{convert_folder_meaning, SchedulerState}; use crate::scheduler::{convert_folder_meaning, SchedulerState};
use crate::sql::Sql; use crate::sql::Sql;
@@ -86,6 +87,8 @@ pub struct ContextBuilder {
events: Events, events: Events,
stock_strings: StockStrings, stock_strings: StockStrings,
password: Option<String>, password: Option<String>,
push_subscriber: Option<PushSubscriber>,
} }
impl ContextBuilder { impl ContextBuilder {
@@ -101,6 +104,7 @@ impl ContextBuilder {
events: Events::new(), events: Events::new(),
stock_strings: StockStrings::new(), stock_strings: StockStrings::new(),
password: None, password: None,
push_subscriber: None,
} }
} }
@@ -155,10 +159,23 @@ impl ContextBuilder {
self 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. /// Builds the [`Context`] without opening it.
pub async fn build(self) -> Result<Context> { pub async fn build(self) -> Result<Context> {
let context = let push_subscriber = self.push_subscriber.unwrap_or_default();
Context::new_closed(&self.dbfile, self.id, self.events, self.stock_strings).await?; let context = Context::new_closed(
&self.dbfile,
self.id,
self.events,
self.stock_strings,
push_subscriber,
)
.await?;
Ok(context) Ok(context)
} }
@@ -263,6 +280,13 @@ pub struct InnerContext {
/// Standard RwLock instead of [`tokio::sync::RwLock`] is used /// Standard RwLock instead of [`tokio::sync::RwLock`] is used
/// because the lock is used from synchronous [`Context::emit_event`]. /// because the lock is used from synchronous [`Context::emit_event`].
pub(crate) debug_logging: std::sync::RwLock<Option<DebugLogging>>, pub(crate) debug_logging: std::sync::RwLock<Option<DebugLogging>>,
/// 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. /// The state of ongoing process.
@@ -308,7 +332,8 @@ impl Context {
events: Events, events: Events,
stock_strings: StockStrings, stock_strings: StockStrings,
) -> Result<Context> { ) -> Result<Context> {
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. // Open the database if is not encrypted.
if context.check_passphrase("".to_string()).await? { if context.check_passphrase("".to_string()).await? {
@@ -323,6 +348,7 @@ impl Context {
id: u32, id: u32,
events: Events, events: Events,
stockstrings: StockStrings, stockstrings: StockStrings,
push_subscriber: PushSubscriber,
) -> Result<Context> { ) -> Result<Context> {
let mut blob_fname = OsString::new(); let mut blob_fname = OsString::new();
blob_fname.push(dbfile.file_name().unwrap_or_default()); blob_fname.push(dbfile.file_name().unwrap_or_default());
@@ -331,7 +357,14 @@ impl Context {
if !blobdir.exists() { if !blobdir.exists() {
tokio::fs::create_dir_all(&blobdir).await?; 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) Ok(context)
} }
@@ -374,6 +407,7 @@ impl Context {
id: u32, id: u32,
events: Events, events: Events,
stockstrings: StockStrings, stockstrings: StockStrings,
push_subscriber: PushSubscriber,
) -> Result<Context> { ) -> Result<Context> {
ensure!( ensure!(
blobdir.is_dir(), blobdir.is_dir(),
@@ -408,6 +442,8 @@ impl Context {
last_full_folder_scan: Mutex::new(None), last_full_folder_scan: Mutex::new(None),
last_error: std::sync::RwLock::new("".to_string()), last_error: std::sync::RwLock::new("".to_string()),
debug_logging: std::sync::RwLock::new(None), debug_logging: std::sync::RwLock::new(None),
push_subscriber,
push_subscribed: AtomicBool::new(false),
}; };
let ctx = Context { let ctx = Context {
@@ -1509,7 +1545,14 @@ mod tests {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let dbfile = tmp.path().join("db.sqlite"); let dbfile = tmp.path().join("db.sqlite");
let blobdir = PathBuf::new(); 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()); assert!(res.is_err());
} }
@@ -1518,7 +1561,14 @@ mod tests {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let dbfile = tmp.path().join("db.sqlite"); let dbfile = tmp.path().join("db.sqlite");
let blobdir = tmp.path().join("blobs"); 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()); assert!(res.is_err());
} }
@@ -1741,16 +1791,18 @@ mod tests {
let dir = tempdir()?; let dir = tempdir()?;
let dbfile = dir.path().join("db.sqlite"); let dbfile = dir.path().join("db.sqlite");
let id = 1; let context = ContextBuilder::new(dbfile.clone())
let context = Context::new_closed(&dbfile, id, Events::new(), StockStrings::new()) .with_id(1)
.build()
.await .await
.context("failed to create context")?; .context("failed to create context")?;
assert_eq!(context.open("foo".to_string()).await?, true); assert_eq!(context.open("foo".to_string()).await?, true);
assert_eq!(context.is_open().await, true); assert_eq!(context.is_open().await, true);
drop(context); drop(context);
let id = 2; let context = ContextBuilder::new(dbfile)
let context = Context::new(&dbfile, id, Events::new(), StockStrings::new()) .with_id(2)
.build()
.await .await
.context("failed to create context")?; .context("failed to create context")?;
assert_eq!(context.is_open().await, false); assert_eq!(context.is_open().await, false);
@@ -1766,8 +1818,9 @@ mod tests {
let dir = tempdir()?; let dir = tempdir()?;
let dbfile = dir.path().join("db.sqlite"); let dbfile = dir.path().join("db.sqlite");
let id = 1; let context = ContextBuilder::new(dbfile)
let context = Context::new_closed(&dbfile, id, Events::new(), StockStrings::new()) .with_id(1)
.build()
.await .await
.context("failed to create context")?; .context("failed to create context")?;
assert_eq!(context.open("foo".to_string()).await?, true); assert_eq!(context.open("foo".to_string()).await?, true);

View File

@@ -8,6 +8,7 @@ use std::{
collections::{BTreeMap, BTreeSet, HashMap}, collections::{BTreeMap, BTreeSet, HashMap},
iter::Peekable, iter::Peekable,
mem::take, mem::take,
sync::atomic::Ordering,
time::Duration, time::Duration,
}; };
@@ -1445,6 +1446,37 @@ impl Session {
*lock = Some(ServerMetadata { comment, admin }); *lock = Some(ServerMetadata { comment, admin });
Ok(()) 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 { impl Session {

View File

@@ -25,6 +25,13 @@ pub(crate) struct Capabilities {
/// <https://tools.ietf.org/html/rfc5464> /// <https://tools.ietf.org/html/rfc5464>
pub can_metadata: bool, 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 <https://github.com/deltachat/chatmail>
pub can_push: bool,
/// Server ID if the server supports ID capability. /// Server ID if the server supports ID capability.
pub server_id: Option<HashMap<String, String>>, pub server_id: Option<HashMap<String, String>>,
} }

View File

@@ -60,6 +60,7 @@ async fn determine_capabilities(
can_check_quota: caps.has_str("QUOTA"), can_check_quota: caps.has_str("QUOTA"),
can_condstore: caps.has_str("CONDSTORE"), can_condstore: caps.has_str("CONDSTORE"),
can_metadata: caps.has_str("METADATA"), can_metadata: caps.has_str("METADATA"),
can_push: caps.has_str("XDELTAPUSH"),
server_id, server_id,
}; };
Ok(capabilities) Ok(capabilities)

View File

@@ -90,6 +90,10 @@ impl Session {
self.capabilities.can_metadata self.capabilities.can_metadata
} }
pub fn can_push(&self) -> bool {
self.capabilities.can_push
}
/// Returns the names of all folders on the IMAP server. /// Returns the names of all folders on the IMAP server.
pub async fn list_folders(&mut self) -> Result<Vec<async_imap::types::Name>> { pub async fn list_folders(&mut self) -> Result<Vec<async_imap::types::Name>> {
let list = self.list(Some(""), Some("*")).await?.try_collect().await?; let list = self.list(Some(""), Some("*")).await?.try_collect().await?;

View File

@@ -98,6 +98,7 @@ mod color;
pub mod html; pub mod html;
pub mod net; pub mod net;
pub mod plaintext; pub mod plaintext;
mod push;
pub mod summary; pub mod summary;
mod debug_logging; mod debug_logging;

115
src/push.rs Normal file
View File

@@ -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
/// <https://developer.apple.com/documentation/usernotifications/registering-your-app-with-apns>
/// 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<RwLock<PushSubscriberState>>,
}
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<String> {
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<String>,
/// 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
}
}
}

View File

@@ -525,6 +525,10 @@ async fn inbox_fetch_idle(ctx: &Context, imap: &mut Imap, mut session: Session)
.fetch_metadata(ctx) .fetch_metadata(ctx)
.await .await
.context("Failed to fetch metadata")?; .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?; let session = fetch_idle(ctx, imap, session, FolderMeaning::Inbox).await?;
Ok(session) Ok(session)