Skip to main content

sioul_core/
appnotes.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Other apps' notifications on a phone (docs/android.md, "Notifications from
5//! other apps"; research: docs/research/android-chats.md, 7 and 8). Android
6//! hands Sioul's listener every notification the person lets it see;
7//! Sioul decides, notification by notification, whether it comes now or
8//! waits, and Android holds it (it is snoozed: Android brings it back at its
9//! time, whole, with its own tap and actions; Sioul never cancels one).
10//!
11//! Two kinds, decided per notification, not per app:
12//! - **between people**: SMS, chats, mail, a missed call. Who wrote, as far
13//!   as the app says it (a number, an address, a contact of the phone's
14//!   address book, else a name), then what reaches you when (the matrix of
15//!   `attention`, its Messages rows; a mail app's, its Mail rows; a missed
16//!   call, its Calls rows). A group is judged as the conversation, on its
17//!   own row, never by a member's name; "always through" goes by
18//!   conversation, which the app identifies, never by a name, which anyone
19//!   can take (research 9.3); the blocked never, whatever a conversation says.
20//! - **from an automaton**: everything else (shops, news, social networks,
21//!   a browser's sites). Gathered: it comes at the next of the times set for
22//!   that, `[reminders] gathered` (three a day unless set otherwise, as the
23//!   sites' notifications on a computer), never while you sleep, pause or
24//!   take free time.
25//!
26//! When each kind may come is the matrix of what reaches you's to say
27//! (`attention`: its people's rows, the rows of automatons and of the apps
28//! you set to come at once); the times above are its usual values.
29//!
30//! Never held: calls, alarms, what runs (music, a call in progress, a
31//! download), the reminders you set, Sioul's own; codes and sign-ins or
32//! payments to approve (`codes::detect` on the notification's words, the
33//! approval phrases below, and Android 15's redaction, which says the
34//! phone's own assistant judged it a code).
35//!
36//! What is kept, on the phone alone and never shared: the person's choices
37//! (`Choices`, `app-notes.toml` in the configuration), and what Sioul held,
38//! by a hash of Android's key, with the apps' and conversations' names it
39//! saw in the last weeks, so that the person can choose for them (`Ledger`,
40//! `app-notes-seen.toml` in the state). Never what a notification says.
41
42use crate::areas::{Area, in_view};
43use crate::attention::{self, Attention, Event, Level, Source};
44use crate::i18n::Translator;
45use crate::quiet::Mode;
46use crate::reach::{Channel, Who};
47use jiff::Zoned;
48use serde::{Deserialize, Serialize};
49use std::collections::BTreeMap;
50use std::path::{Path, PathBuf};
51
52/// Sioul's own package: its notifications are never touched.
53pub const OWN: &str = "com.aurelienpierre.sioul";
54/// A gathered time still counts this long after it (seconds): what arrives
55/// in the very minute of a gathering comes with it.
56pub const GRACE: i64 = 60;
57/// How far ahead a moment is looked for (days).
58pub const HORIZON_DAYS: i64 = 8;
59/// Held with no moment found in the coming days (a pause, which lasts until
60/// you come back): asked again this late (seconds). Sioul brings it back
61/// sooner when what moves its time changes (the pause ends: `review`).
62pub const RECHECK: i64 = 6 * 3600;
63/// Do-not-disturb's switch or a focus session without a known end: asked again this soon.
64pub const GATE_RECHECK: i64 = 30 * 60;
65/// Held for good (a blocked sender, a conversation set to never): looked at again weekly.
66pub const FOR_GOOD: i64 = 7 * 86_400;
67/// Held again on its return for less than this (seconds)…
68pub const AGAIN_SHORT: i64 = 5 * 60;
69/// …this many times in a row, it is let through rather than held again and
70/// again: the times worked out disagree with the time it is. A long hold (a
71/// pause, the night) is no such loop and never counts.
72pub const AGAIN_MAX: u32 = 3;
73/// Apps and sites seen are forgotten this long after (seconds), unless chosen for.
74const SEEN_KEPT: i64 = 30 * 86_400;
75/// Conversations seen, the same.
76const TALK_KEPT: i64 = 7 * 86_400;
77/// At most so many conversations kept seen.
78const TALKS_MAX: usize = 60;
79/// What came back is said on the Porch this long after.
80const BACK_SAID: i64 = 2 * 3600;
81
82// ---------------------------------------------------------------- what Java hands
83
84/// A person as an app names them in its notification (`android.app.Person`),
85/// with what the phone's address book says of them when Java could ask it.
86#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
87#[serde(default)]
88pub struct Person {
89    pub name: String,
90    /// The app's own key for them.
91    pub key: String,
92    /// "tel:+33…", "mailto:…", a contact's lookup address, or nothing.
93    pub uri: String,
94    /// The phone's address book: the numbers and addresses of the contact
95    /// its lookup address names.
96    pub numbers: Vec<String>,
97    pub emails: Vec<String>,
98    /// The phone's address book again: those of the one contact with this
99    /// name, when the app gave a name alone. Counted only for an app that
100    /// names people as the address book does (`Sender::Named::book`).
101    pub by_name: Vec<String>,
102    pub emails_by_name: Vec<String>,
103    pub bot: bool,
104}
105
106/// One message of a conversation (`Notification.MessagingStyle`); no sender: the person themselves.
107#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
108#[serde(default)]
109pub struct Message {
110    pub sender: Option<Person>,
111    pub text: String,
112}
113
114/// A notification as the listener hands it (AppNotes.java): what Android
115/// says of it, its words included. Read in memory to decide, never kept.
116#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
117#[serde(default)]
118pub struct Posted {
119    /// Android's key: the same notification updated keeps it.
120    pub key: String,
121    pub package: String,
122    /// The app's name as the phone shows it.
123    pub app: String,
124    pub channel: String,
125    pub channel_name: String,
126    /// It sounded or vibrated as it came (`Ranking.getLastAudiblyAlertedMillis`).
127    pub alerted: bool,
128    /// Its importance (`Ranking.getImportance`): under 3, its channel makes
129    /// no sound (made silent); unknown, under 0.
130    #[serde(default = "unknown_importance")]
131    pub importance: i32,
132    /// Android's own do-not-disturb held it (no sound, no banner).
133    pub intercepted: bool,
134    /// `Notification.category`: "msg", "email", "call", "promo"…
135    pub category: String,
136    pub tag: String,
137    pub group: String,
138    /// A group's summary: Android takes it with its last child.
139    pub summary: bool,
140    /// Ongoing, a foreground service's, or not clearable: never touched.
141    pub ongoing: bool,
142    /// A full-screen intent: a call ringing, an alarm.
143    pub full_screen: bool,
144    /// "android.app.Notification$MessagingStyle", "…$BigTextStyle"…
145    pub template: String,
146    pub title: String,
147    pub text: String,
148    pub sub: String,
149    pub big: String,
150    pub lines: Vec<String>,
151    /// The conversation's title (a group's name, `<server> #<channel>`).
152    pub conversation: String,
153    pub group_conversation: bool,
154    /// Android counts it as a conversation (`Ranking.isConversation`).
155    pub is_conversation: bool,
156    /// The conversation's shortcut.
157    pub shortcut: String,
158    pub messages: Vec<Message>,
159    /// The person themselves, as the conversation names them (`EXTRA_MESSAGING_PERSON`).
160    pub user: Option<Person>,
161    /// `EXTRA_PEOPLE_LIST`, and the older `EXTRA_PEOPLE` as people with a URI only.
162    pub people: Vec<Person>,
163    /// The phone's own assistant judged it sensitive (a code), and Android
164    /// gave Sioul a redacted copy (Android 15).
165    pub redacted: bool,
166    /// The phone's default SMS app.
167    pub sms_app: bool,
168}
169
170fn unknown_importance() -> i32 {
171    -1
172}
173
174// ---------------------------------------------------------------- what it is
175
176/// Who wrote, as the notification identifies them.
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub enum Sender {
179    /// Numbers and addresses: from the app (tel:, mailto:) or from the phone's
180    /// address book. `contact`: the phone's address book has them, so they
181    /// are someone you know (neutral) even when Sioul's address books do not.
182    Known { numbers: Vec<String>, emails: Vec<String>, contact: bool },
183    /// A name only. `book`: the app names people as the phone's address book
184    /// does (SMS apps, WhatsApp): a name that says who; else a name anyone
185    /// can take, which may only hold more (`named`).
186    Named { name: String, book: bool },
187    /// Nobody named.
188    Unknown,
189}
190
191/// A conversation, as the app identifies it.
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct Conversation {
194    /// Hashed, the app's own id within (its shortcut, else its title): `talk_key`.
195    pub key: String,
196    /// As the app titles it: a group's name, `<server> #<channel>`, a person's name.
197    pub title: String,
198    pub group: bool,
199}
200
201/// Between people.
202#[derive(Debug, Clone, PartialEq, Eq)]
203pub struct Talk {
204    pub via: Channel,
205    pub sender: Sender,
206    pub conversation: Option<Conversation>,
207    /// The address it came to (a mail app's sub-text), for that address's area.
208    pub to: String,
209    /// An SMS: a sender named only and found in no address book is a
210    /// business's sender id ("AMELI"), an automaton.
211    pub sms: bool,
212}
213
214/// What a notification is, for whether it may come now.
215#[derive(Debug, Clone, PartialEq, Eq)]
216pub enum Kind {
217    /// Never held: calls, alarms, what runs, the reminders you set, Sioul's own.
218    Untouched,
219    /// A code, a sign-in or a payment to approve: at once, whatever the time.
220    Code,
221    /// The app or the site set to come at once.
222    AtOnce,
223    People(Talk),
224    /// From an automaton: the app, or a site in a browser (its host).
225    Automaton { site: String },
226}
227
228/// Categories never held: what runs or shows a state, what the person set.
229const UNTOUCHED: &[&str] = &[
230    "call", "alarm", "navigation", "transport", "stopwatch", "location_sharing", "sys", "service", "progress", "status", "err", "car_emergency",
231    "car_warning", "car_information", "workout", "reminder", "event",
232];
233
234/// Apps that name people as the phone's address book does: their names say who.
235const BOOK_APPS: &[&str] = &["com.whatsapp", "com.whatsapp.w4b"];
236
237/// SMS apps, besides the phone's default one (`Posted::sms_app`).
238const SMS_APPS: &[&str] = &[
239    "com.google.android.apps.messaging", "com.android.messaging", "com.android.mms", "com.samsung.android.messaging", "org.fossify.messages",
240    "com.simplemobiletools.smsmessenger", "com.moez.QKSMS", "dev.octoshrimpy.quik", "foundation.e.message",
241];
242
243/// Mail apps: their notifications are mail, judged on the Mail row.
244const MAIL_APPS: &[&str] = &[
245    "ch.protonmail.android", "com.google.android.gm", "com.fsck.k9", "net.thunderbird.android", "net.thunderbird.android.beta", "eu.faircode.email",
246    "com.microsoft.office.outlook", "foundation.e.mail", "de.tutao.tutanota", "org.kman.AquaMail", "com.samsung.android.email.provider",
247    "com.yahoo.mobile.client.android.mail", "me.proton.android.mail",
248];
249
250/// Firefox's browsers post every site's notification on this one channel, the site in the sub-text.
251const FIREFOX_SITES: &str = "mozac.feature.webnotifications.generic.channel";
252
253/// Whether a notification hands over a code or asks for an approval now:
254/// a code as mail gives one (`codes::detect`); words asking for an approval
255/// (a sign-in, a payment, an identity to confirm); "is this you?" beside what
256/// it is about (a friend's "is this you on the photo?" is none); a channel
257/// named for codes or sign-ins (Proton Mail's "login" channel). Read folded
258/// (case and accents aside), as whole words (`words::Approvals`).
259pub fn is_code(p: &Posted, words: &crate::words::Words) -> bool {
260    if p.redacted {
261        return true;
262    }
263    let mut body = vec![p.text.as_str(), p.big.as_str(), p.sub.as_str()];
264    body.extend(p.lines.iter().map(String::as_str));
265    body.extend(p.messages.iter().map(|m| m.text.as_str()));
266    let body = body.into_iter().filter(|t| !t.trim().is_empty()).collect::<Vec<_>>().join("\n");
267    if crate::codes::detect(&words.codes, &p.title, &body).is_some() {
268        return true;
269    }
270    let folded: Vec<char> = crate::text::fold(&format!("{}\n{body}", p.title)).into_iter().map(|c| if c == '\n' { ' ' } else { c }).collect();
271    let said = |phrases: &[String]| phrases.iter().any(|phrase| crate::text::find_word(&folded, phrase, 0).is_some());
272    let approvals = &words.approvals;
273    if said(&approvals.phrases) || (said(&approvals.asks) && said(&approvals.asked_about)) {
274        return true;
275    }
276    let channel: Vec<char> = crate::text::fold(&format!("{} {}", p.channel.replace(['_', '.'], " "), p.channel_name));
277    approvals.channels.iter().any(|word| crate::text::find_word(&channel, word, 0).is_some())
278}
279
280/// A browser's site the notification is from, as its host: Chromium's
281/// browsers name it in the tag ("p#https://site#…", "n#…"), the channel
282/// ("web:https://site;…") and the group ("Web:site"); Firefox's in the
283/// sub-text, on their one channel for sites (research 8.1).
284pub fn site_of(p: &Posted) -> Option<String> {
285    let tagged = p.tag.strip_prefix("p#").or_else(|| p.tag.strip_prefix("n#")).and_then(|rest| rest.split('#').next());
286    let channel = p.channel.strip_prefix("web:").and_then(|rest| rest.split(';').next());
287    let grouped = p.group.strip_prefix("Web:");
288    let firefox = (p.channel == FIREFOX_SITES).then_some(p.sub.as_str());
289    [tagged, channel, grouped, firefox].into_iter().flatten().map(host_of).find(|h| !h.is_empty())
290}
291
292/// `https://Mail.Example.org:443/inbox` → `mail.example.org`; `""` when it is no host.
293fn host_of(origin: &str) -> String {
294    let rest = origin.trim().split_once("://").map_or(origin.trim(), |(_, rest)| rest);
295    let host = rest.split(['/', '?', '#']).next().unwrap_or("");
296    let host = host.rsplit_once('@').map_or(host, |(_, h)| h);
297    let host = host.split(':').next().unwrap_or("").trim_end_matches('.').to_lowercase();
298    let fine = host.contains('.') && host.chars().all(|c| c.is_alphanumeric() || c == '.' || c == '-');
299    if fine { host } else { String::new() }
300}
301
302/// A phone number as written in a notification: digits, at least seven (a
303/// short code has six at most), with spaces, dots, dashes, brackets and a
304/// leading "+" allowed ("+262 6 39 98 01 01").
305pub fn number_in(text: &str) -> Option<String> {
306    let text = text.trim().trim_start_matches('\u{202a}').trim_end_matches('\u{202c}').trim();
307    let fine = !text.is_empty() && text.chars().enumerate().all(|(i, c)| c.is_ascii_digit() || " .-()/\u{a0}\u{202f}".contains(c) || (c == '+' && i == 0));
308    let digits = text.chars().filter(char::is_ascii_digit).count();
309    (fine && digits >= 7).then(|| text.to_string())
310}
311
312/// A short code an SMS comes from ("38015", "36179"): a business's, an automaton.
313fn short_code(text: &str) -> bool {
314    let text = text.trim();
315    !text.is_empty() && text.len() <= 6 && text.chars().all(|c| c.is_ascii_digit())
316}
317
318/// An e-mail address as an app writes a sender: "alice@example.org", "Alice <alice@example.org>".
319fn address_in(text: &str) -> Option<String> {
320    let text = text.trim();
321    let inner = text.rsplit_once('<').and_then(|(_, rest)| rest.split_once('>')).map_or(text, |(a, _)| a).trim();
322    let (local, domain) = inner.split_once('@')?;
323    let fine = !local.is_empty() && domain.contains('.') && !inner.contains(char::is_whitespace);
324    fine.then(|| inner.to_lowercase())
325}
326
327/// What a person is, as the app identifies them: their numbers and
328/// addresses (the app's URI, the phone's address book), else their name.
329fn sender_of(person: &Person, book: bool) -> Sender {
330    let mut numbers = person.numbers.clone();
331    let mut emails = person.emails.clone();
332    if book && person.uri.trim().is_empty() {
333        numbers.extend(person.by_name.iter().cloned());
334        emails.extend(person.emails_by_name.iter().cloned());
335    }
336    let contact = !numbers.is_empty() || !emails.is_empty();
337    let uri = person.uri.trim();
338    if let Some(number) = uri.strip_prefix("tel:") {
339        numbers.insert(0, percent_decoded(number));
340    } else if let Some(address) = uri.strip_prefix("mailto:") {
341        emails.insert(0, percent_decoded(address).to_lowercase());
342    }
343    numbers.retain(|n| !n.trim().is_empty());
344    emails.retain(|e| !e.trim().is_empty());
345    if !numbers.is_empty() || !emails.is_empty() {
346        return Sender::Known { numbers, emails, contact };
347    }
348    // An app's name for someone may be their number or their address itself.
349    if let Some(number) = number_in(&person.name) {
350        return Sender::Known { numbers: vec![number], emails: Vec::new(), contact: false };
351    }
352    if let Some(address) = address_in(&person.name) {
353        return Sender::Known { numbers: Vec::new(), emails: vec![address], contact: false };
354    }
355    let name = one_line(&person.name);
356    if name.is_empty() { Sender::Unknown } else { Sender::Named { name, book } }
357}
358
359/// "%2B262639980101" → "+262639980101": what a URI keeps encoded.
360fn percent_decoded(text: &str) -> String {
361    let bytes = text.as_bytes();
362    let mut out = Vec::with_capacity(bytes.len());
363    let mut i = 0;
364    while i < bytes.len() {
365        let hex = (bytes[i] == b'%').then(|| bytes.get(i + 1..i + 3)).flatten().and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok());
366        match hex {
367            Some(byte) => {
368                out.push(byte);
369                i += 3;
370            }
371            None => {
372                out.push(bytes[i]);
373                i += 1;
374            }
375        }
376    }
377    String::from_utf8_lossy(&out).to_string()
378}
379
380/// A name on one line: an app's words never break Sioul's.
381fn one_line(text: &str) -> String {
382    text.split_whitespace().collect::<Vec<_>>().join(" ")
383}
384
385fn fnv(text: &str) -> u64 {
386    text.bytes().fold(0xcbf2_9ce4_8422_2325u64, |hash, b| (hash ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3))
387}
388
389/// A notification's key as the ledger keeps it: hashed, Android's own key
390/// naming the app and its tags (a site, a chat's id).
391pub fn key_hash(key: &str) -> String {
392    format!("{:016x}", fnv(key))
393}
394
395/// A conversation's key: the app and its own id for it, hashed.
396pub fn talk_key(package: &str, id: &str) -> String {
397    format!("{:016x}", fnv(&format!("{package}\u{1f}{id}")))
398}
399
400/// The conversation a notification belongs to: its shortcut, else its
401/// title (a group), else, for a conversation between two, the one who
402/// writes; for an SMS app's plain notification, its title (the address
403/// book's name, or the number).
404fn conversation_of(p: &Posted, sender: Option<&Person>, sms: bool) -> Option<Conversation> {
405    // A group as the app says it, or as its messages show it: two people or more write in it, the person aside.
406    let mut writers: Vec<String> = p.messages.iter().filter_map(|m| m.sender.as_ref()).filter(|s| !is_user(p, s)).map(|s| if s.key.trim().is_empty() { one_line(&s.name) } else { s.key.trim().to_string() }).filter(|w| !w.is_empty()).collect();
407    writers.sort();
408    writers.dedup();
409    let group = p.group_conversation || writers.len() >= 2;
410    let title = [p.conversation.as_str(), sender.map_or("", |s| s.name.as_str()), p.title.as_str()].into_iter().map(one_line).find(|t| !t.is_empty()).unwrap_or_default();
411    let from = sender.map_or("", |s| if s.key.trim().is_empty() { s.name.trim() } else { s.key.trim() });
412    let plain = if sms { p.title.trim() } else { "" };
413    let id = [p.shortcut.trim(), p.conversation.trim(), from, plain].into_iter().find(|i| !i.is_empty())?;
414    Some(Conversation { key: talk_key(&p.package, id), title, group })
415}
416
417/// Whether a message's sender is the person themselves, as the conversation
418/// names them (some apps name them rather than leave the sender out).
419fn is_user(p: &Posted, sender: &Person) -> bool {
420    p.user.as_ref().is_some_and(|user| {
421        let (key, name) = (user.key.trim(), one_line(&user.name));
422        (!key.is_empty() && key == sender.key.trim()) || (!name.is_empty() && name == one_line(&sender.name))
423    })
424}
425
426/// What a notification is (`Kind`), as its app and Android say it, and as
427/// the person chose for its app and site (`Choices`).
428pub fn classify(p: &Posted, choices: &Choices, words: &crate::words::Words) -> Kind {
429    if p.package == OWN || p.ongoing || p.full_screen || p.summary || UNTOUCHED.contains(&p.category.as_str()) {
430        return Kind::Untouched;
431    }
432    if is_code(p, words) {
433        return Kind::Code;
434    }
435    let app = choices.app(&p.package);
436    if app.kind == AppKind::AtOnce {
437        return Kind::AtOnce;
438    }
439    let site = site_of(p);
440    if let Some(site) = &site
441        && choices.site.get(site).is_some_and(|s| s.at_once)
442    {
443        return Kind::AtOnce;
444    }
445    let automaton = Kind::Automaton { site: site.clone().unwrap_or_default() };
446    if app.kind == AppKind::Automaton || (app.kind == AppKind::Usual && site.is_some()) {
447        return automaton;
448    }
449    let mail = MAIL_APPS.contains(&p.package.as_str()) || p.category == "email";
450    let sms = p.sms_app || SMS_APPS.contains(&p.package.as_str());
451    let via = if matches!(p.category.as_str(), "missed_call" | "voicemail") {
452        Channel::Calls
453    } else if mail {
454        Channel::Mail
455    } else {
456        Channel::Messages
457    };
458    let styled = p.template.ends_with("$MessagingStyle") || !p.messages.is_empty() || p.is_conversation || !p.shortcut.trim().is_empty();
459    let identified = p.people.iter().any(|person| person.uri.starts_with("tel:") || person.uri.starts_with("mailto:"));
460    let between = app.kind == AppKind::People || via != Channel::Messages || styled || identified || sms || p.category == "msg";
461    let advert = matches!(p.category.as_str(), "promo" | "social" | "recommendation");
462    if !between || (advert && app.kind != AppKind::People) {
463        return automaton;
464    }
465    // SMS apps and the phone's own (a missed call) name people as the address book does.
466    let book = sms || via == Channel::Calls || BOOK_APPS.contains(&p.package.as_str());
467    // Who wrote: the last message's sender, else the people named, else the title.
468    let last = p.messages.iter().rev().find_map(|m| m.sender.as_ref().filter(|s| !is_user(p, s)));
469    let person = last.or_else(|| p.people.first());
470    // A machine that says it is one (a bank's assistant, a bot in a chat).
471    if person.is_some_and(|s| s.bot) && app.kind != AppKind::People {
472        return automaton;
473    }
474    let sender = match person {
475        Some(person) => sender_of(person, book),
476        None => {
477            let title = one_line(&p.title);
478            if sms && short_code(&title) {
479                return automaton;
480            }
481            sender_of(&Person { name: title, ..Person::default() }, book)
482        }
483    };
484    if sms && let Sender::Named { name, .. } = &sender
485        && short_code(name)
486    {
487        return automaton;
488    }
489    let to = if via == Channel::Mail { address_in(&p.sub).unwrap_or_default() } else { String::new() };
490    let conversation = if via == Channel::Messages { conversation_of(p, person, sms) } else { None };
491    Kind::People(Talk { via, sender, conversation, to, sms })
492}
493
494/// Who wrote, from what the address books and lists say of a name
495/// (`found`: none when no card has it): an app that names people as the
496/// phone's address book does is taken at its word; a name anyone can take
497/// may only hold more (restricted and blocked kept), never let through more
498/// than someone unknown.
499pub fn named(found: Option<Who>, book: bool) -> Who {
500    match found {
501        Some(who) if book => who,
502        Some(who @ (Who::Restricted | Who::Blocked)) => who,
503        _ => Who::Stranger,
504    }
505}
506
507// ---------------------------------------------------------------- the person's choices
508
509/// What an app is, as the person says.
510#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
511#[serde(rename_all = "kebab-case")]
512pub enum AppKind {
513    /// Notification by notification: between people, or from an automaton.
514    #[default]
515    Usual,
516    /// Messages between people, all of them.
517    People,
518    /// An automaton's: gathered, all of them.
519    Automaton,
520    /// At once, as Sioul's own notifications come.
521    AtOnce,
522}
523
524impl AppKind {
525    pub const ALL: [AppKind; 4] = [AppKind::Usual, AppKind::People, AppKind::Automaton, AppKind::AtOnce];
526
527    pub fn id(self) -> &'static str {
528        match self {
529            AppKind::Usual => "usual",
530            AppKind::People => "people",
531            AppKind::Automaton => "automaton",
532            AppKind::AtOnce => "at-once",
533        }
534    }
535
536    pub fn read(id: &str) -> Option<AppKind> {
537        AppKind::ALL.into_iter().find(|k| k.id() == id)
538    }
539}
540
541/// When a conversation comes, as the person says.
542#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
543#[serde(rename_all = "kebab-case")]
544pub enum Through {
545    /// As who writes says: a group, as people you do not know.
546    #[default]
547    Usual,
548    /// At once, whatever the time: sleep, pauses and do-not-disturb included.
549    Always,
550    /// With the automatons' notifications, at the gathered times.
551    Gathered,
552    /// Held for good.
553    Never,
554}
555
556impl Through {
557    pub const ALL: [Through; 4] = [Through::Usual, Through::Always, Through::Gathered, Through::Never];
558
559    pub fn id(self) -> &'static str {
560        match self {
561            Through::Usual => "usual",
562            Through::Always => "always",
563            Through::Gathered => "gathered",
564            Through::Never => "never",
565        }
566    }
567
568    pub fn read(id: &str) -> Option<Through> {
569        Through::ALL.into_iter().find(|t| t.id() == id)
570    }
571}
572
573#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
574pub struct AppChoice {
575    #[serde(default)]
576    pub kind: AppKind,
577    /// What the app is for: its automaton's notifications then come only in
578    /// that area's times, and its messages from anyone but your safe people.
579    #[serde(default, skip_serializing_if = "Option::is_none")]
580    pub area: Option<Area>,
581    /// Its name, as last seen.
582    #[serde(default, skip_serializing_if = "String::is_empty")]
583    pub label: String,
584}
585
586#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
587pub struct TalkChoice {
588    #[serde(default)]
589    pub through: Through,
590    /// Its title and its app's name, to show it.
591    #[serde(default, skip_serializing_if = "String::is_empty")]
592    pub title: String,
593    #[serde(default, skip_serializing_if = "String::is_empty")]
594    pub app: String,
595}
596
597#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
598pub struct SiteChoice {
599    #[serde(default)]
600    pub at_once: bool,
601}
602
603/// The person's choices, this phone's own (`$XDG_CONFIG_HOME/sioul/app-notes.toml`):
604/// never shared, like what the listener sees.
605#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
606pub struct Choices {
607    /// Holding at all; off, everything comes as the apps send it.
608    #[serde(default = "yes")]
609    pub hold: bool,
610    /// By the app's package.
611    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
612    pub app: BTreeMap<String, AppChoice>,
613    /// By the conversation's key (`talk_key`).
614    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
615    pub conversation: BTreeMap<String, TalkChoice>,
616    /// By the site's host.
617    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
618    pub site: BTreeMap<String, SiteChoice>,
619}
620
621fn yes() -> bool {
622    true
623}
624
625impl Default for Choices {
626    fn default() -> Choices {
627        Choices { hold: true, app: BTreeMap::new(), conversation: BTreeMap::new(), site: BTreeMap::new() }
628    }
629}
630
631impl Choices {
632    pub fn default_path() -> PathBuf {
633        crate::config::config_dir().join("app-notes.toml")
634    }
635
636    /// As kept; what does not read is no choice at all (everything as usual).
637    pub fn load(path: &Path) -> Choices {
638        std::fs::read_to_string(path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
639    }
640
641    pub fn save(&self, path: &Path) -> Result<(), String> {
642        write_whole(path, &toml::to_string(self).map_err(|e| e.to_string())?)
643    }
644
645    pub fn app(&self, package: &str) -> AppChoice {
646        self.app.get(package).cloned().unwrap_or_default()
647    }
648
649    pub fn through(&self, conversation: &str) -> Through {
650        self.conversation.get(conversation).map_or(Through::Usual, |c| c.through)
651    }
652}
653
654/// Written whole under another name, then renamed: never half a file.
655fn write_whole(path: &Path, text: &str) -> Result<(), String> {
656    let fail = |e: std::io::Error| format!("{}: {e}", path.display());
657    if let Some(parent) = path.parent() {
658        std::fs::create_dir_all(parent).map_err(fail)?;
659    }
660    let temporary = path.with_extension(format!("toml.{}", std::process::id()));
661    std::fs::write(&temporary, text).map_err(fail)?;
662    std::fs::rename(&temporary, path).map_err(fail)
663}
664
665// ---------------------------------------------------------------- when
666
667/// What time it is at any moment (`reach::Clock`, through `Live`): sleep, a
668/// pause, free time, the hours, as they stand now.
669pub trait TimeSource {
670    fn mode(&self, at: &Zoned) -> Mode;
671    /// Where what time it is changes between `from` and `until`: each
672    /// frame's start, in order, after `from`.
673    fn starts(&self, from: &Zoned, until: &Zoned) -> Vec<Zoned>;
674}
675
676/// The clock as it stands now (`reach::Clock`): what a decision asks of it.
677#[derive(Debug, Clone)]
678pub struct Live {
679    pub clock: crate::reach::Clock,
680}
681
682impl TimeSource for Live {
683    fn mode(&self, at: &Zoned) -> Mode {
684        self.clock.mode(at)
685    }
686
687    fn starts(&self, from: &Zoned, until: &Zoned) -> Vec<Zoned> {
688        let stamp = from.timestamp().as_second();
689        self.clock.frames(from, until).into_iter().filter(|f| f.start > stamp).filter_map(|f| jiff::Timestamp::from_second(f.start).ok()).map(|t| t.to_zoned(from.time_zone().clone())).collect()
690    }
691}
692
693/// Do-not-disturb's switch or a focus session, holding now: a layer of the
694/// matrix (as usual, only Always through's messages come, docs/attention.md).
695pub struct Gate {
696    /// When it ends, when known (Unix seconds); else asked again in half an hour.
697    pub until: Option<i64>,
698}
699
700/// What a decision asks of the moment.
701pub struct Ask<'a> {
702    pub now: &'a Zoned,
703    pub clock: &'a dyn TimeSource,
704    /// What reaches you when (`attention`, `[attention]`).
705    pub attention: &'a Attention,
706    /// The gathered times, "09:00" (`[reminders] gathered`).
707    pub gathered: &'a [String],
708    /// What the app (or the address a mail came to) is for, when said.
709    pub area: Option<Area>,
710    pub gate: Option<Gate>,
711    /// One of today's slots of time for you then (`attention::Slots`): a layer of the matrix.
712    pub slot_at: &'a dyn Fn(&Zoned) -> bool,
713    /// Free time's "Nothing at all", for this Free time.
714    pub nothing: bool,
715    /// Where things change that the clock does not know (Unix seconds): the
716    /// ends of slots of time for you, of do-not-disturb's switch.
717    pub also: &'a [i64],
718    /// The notification's own rows in the matrix, its conversation's then its
719    /// app's (`attention::source_rows`): read over its usual row when set.
720    pub sources: &'a [String],
721}
722
723impl Ask<'_> {
724    fn stamp(&self) -> i64 {
725        self.now.timestamp().as_second()
726    }
727
728    /// Do-not-disturb's switch or a focus session holding at `at`.
729    fn gated(&self, at: &Zoned) -> bool {
730        self.gate.as_ref().is_some_and(|g| at.timestamp().as_second() < g.until.unwrap_or(self.stamp() + GATE_RECHECK))
731    }
732
733    /// The moments after now where what may come can change: where what
734    /// time it is changes, the ends the clock does not know, the end of
735    /// do-not-disturb (or when to ask again, without one), in order.
736    fn moments(&self) -> Vec<Zoned> {
737        let stamp = self.stamp();
738        let Ok(until) = self.now.checked_add(jiff::Span::new().hours(HORIZON_DAYS * 24)) else { return Vec::new() };
739        let mut moments = self.clock.starts(self.now, &until);
740        let gate_end = self.gate.as_ref().map(|g| g.until.unwrap_or(stamp + GATE_RECHECK));
741        for second in self.also.iter().copied().chain(gate_end).filter(|s| *s > stamp && *s < until.timestamp().as_second()) {
742            if let Ok(t) = jiff::Timestamp::from_second(second) {
743                moments.push(t.to_zoned(self.now.time_zone().clone()));
744            }
745        }
746        moments.sort_by_key(Zoned::timestamp);
747        moments.dedup_by_key(|z| z.timestamp());
748        moments
749    }
750
751    /// The moment at `at` as the matrix reads it: what time it is then, a
752    /// slot of time for you, do-not-disturb, Free time's "Nothing at all".
753    fn moment(&self, at: &Zoned, mode: &Mode) -> attention::Now {
754        let mut now = attention::Now::of(mode).layers((self.slot_at)(at), self.gated(at));
755        now.nothing = self.nothing && mode.free();
756        now
757    }
758
759    /// What the matrix says of a kind at `at`.
760    fn level(&self, kind: attention::Kind, at: &Zoned, mode: &Mode) -> Level {
761        self.attention.level(attention::Row::Own(kind), &self.moment(at, mode))
762    }
763
764    /// Whether the app is for that time (its area), or says nothing of it.
765    fn area_fits(&self, mode: &Mode) -> bool {
766        self.area.is_none_or(|a| in_view(a, mode.time, mode.week))
767    }
768}
769
770/// Why a notification comes now or waits.
771#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
772#[serde(rename_all = "kebab-case")]
773pub enum Why {
774    /// Never held: calls, alarms, what runs, the reminders you set.
775    Untouched,
776    /// A code, a sign-in or a payment to approve.
777    Code,
778    /// As you chose for this app or conversation at this time (its own row in the matrix).
779    Times,
780    /// Holding is off.
781    Off,
782    /// The app or the site comes at once.
783    AtOnce,
784    /// Someone who may reach you now.
785    Allowed,
786    /// A conversation let through always.
787    Always,
788    /// Someone who may not reach you now: until they may.
789    Waiting,
790    /// From an automaton: at the next gathered time.
791    Gathered,
792    /// Held for good: blocked, or a conversation set to never.
793    Never,
794    /// Held again and again within minutes on its return: let through.
795    Again,
796}
797
798impl Why {
799    pub fn id(self) -> &'static str {
800        match self {
801            Why::Untouched => "untouched",
802            Why::Code => "code",
803            Why::Times => "times",
804            Why::Off => "off",
805            Why::AtOnce => "at-once",
806            Why::Allowed => "allowed",
807            Why::Always => "always",
808            Why::Waiting => "waiting",
809            Why::Gathered => "gathered",
810            Why::Never => "never",
811            Why::Again => "again",
812        }
813    }
814}
815
816/// Now, or held until then.
817#[derive(Debug, Clone, Copy, PartialEq, Eq)]
818pub struct Decision {
819    /// Unix seconds; none: it comes now.
820    pub until: Option<i64>,
821    pub why: Why,
822}
823
824impl Decision {
825    pub fn through(why: Why) -> Decision {
826        Decision { until: None, why }
827    }
828
829    pub fn held(until: i64, why: Why) -> Decision {
830        Decision { until: Some(until), why }
831    }
832}
833
834/// The first moment from now on, within the coming days, when `fits`
835/// holds: now itself, else where something changes (`Ask::moments`).
836pub fn first_moment(ask: &Ask, fits: &dyn Fn(&Zoned, &Mode) -> bool) -> Option<Zoned> {
837    if fits(ask.now, &ask.clock.mode(ask.now)) {
838        return Some(ask.now.clone());
839    }
840    ask.moments().into_iter().find(|at| fits(at, &ask.clock.mode(at)))
841}
842
843/// The gathered times as times of day, in order; "8:30" reads as 08:30.
844/// None that reads: 09:00, 13:00 and 18:00.
845pub fn times_of(gathered: &[String]) -> Vec<jiff::civil::Time> {
846    let read = |text: &str| {
847        let (h, m) = text.trim().split_once([':', 'h', 'H'])?;
848        let (h, m) = (h.trim().parse::<i8>().ok()?, if m.trim().is_empty() { 0 } else { m.trim().parse::<i8>().ok()? });
849        jiff::civil::Time::new(h, m, 0, 0).ok()
850    };
851    let mut times: Vec<jiff::civil::Time> = gathered.iter().filter_map(|t| read(t)).collect();
852    if times.is_empty() {
853        times = [9, 13, 18].iter().filter_map(|h| jiff::civil::Time::new(*h, 0, 0, 0).ok()).collect();
854    }
855    times.sort();
856    times.dedup();
857    times
858}
859
860/// The next gathering from now in which an automaton's notification may
861/// come: one the notification matrix gathers at (as usual, not while you
862/// sleep, pause or take free time, nor in a slot of time for you or under
863/// do-not-disturb), within its app's area. One that began less than a minute
864/// ago counts. None within the coming days.
865pub fn next_gathering(ask: &Ask) -> Option<Zoned> {
866    let times = times_of(ask.gathered);
867    let zone = ask.now.time_zone().clone();
868    let stamp = ask.stamp();
869    for day in 0..=HORIZON_DAYS {
870        let Ok(date) = ask.now.date().checked_add(jiff::Span::new().days(day)) else { continue };
871        for time in &times {
872            let Ok(at) = date.to_datetime(*time).to_zoned(zone.clone()) else { continue };
873            if at.timestamp().as_second() + GRACE <= stamp {
874                continue;
875            }
876            let mode = ask.clock.mode(&at);
877            if ask.level(attention::Kind::AppAutomatons, &at, &mode) == Level::Gathered && ask.area_fits(&mode) {
878                return Some(at);
879            }
880        }
881    }
882    None
883}
884
885/// The gathered times from now on, within the coming days, in order; one
886/// that began less than a minute ago among them.
887fn gatherings(ask: &Ask) -> Vec<Zoned> {
888    let zone = ask.now.time_zone().clone();
889    let stamp = ask.stamp();
890    let times = times_of(ask.gathered);
891    let mut out = Vec::new();
892    for day in 0..=HORIZON_DAYS {
893        let Ok(date) = ask.now.date().checked_add(jiff::Span::new().days(day)) else { continue };
894        for time in &times {
895            if let Ok(at) = date.to_datetime(*time).to_zoned(zone.clone())
896                && at.timestamp().as_second() + GRACE > stamp
897            {
898                out.push(at);
899            }
900        }
901    }
902    out
903}
904
905/// The first moment from now on, within the coming days, when what the
906/// matrix says of a notification lets it come (`level_at`): at once (●) now
907/// or where something changes; at the gathered times (◎) at a gathered time.
908pub fn first_time(ask: &Ask, level_at: &dyn Fn(&Zoned, &Mode) -> Level) -> Option<Zoned> {
909    let mut candidates: Vec<(Zoned, bool)> = vec![(ask.now.clone(), false)];
910    candidates.extend(ask.moments().into_iter().map(|at| (at, false)));
911    candidates.extend(gatherings(ask).into_iter().map(|at| (at, true)));
912    candidates.sort_by_key(|(at, gathering)| (at.timestamp(), !*gathering));
913    candidates
914        .into_iter()
915        .find(|(at, gathering)| match level_at(at, &ask.clock.mode(at)) {
916            Level::Now => true,
917            Level::Gathered => *gathering,
918            _ => false,
919        })
920        .map(|(at, _)| at)
921}
922
923/// When a notification comes, by what the matrix says of it at each moment
924/// (`Attention::decide`, its app's and conversation's own rows read over its
925/// usual one): through now, or held until its first time (`first_time`), the
926/// next event tried when one finds none (its area left aside: nothing waits
927/// for good). Why: `now` or `later`, or as you chose for this time
928/// (`Why::Times`) when a source's own cell decides it now.
929fn by_matrix(ask: &Ask, events: &[Event], now: Why, later: Why) -> Decision {
930    let stamp = ask.stamp();
931    let Some(first) = events.first() else { return Decision::through(now) };
932    let chosen = ask.attention.decide(first, &ask.moment(ask.now, &ask.clock.mode(ask.now))).step == attention::Step::Chosen;
933    let why = |usual: Why| if chosen { Why::Times } else { usual };
934    let next = events.iter().find_map(|event| first_time(ask, &|at, mode| ask.attention.decide(event, &ask.moment(at, mode)).level));
935    match next {
936        Some(at) if at.timestamp().as_second() <= stamp => Decision::through(why(now)),
937        Some(at) => Decision::held(at.timestamp().as_second(), why(later)),
938        None => Decision::held(stamp + RECHECK, why(later)),
939    }
940}
941
942/// Gathered: held until the next gathered time the matrix gathers at (an
943/// automaton's row, as usual three a day but not while you sleep, pause,
944/// take Free time or have do-not-disturb on), within the app's area; through
945/// when it is this very one, or when the row says at once now (as the
946/// preset More reachable does at work). Its app's own row read over it.
947fn gathered(ask: &Ask) -> Decision {
948    let event = Event::own(attention::Kind::AppAutomatons).for_area(ask.area).from_sources(ask.sources.to_vec());
949    by_matrix(ask, &[event], Why::Gathered, Why::Gathered)
950}
951
952/// At once, at the times the matrix lets these apps come (as usual, as
953/// Sioul's own notifications come): else when it does. Its app's own row
954/// read over it, when set.
955fn at_once(ask: &Ask) -> Decision {
956    if ask.attention.has_sources(ask.sources) {
957        let event = Event::own(attention::Kind::AppAtOnce).for_area(ask.area).from_sources(ask.sources.to_vec());
958        return by_matrix(ask, &[event], Why::AtOnce, Why::AtOnce);
959    }
960    match first_moment(ask, &|at, mode| ask.level(attention::Kind::AppAtOnce, at, mode) == Level::Now && ask.area_fits(mode)) {
961        Some(at) if at.timestamp().as_second() <= ask.stamp() => Decision::through(Why::AtOnce),
962        Some(at) => Decision::held(at.timestamp().as_second(), Why::AtOnce),
963        None => Decision::held(ask.stamp() + RECHECK, Why::AtOnce),
964    }
965}
966
967/// Someone's message: now when the matrix lets them through
968/// (`attention::Attention::decide`: their row on its channel, a group's own
969/// row, Always through's, the time, its layers), the notification's area
970/// fitting now for all but your safe people and Always through (as mail to an
971/// address for another time, docs/areas.md); else at their next time.
972fn person(talk: &Talk, who: Who, group: bool, always: bool, ask: &Ask) -> Decision {
973    if who == Who::Blocked {
974        return Decision::held(ask.stamp() + FOR_GOOD, Why::Never);
975    }
976    // Its app's or conversation's own row, set: at once, gathered or held as it says at each time.
977    if ask.attention.has_sources(ask.sources) {
978        let event = Event::of(Source::Message { via: talk.via, who, group, always }).from_sources(ask.sources.to_vec());
979        return by_matrix(ask, &[event.clone().for_area(ask.area), event], Why::Allowed, Why::Waiting);
980    }
981    let open = |at: &Zoned, mode: &Mode, area: Option<Area>| ask.attention.decide(&Event::of(Source::Message { via: talk.via, who, group, always }).for_area(area), &ask.moment(at, mode)).told;
982    // Its area and its times never meeting in the coming days, the matrix alone decides: nothing waits for good.
983    let next = first_moment(ask, &|at, mode| open(at, mode, ask.area)).or_else(|| first_moment(ask, &|at, mode| open(at, mode, None)));
984    match next {
985        Some(at) if at.timestamp().as_second() <= ask.stamp() => Decision::through(Why::Allowed),
986        Some(at) => Decision::held(at.timestamp().as_second(), Why::Waiting),
987        None => Decision::held(ask.stamp() + RECHECK, Why::Waiting),
988    }
989}
990
991/// A conversation let through always: Always through's row (at once at any
992/// time, as usual), read against who writes, or the group's row.
993fn always(talk: &Talk, who: Who, group: bool, ask: &Ask) -> Decision {
994    match person(talk, who, group, true, ask) {
995        Decision { until: None, why: Why::Allowed } => Decision::through(Why::Always),
996        other => other,
997    }
998}
999
1000/// Whether a notification comes now, or until when it waits.
1001/// `who`: the sender as the address books and lists judge them (`named` for
1002/// a name), none when nobody knows them; `listed`: the Always through list
1003/// holds them. The blocked never, whatever their conversation says (blocked
1004/// beats Always through, docs/attention.md, Q3).
1005pub fn decide(kind: &Kind, who: Option<Who>, listed: bool, choices: &Choices, ask: &Ask) -> Decision {
1006    match kind {
1007        Kind::Untouched => Decision::through(Why::Untouched),
1008        Kind::Code => Decision::through(Why::Code),
1009        _ if !choices.hold => Decision::through(Why::Off),
1010        Kind::AtOnce => at_once(ask),
1011        Kind::Automaton { .. } => gathered(ask),
1012        Kind::People(talk) => {
1013            if who == Some(Who::Blocked) {
1014                return Decision::held(ask.stamp() + FOR_GOOD, Why::Never);
1015            }
1016            // A group is judged as the conversation, on its own row: anyone in it may write.
1017            let group = talk.conversation.as_ref().is_some_and(|c| c.group);
1018            let through = talk.conversation.as_ref().map_or(Through::Usual, |c| choices.through(&c.key));
1019            match through {
1020                Through::Always => always(talk, who.unwrap_or(Who::Stranger), group, ask),
1021                Through::Gathered => gathered(ask),
1022                Through::Never => Decision::held(ask.stamp() + FOR_GOOD, Why::Never),
1023                Through::Usual => {
1024                    // An SMS named only, whom no address book knows: a business's sender id.
1025                    if talk.sms && who.is_none() && matches!(talk.sender, Sender::Named { .. }) {
1026                        return gathered(ask);
1027                    }
1028                    person(talk, who.unwrap_or(Who::Stranger), group, listed && !group, ask)
1029                }
1030            }
1031        }
1032    }
1033}
1034
1035// ---------------------------------------------------------------- what Sioul held
1036
1037/// What a held notification waits for, to work its time out again when
1038/// something changes (a pause ends, the hours or the choices change: `review`).
1039#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1040pub struct Held {
1041    /// The app's package and name.
1042    pub app: String,
1043    pub label: String,
1044    /// "people", "gathered", "at-once", "never".
1045    pub kind: String,
1046    /// The channel (`Channel::id`) and who wrote (`Who::id`), for a message.
1047    #[serde(default, skip_serializing_if = "String::is_empty")]
1048    pub via: String,
1049    #[serde(default, skip_serializing_if = "String::is_empty")]
1050    pub who: String,
1051    /// The Always through list holds the sender.
1052    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1053    pub admitted: bool,
1054    /// A group's conversation: its own row, whoever writes.
1055    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1056    pub group: bool,
1057    #[serde(default, skip_serializing_if = "String::is_empty")]
1058    pub conversation: String,
1059    #[serde(default, skip_serializing_if = "String::is_empty")]
1060    pub site: String,
1061    #[serde(default, skip_serializing_if = "Option::is_none")]
1062    pub area: Option<Area>,
1063    pub since: i64,
1064    pub until: i64,
1065    /// Held again on its return so many times.
1066    #[serde(default, skip_serializing_if = "is_zero")]
1067    pub again: u32,
1068}
1069
1070fn is_zero(n: &u32) -> bool {
1071    *n == 0
1072}
1073
1074/// An app as Sioul saw it last: its name, when, what came of it, the channels that rang while held.
1075#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1076pub struct SeenApp {
1077    pub label: String,
1078    pub seen: i64,
1079    /// The last decision: `Why::id`, and until when when held.
1080    pub why: String,
1081    #[serde(default, skip_serializing_if = "is_zero_i64")]
1082    pub until: i64,
1083    /// Its channels that sounded while Sioul held their notification, by
1084    /// channel id: their names and when. Made silent, they are taken off.
1085    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1086    pub rang: BTreeMap<String, Rang>,
1087}
1088
1089fn is_zero_i64(n: &i64) -> bool {
1090    *n == 0
1091}
1092
1093#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1094pub struct Rang {
1095    pub name: String,
1096    pub at: i64,
1097}
1098
1099/// A conversation seen: to choose for it.
1100#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1101pub struct SeenTalk {
1102    pub app: String,
1103    pub label: String,
1104    pub title: String,
1105    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1106    pub group: bool,
1107    pub seen: i64,
1108}
1109
1110/// A browser's site seen: to choose for it.
1111#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1112pub struct SeenSite {
1113    pub app: String,
1114    pub seen: i64,
1115}
1116
1117/// What came back at its time, for the Porch.
1118#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1119pub struct Back {
1120    pub at: i64,
1121    pub apps: Vec<String>,
1122}
1123
1124/// What Sioul held and saw, on this phone (`$XDG_STATE_HOME/sioul/app-notes-seen.toml`).
1125#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1126pub struct Ledger {
1127    /// Held now, by `key_hash`.
1128    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1129    pub held: BTreeMap<String, Held>,
1130    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1131    pub apps: BTreeMap<String, SeenApp>,
1132    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1133    pub conversations: BTreeMap<String, SeenTalk>,
1134    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1135    pub sites: BTreeMap<String, SeenSite>,
1136    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1137    pub back: Vec<Back>,
1138    /// When the listener last heard from Android (Unix seconds): the access works.
1139    #[serde(default, skip_serializing_if = "is_zero_i64")]
1140    pub heard: i64,
1141}
1142
1143impl Ledger {
1144    pub fn default_path() -> PathBuf {
1145        crate::config::state_dir().join("app-notes-seen.toml")
1146    }
1147
1148    /// As kept; empty when missing or unreadable (what was held comes back at its time all the same).
1149    pub fn load(path: &Path) -> Ledger {
1150        std::fs::read_to_string(path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
1151    }
1152
1153    pub fn save(&self, path: &Path) -> Result<(), String> {
1154        write_whole(path, &toml::to_string(self).map_err(|e| e.to_string())?)
1155    }
1156
1157    /// A notification decided: the app, its conversation or site seen; held, kept to be worked out again.
1158    pub fn note(&mut self, p: &Posted, kind: &Kind, who: Option<Who>, admitted: bool, area: Option<Area>, decision: &Decision, now: i64) {
1159        self.heard = now;
1160        let label = if p.app.trim().is_empty() { p.package.clone() } else { one_line(&p.app) };
1161        let app = self.apps.entry(p.package.clone()).or_default();
1162        app.label = label.clone();
1163        app.seen = now;
1164        app.why = decision.why.id().to_string();
1165        app.until = decision.until.unwrap_or(0);
1166        // A channel that sounded while held: the guide to make it silent. Made silent: no more.
1167        if !p.channel.is_empty() {
1168            if decision.until.is_some() && p.alerted && !p.intercepted {
1169                app.rang.insert(p.channel.clone(), Rang { name: one_line(&p.channel_name), at: now });
1170            } else if (0..3).contains(&p.importance) {
1171                app.rang.remove(&p.channel);
1172            }
1173        }
1174        match kind {
1175            Kind::People(talk) => {
1176                if let Some(c) = &talk.conversation {
1177                    self.conversations.insert(c.key.clone(), SeenTalk { app: p.package.clone(), label: label.clone(), title: c.title.clone(), group: c.group, seen: now });
1178                }
1179            }
1180            Kind::Automaton { site } if !site.is_empty() => {
1181                self.sites.insert(site.clone(), SeenSite { app: p.package.clone(), seen: now });
1182            }
1183            _ => {}
1184        }
1185        let hash = key_hash(&p.key);
1186        let Some(until) = decision.until else {
1187            self.held.remove(&hash);
1188            return;
1189        };
1190        let (kind_id, via, conversation, site) = match kind {
1191            Kind::People(talk) => ("people", talk.via.id(), talk.conversation.as_ref().map(|c| c.key.clone()).unwrap_or_default(), String::new()),
1192            Kind::Automaton { site } => ("gathered", "", String::new(), site.clone()),
1193            Kind::AtOnce => ("at-once", "", String::new(), String::new()),
1194            _ => ("gathered", "", String::new(), String::new()),
1195        };
1196        let kind_id = match decision.why {
1197            Why::Never => "never",
1198            Why::Gathered => "gathered",
1199            _ => kind_id,
1200        };
1201        // A group is judged as the conversation, on its own row, whoever wrote in it (`decide`);
1202        // the one who wrote kept, for the blocked.
1203        let group = matches!(kind, Kind::People(t) if t.conversation.as_ref().is_some_and(|c| c.group));
1204        let admitted = admitted && !group;
1205        let again = self.held.get(&hash).map_or(0, |h| h.again);
1206        self.held.insert(hash, Held { app: p.package.clone(), label, kind: kind_id.into(), via: via.to_string(), who: who.map(|w| w.id().to_string()).unwrap_or_default(), admitted, group, conversation, site, area, since: now, until, again });
1207    }
1208
1209    /// A held notification Android brought back and that comes now: no
1210    /// longer held, and said on the Porch with what came back with it.
1211    pub fn came_back(&mut self, hash: &str, now: i64) {
1212        let Some(held) = self.held.remove(hash) else { return };
1213        match self.back.last_mut() {
1214            Some(back) if now - back.at < 120 => {
1215                if !back.apps.contains(&held.label) {
1216                    back.apps.push(held.label);
1217                }
1218            }
1219            _ => self.back.push(Back { at: now, apps: vec![held.label] }),
1220        }
1221    }
1222
1223    /// Whether this notification is one Sioul held that Android brings back now (or a little before its time).
1224    pub fn returning(&self, hash: &str, now: i64) -> bool {
1225        self.held.get(hash).is_some_and(|h| now >= h.until - 120)
1226    }
1227
1228    /// The old forgotten: held marks long past their time (the app took the
1229    /// notification away meanwhile), apps, sites and conversations not seen
1230    /// for weeks (unless chosen for), what came back yesterday.
1231    pub fn forget_old(&mut self, now: i64, choices: &Choices) {
1232        self.held.retain(|_, h| h.until + 86_400 > now);
1233        self.apps.retain(|package, a| now - a.seen < SEEN_KEPT || choices.app.contains_key(package));
1234        self.sites.retain(|host, s| now - s.seen < SEEN_KEPT || choices.site.contains_key(host));
1235        self.conversations.retain(|key, c| now - c.seen < TALK_KEPT || choices.conversation.contains_key(key));
1236        if self.conversations.len() > TALKS_MAX {
1237            let mut by_age: Vec<(i64, String)> = self.conversations.iter().filter(|(k, _)| !choices.conversation.contains_key(*k)).map(|(k, c)| (c.seen, k.clone())).collect();
1238            by_age.sort();
1239            for (_, key) in by_age.into_iter().take(self.conversations.len() - TALKS_MAX) {
1240                self.conversations.remove(&key);
1241            }
1242        }
1243        for app in self.apps.values_mut() {
1244            app.rang.retain(|_, r| now - r.at < TALK_KEPT);
1245        }
1246        self.back.retain(|b| now - b.at < 86_400);
1247    }
1248}
1249
1250/// A held notification's time worked out again, as things stand now (a
1251/// pause ended, the hours or the choices changed): its new time, none when it
1252/// may come now. `who`, `admitted` and the rest as kept when it was held.
1253pub fn again(held: &Held, choices: &Choices, ask: &Ask) -> Decision {
1254    if !choices.hold {
1255        return Decision::through(Why::Off);
1256    }
1257    let app = choices.app(&held.app);
1258    if app.kind == AppKind::AtOnce || (!held.site.is_empty() && choices.site.get(&held.site).is_some_and(|s| s.at_once)) {
1259        return at_once(ask);
1260    }
1261    let who = Who::read(&held.who);
1262    // The blocked never, whatever their conversation says.
1263    if who == Some(Who::Blocked) {
1264        return Decision::held(held.until.max(ask.stamp() + 60), Why::Never);
1265    }
1266    let talk = Talk { via: Channel::read(&held.via).unwrap_or(Channel::Messages), sender: Sender::Unknown, conversation: None, to: String::new(), sms: false };
1267    if !held.conversation.is_empty() {
1268        match choices.through(&held.conversation) {
1269            Through::Always => return always(&talk, who.unwrap_or(Who::Stranger), held.group, ask),
1270            Through::Gathered => return gathered(ask),
1271            Through::Never => return Decision::held(held.until.max(ask.stamp() + 60), Why::Never),
1272            Through::Usual => {}
1273        }
1274    }
1275    match held.kind.as_str() {
1276        _ if app.kind == AppKind::Automaton => gathered(ask),
1277        "people" | "never" => person(&talk, who.unwrap_or(Who::Stranger), held.group, held.admitted, ask),
1278        _ => gathered(ask),
1279    }
1280}
1281
1282// ---------------------------------------------------------------- words
1283
1284/// "Discord", "Discord and Le Monde", "Discord, Le Monde and WhatsApp",
1285/// "Discord, Le Monde, WhatsApp and two other apps": numbers in words,
1286/// "many other apps" past twelve.
1287pub fn apps_in_words(tr: &Translator, labels: &[String]) -> String {
1288    let and = tr.text("word-and", None);
1289    match labels {
1290        [] => String::new(),
1291        [one] => one.clone(),
1292        [a, b] => format!("{a} {and} {b}"),
1293        [a, b, c] => format!("{a}, {b} {and} {c}"),
1294        [a, b, c, rest @ ..] => {
1295            let others = if rest.len() > 12 { tr.text("appnotes-others-many", None) } else { tr.text("appnotes-others", Some(&tr.counted(rest.len()))) };
1296            format!("{a}, {b}, {c} {and} {others}")
1297        }
1298    }
1299}
1300
1301/// The Porch's line on other apps (docs/porch.md): what waits and until
1302/// when, what came back lately. Names and words, never a count past twelve.
1303pub fn porch_line(tr: &Translator, ledger: &Ledger, now: &Zoned) -> String {
1304    let stamp = now.timestamp().as_second();
1305    let mut sentences = Vec::new();
1306    let mut waiting: Vec<&Held> = ledger.held.values().filter(|h| h.kind != "never" && h.until > stamp).collect();
1307    waiting.sort_by_key(|h| (h.until, h.label.clone()));
1308    if let Some(first) = waiting.first() {
1309        let mut labels: Vec<String> = Vec::new();
1310        for h in &waiting {
1311            if !labels.contains(&h.label) {
1312                labels.push(h.label.clone());
1313            }
1314        }
1315        let mut args = crate::i18n::args();
1316        args.set("apps", apps_in_words(tr, &labels));
1317        args.set("when", when_text(tr, first.until, now));
1318        let one_time = waiting.iter().all(|h| h.until == first.until);
1319        sentences.push(tr.text(if one_time { "appnotes-porch-held" } else { "appnotes-porch-held-first" }, Some(&args)));
1320    }
1321    if let Some(back) = ledger.back.iter().rev().find(|b| stamp - b.at < BACK_SAID && !b.apps.is_empty()) {
1322        let mut args = crate::i18n::args();
1323        args.set("apps", apps_in_words(tr, &back.apps));
1324        args.set("when", when_text(tr, back.at, now));
1325        sentences.push(tr.text("appnotes-porch-back", Some(&args)));
1326    }
1327    sentences.join(" ")
1328}
1329
1330/// "13:00", "tomorrow at 09:00", "Monday 12 October at 09:00".
1331fn when_text(tr: &Translator, at: i64, now: &Zoned) -> String {
1332    match jiff::Timestamp::from_second(at) {
1333        Ok(t) => crate::quiet::until_text(tr, &t.to_zoned(now.time_zone().clone()), now),
1334        Err(_) => String::new(),
1335    }
1336}
1337
1338/// Why an app's last notification came or waits, in a sentence (the setup page).
1339pub fn why_text(tr: &Translator, why: &str, until: i64, now: &Zoned) -> String {
1340    let mut args = crate::i18n::args();
1341    args.set("when", if until > 0 { when_text(tr, until, now) } else { String::new() });
1342    let held = until > now.timestamp().as_second();
1343    let id = match (why, held) {
1344        ("waiting", true) | ("gathered", true) | ("at-once", true) | ("never", true) | ("times", true) => format!("appnotes-why-{why}-held"),
1345        _ => format!("appnotes-why-{why}"),
1346    };
1347    tr.text(&id, Some(&args))
1348}
1349
1350#[cfg(test)]
1351mod tests {
1352    use super::*;
1353
1354    /// What a notification is, with the packs built in (French and English).
1355    fn classify(p: &Posted, choices: &Choices) -> Kind {
1356        super::classify(p, choices, crate::words::Words::builtin_ref())
1357    }
1358
1359    fn is_code(p: &Posted) -> bool {
1360        super::is_code(p, crate::words::Words::builtin_ref())
1361    }
1362    use crate::attention::{Column, Person, Row};
1363    use crate::needs::{Days, Needs};
1364    use crate::quiet::{Blocks, Overrides};
1365    use crate::window::AdminWindow;
1366
1367    fn at(text: &str) -> Zoned {
1368        text.parse().unwrap()
1369    }
1370
1371    /// Working hours Monday to Friday 09:00–17:00, Health's meals, naps and
1372    /// night (bed at 23:00, winding down from 22:00, waking at 07:00), the
1373    /// overrides given (a pause, free time).
1374    struct TestClock {
1375        overrides: Overrides,
1376    }
1377
1378    impl TestClock {
1379        fn new() -> TestClock {
1380            TestClock { overrides: Overrides::default() }
1381        }
1382    }
1383
1384    fn week() -> Vec<AdminWindow> {
1385        ["monday", "tuesday", "wednesday", "thursday", "friday"].iter().map(|d| AdminWindow { day: d.to_string(), start: "09:00".into(), end: Some("17:00".into()), minutes: 0, kind: None }).collect()
1386    }
1387
1388    impl TimeSource for TestClock {
1389        fn mode(&self, at: &Zoned) -> Mode {
1390            let needs = Needs { meals_on: true, naps_on: true, sleep_on: true, ..Needs::default() };
1391            crate::quiet::mode(&week(), &[], &self.overrides, &Blocks::of(&needs, &Days::default(), &[], at), at)
1392        }
1393
1394        fn starts(&self, from: &Zoned, until: &Zoned) -> Vec<Zoned> {
1395            // Every five minutes, kept where what time it is changes.
1396            let mut out = Vec::new();
1397            let mut last = self.mode(from);
1398            let mut at = from.clone();
1399            while at.timestamp() < until.timestamp() {
1400                at = at.checked_add(jiff::Span::new().minutes(5)).unwrap();
1401                let mode = self.mode(&at);
1402                if (mode.time, mode.reason.clone()) != (last.time, last.reason.clone()) {
1403                    out.push(at.clone());
1404                    last = mode;
1405                }
1406            }
1407            out
1408        }
1409    }
1410
1411    fn never_slot(_: &Zoned) -> bool {
1412        false
1413    }
1414
1415    /// The matrix of what reaches you as usual, but the neutral in leisure on
1416    /// mail and messages too (as these tests' people come then).
1417    fn usual() -> &'static Attention {
1418        static USUAL: std::sync::OnceLock<Attention> = std::sync::OnceLock::new();
1419        USUAL.get_or_init(|| {
1420            let mut matrix = Attention::usual();
1421            for channel in [Channel::Mail, Channel::Messages] {
1422                matrix.set(Row::People(channel, Person::Neutral), Column::Leisure, Level::Now).unwrap();
1423            }
1424            matrix
1425        })
1426    }
1427
1428    fn ask<'a>(now: &'a Zoned, clock: &'a TestClock, area: Option<Area>, gate: Option<Gate>) -> Ask<'a> {
1429        static GATHERED: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
1430        let gathered = GATHERED.get_or_init(|| vec!["09:00".into(), "13:00".into(), "18:00".into()]);
1431        Ask { now, clock, attention: usual(), gathered, area, gate, slot_at: &never_slot, nothing: false, also: &[], sources: &[] }
1432    }
1433
1434    fn posted(json: &str) -> Posted {
1435        serde_json::from_str(json).unwrap()
1436    }
1437
1438    fn until_of(d: Decision) -> String {
1439        d.until.map(|u| jiff::Timestamp::from_second(u).unwrap().to_zoned(jiff::tz::TimeZone::get("Europe/Paris").unwrap()).datetime().to_string()).unwrap_or_default()
1440    }
1441
1442    // Fixtures, as AppNotes.java hands them (invented people and numbers).
1443    const SMS_NUMBER: &str = r#"{"key":"0|com.google.android.apps.messaging|1|sms|10001","package":"com.google.android.apps.messaging","app":"Messages","category":"msg","template":"android.app.Notification$MessagingStyle","title":"+262 6 39 98 01 01","text":"Tu es où ?","messages":[{"sender":{"name":"+262 6 39 98 01 01","key":"+262639980101"},"text":"Tu es où ?"}],"sms_app":true,"shortcut":"conv-41"}"#;
1444    const SMS_CONTACT: &str = r#"{"key":"0|com.google.android.apps.messaging|2|sms|10001","package":"com.google.android.apps.messaging","app":"Messages","category":"msg","template":"android.app.Notification$MessagingStyle","title":"Maman","messages":[{"sender":{"name":"Maman","uri":"tel:%2B262639980102"},"text":"Rappelle-moi"}],"sms_app":true}"#;
1445    const SMS_SENDER_ID: &str = r#"{"key":"0|foundation.e.message|3|null|10002","package":"foundation.e.message","app":"Message","title":"AMELI","text":"Votre attestation est disponible dans votre compte.","messages":[{"sender":{"name":"AMELI"},"text":"Votre attestation est disponible dans votre compte."}],"sms_app":true}"#;
1446    const SMS_SHORT: &str = r#"{"key":"0|foundation.e.message|4|null|10002","package":"foundation.e.message","app":"Message","title":"38015","text":"Votre colis arrive demain entre 9h et 13h.","sms_app":true}"#;
1447    const SMS_CODE: &str = r#"{"key":"0|foundation.e.message|5|null|10002","package":"foundation.e.message","app":"Message","title":"BANQUE","text":"Votre code de vérification : 482913. Ne le communiquez à personne.","sms_app":true}"#;
1448    const WHATSAPP_PERSON: &str = r#"{"key":"0|com.whatsapp|1|262639980103@s.whatsapp.net|10100","package":"com.whatsapp","app":"WhatsApp","category":"msg","template":"android.app.Notification$MessagingStyle","title":"Jeanne Martin","shortcut":"262639980103@s.whatsapp.net","is_conversation":true,"messages":[{"sender":{"name":"Jeanne Martin","key":"1","uri":"content://com.android.contacts/contacts/lookup/0r1-ABC/1","numbers":["+262 6 39 98 01 03"]},"text":"On se voit demain ?"}]}"#;
1449    const WHATSAPP_GROUP: &str = r#"{"key":"0|com.whatsapp|1|120363000000000001@g.us|10100","package":"com.whatsapp","app":"WhatsApp","category":"msg","template":"android.app.Notification$MessagingStyle","title":"École Jules Ferry","conversation":"École Jules Ferry","group_conversation":true,"shortcut":"120363000000000001@g.us","is_conversation":true,"messages":[{"sender":{"name":"Mme Durand"},"text":"Sortie annulée demain, les enfants restent à l'école."}]}"#;
1450    const DISCORD_SERVER: &str = r##"{"key":"0|com.discord|7|null|10200","package":"com.discord","app":"Discord","channel":"messages","channel_name":"Messages","category":"msg","group":"GROUP_MESSAGE_CREATE","template":"android.app.Notification$MessagingStyle","title":"Rust FR #général","conversation":"Rust FR #général","group_conversation":true,"shortcut":"110022003344556677","is_conversation":true,"messages":[{"sender":{"name":"ferris","key":"ferris"},"text":"Nouvelle version ce soir"}]}"##;
1451    const DISCORD_DM: &str = r#"{"key":"0|com.discord|8|null|10200","package":"com.discord","app":"Discord","channel":"dm","category":"msg","template":"android.app.Notification$MessagingStyle","title":"alice","shortcut":"110022003344556688","is_conversation":true,"messages":[{"sender":{"name":"alice","key":"alice"},"text":"tu as vu ?"}]}"#;
1452    const CHROME_SITE: &str = r#"{"key":"0|com.android.chrome|-1|p#https://forum.example.net#2|10300","package":"com.android.chrome","app":"Chrome","tag":"p#https://forum.example.net#2","channel":"web:https://forum.example.net;1759000000000","group":"Web:forum.example.net","title":"Nouvelle réponse","text":"Quelqu'un a répondu à votre sujet","sub":"forum.example.net"}"#;
1453    const FIREFOX_SITE: &str = r#"{"key":"0|org.mozilla.fennec_fdroid|5|a1b2|10301","package":"org.mozilla.fennec_fdroid","app":"Fennec","channel":"mozac.feature.webnotifications.generic.channel","title":"New post","text":"Your feed has news","sub":"news.example.com"}"#;
1454    const PROTON: &str = r#"{"key":"0|ch.protonmail.android|42|null|10400","package":"ch.protonmail.android","app":"Proton Mail","channel":"v7_email_channel_id","channel_name":"Emails","title":"Alice Martin","text":"Dîner vendredi","sub":"me@proton.example"}"#;
1455    const PROTON_LOGIN: &str = r#"{"key":"0|ch.protonmail.android|43|null|10400","package":"ch.protonmail.android","app":"Proton Mail","channel":"v7_login_channel_id","channel_name":"Login","title":"New sign-in to your account","text":"A new device signed in."}"#;
1456    const SHOP: &str = r#"{"key":"0|com.shop.example|1|null|10500","package":"com.shop.example","app":"Shop","category":"promo","channel":"offers","title":"-30 % sur les chaussures","text":"Jusqu'à dimanche seulement","alerted":true}"#;
1457    const BANK_CODE: &str = r#"{"key":"0|com.bank.example|1|null|10600","package":"com.bank.example","app":"Bank","title":"Your security code","text":"Your security code is 482913. It expires in 5 minutes."}"#;
1458    const BANK_APPROVAL: &str = r#"{"key":"0|com.bank.example|2|null|10600","package":"com.bank.example","app":"Banque","title":"Paiement en ligne","text":"Validez votre paiement de 42,00 € dans l'application."}"#;
1459    const REDACTED: &str = r#"{"key":"0|com.otp.example|1|null|10700","package":"com.otp.example","app":"Authenticator","title":"Authenticator","text":"Sensitive notification content hidden","redacted":true}"#;
1460
1461    #[test]
1462    fn what_a_notification_is() {
1463        let choices = Choices::default();
1464        let talk = |json: &str| match classify(&posted(json), &choices) {
1465            Kind::People(t) => t,
1466            other => panic!("{other:?}"),
1467        };
1468        // SMS: a number is someone, a contact's tel: URI too; a sender id or a short code is an automaton.
1469        let sms = talk(SMS_NUMBER);
1470        assert_eq!((sms.via, sms.sms), (Channel::Messages, true));
1471        assert_eq!(sms.sender, Sender::Known { numbers: vec!["+262 6 39 98 01 01".into()], emails: vec![], contact: false });
1472        assert_eq!(talk(SMS_CONTACT).sender, Sender::Known { numbers: vec!["+262639980102".into()], emails: vec![], contact: false });
1473        assert_eq!(talk(SMS_SENDER_ID).sender, Sender::Named { name: "AMELI".into(), book: true });
1474        assert_eq!(classify(&posted(SMS_SHORT), &choices), Kind::Automaton { site: String::new() });
1475        assert_eq!(classify(&posted(SMS_CODE), &choices), Kind::Code);
1476        // WhatsApp: a person by the phone's address book; a group by its conversation.
1477        let jeanne = talk(WHATSAPP_PERSON);
1478        assert_eq!(jeanne.sender, Sender::Known { numbers: vec!["+262 6 39 98 01 03".into()], emails: vec![], contact: true }, "the phone's contact");
1479        let school = talk(WHATSAPP_GROUP);
1480        let conversation = school.conversation.clone().unwrap();
1481        assert_eq!((conversation.title.as_str(), conversation.group), ("École Jules Ferry", true));
1482        assert_eq!(conversation.key, talk_key("com.whatsapp", "120363000000000001@g.us"));
1483        assert_eq!(school.sender, Sender::Named { name: "Mme Durand".into(), book: true });
1484        // A name alone, found in the phone's address book: WhatsApp's counts, Discord's does not.
1485        let by_name = r#"{"key":"k","package":"com.whatsapp","app":"WhatsApp","category":"msg","template":"android.app.Notification$MessagingStyle","title":"Paul","messages":[{"sender":{"name":"Paul","by_name":["+262 6 39 98 01 04"]},"text":"Salut"}]}"#;
1486        assert_eq!(talk(by_name).sender, Sender::Known { numbers: vec!["+262 6 39 98 01 04".into()], emails: vec![], contact: true });
1487        let discord_by_name = by_name.replace("com.whatsapp", "com.discord");
1488        assert_eq!(talk(discord_by_name.as_str()).sender, Sender::Named { name: "Paul".into(), book: false });
1489        // A bot says it is one: an automaton. A six-digit short code is no phone number.
1490        let bot = r#"{"key":"b","package":"com.bank.example","app":"Bank","category":"msg","template":"android.app.Notification$MessagingStyle","title":"Assistant","messages":[{"sender":{"name":"Assistant","bot":true},"text":"Votre relevé est prêt."}]}"#;
1491        assert_eq!(classify(&posted(bot), &choices), Kind::Automaton { site: String::new() });
1492        assert_eq!(classify(&posted(r#"{"key":"s6","package":"foundation.e.message","title":"123456","text":"Votre colis est arrivé.","sms_app":true}"#), &choices), Kind::Automaton { site: String::new() });
1493        assert_eq!(number_in("06 39 98 01"), Some("06 39 98 01".into()), "eight digits");
1494        // The person's own replies, named by the app: neither who wrote nor a second writer.
1495        let replied = talk(r#"{"key":"r","package":"com.discord","category":"msg","user":{"name":"Me","key":"me"},"messages":[{"sender":{"name":"alice","key":"alice"},"text":"tu viens ?"},{"sender":{"name":"Me","key":"me"},"text":"oui"}]}"#);
1496        assert_eq!(replied.sender, Sender::Named { name: "alice".into(), book: false });
1497        assert!(!replied.conversation.unwrap().group);
1498        // Two people writing in a conversation the app does not call a group: a group all the same.
1499        let two = talk(r#"{"key":"t","package":"org.example.chat","category":"msg","conversation":"Voisins","messages":[{"sender":{"name":"Ana"},"text":"Bonjour"},{"sender":{"name":"Bob"},"text":"Salut"}]}"#);
1500        assert!(two.conversation.unwrap().group);
1501        // A missed call: the phone's names are the address book's.
1502        let missed = talk(r#"{"key":"m","package":"com.android.dialer","category":"missed_call","title":"Maman"}"#);
1503        assert_eq!((missed.via, missed.sender), (Channel::Calls, Sender::Named { name: "Maman".into(), book: true }));
1504        // An SMS app's plain notification: its title is the conversation.
1505        let plain = talk(r#"{"key":"k2","package":"com.android.mms","app":"SMS","title":"Paul","text":"Salut"}"#);
1506        assert_eq!(plain.conversation.map(|c| c.key), Some(talk_key("com.android.mms", "Paul")));
1507        // Discord: names anyone can take; a server's channel is a group, a direct message is not.
1508        let server = talk(DISCORD_SERVER);
1509        assert!(server.conversation.as_ref().unwrap().group);
1510        assert_eq!(server.sender, Sender::Named { name: "ferris".into(), book: false });
1511        let dm = talk(DISCORD_DM);
1512        assert_eq!((dm.conversation.as_ref().unwrap().group, dm.conversation.as_ref().unwrap().title.as_str()), (false, "alice"));
1513        // Browsers: the site, by Chromium's tag and channel, by Firefox's sub-text.
1514        assert_eq!(classify(&posted(CHROME_SITE), &choices), Kind::Automaton { site: "forum.example.net".into() });
1515        assert_eq!(classify(&posted(FIREFOX_SITE), &choices), Kind::Automaton { site: "news.example.com".into() });
1516        // Proton Mail: mail, a sender's name, the address it came to; its sign-ins never held.
1517        let proton = talk(PROTON);
1518        assert_eq!((proton.via, proton.to.as_str()), (Channel::Mail, "me@proton.example"));
1519        assert_eq!(proton.sender, Sender::Named { name: "Alice Martin".into(), book: false });
1520        assert_eq!(classify(&posted(PROTON_LOGIN), &choices), Kind::Code);
1521        // A shop's promotion: an automaton. A bank's code, its approval, a redacted copy: at once.
1522        assert_eq!(classify(&posted(SHOP), &choices), Kind::Automaton { site: String::new() });
1523        for json in [BANK_CODE, BANK_APPROVAL, REDACTED] {
1524            assert_eq!(classify(&posted(json), &choices), Kind::Code, "{json}");
1525        }
1526        let asked = |text: &str| is_code(&Posted { title: "Alice".into(), text: text.into(), ..Posted::default() });
1527        assert!(asked("Is this you signing in from Lyon?") && asked("Est-ce bien vous qui tentez une connexion ?"));
1528        assert!(!asked("Is this you on the photo? Haha") && !asked("C'est bien vous sur la photo ?"));
1529        // Your words: a German bank's approval, once its words are added.
1530        let config: crate::config::Config = toml::from_str("[words]\nlanguages = [\"fr\", \"en\"]\ncountries = [\"FR\"]\n[words.approvals.phrases]\nadd = [\"Zahlung freigeben\"]\n").unwrap();
1531        let german = Posted { title: "Bank".into(), text: "Bitte Zahlung freigeben in der App.".into(), ..Posted::default() };
1532        assert!(!is_code(&german) && super::is_code(&german, &crate::words::Words::of(&config)));
1533        // Never held: a call, an alarm, what runs, a summary, a reminder you set, Sioul's own.
1534        for json in [
1535            r#"{"package":"com.whatsapp","category":"call","title":"Jeanne"}"#,
1536            r#"{"package":"org.fossify.clock","category":"alarm","full_screen":true}"#,
1537            r#"{"package":"org.example.music","ongoing":true,"title":"A song"}"#,
1538            r#"{"package":"com.whatsapp","summary":true,"title":"3 messages"}"#,
1539            r#"{"package":"ws.xsoh.etar","category":"event","title":"Dentist at 14:00"}"#,
1540            r#"{"package":"com.aurelienpierre.sioul","title":"Taken"}"#,
1541        ] {
1542            assert_eq!(classify(&posted(json), &choices), Kind::Untouched, "{json}");
1543        }
1544        // The person's word for an app or a site comes first.
1545        let mut chosen = Choices::default();
1546        chosen.app.insert("com.discord".into(), AppChoice { kind: AppKind::Automaton, ..AppChoice::default() });
1547        chosen.app.insert("com.shop.example".into(), AppChoice { kind: AppKind::AtOnce, ..AppChoice::default() });
1548        chosen.site.insert("forum.example.net".into(), SiteChoice { at_once: true });
1549        assert_eq!(classify(&posted(DISCORD_DM), &chosen), Kind::Automaton { site: String::new() });
1550        assert_eq!(classify(&posted(SHOP), &chosen), Kind::AtOnce);
1551        assert_eq!(classify(&posted(CHROME_SITE), &chosen), Kind::AtOnce);
1552        // An app said to carry messages between people: its plain notifications too.
1553        chosen.app.insert("org.example.old.sms".into(), AppChoice { kind: AppKind::People, ..AppChoice::default() });
1554        let old = classify(&posted(r#"{"package":"org.example.old.sms","title":"+262 6 39 98 01 05","text":"Coucou"}"#), &chosen);
1555        assert!(matches!(old, Kind::People(Talk { sender: Sender::Known { .. }, .. })), "{old:?}");
1556    }
1557
1558    #[test]
1559    fn names_anyone_can_take_only_hold_more() {
1560        assert_eq!(named(Some(Who::Safe), true), Who::Safe, "WhatsApp names people as the address book does");
1561        assert_eq!(named(Some(Who::Safe), false), Who::Stranger, "a Discord name is anyone's");
1562        assert_eq!(named(Some(Who::Neutral), false), Who::Stranger);
1563        assert_eq!(named(Some(Who::Restricted), false), Who::Restricted);
1564        assert_eq!(named(Some(Who::Blocked), false), Who::Blocked);
1565        assert_eq!(named(None, true), Who::Stranger);
1566    }
1567
1568    #[test]
1569    fn who_and_when_decide() {
1570        let clock = TestClock::new();
1571        let choices = Choices::default();
1572        let kind = |json: &str| classify(&posted(json), &choices);
1573        // Tuesday 6 October 2026, 10:00: working hours.
1574        let morning = at("2026-10-06T10:00[Europe/Paris]");
1575        let evening = at("2026-10-06T20:30[Europe/Paris]");
1576        let night = at("2026-10-06T23:30[Europe/Paris]");
1577        let decide_at = |json: &str, who: Option<Who>, now: &Zoned| decide(&kind(json), who, false, &choices, &ask(now, &clock, None, None));
1578        // A stranger's SMS: now in work time; in the evening, held until work starts on Wednesday.
1579        assert_eq!(decide_at(SMS_NUMBER, Some(Who::Stranger), &morning), Decision::through(Why::Allowed));
1580        let held = decide_at(SMS_NUMBER, Some(Who::Stranger), &evening);
1581        assert_eq!((held.why, until_of(held).as_str()), (Why::Waiting, "2026-10-07T09:00:00"));
1582        // Someone in the address book: in the evening yes, during the night no, until… leisure at waking.
1583        assert_eq!(decide_at(WHATSAPP_PERSON, Some(Who::Neutral), &evening), Decision::through(Why::Allowed));
1584        let held = decide_at(WHATSAPP_PERSON, Some(Who::Neutral), &night);
1585        assert_eq!((held.why, until_of(held).as_str()), (Why::Waiting, "2026-10-07T07:00:00"));
1586        // Safe people in the evening; in the night held until waking (docs/attention.md, Q4: before, let through);
1587        // on the Always through list, at once; the blocked held for good, on the list or not.
1588        assert_eq!(decide_at(SMS_CONTACT, Some(Who::Safe), &evening), Decision::through(Why::Allowed));
1589        let held = decide_at(SMS_CONTACT, Some(Who::Safe), &night);
1590        assert_eq!((held.why, until_of(held).as_str()), (Why::Waiting, "2026-10-07T07:00:00"));
1591        assert_eq!(decide(&kind(SMS_CONTACT), Some(Who::Safe), true, &choices, &ask(&night, &clock, None, None)), Decision::through(Why::Allowed));
1592        assert_eq!(decide_at(SMS_CONTACT, Some(Who::Blocked), &morning).why, Why::Never);
1593        assert_eq!(decide(&kind(SMS_CONTACT), Some(Who::Blocked), true, &choices, &ask(&morning, &clock, None, None)).why, Why::Never);
1594        // A business's SMS id nobody knows: gathered; the same name found in a card: a person.
1595        let ameli = decide_at(SMS_SENDER_ID, None, &morning);
1596        assert_eq!((ameli.why, until_of(ameli).as_str()), (Why::Gathered, "2026-10-06T13:00:00"));
1597        assert_eq!(decide_at(SMS_SENDER_ID, Some(Who::Neutral), &morning), Decision::through(Why::Allowed));
1598        // A group: as people you do not know, whoever wrote in it.
1599        let group = decide_at(WHATSAPP_GROUP, Some(Who::Safe), &evening);
1600        assert_eq!((group.why, until_of(group).as_str()), (Why::Waiting, "2026-10-07T09:00:00"));
1601        // The school's group let through always: in the evening and in the night.
1602        let mut chosen = Choices::default();
1603        let school = talk_key("com.whatsapp", "120363000000000001@g.us");
1604        chosen.conversation.insert(school.clone(), TalkChoice { through: Through::Always, title: "École Jules Ferry".into(), app: "WhatsApp".into() });
1605        for now in [&evening, &night] {
1606            assert_eq!(decide(&kind(WHATSAPP_GROUP), None, false, &chosen, &ask(now, &clock, None, None)), Decision::through(Why::Always));
1607        }
1608        // Blocked beats Always through (Q3): a blocked person writing there is held (before: let through).
1609        assert_eq!(decide(&kind(WHATSAPP_GROUP), Some(Who::Blocked), false, &chosen, &ask(&evening, &clock, None, None)).why, Why::Never);
1610        // A noisy group gathered, another never.
1611        chosen.conversation.insert(school.clone(), TalkChoice { through: Through::Gathered, ..TalkChoice::default() });
1612        let gathered = decide(&kind(WHATSAPP_GROUP), None, false, &chosen, &ask(&morning, &clock, None, None));
1613        assert_eq!((gathered.why, until_of(gathered).as_str()), (Why::Gathered, "2026-10-06T13:00:00"));
1614        chosen.conversation.insert(school, TalkChoice { through: Through::Never, ..TalkChoice::default() });
1615        assert_eq!(decide(&kind(WHATSAPP_GROUP), None, false, &chosen, &ask(&morning, &clock, None, None)).why, Why::Never);
1616        // Codes and untouched ones come whatever the time; holding off, everything comes.
1617        assert_eq!(decide_at(BANK_CODE, None, &night), Decision::through(Why::Code));
1618        let off = Choices { hold: false, ..Choices::default() };
1619        assert_eq!(decide(&kind(SHOP), None, false, &off, &ask(&night, &clock, None, None)), Decision::through(Why::Off));
1620        // An app at once comes as Sioul's own notifications: now in the evening, at waking in the night.
1621        let mut at_once = Choices::default();
1622        at_once.app.insert("com.shop.example".into(), AppChoice { kind: AppKind::AtOnce, ..AppChoice::default() });
1623        let shop = classify(&posted(SHOP), &at_once);
1624        assert_eq!(decide(&shop, None, false, &at_once, &ask(&evening, &clock, None, None)), Decision::through(Why::AtOnce));
1625        let waking = decide(&shop, None, false, &at_once, &ask(&night, &clock, None, None));
1626        assert_eq!((waking.why, until_of(waking).as_str()), (Why::AtOnce, "2026-10-07T07:00:00"));
1627    }
1628
1629    #[test]
1630    fn mail_and_areas() {
1631        let clock = TestClock::new();
1632        let choices = Choices::default();
1633        let proton = classify(&posted(PROTON), &choices);
1634        let evening = at("2026-10-06T20:30[Europe/Paris]");
1635        let morning = at("2026-10-06T10:00[Europe/Paris]");
1636        // A name only, in Proton Mail: someone unknown; to a work address, in the evening: tomorrow's work.
1637        let who = named(Some(Who::Safe), false);
1638        let held = decide(&proton, Some(who), false, &choices, &ask(&evening, &clock, Some(Area::WORK), None));
1639        assert_eq!((held.why, until_of(held).as_str()), (Why::Waiting, "2026-10-07T09:00:00"));
1640        // Someone known writing to a leisure address in work time: held until the evening's leisure.
1641        let held = decide(&proton, Some(Who::Neutral), false, &choices, &ask(&morning, &clock, Some(Area::LEISURE), None));
1642        assert_eq!((held.why, until_of(held).as_str()), (Why::Waiting, "2026-10-06T17:00:00"));
1643        // Safe people to any address, any time.
1644        assert_eq!(decide(&proton, Some(Who::Safe), false, &choices, &ask(&morning, &clock, Some(Area::LEISURE), None)).why, Why::Allowed);
1645        // An automaton for work: its gatherings in work time only.
1646        let shop = classify(&posted(SHOP), &choices);
1647        let held = decide(&shop, None, false, &choices, &ask(&evening, &clock, Some(Area::WORK), None));
1648        assert_eq!(until_of(held), "2026-10-07T09:00:00");
1649        // An app for leisure in work time: the 18:00 gathering, after work.
1650        let held = decide(&shop, None, false, &choices, &ask(&morning, &clock, Some(Area::LEISURE), None));
1651        assert_eq!(until_of(held), "2026-10-06T18:00:00");
1652    }
1653
1654    #[test]
1655    fn gathered_times_across_midnight_sleep_and_pauses() {
1656        let clock = TestClock::new();
1657        let choices = Choices::default();
1658        let shop = classify(&posted(SHOP), &choices);
1659        let when = |now: &str, clock: &TestClock| until_of(decide(&shop, None, false, &choices, &ask(&at(now), clock, None, None)));
1660        // n times a day: 09:00, 13:00, 18:00.
1661        assert_eq!(when("2026-10-06T08:00[Europe/Paris]", &clock), "2026-10-06T09:00:00");
1662        assert_eq!(when("2026-10-06T09:30[Europe/Paris]", &clock), "2026-10-06T13:00:00");
1663        assert_eq!(when("2026-10-06T13:20[Europe/Paris]", &clock), "2026-10-06T18:00:00");
1664        // After the last one, across midnight, to the first of the next day.
1665        assert_eq!(when("2026-10-06T18:30[Europe/Paris]", &clock), "2026-10-07T09:00:00");
1666        // Arriving within the minute of a gathering: it comes with it.
1667        assert_eq!(decide(&shop, None, false, &choices, &ask(&at("2026-10-06T13:00:30[Europe/Paris]"), &clock, None, None)), Decision::through(Why::Gathered));
1668        // A gathering in the night, or at lunch, is skipped: never while you sleep; meals are leisure, so 13:00 is free.
1669        let late = vec!["06:30".to_string(), "13:00".into(), "22:30".into()];
1670        let ask_late = |now: &'static str| {
1671            let now = Box::leak(Box::new(at(now)));
1672            Ask { now, clock: &clock, attention: usual(), gathered: &late, area: None, gate: None, slot_at: &never_slot, nothing: false, also: &[], sources: &[] }
1673        };
1674        assert_eq!(until_of(decide(&shop, None, false, &choices, &ask_late("2026-10-06T20:00[Europe/Paris]"))), "2026-10-07T13:00:00", "22:30 and 06:30 are asleep");
1675        // A pause: nothing gathered until it ends; asked again six hours on, brought back when it ends (`again`).
1676        let paused = TestClock { overrides: Overrides { paused_since: Some(at("2026-10-06T08:00[Europe/Paris]").timestamp().as_second()), ..Overrides::default() } };
1677        let now = at("2026-10-06T08:30[Europe/Paris]");
1678        let held = decide(&shop, None, false, &choices, &ask(&now, &paused, None, None));
1679        assert_eq!((held.why, until_of(held).as_str()), (Why::Gathered, "2026-10-06T14:30:00"));
1680        // Free time: automatons wait for it to end (the night's start ends it), then the next gathering.
1681        let free = TestClock { overrides: Overrides { free_since: Some(at("2026-10-06T15:00[Europe/Paris]").timestamp().as_second()), ..Overrides::default() } };
1682        let held = decide(&shop, None, false, &choices, &ask(&at("2026-10-06T16:00[Europe/Paris]"), &free, None, None));
1683        assert_eq!(until_of(held), "2026-10-07T09:00:00", "18:00 falls in free time, which lasts until the night");
1684        // Do-not-disturb's switch until 18:30: the 18:00 gathering skipped.
1685        let gate = Gate { until: Some(at("2026-10-06T18:30[Europe/Paris]").timestamp().as_second()) };
1686        let held = decide(&shop, None, false, &choices, &ask(&at("2026-10-06T14:00[Europe/Paris]"), &clock, None, Some(gate)));
1687        assert_eq!(until_of(held), "2026-10-07T09:00:00");
1688        // A slot of time for you at 13:00: that gathering skipped.
1689        let slot = |z: &Zoned| z.date() == at("2026-10-06T00:00[Europe/Paris]").date() && z.hour() == 13;
1690        let now = at("2026-10-06T10:00[Europe/Paris]");
1691        let usual = vec!["09:00".to_string(), "13:00".into(), "18:00".into()];
1692        let with_slot = Ask { now: &now, clock: &clock, attention: self::usual(), gathered: &usual, area: None, gate: None, slot_at: &slot, nothing: false, also: &[], sources: &[] };
1693        assert_eq!(until_of(decide(&shop, None, false, &choices, &with_slot)), "2026-10-06T18:00:00");
1694        // No time that reads: three a day.
1695        assert_eq!(times_of(&["later".into()]).len(), 3);
1696        assert_eq!(times_of(&["8h30".into(), "08:30".into(), " 17:00 ".into()]).iter().map(|t| t.to_string()).collect::<Vec<_>>(), ["08:30:00", "17:00:00"]);
1697    }
1698
1699    #[test]
1700    fn do_not_disturb_lets_its_list_through() {
1701        let clock = TestClock::new();
1702        let choices = Choices::default();
1703        let sms = classify(&posted(SMS_CONTACT), &choices);
1704        let morning = at("2026-10-06T10:00[Europe/Paris]");
1705        let gate = || Some(Gate { until: Some(at("2026-10-06T11:00[Europe/Paris]").timestamp().as_second()) });
1706        // Mum is Always through: she comes during focus; someone neutral waits for its end.
1707        assert_eq!(decide(&sms, Some(Who::Neutral), true, &choices, &ask(&morning, &clock, None, gate())).why, Why::Allowed);
1708        let held = decide(&sms, Some(Who::Neutral), false, &choices, &ask(&morning, &clock, None, gate()));
1709        assert_eq!((held.why, until_of(held).as_str()), (Why::Waiting, "2026-10-06T11:00:00"));
1710        // A focus session without a known end: asked again in half an hour.
1711        let open = Some(Gate { until: None });
1712        let held = decide(&sms, Some(Who::Neutral), false, &choices, &ask(&morning, &clock, None, open));
1713        assert_eq!(until_of(held), "2026-10-06T10:30:00");
1714    }
1715
1716    #[test]
1717    fn the_matrix_changes_when() {
1718        let clock = TestClock::new();
1719        let choices = Choices::default();
1720        let sms = classify(&posted(SMS_CONTACT), &choices);
1721        let shop = classify(&posted(SHOP), &choices);
1722        let gathered = vec!["09:00".to_string(), "13:00".into(), "18:00".into()];
1723        let decided = |kind: &Kind, who: Option<Who>, listed: bool, matrix: &Attention, now: &Zoned, clock: &TestClock, gate: Option<Gate>| {
1724            decide(kind, who, listed, &choices, &Ask { now, clock, attention: matrix, gathered: &gathered, area: None, gate, slot_at: &never_slot, nothing: false, also: &[], sources: &[] })
1725        };
1726        let mut matrix = usual().clone();
1727        // Always through under do-not-disturb: at once whatever their row (Q2), as usual; their row's times with ☆.
1728        let evening = at("2026-10-06T20:30[Europe/Paris]");
1729        let gate = || Some(Gate { until: Some(at("2026-10-06T21:00[Europe/Paris]").timestamp().as_second()) });
1730        assert_eq!(decided(&sms, Some(Who::Stranger), true, &matrix, &evening, &clock, gate()).why, Why::Allowed);
1731        matrix.set(Row::People(Channel::Messages, Person::Always), Column::Leisure, Level::As).unwrap();
1732        matrix.set(Row::People(Channel::Messages, Person::Always), Column::Dnd, Level::Through).unwrap();
1733        assert_eq!(decided(&sms, Some(Who::Stranger), true, &matrix, &evening, &clock, gate()).why, Why::Waiting, "a stranger's row in the evening");
1734        assert_eq!(decided(&sms, Some(Who::Neutral), true, &matrix, &evening, &clock, gate()).why, Why::Allowed, "the neutral come in leisure");
1735        assert_eq!(decided(&sms, Some(Who::Safe), false, &matrix, &evening, &clock, gate()).why, Why::Waiting, "not on the list: do-not-disturb holds them");
1736        // Messages from people in Free time: your safe people's come, as usual; held when you say so, until it ends
1737        // at the night, and the night holds them too (Q4: before, they came at 22:00): at waking.
1738        let free = TestClock { overrides: Overrides { free_since: Some(at("2026-10-06T15:00[Europe/Paris]").timestamp().as_second()), ..Overrides::default() } };
1739        let afternoon = at("2026-10-06T16:00[Europe/Paris]");
1740        assert_eq!(decided(&sms, Some(Who::Safe), false, &matrix, &afternoon, &free, None).why, Why::Allowed, "as usual");
1741        matrix.set(Row::People(Channel::Messages, Person::Safe), Column::Free, Level::Later).unwrap();
1742        let held = decided(&sms, Some(Who::Safe), false, &matrix, &afternoon, &free, None);
1743        assert_eq!((held.why, until_of(held).as_str()), (Why::Waiting, "2026-10-07T07:00:00"));
1744        // Automatons not gathered in working hours: Tuesday's 09:00 and 13:00 skipped, 18:00 kept.
1745        let morning = at("2026-10-06T08:00[Europe/Paris]");
1746        assert_eq!(until_of(decided(&shop, None, false, &matrix, &morning, &clock, None)), "2026-10-06T09:00:00", "as usual");
1747        matrix.set(Row::Own(crate::attention::Kind::AppAutomatons), Column::Work, Level::Later).unwrap();
1748        assert_eq!(until_of(decided(&shop, None, false, &matrix, &morning, &clock, None)), "2026-10-06T18:00:00");
1749        // Time for you everywhere (Q12): the listener reads the slots, which hold automatons as the window's did.
1750        let in_slot = |z: &Zoned| z.hour() == 9;
1751        let slotted = Ask { now: &morning, clock: &clock, attention: usual(), gathered: &gathered, area: None, gate: None, slot_at: &in_slot, nothing: false, also: &[], sources: &[] };
1752        assert_eq!(until_of(decide(&shop, None, false, &choices, &slotted)), "2026-10-06T13:00:00");
1753        // "Nothing at all" in Free time: the safe wait too, Always through comes.
1754        let nothing = Ask { now: &afternoon, clock: &free, attention: usual(), gathered: &gathered, area: None, gate: None, slot_at: &never_slot, nothing: true, also: &[], sources: &[] };
1755        assert_eq!(decide(&sms, Some(Who::Safe), false, &choices, &nothing).why, Why::Waiting);
1756        assert_eq!(decide(&sms, Some(Who::Safe), true, &choices, &nothing).why, Why::Allowed);
1757        // A mail app's notification takes the Mail rows (Q5): a safe sender's at night waits for waking (before: let through).
1758        let proton = classify(&posted(PROTON), &choices);
1759        let night = at("2026-10-06T23:30[Europe/Paris]");
1760        let held = decide(&proton, Some(Who::Safe), false, &choices, &ask(&night, &clock, None, None));
1761        assert_eq!((held.why, until_of(held).as_str()), (Why::Waiting, "2026-10-07T07:00:00"));
1762        // A missed call by the Calls row (Q23): a stranger's in work time, never at night.
1763        let missed = classify(&posted(r#"{"key":"m","package":"com.android.dialer","category":"missed_call","title":"+262 6 39 98 01 07"}"#), &choices);
1764        assert_eq!(decide(&missed, Some(Who::Stranger), false, &choices, &ask(&at("2026-10-06T10:00[Europe/Paris]"), &clock, None, None)).why, Why::Allowed);
1765        assert_eq!(decide(&missed, Some(Who::Stranger), false, &choices, &ask(&night, &clock, None, None)).why, Why::Waiting);
1766    }
1767
1768    #[test]
1769    fn what_was_held_and_came_back() {
1770        let clock = TestClock::new();
1771        let choices = Choices::default();
1772        let now = at("2026-10-06T10:00[Europe/Paris]");
1773        let stamp = now.timestamp().as_second();
1774        let mut ledger = Ledger::default();
1775        for json in [SHOP, DISCORD_SERVER, CHROME_SITE, WHATSAPP_GROUP] {
1776            let p = posted(json);
1777            let kind = classify(&p, &choices);
1778            let decision = decide(&kind, Some(Who::Stranger), false, &choices, &ask(&now, &clock, None, None));
1779            ledger.note(&p, &kind, Some(Who::Stranger), false, None, &decision, stamp);
1780        }
1781        // The shop's sounded while held: its channel to make silent.
1782        assert_eq!(ledger.apps["com.shop.example"].rang.keys().collect::<Vec<_>>(), ["offers"]);
1783        assert_eq!(ledger.conversations.len(), 2, "Discord's channel and the school's group");
1784        assert!(ledger.sites.contains_key("forum.example.net"));
1785        assert_eq!(ledger.held.len(), 2, "the shop and the site wait for 13:00; strangers write in work time");
1786        let tr = Translator::new("en");
1787        assert_eq!(porch_line(&tr, &ledger, &now), "Notifications held until 13:00: Chrome and Shop.");
1788        let fr = Translator::new("fr");
1789        assert_eq!(porch_line(&fr, &ledger, &now), "Notifications retenues jusqu’à 13:00\u{202f}: Chrome et Shop.");
1790        // Android brings them back at 13:00: they come, and the Porch says so for two hours.
1791        let one = at("2026-10-06T13:00[Europe/Paris]");
1792        let hash = key_hash(&posted(SHOP).key);
1793        assert!(ledger.returning(&hash, one.timestamp().as_second()) && !ledger.returning(&hash, stamp));
1794        ledger.came_back(&hash, one.timestamp().as_second());
1795        ledger.came_back(&key_hash(&posted(CHROME_SITE).key), one.timestamp().as_second() + 5);
1796        assert!(ledger.held.is_empty());
1797        assert_eq!(porch_line(&tr, &ledger, &one), "Back in your notifications at 13:00: Shop and Chrome.");
1798        assert_eq!(porch_line(&tr, &ledger, &at("2026-10-06T15:30[Europe/Paris]")), "");
1799        // Nothing of what the notifications said is kept: names of apps and conversations only.
1800        let text = toml::to_string(&ledger).unwrap();
1801        for said in ["chaussures", "Sortie", "Nouvelle version", "répondu", "+33", "Mme Durand", "ferris", "120363"] {
1802            assert!(!text.contains(said), "{said} kept: {text}");
1803        }
1804        assert!(text.contains("École Jules Ferry") && text.contains("Rust FR #général"), "{text}");
1805        // Kept a day, then forgotten; a conversation unseen for a week forgotten unless chosen for.
1806        let mut chosen = Choices::default();
1807        let school = talk_key("com.whatsapp", "120363000000000001@g.us");
1808        chosen.conversation.insert(school.clone(), TalkChoice { through: Through::Always, ..TalkChoice::default() });
1809        ledger.forget_old(stamp + 8 * 86_400, &chosen);
1810        assert!(ledger.back.is_empty());
1811        assert_eq!(ledger.conversations.keys().collect::<Vec<_>>(), [&school]);
1812    }
1813
1814    #[test]
1815    fn held_ones_worked_out_again() {
1816        let choices = Choices::default();
1817        let paused = TestClock { overrides: Overrides { paused_since: Some(at("2026-10-06T08:00[Europe/Paris]").timestamp().as_second()), ..Overrides::default() } };
1818        let back = TestClock { overrides: Overrides { paused_since: Some(at("2026-10-06T08:00[Europe/Paris]").timestamp().as_second()), paused_ended: Some(at("2026-10-06T10:00[Europe/Paris]").timestamp().as_second()), ..Overrides::default() } };
1819        let held = Held { app: "com.shop.example".into(), label: "Shop".into(), kind: "gathered".into(), since: 0, until: at("2026-10-06T11:30[Europe/Paris]").timestamp().as_second(), ..Held::default() };
1820        let now = at("2026-10-06T10:00:30[Europe/Paris]");
1821        // Still paused: after the pause, asked again.
1822        assert_eq!(again(&held, &choices, &ask(&now, &paused, None, None)).why, Why::Gathered);
1823        // The pause over at 10:00: the next gathering, 13:00.
1824        assert_eq!(until_of(again(&held, &choices, &ask(&now, &back, None, None))), "2026-10-06T13:00:00");
1825        // A message from a stranger held in the evening; its conversation now let through always: now.
1826        let talk = Held { kind: "people".into(), via: "messages".into(), who: "stranger".into(), conversation: "c1".into(), ..held.clone() };
1827        let mut chosen = Choices::default();
1828        chosen.conversation.insert("c1".into(), TalkChoice { through: Through::Always, ..TalkChoice::default() });
1829        let evening = at("2026-10-06T20:30[Europe/Paris]");
1830        assert_eq!(again(&talk, &chosen, &ask(&evening, &TestClock::new(), None, None)), Decision::through(Why::Always));
1831        assert_eq!(until_of(again(&talk, &choices, &ask(&evening, &TestClock::new(), None, None))), "2026-10-07T09:00:00");
1832        // A group held in the evening, a safe person writing in it: still a group's row on review.
1833        let clock = TestClock::new();
1834        let mut ledger = Ledger::default();
1835        let p = posted(WHATSAPP_GROUP);
1836        let kind = classify(&p, &choices);
1837        let decision = decide(&kind, Some(Who::Safe), true, &choices, &ask(&evening, &clock, None, None));
1838        ledger.note(&p, &kind, Some(Who::Safe), true, None, &decision, evening.timestamp().as_second());
1839        let group = ledger.held.values().next().unwrap().clone();
1840        assert_eq!((group.who.as_str(), group.admitted, group.group), ("safe", false, true));
1841        assert_eq!(until_of(again(&group, &choices, &ask(&evening, &clock, None, None))), "2026-10-07T09:00:00");
1842        // Its conversation set to gathered, then to always: back now.
1843        let mut gathered_then_always = Choices::default();
1844        gathered_then_always.conversation.insert(group.conversation.clone(), TalkChoice { through: Through::Always, ..TalkChoice::default() });
1845        let gathered_held = Held { kind: "gathered".into(), ..group.clone() };
1846        assert_eq!(again(&gathered_held, &gathered_then_always, &ask(&evening, &clock, None, None)), Decision::through(Why::Always));
1847        // Holding switched off: everything back now.
1848        let off = Choices { hold: false, ..Choices::default() };
1849        assert_eq!(again(&held, &off, &ask(&now, &paused, None, None)), Decision::through(Why::Off));
1850    }
1851
1852    /// Other apps by time (docs/attention.md, §1.3): Discord let through
1853    /// during work and held otherwise, a conversation's row over its app's,
1854    /// gathered, under do-not-disturb, worked out again; nothing changes for
1855    /// an app without a row of its own; the blocked and codes as before; an
1856    /// automaton the matrix lets through at once comes at once.
1857    #[test]
1858    fn an_app_s_own_row_decides_when() {
1859        fn with<'a>(now: &'a Zoned, clock: &'a TestClock, attention: &'a Attention, gathered: &'a [String], sources: &'a [String], gate: Option<Gate>) -> Ask<'a> {
1860            Ask { now, clock, attention, gathered, area: None, gate, slot_at: &never_slot, nothing: false, also: &[], sources }
1861        }
1862        let clock = TestClock::new();
1863        let choices = Choices::default();
1864        let gathered = vec!["09:00".to_string(), "13:00".into(), "18:00".into()];
1865        let (morning, evening) = (at("2026-10-06T10:00[Europe/Paris]"), at("2026-10-06T20:00[Europe/Paris]"));
1866        let dm = classify(&posted(DISCORD_DM), &choices);
1867        let server = classify(&posted(DISCORD_SERVER), &choices);
1868        let key = |k: &Kind| match k {
1869            Kind::People(t) => t.conversation.as_ref().map(|c| c.key.clone()).unwrap_or_default(),
1870            _ => String::new(),
1871        };
1872        let dm_rows = attention::source_rows("com.discord", &key(&dm));
1873        let server_rows = attention::source_rows("com.discord", &key(&server));
1874        assert_eq!(server_rows, vec![format!("conversation.{}", key(&server)), "app.com.discord".to_string()]);
1875        // Nothing set: every fixture at every hour as before, its rows read or not.
1876        for fixture in [SMS_NUMBER, SMS_SENDER_ID, WHATSAPP_PERSON, WHATSAPP_GROUP, DISCORD_SERVER, DISCORD_DM, CHROME_SITE, PROTON, SHOP, BANK_CODE] {
1877            let p = posted(fixture);
1878            let kind = classify(&p, &choices);
1879            let rows = attention::source_rows(&p.package, &key(&kind));
1880            for hour in ["03:00", "08:30", "10:00", "12:30", "17:30", "20:00", "22:30"] {
1881                let now = at(&format!("2026-10-06T{hour}[Europe/Paris]"));
1882                for who in [None, Some(Who::Safe), Some(Who::Stranger)] {
1883                    assert_eq!(decide(&kind, who, false, &choices, &with(&now, &clock, usual(), &gathered, &rows, None)), decide(&kind, who, false, &choices, &with(&now, &clock, usual(), &gathered, &[], None)), "{fixture} {hour} {who:?}");
1884                }
1885            }
1886        }
1887        // The owner's example: Discord through during work, held otherwise.
1888        let mut work_only = usual().clone();
1889        for column in Column::ALL {
1890            work_only.set_source("app.com.discord", column, if column == Column::Work { Level::Now } else { Level::Later }).unwrap();
1891        }
1892        assert_eq!(decide(&dm, Some(Who::Stranger), false, &choices, &with(&morning, &clock, &work_only, &gathered, &dm_rows, None)), Decision::through(Why::Times));
1893        let held = decide(&dm, Some(Who::Stranger), false, &choices, &with(&evening, &clock, &work_only, &gathered, &dm_rows, None));
1894        assert_eq!((held.why, until_of(held).as_str()), (Why::Times, "2026-10-07T09:00:00"));
1895        // A safe friend's message too, which comes in the evening as usual.
1896        assert_eq!(decide(&dm, Some(Who::Safe), false, &choices, &with(&evening, &clock, usual(), &gathered, &dm_rows, None)), Decision::through(Why::Allowed));
1897        assert_eq!(until_of(decide(&dm, Some(Who::Safe), false, &choices, &with(&evening, &clock, &work_only, &gathered, &dm_rows, None))), "2026-10-07T09:00:00");
1898        // The blocked never, a code always, whatever the row says.
1899        assert_eq!(decide(&dm, Some(Who::Blocked), false, &choices, &with(&morning, &clock, &work_only, &gathered, &dm_rows, None)).why, Why::Never);
1900        let code = classify(&posted(BANK_CODE), &choices);
1901        let mut code_held = usual().clone();
1902        code_held.set_source("app.com.bank.example", Column::Work, Level::Later).unwrap();
1903        let code_rows = attention::source_rows("com.bank.example", "");
1904        assert_eq!(decide(&code, None, false, &choices, &with(&morning, &clock, &code_held, &gathered, &code_rows, None)), Decision::through(Why::Code));
1905        // A conversation's choice wins over its app's: the server comes in the evening, the rest of Discord waits.
1906        let mut server_evening = work_only.clone();
1907        server_evening.set_source(&server_rows[0], Column::Leisure, Level::Now).unwrap();
1908        assert_eq!(decide(&server, Some(Who::Stranger), false, &choices, &with(&evening, &clock, &server_evening, &gathered, &server_rows, None)), Decision::through(Why::Times));
1909        assert_eq!(decide(&dm, Some(Who::Stranger), false, &choices, &with(&evening, &clock, &server_evening, &gathered, &dm_rows, None)).why, Why::Times);
1910        assert!(decide(&dm, Some(Who::Stranger), false, &choices, &with(&evening, &clock, &server_evening, &gathered, &dm_rows, None)).until.is_some());
1911        // Gathered by day: the next gathered time.
1912        let mut by_day = usual().clone();
1913        for column in [Column::Work, Column::Admin, Column::Meals] {
1914            by_day.set_source("app.com.discord", column, Level::Gathered).unwrap();
1915        }
1916        let held = decide(&dm, Some(Who::Stranger), false, &choices, &with(&morning, &clock, &by_day, &gathered, &dm_rows, None));
1917        assert_eq!((held.why, until_of(held).as_str()), (Why::Times, "2026-10-06T13:00:00"));
1918        // Under do-not-disturb's switch until 11:00: its cell holds (held here), as usual too; set at once, through.
1919        let gate = || Some(Gate { until: Some(at("2026-10-06T11:00[Europe/Paris]").timestamp().as_second()) });
1920        assert_eq!(until_of(decide(&dm, Some(Who::Stranger), false, &choices, &with(&morning, &clock, &work_only, &gathered, &dm_rows, gate()))), "2026-10-06T11:00:00");
1921        let mut through_dnd = work_only.clone();
1922        through_dnd.set_source("app.com.discord", Column::Dnd, Level::As).unwrap();
1923        assert_eq!(until_of(decide(&dm, Some(Who::Stranger), false, &choices, &with(&morning, &clock, &through_dnd, &gathered, &dm_rows, gate()))), "2026-10-06T11:00:00");
1924        through_dnd.set_source("app.com.discord", Column::Dnd, Level::Now).unwrap();
1925        assert_eq!(decide(&dm, Some(Who::Stranger), false, &choices, &with(&morning, &clock, &through_dnd, &gathered, &dm_rows, gate())), Decision::through(Why::Times));
1926        // An automaton's app at once in the evening, as usual held until the next gathering.
1927        let shop = classify(&posted(SHOP), &choices);
1928        let shop_rows = attention::source_rows("com.shop.example", "");
1929        let mut shop_evening = usual().clone();
1930        shop_evening.set_source("app.com.shop.example", Column::Leisure, Level::Now).unwrap();
1931        assert_eq!(decide(&shop, None, false, &choices, &with(&evening, &clock, &shop_evening, &gathered, &shop_rows, None)), Decision::through(Why::Times));
1932        assert_eq!(until_of(decide(&shop, None, false, &choices, &with(&evening, &clock, usual(), &gathered, &shop_rows, None))), "2026-10-07T09:00:00");
1933        // The automatons' row at once at work (More reachable): at once, not at the next gathering.
1934        let reachable = crate::attention::Preset::Reachable.matrix();
1935        assert_eq!(decide(&shop, None, false, &choices, &with(&morning, &clock, &reachable, &gathered, &[], None)), Decision::through(Why::Gathered));
1936        // Worked out again: a message held in the evening comes once work begins, or now when its row says so.
1937        let kept = Held { app: "com.discord".into(), label: "Discord".into(), kind: "people".into(), via: "messages".into(), who: "stranger".into(), conversation: key(&dm), since: 0, until: 0, ..Held::default() };
1938        assert_eq!(again(&kept, &choices, &with(&morning, &clock, &work_only, &gathered, &dm_rows, None)), Decision::through(Why::Times));
1939        assert_eq!(until_of(again(&kept, &choices, &with(&evening, &clock, &work_only, &gathered, &dm_rows, None))), "2026-10-07T09:00:00");
1940        let mut dm_evening = work_only.clone();
1941        dm_evening.set_source(&dm_rows[0], Column::Leisure, Level::Now).unwrap();
1942        assert_eq!(again(&kept, &choices, &with(&evening, &clock, &dm_evening, &gathered, &dm_rows, None)), Decision::through(Why::Times));
1943    }
1944
1945    #[test]
1946    fn choices_and_ledger_read_and_written() {
1947        let dir = std::env::temp_dir().join(format!("sioul-appnotes-{}", std::process::id()));
1948        let _ = std::fs::remove_dir_all(&dir);
1949        let mut choices = Choices::default();
1950        assert!(choices.hold, "holding is on until switched off");
1951        choices.app.insert("com.discord".into(), AppChoice { kind: AppKind::Automaton, area: Some(Area::LEISURE), label: "Discord".into() });
1952        choices.conversation.insert("c1".into(), TalkChoice { through: Through::Always, title: "École".into(), app: "WhatsApp".into() });
1953        let path = dir.join("app-notes.toml");
1954        choices.save(&path).unwrap();
1955        assert_eq!(Choices::load(&path), choices);
1956        let text = std::fs::read_to_string(&path).unwrap();
1957        assert!(text.contains("kind = \"automaton\"") && text.contains("area = \"leisure\"") && text.contains("through = \"always\""), "{text}");
1958        assert_eq!(Choices::load(&dir.join("none.toml")), Choices::default());
1959        std::fs::write(&path, "hold = false\n").unwrap();
1960        assert!(!Choices::load(&path).hold);
1961        std::fs::write(&path, "hold = [broken").unwrap();
1962        assert_eq!(Choices::load(&path), Choices::default(), "a file that does not read is no choice at all");
1963        let _ = std::fs::remove_dir_all(&dir);
1964        assert_eq!(AppKind::read("at-once"), Some(AppKind::AtOnce));
1965        assert_eq!(Through::read("never"), Some(Through::Never));
1966    }
1967
1968    #[test]
1969    fn words_in_both_languages() {
1970        let now = at("2026-10-06T10:00[Europe/Paris]");
1971        let names = |n: usize| (1..=n).map(|i| format!("App{i}")).collect::<Vec<_>>();
1972        for (language, n, said) in [
1973            ("en", 1, "App1"),
1974            ("en", 2, "App1 and App2"),
1975            ("en", 3, "App1, App2 and App3"),
1976            ("en", 4, "App1, App2, App3 and one other app"),
1977            ("en", 6, "App1, App2, App3 and three other apps"),
1978            ("en", 15, "App1, App2, App3 and twelve other apps"),
1979            ("en", 16, "App1, App2, App3 and many other apps"),
1980            ("fr", 2, "App1 et App2"),
1981            ("fr", 4, "App1, App2, App3 et une autre application"),
1982            ("fr", 5, "App1, App2, App3 et deux autres applications"),
1983            ("fr", 20, "App1, App2, App3 et beaucoup d’autres applications"),
1984        ] {
1985            assert_eq!(apps_in_words(&Translator::new(language), &names(n)), said, "{language} {n}");
1986        }
1987        let tr = Translator::new("en");
1988        let at13 = at("2026-10-06T13:00[Europe/Paris]").timestamp().as_second();
1989        assert_eq!(why_text(&tr, "gathered", at13, &now), "From an automaton: held until 13:00, a gathered time.");
1990        assert_eq!(why_text(&tr, "allowed", 0, &now), "From someone who may reach you now: let through.");
1991        let fr = Translator::new("fr");
1992        assert_eq!(why_text(&fr, "gathered", at13, &now), "D’un automate\u{202f}: retenue jusqu’à 13:00, une heure de regroupement.");
1993        assert_eq!(why_text(&tr, "times", at13, &now), "As you chose for this time: held until 13:00.");
1994        for why in ["untouched", "code", "off", "at-once", "allowed", "always", "waiting", "gathered", "never", "again", "times"] {
1995            for tr in [&tr, &fr] {
1996                let said = why_text(tr, why, 0, &now);
1997                assert!(!said.starts_with("appnotes-"), "{why}: {said}");
1998                let held = why_text(tr, why, at13, &now);
1999                assert!(!held.starts_with("appnotes-"), "{why}: {held}");
2000            }
2001        }
2002    }
2003}