diff --git a/build.rs b/build.rs index 0a67eb8d..8e5aee49 100644 --- a/build.rs +++ b/build.rs @@ -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") { diff --git a/platform/README.md b/platform/README.md index 97994afc..08d0ce95 100644 --- a/platform/README.md +++ b/platform/README.md @@ -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). | diff --git a/src/main.rs b/src/main.rs index 8f67115c..1fad9b1b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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> 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()) })?; diff --git a/src/notifications.rs b/src/notifications.rs index c88b61aa..00969186 100644 --- a/src/notifications.rs +++ b/src/notifications.rs @@ -123,6 +123,10 @@ pub trait NotificationHost: Send + Sync + 'static { fn set_badge(&self, _badge: Option) -> 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) + 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) { let waiting = { @@ -498,12 +521,26 @@ fn core_opened(runtime: &tokio::runtime::Handle, core: &Arc) { /// 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); @@ -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::>() + .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, host: Option>, ) { + #[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::*; diff --git a/src/notifications/engine.rs b/src/notifications/engine.rs new file mode 100644 index 00000000..2e45c48e --- /dev/null +++ b/src/notifications/engine.rs @@ -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, +} + +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 = 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 = Mutex::new(0); +/// The checks in progress, by the job that runs each. +static CHECKS: Mutex> = 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); + +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) -> Result { + 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, 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, + 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) -> Option> { + Some(BTreeSet::new()) + } + + async fn words(&self, summaries: Vec<(u32, Vec)>) -> Option { + 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 { + Some( + self.0 + .unread_count(i32::try_from(count).unwrap_or(i32::MAX)), + ) + } +} diff --git a/src/notifications/ui.rs b/src/notifications/ui.rs index 90d99263..79c58edb 100644 --- a/src/notifications/ui.rs +++ b/src/notifications/ui.rs @@ -165,22 +165,10 @@ async fn words(summaries: Vec<(u32, Vec)>) -> Option { 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::>() - .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(); diff --git a/src/platform/android.rs b/src/platform/android.rs index 17a43542..3af914fb 100644 --- a/src/platform/android.rs +++ b/src/platform/android.rs @@ -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 = 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 super::BackgroundRefresh + Send + Sync>, + /// The app's page in Settings, where its background use is allowed. + pub open_settings: Box, +} + +static BACKGROUND: OnceLock = 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() +} diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 36ae1747..442c27a2 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -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,