//! `GET /notifications`, the attention stream PLAN.md's "Notifications: two //! places, never both" describes. Ported from the parsing half of //! `app/.../Notifications.kt`'s `NotificationService` -- the framing //! ([`crate::client::sse`]) and the wire shape ([`SessionNotification`], //! [`NotificationKind`], mirroring `server/src/session/mod.rs`'s //! `Notification`/`NotificationKind`). //! //! What is deliberately **not** here, because it is a decision rather than //! logic: whether a given notification is shown at all (the session on //! screen gets nothing), handed to the app as a banner, or posted to the //! platform's own notification drawer. That three-way choice reads //! process-wide state (what screen is open, whether the app is in front) //! that has no meaning to a pure crate with no UI and no Android in it -- //! see `android-shell` for where it lives for this port. use std::io::{BufRead, BufReader}; use serde::Deserialize; use crate::client::api::{ApiError, Transport}; use crate::client::sse::SseReader; /// One frame of `GET /notifications`, matching `server/src/session/mod.rs`'s /// `Notification` field for field. #[derive(Debug, Clone, PartialEq, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionNotification { pub session_id: String, pub title: String, pub kind: NotificationKind, /// Epoch seconds, so a phone that was asleep can say how long ago. pub at: f64, } /// Mirrors `server/src/session/mod.rs`'s `NotificationKind` -- serialized /// the same way, so this deserializes the wire's `"awaitingInput"` / /// `"finished"` directly rather than through a string match. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "camelCase")] pub enum NotificationKind { AwaitingInput, Finished, } impl NotificationKind { /// What a notification asks of the reader, in the words they see -- /// ported verbatim from `Notifications.kt`'s `attentionLine`. One /// function because the same fact is shown in two places (the /// platform's drawer and the app's own banner) and two mappings of one /// word drift. pub fn attention_line(self) -> &'static str { match self { NotificationKind::AwaitingInput => "Waiting for you", NotificationKind::Finished => "Finished", } } } /// Follows `/notifications`, calling `on_notification` for each frame until /// the connection drops or the callback asks to stop (by returning /// `false`). Reconnecting is the caller's job -- mirroring /// `NotificationService.follow`'s retry loop, which is a platform policy /// (how long to wait, whether to give up) rather than parsing logic. pub fn follow_notifications( transport: &dyn Transport, mut on_notification: impl FnMut(SessionNotification) -> bool, ) -> Result<(), ApiError> { let body = transport.stream("/notifications")?; let mut lines = BufReader::new(body).lines(); let mut reader = SseReader::new(); while let Some(line) = lines.next().transpose().map_err(|e| ApiError { message: format!("Can't reach the server -- retrying. ({e})"), status: None, })? { let Some(frame) = reader.feed_line(&line) else { continue; }; if frame.data.is_empty() { continue; } let notification: SessionNotification = serde_json::from_str(&frame.data).map_err(|e| ApiError { message: format!("The server sent a notification this build couldn't parse: {e}"), status: None, })?; if !on_notification(notification) { return Ok(()); } } Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::client::api::{Body, RawResponse}; use std::io::Cursor; struct FixtureTransport { body: &'static str, } impl Transport for FixtureTransport { fn request( &self, _method: &str, _path: &str, _body: Option
, ) -> Result