feat: open the mail store without the window on Android

This commit is contained in:
Tomás Romero committed 2026-11-10 01:44:55 +01:00
1 parent f6a6e6c256
commit df13f1f41b
8 files changed
+484 -29

No files matched your search

+4 -1
View File
@@ -16,7 +16,10 @@ fn main() {
user_notifications: { any(target_os = "macos", target_os = "ios") },
// The system suspends the app in the background and wakes it to
// check for new mail.
background_checks: { target_os = "ios" },
background_checks: { any(target_os = "ios", target_os = "android") },
// The system may start the app without its window, for a check for
// new mail or a button on a notification.
background_engine: { target_os = "android" },
}
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("ios") {
+2 -1
View File
@@ -26,7 +26,8 @@ these aliases with [`cfg_aliases`](https://crates.io/crates/cfg_aliases):
| `linux_desktop` | A Linux desktop session (portals, X11/Wayland, Secret Service). Excludes OpenHarmony, which also reports `target_os = "linux"`. |
| `glibc` | Linux with the GNU C library. |
| `user_notifications` | New-mail notifications through Apple's User Notifications framework (macOS, iOS). |
| `background_checks` | The system suspends the app in the background and wakes it to check for new mail (iOS). |
| `background_checks` | The system suspends the app in the background and wakes it to check for new mail (iOS, Android). |
| `background_engine` | The system may start the app without its window, for a check for new mail or a button on a notification (Android). |
| `rename_noreplace` | Core only: kernels with an atomic no-replace directory rename. |
| `secret_service` | Core only: desktops where `keyring` stores secrets through the freedesktop Secret Service (Linux, FreeBSD and OpenBSD, not OpenHarmony). |
+17 -3
View File
@@ -42,9 +42,15 @@ mod memory;
mod message_appearance;
mod move_picker;
mod notifications;
/// What Android asks of the app without its window.
#[cfg(background_engine)]
pub use notifications::engine::{
EngineServices, check_for_mail, respond_in_background, stop_check, window_ended,
window_starting,
};
pub use notifications::{
Act, Alert, AlertId, Availability, Failure, FixedWords, HostFuture, NewMailNotification,
NotificationHost, Response, Route, parse_payload, payload, respond,
Act, Alert, AlertId, Availability, Badge, Controls, Failure, FixedWords, HostFuture,
NewMailNotification, NotificationHost, Response, Route, parse_payload, payload, respond,
};
mod number_format;
mod oauth_browser;
@@ -1468,7 +1474,7 @@ impl PlatformContext {
oauth_redirects: flectar_mail_core::oauth::redirect::OAuthRedirectBrokerHandle,
) -> Self {
Self {
paths: Paths::new(data_root.join("data"), cache_dir),
paths: Self::app_private_paths(data_root, cache_dir),
external_urls: ExternalUrlOpener::default(),
notifications: None,
documents,
@@ -1476,6 +1482,12 @@ impl PlatformContext {
oauth_redirects,
}
}
/// The profile in a host-owned sandbox, as [`Self::app_private`] keeps it:
/// what a process that runs without the window opens too.
pub fn app_private_paths(data_root: PathBuf, cache_dir: PathBuf) -> Paths {
Paths::new(data_root.join("data"), cache_dir)
}
}
#[cfg(desktop)]
@@ -7272,6 +7284,8 @@ pub fn run(platform: PlatformContext) -> Result<(), Box<dyn std::error::Error>>
composer::save_open_messages();
let core = state.borrow().core.as_ref().map(CoreMailSource::file_core);
notifications::withdraw_before_exit(&runtime, core, platform.notifications.clone());
// The runtime ends with this function.
notifications::core_closed();
event_loop_result.map_err(|error| {
renderer_preferences::startup_error(error, use_wgpu && !gpu_startup_completed.get())
})?;
+76 -6
View File
@@ -123,6 +123,10 @@ pub trait NotificationHost: Send + Sync + 'static {
fn set_badge(&self, _badge: Option<Badge>) -> HostFuture<()> {
Box::pin(std::future::ready(()))
}
/// Whether the user wants what a check in the background brings, as it
/// changes: notifications, or the count on the app icon. Hosts that ask
/// the system for such checks schedule or stop them here.
fn checks_wanted(&self, _wanted: bool) {}
}
/// The count of unread mail on the app icon.
@@ -484,6 +488,25 @@ fn with_core(errand: impl FnOnce(&tokio::runtime::Handle, &Arc<Core>) + Send + '
errand(&runtime, &core);
}
/// The mail store closed with the run that opened it. Errands from now on
/// wait for the next one, such as a check in the background opens.
pub(crate) fn core_closed() {
ERRANDS.lock().unwrap_or_else(PoisonError::into_inner).ready = None;
}
/// The mail store could not open: give up the errands that waited for it,
/// which tells whoever waits on them.
#[cfg(background_engine)]
fn drop_waiting_errands() {
let waiting = std::mem::take(
&mut ERRANDS
.lock()
.unwrap_or_else(PoisonError::into_inner)
.waiting,
);
drop(waiting);
}
/// The mail store opened: run the errands that waited for it, in order.
fn core_opened(runtime: &tokio::runtime::Handle, core: &Arc<Core>) {
let waiting = {
@@ -498,12 +521,26 @@ fn core_opened(runtime: &tokio::runtime::Handle, core: &Arc<Core>) {
/// The user brought the app to the foreground, where the Inbox shows what
/// came while it wasn't running: that is no news for a check in the
/// background ([`flectar_mail_core::Core::forget_missed`]).
/// background ([`flectar_mail_core::Core::forget_missed`]). The store open
/// now forgets it, and so does the window's as it opens. A store a check
/// opens later doesn't: the user hasn't seen what came since.
#[cfg(background_checks)]
pub(crate) fn app_became_active() {
with_core(|_, core| core.forget_missed());
FORGET_WHEN_THE_WINDOW_OPENS.store(true, Ordering::Relaxed);
let ready = ERRANDS
.lock()
.unwrap_or_else(PoisonError::into_inner)
.ready
.clone();
if let Some((_, core)) = ready {
core.forget_missed();
}
}
/// The app came to the front before the window's store opened.
#[cfg(background_checks)]
static FORGET_WHEN_THE_WINDOW_OPENS: AtomicBool = AtomicBool::new(false);
/// Stops a [`check_in_background`].
#[cfg_attr(not(background_checks), allow(dead_code))]
pub(crate) struct BackgroundCheck(tokio::sync::watch::Sender<bool>);
@@ -602,6 +639,21 @@ pub(crate) fn serve_requests(handler: impl Fn(Request) + Send + Sync + 'static)
}
}
/// The body of an account's summary of new mail: the first senders it
/// names, then how many others, as `more` words it.
fn summary_body(senders: &[String], more: impl FnOnce(String, i32) -> String) -> String {
let named = senders
.iter()
.take(SUMMARY_SENDERS)
.cloned()
.collect::<Vec<_>>()
.join(", ");
match senders.len().saturating_sub(SUMMARY_SENDERS) {
0 => named,
others => more(named, i32::try_from(others).unwrap_or(i32::MAX)),
}
}
/// More new messages than this for one account at once are summed up in one
/// notification. Peers sum up from three (Proton) to five (Mailspring).
const SUMMARY_AFTER: usize = 3;
@@ -632,8 +684,9 @@ pub(crate) fn preferences_changed() {
static BACKGROUND_CHECKS_WANTED: AtomicBool = AtomicBool::new(false);
/// Whether to ask the system to wake the app now and then to check for new
/// mail.
#[cfg(background_checks)]
/// mail, for iOS, which asks as the app leaves the front. Android hosts hear
/// it as it changes ([`NotificationHost::checks_wanted`]).
#[cfg(all(user_notifications, background_checks))]
pub(crate) fn background_checks_wanted() -> bool {
BACKGROUND_CHECKS_WANTED.load(Ordering::Relaxed)
}
@@ -665,6 +718,10 @@ pub(crate) fn spawn_dispatcher(
core: Arc<Core>,
host: Option<Arc<dyn NotificationHost>>,
) {
#[cfg(background_checks)]
if FORGET_WHEN_THE_WINDOW_OPENS.swap(false, Ordering::Relaxed) {
core.forget_missed();
}
dispatch_through(runtime, core, host, ui::Live);
}
@@ -684,6 +741,7 @@ fn dispatch_through(
let mut pruned_at = None;
let mut enabled = notifications_enabled(&core).await;
let mut badge = None;
let mut told_wanted = None;
let mut flushed = 0;
loop {
let flushing = FLUSHES_ASKED.load(Ordering::Acquire);
@@ -716,8 +774,14 @@ fn dispatch_through(
}
}
}
BACKGROUND_CHECKS_WANTED
.store(host.is_some() && (enabled || counting), Ordering::Relaxed);
let wanted = host.is_some() && (enabled || counting);
BACKGROUND_CHECKS_WANTED.store(wanted, Ordering::Relaxed);
if let Some(host) = host.as_deref()
&& told_wanted != Some(wanted)
{
host.checks_wanted(wanted);
told_wanted = Some(wanted);
}
flushed = flushing;
FLUSHES_SERVED.send_replace(flushed);
tokio::select! {
@@ -1179,6 +1243,12 @@ pub(crate) mod ui;
#[path = "notifications/words.rs"]
mod words;
/// The mail store without the window, for what Android asks of the app
/// after its process had ended.
#[cfg(background_engine)]
#[path = "notifications/engine.rs"]
pub mod engine;
#[cfg(test)]
mod tests {
use super::*;
+321
View File
@@ -0,0 +1,321 @@
//! The mail store for what Android asks of the app without its window: a
//! check for new mail, or a button on a notification after the app's process
//! had ended. While the window runs, its store serves both. Otherwise one
//! opens for the errand and closes once nothing holds it. Only one store is
//! ever open, as two would sync the same accounts.
use super::{NotificationHost, Response, Window, Words, parse_payload, words::Speech};
use flectar_mail_core::{
Core, accounts::credentials::CredentialStoreHandle, config::Paths,
oauth::redirect::OAuthRedirectBrokerHandle,
};
use std::{
collections::{BTreeMap, BTreeSet},
sync::{Arc, Mutex, MutexGuard, PoisonError, mpsc},
time::Duration,
};
/// What opens the mail store without the window.
pub struct EngineServices {
pub paths: Paths,
pub credentials: CredentialStoreHandle,
pub oauth_redirects: OAuthRedirectBrokerHandle,
pub host: Arc<dyn NotificationHost>,
}
enum State {
/// No store is open.
Closed,
/// The window runs, with the store it opened.
Window,
/// A store opened for errands, held this many times.
Open {
runtime: tokio::runtime::Runtime,
held: usize,
generation: u64,
},
}
static STATE: Mutex<State> = Mutex::new(State::Closed);
/// Tells the stores opened for errands apart, so a late release or timer
/// of one never touches the next.
static GENERATION: Mutex<u64> = Mutex::new(0);
/// The checks in progress, by the job that runs each.
static CHECKS: Mutex<BTreeMap<i32, super::BackgroundCheck>> = Mutex::new(BTreeMap::new());
/// How long a store opened for errands stays open after the last one ends,
/// for errands that come in bursts, such as several buttons in a row.
const LINGER: Duration = Duration::from_secs(5);
/// How long the window waits for such a store to close.
const HAND_OVER: Duration = Duration::from_secs(3);
/// How long a button's receiver waits for its action to be saved, within
/// the time Android gives a receiver.
const SAVE_LIMIT: Duration = Duration::from_secs(8);
fn state() -> MutexGuard<'static, State> {
STATE.lock().unwrap_or_else(PoisonError::into_inner)
}
/// Keeps the store an errand uses open while it lives. The window's store
/// needs no holding.
pub(crate) struct Hold(Option<u64>);
impl Drop for Hold {
fn drop(&mut self) {
let Some(generation) = self.0 else {
return;
};
let mut state = state();
if let State::Open {
held,
generation: current,
..
} = &mut *state
&& *current == generation
{
*held -= 1;
if *held == 0 {
drop(state);
// Not on a runtime thread: closing waits for the runtime.
std::thread::spawn(move || {
std::thread::sleep(LINGER);
close_unheld(generation);
});
}
}
}
}
/// Hold whichever store is open.
pub(crate) fn hold() -> Hold {
match &mut *state() {
State::Open {
held, generation, ..
} => {
*held += 1;
Hold(Some(*generation))
}
State::Closed | State::Window => Hold(None),
}
}
/// Open a store for errands unless one is open, and hold it.
fn open(services: impl FnOnce() -> Result<EngineServices, String>) -> Result<Hold, String> {
let mut state = state();
match &mut *state {
State::Window => return Ok(Hold(None)),
State::Open {
held, generation, ..
} => {
*held += 1;
return Ok(Hold(Some(*generation)));
}
State::Closed => {}
}
let EngineServices {
paths,
credentials,
oauth_redirects,
host,
} = services()?;
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.map_err(|error| error.to_string())?;
let handle = runtime.handle().clone();
runtime.spawn(async move {
let core =
match Core::start_mail_ui_with_platform(paths, credentials, oauth_redirects).await {
Ok(core) => core,
Err(error) => {
tracing::warn!(%error, "notifications: could not open the mail store");
super::drop_waiting_errands();
return;
}
};
let speech = Speech::new(language(&core).await);
// Names the channels, in a process the window never ran in.
host.set_words(&speech.fixed());
// Runs the errands that waited for the store.
super::dispatch_through(&handle, Arc::new(core), Some(host), Headless(speech));
});
let generation = {
let mut generation = GENERATION.lock().unwrap_or_else(PoisonError::into_inner);
*generation += 1;
*generation
};
*state = State::Open {
runtime,
held: 1,
generation,
};
Ok(Hold(Some(generation)))
}
/// The UI's language, as the window chooses it.
async fn language(core: &Core) -> &'static str {
let preference = core
.get_settings()
.await
.map(|settings| settings.language)
.unwrap_or_default();
match crate::normalized_language_preference(&preference) {
"system" => sys_locale::get_locale()
.as_deref()
.and_then(crate::bundled_language_for_locale)
.unwrap_or("en"),
language => language,
}
}
/// Close the store opened for errands once nothing holds it.
fn close_unheld(generation: u64) {
let mut state = state();
let unheld = matches!(
&*state,
State::Open { held: 0, generation: current, .. } if *current == generation
);
if !unheld {
return;
}
let State::Open { runtime, .. } = std::mem::replace(&mut *state, State::Closed) else {
return;
};
super::core_closed();
drop(state);
runtime.shutdown_timeout(HAND_OVER);
}
/// The window is about to open its own store: a store opened for errands
/// closes first, and the checks in it stop to run again later. Errands from
/// now on use the window's store.
pub fn window_starting() {
let previous = std::mem::replace(&mut *state(), State::Window);
if let State::Open { runtime, .. } = previous {
for check in CHECKS
.lock()
.unwrap_or_else(PoisonError::into_inner)
.values()
{
check.cancel();
}
super::core_closed();
runtime.shutdown_timeout(HAND_OVER);
}
}
/// The window closed its store.
pub fn window_ended() {
let mut state = state();
if matches!(*state, State::Window) {
*state = State::Closed;
}
}
/// Check every account for new mail and tell of it, as a job Android runs
/// does: with the window's store, or one opened for the check. Returns once
/// done, with whether every account answered: `false` when no store could
/// open or the job was stopped.
pub fn check_for_mail(services: impl FnOnce() -> Result<EngineServices, String>, job: i32) -> bool {
let hold = match open(services) {
Ok(hold) => hold,
Err(error) => {
tracing::warn!(%error, "notifications: no mail store to check");
return false;
}
};
let (sender, done) = mpsc::channel();
let check = super::check_in_background(move |every| {
let _ = sender.send(every);
});
CHECKS
.lock()
.unwrap_or_else(PoisonError::into_inner)
.insert(job, check);
// A store that closes under the check drops its sender.
let every = done.recv().unwrap_or(false);
CHECKS
.lock()
.unwrap_or_else(PoisonError::into_inner)
.remove(&job);
drop(hold);
every
}
/// Android wants the job's time back.
pub fn stop_check(job: i32) {
if let Some(check) = CHECKS
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get(&job)
{
check.cancel();
}
}
/// Carry out a button on a notification, as its receiver asks: returns once
/// the action is saved, or after [`SAVE_LIMIT`]. The store stays open while
/// the action goes to the server.
pub fn respond_in_background(
services: impl FnOnce() -> Result<EngineServices, String>,
request: &str,
) {
let Some((route, act)) = parse_payload(request) else {
return;
};
let hold = match open(services) {
Ok(hold) => hold,
Err(error) => {
tracing::warn!(%error, "notifications: no mail store for the button");
return;
}
};
let (sender, saved) = mpsc::channel();
super::respond_then(
Response {
route,
act,
activation_token: None,
},
move || {
let _ = sender.send(());
},
);
let _ = saved.recv_timeout(SAVE_LIMIT);
drop(hold);
}
/// What the dispatcher asks of the window, without one: nothing is on
/// screen, and the words come from the catalogs.
struct Headless(Speech);
impl Window for Headless {
async fn on_screen(&self, _accounts: BTreeSet<i64>) -> Option<BTreeSet<i64>> {
Some(BTreeSet::new())
}
async fn words(&self, summaries: Vec<(u32, Vec<String>)>) -> Option<Words> {
let summaries = summaries
.into_iter()
.map(|(count, senders)| {
(
self.0
.new_messages(i32::try_from(count).unwrap_or(i32::MAX)),
super::summary_body(&senders, |named, more| self.0.more_senders(&named, more)),
)
})
.collect();
Some(Words {
fixed: self.0.fixed(),
summaries,
})
}
async fn badge_description(&self, count: u32) -> Option<String> {
Some(
self.0
.unread_count(i32::try_from(count).unwrap_or(i32::MAX)),
)
}
}
+4 -16
View File
@@ -165,22 +165,10 @@ async fn words(summaries: Vec<(u32, Vec<String>)>) -> Option<Words> {
let title = i18n
.invoke_notification_new_messages(i32::try_from(count).unwrap_or(i32::MAX))
.into();
let named = senders
.iter()
.take(super::SUMMARY_SENDERS)
.cloned()
.collect::<Vec<_>>()
.join(", ");
let more = senders.len().saturating_sub(super::SUMMARY_SENDERS);
let body = if more == 0 {
named
} else {
i18n.invoke_notification_more_senders(
named.into(),
i32::try_from(more).unwrap_or(i32::MAX),
)
.into()
};
let body = super::summary_body(&senders, |named, more| {
i18n.invoke_notification_more_senders(named.into(), more)
.into()
});
(title, body)
})
.collect();
+59 -1
View File
@@ -2,7 +2,13 @@
//! JNI services live in that shell; this module only holds what the shared
//! application needs at runtime.
use flectar_mail_core::models::AppIcon;
use std::{path::PathBuf, sync::OnceLock};
use std::{
path::PathBuf,
sync::{
OnceLock,
atomic::{AtomicBool, Ordering},
},
};
static PDFIUM_LIBRARY: OnceLock<PathBuf> = OnceLock::new();
/// Hands the chosen icon's name to the activity.
@@ -111,3 +117,55 @@ pub(crate) fn show_app_icon(icon: AppIcon) {
pub(crate) fn email_fonts() -> Option<&'static super::EmailFonts> {
None
}
/// Whether the activity is in front of the user, as its host reports it.
static ACTIVE: AtomicBool = AtomicBool::new(false);
/// The activity was resumed (`true`) or paused. Coming to the front, the
/// user sees what came while the app wasn't running, and with an account set
/// up it is the moment to ask for permission to notify.
pub fn set_app_active(active: bool) {
ACTIVE.store(active, Ordering::Relaxed);
if active {
crate::notifications::app_became_active();
let _ = slint::invoke_from_event_loop(crate::notifications::ui::came_to_front);
}
}
/// Whether the activity is in front of the user.
pub(crate) fn app_is_active() -> bool {
ACTIVE.load(Ordering::Relaxed)
}
/// What the host reports and opens about the app's work in the background.
pub struct BackgroundBridge {
/// Whether Android lets the app check for mail in the background now.
pub state: Box<dyn Fn() -> super::BackgroundRefresh + Send + Sync>,
/// The app's page in Settings, where its background use is allowed.
pub open_settings: Box<dyn Fn() + Send + Sync>,
}
static BACKGROUND: OnceLock<BackgroundBridge> = OnceLock::new();
/// Supply the bridge to the host's background state, once.
pub fn configure_background(bridge: BackgroundBridge) {
let _ = BACKGROUND.set(bridge);
}
pub(crate) fn background_refresh() -> super::BackgroundRefresh {
BACKGROUND
.get()
.map_or(super::BackgroundRefresh::On, |bridge| (bridge.state)())
}
pub(crate) fn open_app_settings() {
if let Some(bridge) = BACKGROUND.get() {
(bridge.open_settings)();
}
}
/// Keeps a mail store opened for a background errand open while the guard
/// lives, such as while a button's action goes to the server.
pub(crate) fn stay_awake() -> crate::notifications::engine::Hold {
crate::notifications::engine::hold()
}
+1 -1
View File
@@ -25,7 +25,7 @@ mod web_authentication;
/// Whether the system lets the app check for new mail in the background.
#[cfg(background_checks)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum BackgroundRefresh {
pub enum BackgroundRefresh {
On,
/// Turned off for the app, or not allowed on the device.
Off,