Skip to main content

sioul_app/
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"). Android's listener (AppNotes.java, in a process of its own,
6//! ":listener") asks here, for each notification of another app, whether it
7//! comes now or how long it waits (`sioul_appnotes_decide`), and, when
8//! something moved the times (a pause ended, the hours or the choices
9//! changed), how long what it holds waits now (`sioul_appnotes_review`). The
10//! rules are `sioul_core::appnotes`; what reaches you when, `sioul_core::attention`,
11//! with today's slots of time for you from their file (`hours::slots`): Time
12//! for you holds in this process too.
13//!
14//! Also here, for the window: Settings ▸ Other apps (`setup`, `change`), and
15//! the Porch's line on what waits and what came back (`porch_line`). On a
16//! computer, nothing of it runs: the listener is the phone's.
17
18use crate::backend::{load_config, tr};
19use crate::everywhere::Gated;
20use jiff::Zoned;
21use serde_json::{Value, json};
22use sioul_core::appnotes::{self, AppKind, Ask, Cause, Choices, Decision, Gate, Gathering, Kind, Ledger, Live, Own, Posted, Sender, Through, Why};
23use sioul_core::areas::Area;
24use sioul_core::attention::Column;
25use sioul_core::config::Config;
26use sioul_core::everywhere::People;
27use sioul_core::porch::{By, Senders};
28use sioul_core::reach::{Channel, Clock, Who};
29use std::ffi::c_char;
30use std::path::Path;
31use std::sync::Mutex;
32use std::time::SystemTime;
33
34#[cfg(target_os = "android")]
35// SAFETY: declared as android/main.cpp defines them: extern "C", the same types.
36unsafe extern "C" {
37    /// AppNotes.call (android/main.cpp): a verb and its JSON, a JSON answer or null.
38    fn sioul_android_appnotes(verb: *const c_char, json: *const c_char) -> *mut c_char;
39    /// An answer of `sioul_android_appnotes` given back.
40    fn sioul_android_dnd_free(text: *mut c_char);
41}
42
43/// Java's answer to `verb` (AppNotes.call); null elsewhere, or without one.
44fn java(verb: &str, json: &str) -> Value {
45    #[cfg(target_os = "android")]
46    {
47        use std::ffi::{CStr, CString};
48        let (Ok(verb), Ok(json)) = (CString::new(verb), CString::new(json)) else { return Value::Null };
49        // SAFETY: two zero-terminated texts, valid for the call.
50        let answer = unsafe { sioul_android_appnotes(verb.as_ptr(), json.as_ptr()) };
51        if answer.is_null() {
52            return Value::Null;
53        }
54        // SAFETY: a zero-terminated text from sioul_android_appnotes, read before it is given back.
55        let text = unsafe { CStr::from_ptr(answer) }.to_string_lossy().to_string();
56        // SAFETY: given back once, as main.cpp asks; not read after.
57        unsafe { sioul_android_dnd_free(answer) };
58        serde_json::from_str(&text).unwrap_or(Value::Null)
59    }
60    #[cfg(not(target_os = "android"))]
61    {
62        let _ = (verb, json);
63        Value::Null
64    }
65}
66
67/// Whether Android lets Sioul hold other apps' notifications here
68/// (notification access): on a phone, as Java says now; None elsewhere, or
69/// when Java cannot say (its library not loaded in this process).
70pub(crate) fn access_here() -> Option<bool> {
71    java("access", "{}").as_bool()
72}
73
74/// When a file last changed; none when it is not there.
75fn changed(path: &Path) -> Option<SystemTime> {
76    std::fs::metadata(path).and_then(|m| m.modified()).ok()
77}
78
79/// The clock as it stands, kept a minute in the listener's process: read
80/// again sooner when the pause or free time, Health, the hours or the
81/// settings change. Asked at every notification.
82fn live(config: &Config, now: &Zoned) -> Live {
83    type Key = (i64, [Option<SystemTime>; 4]);
84    static KEPT: Mutex<Option<(Key, Live)>> = Mutex::new(None);
85    let files = [
86        sioul_core::quiet::Overrides::default_path(),
87        sioul_core::health::Health::default_path(),
88        sioul_core::needs::Days::default_path(),
89        sioul_core::config::config_dir().join("config.toml"),
90    ];
91    let key: Key = (now.timestamp().as_second() / 60, files.each_ref().map(|f| changed(f)));
92    if let Ok(kept) = KEPT.lock()
93        && let Some((known, live)) = kept.as_ref()
94        && *known == key
95    {
96        return live.clone();
97    }
98    let live = Live { clock: Clock::load(config, now) };
99    if let Ok(mut kept) = KEPT.lock() {
100        *kept = Some((key, live.clone()));
101    }
102    live
103}
104
105/// Who wrote, as your address books and lists say (`porch::Senders`): none
106/// when nobody knows a name; and whether the Always through list holds them
107/// (by a number or an address, never a name, which anyone can take).
108fn who_of(sender: &Sender, senders: &Senders, always: &People, config: &Config) -> (Option<Who>, bool) {
109    match sender {
110        Sender::Known { numbers, emails, contact } => {
111            let by_number = numbers.iter().map(|n| senders.judge_number(n)).find(|j| j.by != By::Default).map(|j| j.who);
112            let by_address = emails.iter().map(|e| senders.who(e)).find(|w| *w != Who::Stranger);
113            let region = sioul_core::reach::region(config);
114            let admitted = numbers.iter().any(|n| always.admits_number(n, region)) || emails.iter().any(|e| always.admits_address(e));
115            // In the phone's address book though not in Sioul's: someone you know.
116            let unknown = if *contact { Who::Neutral } else { Who::Stranger };
117            (Some(by_number.or(by_address).unwrap_or(unknown)), admitted)
118        }
119        Sender::Named { name, book } => (senders.judge_name(name).map(|j| appnotes::named(Some(j.who), *book)), false),
120        Sender::Unknown => (None, false),
121    }
122}
123
124/// Reachable's end, for the listener's question (`Ask::reachable`, Unix
125/// seconds): while it lasts, everyone not blocked comes at once (part 3).
126fn reachable_until() -> Option<i64> {
127    crate::everywhere::reachable().map(|r| r.until.unwrap_or(i64::MAX))
128}
129
130/// The why of a text gathered because its number is one ARCEP keeps for
131/// machines (`appnotes::why_text`, "appnotes-why-machine").
132const MACHINE: &str = "machine";
133
134/// Whether a sender's number comes from a root ARCEP keeps for automated
135/// systems (`screening::Numbers`, the call screening's data), while the call
136/// screening sends automated callers to voicemail (its `automated`).
137fn machine_number(sender: &Sender, config: &Config, now: &Zoned) -> bool {
138    let Sender::Known { numbers, .. } = sender else { return false };
139    if sioul_core::screening::Screening::of(config).automated != sioul_core::screening::Spam::Voicemail {
140        return false;
141    }
142    let roots = sioul_core::screening::shipped().automated(now.date());
143    let region = sioul_core::reach::region(config);
144    numbers.iter().any(|n| sioul_core::screening::national(&sioul_core::phones::key(n, region)).is_some_and(|national| sioul_core::screening::starts_with_any(&national, &roots)))
145}
146
147/// What a notification's source is for: the app's, as you set it; else, for
148/// mail, the address it came to, when it is one of your accounts' (unsaid: work).
149fn area_of(posted: &Posted, kind: &Kind, choices: &Choices, config: &Config) -> Option<Area> {
150    choices.app(&posted.package).area.or_else(|| match kind {
151        Kind::People(talk) if !talk.to.is_empty() => config
152            .accounts
153            .iter()
154            .find(|a| a.address.as_deref().is_some_and(|address| address.trim().eq_ignore_ascii_case(&talk.to)))
155            .map(|a| a.area.as_deref().and_then(Area::parse).unwrap_or(Area::WORK)),
156        _ => None,
157    })
158}
159
160/// Do-not-disturb's switch or a focus session, holding now: when it ends,
161/// when that is known (a layer of the matrix), and which of them holds (for
162/// the log).
163fn gate() -> Option<Gated> {
164    crate::everywhere::gated()
165}
166
167/// One notification decided (`AppNotes.decide`): {"hold": milliseconds (0:
168/// it comes now), "why"}. What it says is read here and dropped; the ledger
169/// keeps its key hashed, its app's and conversation's names, and its time.
170/// Only for the apps you send to your computers, while that part of the
171/// sharing is on, its messages also go, sealed (`phonemsgs::note`; "shared":
172/// a line was written, Java asks for a step soon).
173fn decide(json: &str) -> String {
174    let Ok(posted) = serde_json::from_str::<Posted>(json) else { return "null".into() };
175    let config = load_config();
176    let choices = Choices::load(&Choices::default_path());
177    let kind = appnotes::classify(&posted, &choices, &sioul_core::words::Words::of(&config));
178    let now = Zoned::now();
179    let stamp = now.timestamp().as_second();
180    let gate = gate();
181    let senders = Senders::load(&config);
182    let (who, admitted) = match &kind {
183        Kind::People(talk) => who_of(&talk.sender, &senders, &People::load(&People::default_path()), &config),
184        _ => (None, false),
185    };
186    // The person's own rows it reads, after its conversation's and before its app's (decision 16):
187    // by the card's UID, each number's key, each address; by name only as the phone's address book names.
188    let persons = match &kind {
189        Kind::People(talk) => match &talk.sender {
190            Sender::Known { numbers, emails, .. } => senders.person_rows(numbers, emails),
191            Sender::Named { name, book: true } => senders.person_rows_named(name),
192            _ => Vec::new(),
193        },
194        _ => Vec::new(),
195    };
196    // A text from a number nobody knows, from a root ARCEP keeps for machines: an automaton's, gathered
197    // with them, as the call screening sends their calls to voicemail (`screening`, unless set otherwise);
198    // said so in its why and its line (review of part 2, F11).
199    let machine = matches!(&kind, Kind::People(talk) if talk.sms && who == Some(Who::Stranger) && machine_number(&talk.sender, &config, &now));
200    let kind = if machine { Kind::Automaton { site: String::new() } } else { kind };
201    let (who, admitted) = if matches!(kind, Kind::People(_)) { (who, admitted) } else { (None, false) };
202    let area = area_of(&posted, &kind, &choices, &config);
203    let live = live(&config, &now);
204    let gathered = config.reminders.gathered_times();
205    // Today's slots of time for you, as the window wrote them; where they begin and end.
206    let slots = crate::hours::slots();
207    let slot = |at: &Zoned| slots.at(at);
208    let also = slots.edges();
209    // What reaches you when (Settings ▸ What reaches you); Free time's "Nothing at all".
210    let attention = sioul_core::attention::Attention::of(&config);
211    let nothing = sioul_core::pause::nothing_now(&sioul_core::quiet::Overrides::load(&sioul_core::quiet::Overrides::default_path()), &config.free_time);
212    // Its own rows in the matrix, when set: its conversation's, then its app's.
213    let conversation = match &kind {
214        Kind::People(talk) => talk.conversation.as_ref().map(|c| c.key.clone()).unwrap_or_default(),
215        _ => String::new(),
216    };
217    let sources = sioul_core::attention::source_rows_with(&posted.package, &conversation, &persons);
218    let reachable = reachable_until();
219    // Android silences it now (Sioul's mode or yours): a text whose cell says ◑ comes, without a sound (decision 13).
220    let silenced = posted.intercepted;
221    let ask = Ask { now: &now, clock: &live, attention: &attention, gathered: &gathered, area, gate: gate.map(|g| Gate { until: g.until }), slot_at: &slot, nothing, also: &also, sources: &sources, reachable, silenced };
222    let hash = appnotes::key_hash(&posted.key);
223    // A conversation you answered from a computer in the last twenty minutes, or a number
224    // texted from one, by the SMS app: at once, but in a pause (`replies`).
225    let paused = live.clock.mode(&now).paused();
226    let answered = !paused
227        && match &kind {
228            Kind::People(talk) => {
229                talk.conversation.as_ref().is_some_and(|c| crate::replies::answered_lately(&c.key))
230                    || ((talk.sms || posted.sms_app) && matches!(&talk.sender, Sender::Known { numbers, .. } if crate::replies::texted_lately(&numbers.iter().map(|n| sioul_core::phones::key(n, sioul_core::reach::region(&config))).collect::<Vec<_>>())))
231            }
232            _ => false,
233        };
234    let path = Ledger::default_path();
235    let (decision, released, returning) = sioul_core::filelock::with_lock(&path, || {
236        let mut ledger = Ledger::load(&path);
237        let returning = ledger.returning(&hash, stamp);
238        let mut decision = appnotes::decide(&kind, who, admitted, &choices, &ask);
239        if answered {
240            decision = decision.answered();
241        }
242        // Back from a release you asked (Reachable, or "Let what your phone holds through"): held
243        // before it, it comes now, once; never the blocked nor a conversation set to never, and
244        // never during a pause, when everything Sioul shows is held (review of part 3, F5).
245        if returning
246            && !paused
247            && let Some(held) = ledger.held.get(&hash)
248        {
249            decision = decision.released(held, &ledger);
250        }
251        // Back at its time and held again within minutes: not again and again.
252        if returning
253            && let Some(until) = decision.until
254            && decision.why != Why::Never
255        {
256            let short = until - stamp < appnotes::AGAIN_SHORT;
257            let again = if short { ledger.held.get(&hash).map_or(0, |h| h.again) + 1 } else { 0 };
258            if again > appnotes::AGAIN_MAX {
259                decision = Decision::through(Why::Again);
260            } else if let Some(held) = ledger.held.get_mut(&hash) {
261                held.again = again;
262            }
263        }
264        // Back at its time and let come: one released, said in the log with what is still held.
265        let mut released = None;
266        if returning && decision.until.is_none() {
267            let kind = ledger.held.get(&hash).map_or("other apps", kind_of);
268            ledger.came_back(&hash, stamp);
269            let mut counted = vec![Counted { kind, until: None, why: decision.why }];
270            counted.extend(ledger.held.values().filter(|h| h.until > stamp).map(|h| Counted::held(h, h.until)));
271            released = Some(counted);
272        }
273        ledger.note(&posted, &kind, who, admitted, area, &decision, stamp);
274        if let Some(held) = ledger.held.get_mut(&hash) {
275            held.persons = persons.clone();
276        }
277        if machine && decision.why == Why::Gathered
278            && let Some(app) = ledger.apps.get_mut(&posted.package)
279        {
280            app.why = MACHINE.to_string();
281        }
282        ledger.forget_old(stamp, &choices);
283        if let Err(e) = ledger.save(&path) {
284            eprintln!("Notes: {e}");
285        }
286        (decision, released, returning)
287    });
288    // The phone's messages on your computers: its lines written when its app sends them (`phonemsgs`);
289    // a reply from a computer handed lately, looked for in it as shown (`replies`).
290    let shared = crate::phonemsgs::note(json, &posted, &kind, who, admitted, &decision) | crate::replies::heard(json, &posted);
291    let hold = decision.until.map_or(0, |until| (until - stamp).max(1) * 1000);
292    let why = if machine && decision.why == Why::Gathered { MACHINE } else { decision.why.id() };
293    let mut answer = json!({ "hold": hold, "why": why, "shared": shared });
294    if let Some(counted) = released {
295        let columns = column_words(&columns_now(&live, &now, slot(&now), gate.is_some()), nothing && live.clock.mode(&now).free(), gate.as_ref());
296        answer["line"] = json!(log_line("release", &counted, &columns.iter().map(String::as_str).collect::<Vec<_>>(), &now));
297    } else if decision.until.is_some() {
298        // Held: a line of its own, with what decided it (`appnotes::cause`), a machine's number said.
299        let cause = appnotes::cause(&posted.package, &kind, who, admitted, &choices, &ask).map(|mut cause| {
300            if machine && cause.gathered == Some(Gathering::Automaton) {
301                cause.gathered = Some(Gathering::Machine);
302            }
303            cause
304        });
305        answer["line"] = json!(hold_line(if returning { "held again" } else { "held" }, kind_word(&kind), &decision, cause.as_ref(), gate.as_ref(), &now));
306    }
307    answer.to_string()
308}
309
310/// What Android holds for Sioul now, worked out again (`AppNotes.review`):
311/// {"snooze": [{"key", "ms"}], "line"}, those whose time moved by more than a
312/// minute (1 s: it may come now), and the line the listener writes in the
313/// phone's log (`log_line`). Marks of notifications Android no longer holds
314/// (their app took them away, or they came back unseen) are let go.
315fn review(json: &str) -> String {
316    let asked: Value = serde_json::from_str(json).unwrap_or(Value::Null);
317    let keys: Vec<String> = asked["snoozed"].as_array().map(|a| a.iter().filter_map(|k| k.as_str().map(str::to_string)).collect()).unwrap_or_default();
318    // Why the review runs, as Java says it ("files", "connected"): a word of its own, never a name.
319    let reason: String = asked["reason"].as_str().unwrap_or_default().chars().filter(char::is_ascii_lowercase).take(16).collect();
320    // The phone's do-not-disturb silences now, as Java read its filter: a text whose cell says ◑
321    // comes then, without a sound (decision 13); an older Java says nothing, read as not silenced.
322    let silenced = asked["silenced"].as_bool().unwrap_or(false);
323    let config = load_config();
324    let choices = Choices::load(&Choices::default_path());
325    let now = Zoned::now();
326    let stamp = now.timestamp().as_second();
327    let gate = gate();
328    let live = live(&config, &now);
329    let gathered = config.reminders.gathered_times();
330    let slots = crate::hours::slots();
331    let slot = |at: &Zoned| slots.at(at);
332    let also = slots.edges();
333    let attention = sioul_core::attention::Attention::of(&config);
334    let nothing = sioul_core::pause::nothing_now(&sioul_core::quiet::Overrides::load(&sioul_core::quiet::Overrides::default_path()), &config.free_time);
335    // A conversation answered from a computer in the last twenty minutes: what Sioul holds of it comes now, but in a pause (`replies`).
336    let paused = live.clock.mode(&now).paused();
337    let reachable = reachable_until();
338    // A release asked since the last one applied (Reachable pressed on any device, or "Let what your phone
339    // holds through" on a computer): what was held before it comes now, once (part 3).
340    // Never during a pause: the stamps wait for its end, then apply once (F5).
341    let releases = crate::everywhere::release_stamps();
342    let talks = released_talks();
343    let path = Ledger::default_path();
344    let (again, counted, ahead) = sioul_core::filelock::with_lock(&path, || {
345        let mut ledger = Ledger::load(&path);
346        let (_, ahead) = if paused { (false, Vec::new()) } else { ledger.apply_releases(&releases, stamp) };
347        let present: std::collections::BTreeSet<String> = keys.iter().map(|k| appnotes::key_hash(k)).collect();
348        ledger.held.retain(|hash, held| present.contains(hash) || stamp - held.since < 60);
349        let mut again = Vec::new();
350        let mut counted = Vec::new();
351        for key in &keys {
352            let hash = appnotes::key_hash(key);
353            let Some(held) = ledger.held.get(&hash).cloned() else { continue };
354            let sources = sioul_core::attention::source_rows_with(&held.app, &held.conversation, &held.persons);
355            let ask = Ask { now: &now, clock: &live, attention: &attention, gathered: &gathered, area: held.area, gate: gate.map(|g| Gate { until: g.until }), slot_at: &slot, nothing, also: &also, sources: &sources, reachable, silenced };
356            let mut decided = appnotes::again(&held, &choices, &ask);
357            if !paused && crate::replies::answered_lately(&held.conversation) {
358                decided = decided.answered();
359            }
360            // Held before the release: comes now (snoozed again for a second, never cancelled:
361            // a reply from a computer still finds it), every kind, but what is held for good;
362            // never during a pause.
363            if !paused {
364                decided = decided.released(&held, &ledger);
365                // Its conversation let through now from the card (`release_talk`): what was held before then.
366                if decided.until.is_some() && decided.why != Why::Never && talks.get(&held.conversation).is_some_and(|at| held.first_held() <= *at) {
367                    decided = Decision::through(Why::Released);
368                }
369            }
370            let until = decided.until.unwrap_or(stamp);
371            counted.push(Counted { why: decided.why, ..Counted::held(&held, until) });
372            // Held for good now (its conversation set to never since): kept so, so that the log never
373            // counts it as waiting for a time (review of part 3, I8).
374            if decided.why == Why::Never
375                && let Some(kept) = ledger.held.get_mut(&hash)
376            {
377                kept.kind = "never".to_string();
378            }
379            if (until - held.until).abs() > 60 {
380                if let Some(kept) = ledger.held.get_mut(&hash) {
381                    kept.until = until;
382                }
383                again.push(json!({ "key": key, "ms": (until - stamp).max(1) * 1000 }));
384            }
385        }
386        ledger.forget_old(stamp, &choices);
387        if let Err(e) = ledger.save(&path) {
388            eprintln!("Notes: {e}");
389        }
390        (again, counted, ahead.len())
391    });
392    let event = if reason.is_empty() { "review".to_string() } else { format!("review ({reason})") };
393    let columns = column_words(&columns_now(&live, &now, slot(&now), gate.is_some()), nothing && live.clock.mode(&now).free(), gate.as_ref());
394    let mut line = log_line(&event, &counted, &columns.iter().map(String::as_str).collect::<Vec<_>>(), &now);
395    // A device's release stamp more than a day ahead of this phone's clock: passed over, said once (F3).
396    if ahead > 0 {
397        line.push_str(&format!(" A release asked from {ahead} device(s) more than a day ahead of this phone's clock: passed over."));
398    }
399    json!({ "snooze": again, "line": line }).to_string()
400}
401
402// ---------------------------------------------------------------- the log
403
404/// A notification Sioul holds, as the listener's log line counts it
405/// (`log_line`): the kind of its channel, until when it waits (none, or a
406/// time past: it comes now), and why.
407#[derive(Debug, Clone, Copy, PartialEq, Eq)]
408struct Counted {
409    kind: &'static str,
410    until: Option<i64>,
411    why: Why,
412}
413
414impl Counted {
415    /// As the ledger keeps it, waiting until `until`: held for good (the
416    /// blocked, a conversation set to never) or until its time.
417    fn held(held: &appnotes::Held, until: i64) -> Counted {
418        Counted { kind: kind_of(held), until: Some(until), why: if held.kind == "never" { Why::Never } else { Why::Waiting } }
419    }
420}
421
422/// The kind of channel a held notification came on, as the log says it:
423/// calls, messages, mail, or other apps (an automaton's, an app set to come
424/// at once); never its app nor who wrote.
425fn kind_of(held: &appnotes::Held) -> &'static str {
426    match held.via.as_str() {
427        "calls" => "calls",
428        "messages" => "messages",
429        "mail" => "mail",
430        _ => "other apps",
431    }
432}
433
434/// What reaches you now, as the matrix reads it: the time's column (or two,
435/// work and admin at once), then its layers, a slot of time for you and
436/// do-not-disturb (its switch or a focus session).
437fn columns_now(live: &Live, now: &Zoned, slot: bool, dnd: bool) -> Vec<Column> {
438    let moment = sioul_core::attention::Now::of(&live.clock.mode(now)).layers(slot, dnd);
439    let mut columns = moment.times.clone();
440    if moment.slot {
441        columns.push(Column::Slot);
442    }
443    if moment.dnd {
444        columns.push(Column::Dnd);
445    }
446    columns
447}
448
449/// The matrix's columns as the log says them: their ids ("leisure", "slot"),
450/// Free time's "Nothing at all" when it holds ("free (nothing at all)"), and
451/// do-not-disturb with what holds it ("dnd (focus session)", "dnd (switch)").
452fn column_words(columns: &[Column], nothing: bool, gated: Option<&Gated>) -> Vec<String> {
453    columns
454        .iter()
455        .map(|column| match column {
456            Column::Free if nothing => "free (nothing at all)".to_string(),
457            Column::Dnd => match gated {
458                Some(Gated { focus: true, switch: true, .. }) => "dnd (switch and focus session)".to_string(),
459                Some(Gated { focus: true, .. }) => "dnd (focus session)".to_string(),
460                Some(Gated { switch: true, .. }) => "dnd (switch)".to_string(),
461                _ => column.id().to_string(),
462            },
463            _ => column.id().to_string(),
464        })
465        .collect()
466}
467
468/// A time as the log says it: "22:00" today, "tomorrow 07:00", else "2026-10-20 09:00".
469fn at_words(at: i64, now: &Zoned) -> String {
470    match jiff::Timestamp::from_second(at).map(|t| t.to_zoned(now.time_zone().clone())) {
471        Err(_) => "an unknown time".to_string(),
472        Ok(at) if at.date() == now.date() => at.strftime("%H:%M").to_string(),
473        Ok(at) if now.date().tomorrow().is_ok_and(|tomorrow| tomorrow == at.date()) => at.strftime("tomorrow %H:%M").to_string(),
474        Ok(at) => at.strftime("%Y-%m-%d %H:%M").to_string(),
475    }
476}
477
478/// The kind of channel a notification came on, as the log says it (`kind_of`).
479fn kind_word(kind: &Kind) -> &'static str {
480    match kind {
481        Kind::People(talk) => match talk.via {
482            Channel::Calls => "calls",
483            Channel::Messages => "messages",
484            Channel::Mail => "mail",
485        },
486        _ => "other apps",
487    }
488}
489
490/// The listener's line at a hold (docs/android.md, "In the phone's log"):
491/// the kind of its channel, until when it waits, or for good, and what
492/// decided it (`appnotes::cause`): its conversation's or its app's own row,
493/// a gathering and what gathers it, what it is for, the columns of the
494/// matrix that hold it now (do-not-disturb with what holds it). "Notes:
495/// held: messages 1 until 10:50; why: dnd (focus session)." Held again on
496/// its return at its time: "held again". Never a name, a number, an app nor
497/// who wrote.
498fn hold_line(event: &str, kind: &str, decision: &Decision, cause: Option<&Cause>, gated: Option<&Gated>, now: &Zoned) -> String {
499    let until = match decision.until {
500        _ if decision.why == Why::Never => "for good".to_string(),
501        Some(until) => format!("until {}", at_words(until, now)),
502        None => "until now".to_string(),
503    };
504    let mut why: Vec<String> = Vec::new();
505    if let Some(cause) = cause {
506        if cause.for_good {
507            why.push("never".into());
508        }
509        match cause.own {
510            Some(Own::Conversation) => why.push("its conversation's row".into()),
511            Some(Own::Person) => why.push("their own row".into()),
512            Some(Own::App) => why.push("its app's row".into()),
513            None => {}
514        }
515        match cause.gathered {
516            Some(Gathering::Automaton) => why.push("gathered".into()),
517            Some(Gathering::App) => why.push("gathered (its app)".into()),
518            Some(Gathering::Conversation) => why.push("gathered (its conversation)".into()),
519            Some(Gathering::Named) => why.push("gathered (a sender named only)".into()),
520            Some(Gathering::Machine) => why.push("gathered (a number kept for machines)".into()),
521            None => {}
522        }
523        if let Some(area) = cause.area {
524            why.push(format!("area ({})", area.id()));
525        }
526        if !cause.columns.is_empty() {
527            why.push(column_words(&cause.columns, cause.nothing, gated).join(" + "));
528        }
529    }
530    let why = if why.is_empty() { "unknown".to_string() } else { why.join(", ") };
531    format!("Notes: {event}: {kind} 1 {until}; why: {why}.")
532}
533
534/// The listener's line in the phone's log (`adb logcat -s sioul`), at each
535/// review of what is held and at each release (docs/android.md,
536/// "Notifications from other apps"): how many notifications are held, and how
537/// many come now, by the kind of their channel; why, the matrix's column now
538/// and its layers ("leisure + dnd (switch)"), or Always through when that let
539/// one come; when the next one comes back. Never a name, a number, an app nor
540/// a word of a message: kinds, counts and times only. At each hold, a line of
541/// its own (`hold_line`).
542fn log_line(event: &str, counted: &[Counted], columns: &[&str], now: &Zoned) -> String {
543    let stamp = now.timestamp().as_second();
544    let (released, held): (Vec<&Counted>, Vec<&Counted>) = counted.iter().partition(|c| c.until.is_none_or(|until| until <= stamp));
545    let by_kind = |items: &[&Counted]| {
546        let kinds: Vec<String> = ["calls", "messages", "mail", "other apps"].iter().map(|kind| (kind, items.iter().filter(|c| c.kind == *kind).count())).filter(|(_, n)| *n > 0).map(|(kind, n)| format!("{kind} {n}")).collect();
547        if kinds.is_empty() { items.len().to_string() } else { format!("{} ({})", items.len(), kinds.join(", ")) }
548    };
549    let why = if released.iter().any(|c| c.why == Why::Always) {
550        "Always through".to_string()
551    } else if released.iter().any(|c| c.why == Why::Answered) {
552        "answered from a computer".to_string()
553    } else if released.iter().any(|c| c.why == Why::Released) {
554        "released as you asked".to_string()
555    } else if columns.is_empty() {
556        "unknown".to_string()
557    } else {
558        columns.join(" + ")
559    };
560    // Held for good (the blocked, a conversation set to never) comes back at no time of the matrix's.
561    let next = held.iter().filter(|c| c.why != Why::Never).filter_map(|c| c.until).min().filter(|at| jiff::Timestamp::from_second(*at).is_ok()).map_or_else(|| "none".to_string(), |at| at_words(at, now));
562    format!("Notes: {event}: held {}; released {}; why: {why}; next release {next}.", by_kind(&held), by_kind(&released))
563}
564
565/// `decide`, for Java (android/main.cpp); a panic answers "null", and the
566/// notification comes as it was sent. Given back to `sioul_string_free`.
567///
568/// # Safety
569/// `json` is null, or a zero-terminated text valid for the call.
570// SAFETY: no other symbol of the program has this name.
571#[unsafe(no_mangle)]
572pub unsafe extern "C" fn sioul_appnotes_decide(json: *const c_char) -> *mut c_char {
573    // SAFETY: `json` is null or a C string valid for the call, as this function's contract says.
574    let json = unsafe { crate::alarms::key_of(json) };
575    let answer = std::panic::catch_unwind(|| decide(&json)).unwrap_or_else(|_| "null".to_string());
576    crate::alarms::handed(answer)
577}
578
579/// `review`, for Java; "null" when it fails, and what is held keeps its time.
580///
581/// # Safety
582/// `json` is null, or a zero-terminated text valid for the call.
583// SAFETY: no other symbol of the program has this name.
584#[unsafe(no_mangle)]
585pub unsafe extern "C" fn sioul_appnotes_review(json: *const c_char) -> *mut c_char {
586    // SAFETY: `json` is null or a C string valid for the call, as this function's contract says.
587    let json = unsafe { crate::alarms::key_of(json) };
588    let answer = std::panic::catch_unwind(|| review(&json)).unwrap_or_else(|_| "null".to_string());
589    crate::alarms::handed(answer)
590}
591
592// ---------------------------------------------------------------- the window
593
594/// The conversations let through now from the card (`release_talk`), by
595/// their hashed key, with when (Unix seconds): this phone's own file, never
596/// shared, holding no title; the listener reviews when it changes.
597const TALKS_FILE: &str = "app-notes-talks.toml";
598
599fn talks_path() -> std::path::PathBuf {
600    sioul_core::config::state_dir().join(TALKS_FILE)
601}
602
603fn released_talks() -> std::collections::BTreeMap<String, i64> {
604    std::fs::read_to_string(talks_path()).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
605}
606
607/// The conversations the phone holds now, for the card (ReachCard.qml): {key,
608/// title, app} each, the soonest first. Their titles come from the phone's
609/// own ledger and go to its window alone: never shared, never on a computer
610/// (decision 16; none there, where nothing is held).
611pub(crate) fn held_talks() -> Vec<Value> {
612    if !cfg!(target_os = "android") {
613        return Vec::new();
614    }
615    let path = Ledger::default_path();
616    if !path.exists() {
617        return Vec::new();
618    }
619    let ledger = Ledger::load(&path);
620    let now = jiff::Timestamp::now().as_second();
621    let mut held: Vec<&appnotes::Held> = ledger.held.values().filter(|h| h.kind == "people" && !h.conversation.is_empty() && h.until > now).collect();
622    held.sort_by_key(|h| h.until);
623    let mut out: Vec<Value> = Vec::new();
624    for h in held {
625        if out.iter().any(|t| t["key"] == h.conversation.as_str()) {
626            continue;
627        }
628        let title = ledger.conversations.get(&h.conversation).map(|c| c.title.split_whitespace().collect::<Vec<_>>().join(" ")).filter(|t| !t.is_empty()).unwrap_or_else(|| h.label.clone());
629        out.push(json!({ "key": h.conversation, "title": title, "app": h.label }));
630    }
631    out
632}
633
634/// "Let it through now" on a held conversation (the card): what is held of
635/// it comes at the listener's next review, which this file's change brings.
636pub(crate) fn release_talk(key: &str) -> String {
637    let now = jiff::Timestamp::now().as_second();
638    let mut talks = released_talks();
639    talks.retain(|_, at| now - *at < 2 * 86_400);
640    talks.insert(key.to_string(), now);
641    let path = talks_path();
642    let written = toml::to_string(&talks).map_err(|e| e.to_string()).and_then(|text| {
643        if let Some(parent) = path.parent() {
644            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
645        }
646        sioul_core::filelock::replace(&path, text.as_bytes()).map_err(|e| format!("{}: {e}", path.display()))
647    });
648    match written {
649        Ok(()) => tr().text("reach-card-let-it-done", None),
650        Err(e) => e,
651    }
652}
653
654/// Which row and which cells "Let them through then" sets for a held
655/// conversation (`let_talk_then`): the person's own row by their card, else
656/// by their number, else the conversation's; under do-not-disturb that layer,
657/// in a slot of time for you that one, during work Work and Admin together,
658/// else the time now.
659fn talk_then(held: &appnotes::Held, now: &sioul_core::attention::Now) -> (String, Vec<Column>) {
660    // A group's, or a number nobody knows (whose calls stay the screening's): the conversation's row,
661    // never one member's (review of part 6, F5).
662    let person = if held.group {
663        None
664    } else {
665        held.persons.iter().find(|p| p.starts_with("person.contact:")).or_else(|| held.persons.iter().find(|p| p.starts_with("person.tel:")).filter(|_| !matches!(held.who.as_str(), "" | "stranger")))
666    };
667    let id = person.cloned().unwrap_or_else(|| format!("{}{}", sioul_core::attention::CONVERSATION_ROW, held.conversation));
668    let columns: Vec<Column> = if now.dnd {
669        vec![Column::Dnd]
670    } else if now.slot {
671        vec![Column::Slot]
672    } else if now.times.iter().any(|c| matches!(c, Column::Work | Column::Admin)) {
673        vec![Column::Work, Column::Admin]
674    } else {
675        now.times.clone()
676    };
677    (id, columns)
678}
679
680/// "Let them through then" on a held conversation (the card; the proposal's
681/// 3.7): the cell of what holds it now set to at once, on the person's own
682/// row when a card or a number knows them (their card's first), else on the
683/// conversation's: under do-not-disturb, that layer; during work, Work and
684/// Admin together; else the time now. Said in a sentence.
685pub(crate) fn let_talk_then(key: &str) -> String {
686    let path = Ledger::default_path();
687    let ledger = Ledger::load(&path);
688    // The newest of it still held (review of part 6, F5).
689    let stamp = jiff::Timestamp::now().as_second();
690    let Some(held) = ledger.held.values().filter(|h| h.conversation == key && h.until > stamp).max_by_key(|h| h.since) else { return String::new() };
691    let (id, columns) = talk_then(held, &crate::hours::attention_now());
692    let words: Vec<String> = columns.iter().map(|c| c.id().to_string()).collect();
693    if let Err(e) = sioul_core::attention::apply_change(&crate::backend::config_path(), &format!("attention.{id}"), None, &sioul_core::config::SettingValue::Texts(words)) {
694        return e;
695    }
696    let time = columns.iter().map(|c| sioul_core::attention::column_label(tr(), *c)).collect::<Vec<_>>().join(", ");
697    let mut args = sioul_core::i18n::args();
698    args.set("time", time);
699    // Their messages and calls on their own row; this conversation's messages alone on its row.
700    tr().text(if sioul_core::attention::is_person(&id) { "reach-card-let-then-done" } else { "reach-card-let-then-done-talk" }, Some(&args))
701}
702
703/// The Porch's line on other apps (`appnotes::porch_line`); "" when nothing
704/// waits nor came back lately, and on a computer (no listener there).
705pub(crate) fn porch_line() -> String {
706    let path = Ledger::default_path();
707    if !path.exists() {
708        return String::new();
709    }
710    appnotes::porch_line(tr(), &Ledger::load(&path), &Zoned::now())
711}
712
713/// The Porch's JSON with its line on other apps (`apps_line`), when there is one.
714pub(crate) fn with_line(porch: String) -> String {
715    let line = porch_line();
716    if line.is_empty() {
717        return porch;
718    }
719    match serde_json::from_str::<Value>(&porch) {
720        Ok(Value::Object(mut fields)) => {
721            fields.insert("apps_line".into(), Value::String(line));
722            Value::Object(fields).to_string()
723        }
724        _ => porch,
725    }
726}
727
728/// A row of the tab, as `SettingRow.qml` shows it: {key, kind, label, help, value, choices}.
729fn row(key: &str, kind: &str, label: &str, help: &str, value: Value, choices: Vec<(Value, String)>) -> Value {
730    json!({
731        "key": key,
732        "kind": kind,
733        "label": label,
734        "help": help,
735        "value": value,
736        "choices": choices.into_iter().map(|(value, label)| json!({ "value": value, "label": label })).collect::<Vec<_>>(),
737    })
738}
739
740/// The table of other apps by time (What reaches you ▸ Exceptions): each app
741/// seen lately a row of the matrix, by name, and the apps' rows set that this
742/// device has not seen (a computer's, set on the phone: by the name written
743/// with them); then the conversations chosen for (a row or a way through)
744/// and the ten seen last ("Title · App"), never a wall of them. Whether
745/// some were left out. `AttentionGrid.qml` draws it.
746fn by_time(attention: &sioul_core::attention::Attention, ledger: &Ledger, choices: &Choices) -> (Value, bool) {
747    use sioul_core::attention::{APP_ROW, CONVERSATION_ROW, SourceLine};
748    let tr = tr();
749    let apps_group = tr.text("attention-source-group-apps", None);
750    let talks_group = tr.text("attention-source-group-conversations", None);
751    let mut apps: Vec<SourceLine> = ledger.apps.iter().map(|(package, seen)| SourceLine { id: format!("{APP_ROW}{package}"), label: seen.label.clone(), group: apps_group.clone(), group_id: "app-rows".into() }).collect();
752    // Set elsewhere (on the phone, for a computer), or no longer seen here: by the name written with it.
753    for id in attention.sources().keys().filter(|id| id.starts_with(APP_ROW)) {
754        if !apps.iter().any(|a| &a.id == id) {
755            let name = attention.source_name(id);
756            let label = if name.is_empty() { id[APP_ROW.len()..].to_string() } else { name.to_string() };
757            apps.push(SourceLine { id: id.clone(), label, group: apps_group.clone(), group_id: "app-rows".into() });
758        }
759    }
760    apps.sort_by_cached_key(|a| sioul_core::text::fold(&a.label).into_iter().collect::<String>());
761    let mut talks: Vec<(&String, &appnotes::SeenTalk)> = ledger.conversations.iter().collect();
762    talks.sort_by_key(|(_, t)| -t.seen);
763    let total = talks.len();
764    let chosen = |key: &str| choices.conversation.contains_key(key) || attention.source(&format!("{CONVERSATION_ROW}{key}")).is_some();
765    let talks: Vec<(&String, &appnotes::SeenTalk)> = talks.into_iter().enumerate().filter(|(i, (key, _))| *i < 10 || chosen(key)).map(|(_, t)| t).collect();
766    let more = talks.len() < total;
767    let mut lines = apps;
768    lines.extend(talks.into_iter().map(|(key, seen)| SourceLine { id: format!("{CONVERSATION_ROW}{key}"), label: format!("{} · {}", seen.title, seen.label), group: talks_group.clone(), group_id: "conversation-rows".into() }));
769    (serde_json::to_value(sioul_core::attention::source_grid(attention, tr, &lines)).unwrap_or(Value::Null), more)
770}
771
772/// "09:00, 13:00 and 18:00".
773fn times_in_words(times: &[String]) -> String {
774    let times: Vec<String> = appnotes::times_of(times).iter().map(|t| t.strftime("%H:%M").to_string()).collect();
775    match times.as_slice() {
776        [] => String::new(),
777        [one] => one.clone(),
778        [rest @ .., last] => format!("{} {} {last}", rest.join(", "), tr().text("word-and", None)),
779    }
780}
781
782/// Settings ▸ Other apps, as JSON (AppNotesSetup.qml): whether this is a
783/// phone, the access and its steps, the switch, the times, what rang while
784/// held, then a row for each app, conversation and site seen.
785pub(crate) fn setup() -> String {
786    let android = cfg!(target_os = "android");
787    let config = load_config();
788    let choices = Choices::load(&Choices::default_path());
789    let ledger = Ledger::load(&Ledger::default_path());
790    let now = Zoned::now();
791    let tr = tr();
792    let text = |id: &str| tr.text(id, None);
793    let mut times = sioul_core::i18n::args();
794    times.set("times", times_in_words(&config.reminders.gathered_times()));
795    let heard = if ledger.heard > 0 {
796        let mut args = sioul_core::i18n::args();
797        let at = jiff::Timestamp::from_second(ledger.heard).map(|t| t.to_zoned(now.time_zone().clone())).unwrap_or_else(|_| now.clone());
798        args.set("when", tr.when(&at));
799        tr.text("appnotes-heard", Some(&args))
800    } else {
801        String::new()
802    };
803    let mut rang: Vec<Value> = Vec::new();
804    for (package, app) in &ledger.apps {
805        for (channel, r) in &app.rang {
806            let mut args = sioul_core::i18n::args();
807            args.set("app", app.label.clone());
808            args.set("channel", if r.name.is_empty() { channel.clone() } else { r.name.clone() });
809            rang.push(json!({ "package": package, "channel": channel, "line": tr.text("appnotes-rang-line", Some(&args)) }));
810        }
811    }
812    let kinds: Vec<(Value, String)> = AppKind::ALL.iter().map(|k| (json!(k.id()), text(&format!("appnotes-kind-{}", k.id())))).collect();
813    let attention = sioul_core::attention::Attention::of(&config);
814    let (grid, more) = by_time(&attention, &ledger, &choices);
815    let mut apps: Vec<(&String, &appnotes::SeenApp)> = ledger.apps.iter().collect();
816    apps.sort_by_cached_key(|(_, a)| sioul_core::text::fold(&a.label).into_iter().collect::<String>());
817    let apps: Vec<Value> = apps
818        .into_iter()
819        .map(|(package, seen)| {
820            let chosen = choices.app(package);
821            let why = appnotes::why_text(tr, &seen.why, seen.until, &now);
822            json!({
823                "package": package,
824                // Its own row of the matrix, in one sentence: "At once: Work; held: Leisure…".
825                "times": sioul_core::attention::source_sentence(&attention, tr, &format!("{}{package}", sioul_core::attention::APP_ROW)),
826                "kind": row(&format!("app.{package}.kind"), "choice", &seen.label, &why, json!(chosen.kind.id()), kinds.clone()),
827                "area": row(&format!("app.{package}.area"), "areas", &text("appnotes-area"), "", json!(chosen.area.map(Area::id).unwrap_or_default()), Vec::new()),
828            })
829        })
830        .collect();
831    let throughs: Vec<(Value, String)> = Through::ALL.iter().map(|t| (json!(t.id()), text(&format!("appnotes-through-{}", t.id())))).collect();
832    let mut talks: Vec<(&String, &appnotes::SeenTalk)> = ledger.conversations.iter().collect();
833    talks.sort_by_key(|(_, t)| -t.seen);
834    // What Android says of each: marked Priority there (it rings through Sioul's modes), its page.
835    let android_says = if android { java("conversations", "{}") } else { Value::Null };
836    let conversations: Vec<Value> = talks
837        .into_iter()
838        .map(|(key, seen)| {
839            let label = format!("{} · {}", seen.title, seen.label);
840            let help = if seen.group { text("appnotes-group") } else { String::new() };
841            let mut said = row(&format!("conversation.{key}"), "choice", &label, &help, json!(choices.through(key).id()), throughs.clone());
842            said["talk"] = json!(key);
843            said["important"] = json!(android_says["conversations"][key.as_str()]["important"] == true);
844            said
845        })
846        .collect();
847    let at_once = vec![(json!(false), text("appnotes-site-gathered")), (json!(true), text("appnotes-site-at-once"))];
848    let sites: Vec<Value> = ledger
849        .sites
850        .iter()
851        .map(|(host, seen)| {
852            let app = ledger.apps.get(&seen.app).map_or(seen.app.as_str(), |a| a.label.as_str());
853            row(&format!("site.{host}"), "choice", &format!("{host} · {app}"), "", json!(choices.site.get(host).is_some_and(|s| s.at_once)), at_once.clone())
854        })
855        .collect();
856    json!({
857        "android": android,
858        "access": java("access", "{}") == Value::Bool(true),
859        "restricted": java("restricted", "{}") == Value::Bool(true),
860        "contacts": java("contacts", "{}") == Value::Bool(true),
861        "hold": row("hold", "bool", &text("appnotes-hold"), &text("appnotes-hold-help"), json!(choices.hold), Vec::new()),
862        "times": tr.text("appnotes-times", Some(&times)),
863        "heard": heard,
864        "rang": rang,
865        "apps": apps,
866        "conversations": conversations,
867        "sites": sites,
868        "grid": grid,
869        "more": more,
870    })
871    .to_string()
872}
873
874/// A choice of the tab, or one of Android's pages (`AppNotesSetup.act`): the tab again, as JSON.
875/// "set" {key, value}: "hold", `app.<package>.kind`, `app.<package>.area`,
876/// `conversation.<key>`, `site.<host>`; "open-access", "open-info",
877/// "open-app" {package}, "open-channel" {package, channel}; "contacts".
878pub(crate) fn change(verb: &str, json: &str) -> String {
879    let asked: Value = serde_json::from_str(json).unwrap_or(Value::Null);
880    match verb {
881        "set" => {
882            let key = asked["key"].as_str().unwrap_or_default().to_string();
883            let value = asked["value"].clone();
884            let path = Choices::default_path();
885            let result = sioul_core::filelock::with_lock(&path, || {
886                let mut choices = Choices::load(&path);
887                let ledger = Ledger::load(&Ledger::default_path());
888                set(&mut choices, &ledger, &key, &value);
889                choices.save(&path)
890            });
891            if let Err(e) = result {
892                eprintln!("Notes: {e}");
893            }
894        }
895        // Android's pages; a conversation's by its key (`talk`), to mark it Priority there.
896        "open-access" | "open-info" | "open-app" | "open-channel" | "open-conversation" => {
897            java(verb, json);
898        }
899        "contacts" => crate::steps::ask_contacts(),
900        // A row of the table by time: its words whole ("attention.app.<package>", nine words), an app's name with them.
901        "row" => {
902            let key = asked["key"].as_str().unwrap_or_default();
903            let mut words: Vec<String> = asked["words"].as_array().map(|w| w.iter().filter_map(|w| w.as_str().map(str::to_string)).collect()).unwrap_or_default();
904            if let Some(package) = key.strip_prefix("attention.").and_then(|id| id.strip_prefix(sioul_core::attention::APP_ROW))
905                && let Some(seen) = Ledger::load(&Ledger::default_path()).apps.get(package)
906            {
907                words.push(format!("{}{}", sioul_core::attention::NAME, seen.label));
908            }
909            // What the row showed: only the cells changed from it are set, over the row as it is now.
910            let shown: Option<Vec<String>> = asked["shown"].as_array().map(|w| w.iter().filter_map(|w| w.as_str().map(str::to_string)).collect());
911            let shown = shown.map(|mut shown| {
912                shown.extend(words.iter().filter(|w| w.starts_with(sioul_core::attention::NAME)).cloned());
913                sioul_core::config::SettingValue::Texts(shown)
914            });
915            if let Err(e) = sioul_core::attention::apply_change(&crate::backend::config_path(), key, shown.as_ref(), &sioul_core::config::SettingValue::Texts(words)) {
916                eprintln!("Notes: {e}");
917            }
918        }
919        _ => {}
920    }
921    setup()
922}
923
924/// One choice written: the app's kind or area, a conversation's way
925/// through, a site at once. What says "as usual" is taken out.
926fn set(choices: &mut Choices, ledger: &Ledger, key: &str, value: &Value) {
927    if key == "hold" {
928        choices.hold = value.as_bool().unwrap_or(true);
929    } else if let Some(rest) = key.strip_prefix("app.") {
930        let Some((package, field)) = rest.rsplit_once('.') else { return };
931        let mut chosen = choices.app(package);
932        match field {
933            "kind" => chosen.kind = value.as_str().and_then(AppKind::read).unwrap_or_default(),
934            "area" => chosen.area = value.as_str().and_then(Area::parse),
935            _ => return,
936        }
937        chosen.label = ledger.apps.get(package).map(|a| a.label.clone()).unwrap_or(chosen.label);
938        if chosen.kind == AppKind::Usual && chosen.area.is_none() {
939            choices.app.remove(package);
940        } else {
941            choices.app.insert(package.to_string(), chosen);
942        }
943    } else if let Some(conversation) = key.strip_prefix("conversation.") {
944        let through = value.as_str().and_then(Through::read).unwrap_or_default();
945        if through == Through::Usual {
946            choices.conversation.remove(conversation);
947        } else {
948            let seen = ledger.conversations.get(conversation);
949            let title = seen.map(|s| s.title.clone()).or_else(|| choices.conversation.get(conversation).map(|c| c.title.clone())).unwrap_or_default();
950            let app = seen.map(|s| s.label.clone()).or_else(|| choices.conversation.get(conversation).map(|c| c.app.clone())).unwrap_or_default();
951            choices.conversation.insert(conversation.to_string(), appnotes::TalkChoice { through, title, app });
952        }
953    } else if let Some(host) = key.strip_prefix("site.") {
954        if value.as_bool().unwrap_or(false) {
955            choices.site.insert(host.to_string(), appnotes::SiteChoice { at_once: true });
956        } else {
957            choices.site.remove(host);
958        }
959    }
960}
961
962#[cfg(test)]
963mod tests {
964    use super::*;
965
966    /// Decision 16 (the proposal's 3.7): "Let them through then" sets the
967    /// person's own row by their card, else their number, else the
968    /// conversation's, at what holds the text now: do-not-disturb; work and
969    /// admin together; the time.
970    #[test]
971    fn let_them_through_then_sets_the_cell_of_what_held_it() {
972        use sioul_core::attention::Now;
973        let held = |persons: &[&str]| appnotes::Held { conversation: "talk-1".into(), persons: persons.iter().map(|p| p.to_string()).collect(), kind: "people".into(), ..Default::default() };
974        let work = Now::time(Column::Work);
975        assert_eq!(talk_then(&held(&["person.tel:+33199001234", "person.contact:card-1"]), &work), ("person.contact:card-1".to_string(), vec![Column::Work, Column::Admin]));
976        assert_eq!(talk_then(&appnotes::Held { who: "neutral".into(), ..held(&["person.noa@example.org", "person.tel:+33199001234"]) }, &Now::time(Column::Leisure)).0, "person.tel:+33199001234");
977        // A number nobody knows, and a group: the conversation's row, never one member's (review of part 6, F5).
978        assert_eq!(talk_then(&appnotes::Held { who: "stranger".into(), ..held(&["person.tel:+33199001234"]) }, &Now::time(Column::Leisure)).0, "conversation.talk-1");
979        assert_eq!(talk_then(&appnotes::Held { group: true, ..held(&["person.contact:card-1"]) }, &Now::time(Column::Leisure)).0, "conversation.talk-1");
980        assert_eq!(talk_then(&held(&[]), &Now::time(Column::Leisure)), ("conversation.talk-1".to_string(), vec![Column::Leisure]), "no card nor number: the conversation's");
981        assert_eq!(talk_then(&held(&["person.contact:card-1"]), &Now::time(Column::Work).layers(false, true)).1, vec![Column::Dnd]);
982    }
983
984    /// Review of part 3, F5: no release during a pause, when everything
985    /// Sioul shows is held. Reachable pressed on the laptop seven minutes ago,
986    /// heard by the phone only now, five minutes into a pause: its review
987    /// lets nothing through, nor does a notification back at its time; the
988    /// pause over, the next review lets through, once, what was held before
989    /// the press.
990    #[test]
991    fn no_release_during_a_pause_it_waits_for_its_end() {
992        const PEOPLE: &str = "0|com.google.android.apps.messaging|2|sms|10001";
993        const SHOP_KEY: &str = "0|com.shop.example|1|null|10500";
994        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}"#;
995        let _files = crate::everywhere::FILES.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
996        crate::everywhere::files_cleared();
997        let (stamp, ms) = (jiff::Timestamp::now().as_second(), jiff::Timestamp::now().as_millisecond());
998        let held = |app: &str, kind: &str, via: &str, who: &str, since: i64, until: i64| appnotes::Held { app: app.into(), label: app.into(), kind: kind.into(), via: via.into(), who: who.into(), admitted: false, group: false, conversation: String::new(), site: String::new(), area: None, since, until, first: 0, again: 0, persons: Vec::new() };
999        let mut ledger = Ledger::default();
1000        ledger.held.insert(appnotes::key_hash(PEOPLE), held("com.google.android.apps.messaging", "people", "messages", "neutral", stamp - 3600, stamp + 7200));
1001        ledger.held.insert(appnotes::key_hash(SHOP_KEY), held("com.shop.example", "gathered", "", "", stamp - 1800, stamp + 7200));
1002        ledger.save(&Ledger::default_path()).unwrap();
1003        let mut overrides = sioul_core::quiet::Overrides { paused_since: Some(stamp - 300), ..Default::default() };
1004        overrides.save(&sioul_core::quiet::Overrides::default_path()).unwrap();
1005        let laptop = sioul_core::everywhere::Device { kind: "computer".into(), pressed: ms - 420_000, mode: "reachable".into(), until: ms + 3_600_000, at: ms - 420_000, ..Default::default() };
1006        sioul_core::everywhere::Switch { device: [("laptop-1".to_string(), laptop)].into_iter().collect(), ..Default::default() }.save(&sioul_core::everywhere::Switch::default_path()).unwrap();
1007        let released = |answer: &str| -> Vec<String> {
1008            let v: serde_json::Value = serde_json::from_str(answer).unwrap();
1009            v["snooze"].as_array().unwrap().iter().filter(|s| s["ms"].as_i64() == Some(1000)).map(|s| s["key"].as_str().unwrap().to_string()).collect()
1010        };
1011        let all = json!({ "snoozed": [PEOPLE, SHOP_KEY], "reason": "files" }).to_string();
1012        assert!(released(&review(&all)).is_empty(), "nothing let through during the pause");
1013        assert!(Ledger::load(&Ledger::default_path()).released_by.is_empty(), "the stamp waits");
1014        // The shop's notification back at its time during the pause: held, never "released".
1015        let mut ledger = Ledger::load(&Ledger::default_path());
1016        ledger.held.get_mut(&appnotes::key_hash(SHOP_KEY)).unwrap().until = stamp;
1017        ledger.save(&Ledger::default_path()).unwrap();
1018        let back: serde_json::Value = serde_json::from_str(&decide(SHOP)).unwrap();
1019        assert_ne!(back["why"], "released", "{back}");
1020        // The pause over: what was held before the press comes, once, the shop's too, though
1021        // it came back and was held again meanwhile (it was first held before the press).
1022        overrides.paused_ended = Some(stamp);
1023        overrides.save(&sioul_core::quiet::Overrides::default_path()).unwrap();
1024        assert_eq!(released(&review(&all)), vec![PEOPLE.to_string(), SHOP_KEY.to_string()]);
1025        assert!(released(&review(&all)).is_empty(), "once");
1026        crate::everywhere::files_cleared();
1027    }
1028
1029    #[test]
1030    fn choices_set_from_the_tab() {
1031        let mut choices = Choices::default();
1032        let mut ledger = Ledger::default();
1033        ledger.apps.insert("com.discord".into(), appnotes::SeenApp { label: "Discord".into(), ..Default::default() });
1034        ledger.conversations.insert("c1".into(), appnotes::SeenTalk { app: "com.whatsapp".into(), label: "WhatsApp".into(), title: "École".into(), group: true, seen: 1 });
1035        set(&mut choices, &ledger, "app.com.discord.kind", &json!("automaton"));
1036        set(&mut choices, &ledger, "app.com.discord.area", &json!("leisure"));
1037        assert_eq!(choices.app("com.discord"), appnotes::AppChoice { kind: AppKind::Automaton, area: Some(Area::LEISURE), label: "Discord".into() });
1038        set(&mut choices, &ledger, "app.com.discord.kind", &json!("usual"));
1039        set(&mut choices, &ledger, "app.com.discord.area", &json!(""));
1040        assert!(choices.app.is_empty(), "as usual: taken out");
1041        set(&mut choices, &ledger, "conversation.c1", &json!("always"));
1042        assert_eq!(choices.conversation["c1"], appnotes::TalkChoice { through: Through::Always, title: "École".into(), app: "WhatsApp".into() });
1043        set(&mut choices, &ledger, "conversation.c1", &json!("usual"));
1044        assert!(choices.conversation.is_empty());
1045        set(&mut choices, &ledger, "site.forum.example.net", &json!(true));
1046        assert!(choices.site["forum.example.net"].at_once);
1047        set(&mut choices, &ledger, "site.forum.example.net", &json!(false));
1048        assert!(choices.site.is_empty());
1049        set(&mut choices, &ledger, "hold", &json!(false));
1050        assert!(!choices.hold);
1051    }
1052
1053    /// The table of other apps by time: each app seen, by name, and an app's row
1054    /// set elsewhere by the name written with it; the conversations chosen for
1055    /// and the ten seen last, never more; each row's cells as set or as usual.
1056    #[test]
1057    fn the_table_by_time() {
1058        let mut ledger = Ledger::default();
1059        ledger.apps.insert("com.discord".into(), appnotes::SeenApp { label: "Discord".into(), ..Default::default() });
1060        ledger.apps.insert("org.example.news".into(), appnotes::SeenApp { label: "Actualités".into(), ..Default::default() });
1061        for i in 0..14 {
1062            ledger.conversations.insert(format!("c{i:02}"), appnotes::SeenTalk { app: "com.discord".into(), label: "Discord".into(), title: format!("Salon {i}"), group: true, seen: i });
1063        }
1064        let mut choices = Choices::default();
1065        choices.conversation.insert("c00".into(), appnotes::TalkChoice { through: Through::Always, ..Default::default() });
1066        let mut attention = sioul_core::attention::Attention::usual();
1067        attention.set_source("app.com.discord", sioul_core::attention::Column::Work, sioul_core::attention::Level::Now).unwrap();
1068        attention.set_source("app.org.example.chat", sioul_core::attention::Column::Leisure, sioul_core::attention::Level::Later).unwrap();
1069        attention.name_source("app.org.example.chat", "Chat");
1070        attention.set_source("conversation.c01", sioul_core::attention::Column::Sleep, sioul_core::attention::Level::Later).unwrap();
1071        let (grid, more) = by_time(&attention, &ledger, &choices);
1072        let ids: Vec<&str> = grid["rows"].as_array().unwrap().iter().map(|r| r["id"].as_str().unwrap()).collect();
1073        let labels: Vec<&str> = grid["rows"].as_array().unwrap().iter().map(|r| r["label"].as_str().unwrap()).collect();
1074        assert_eq!(&ids[..3], ["app.org.example.news", "app.org.example.chat", "app.com.discord"], "by name: Actualités, Chat, Discord");
1075        assert_eq!(&labels[..3], ["Actualités", "Chat", "Discord"]);
1076        // The ten seen last, and the two chosen for among the older ones.
1077        let talks: Vec<&str> = ids.iter().filter(|id| id.starts_with("conversation.")).copied().collect();
1078        assert_eq!(talks.len(), 12);
1079        assert!(talks.contains(&"conversation.c00") && talks.contains(&"conversation.c01") && !talks.contains(&"conversation.c02") && talks[0] == "conversation.c13");
1080        assert!(more, "two conversations left out");
1081        let discord = &grid["rows"][2];
1082        assert_eq!(discord["cells"][0]["value"], "now");
1083        assert_eq!(discord["cells"][1]["value"], "as");
1084        assert_eq!(discord["choices"].as_array().unwrap().len(), 4);
1085        assert_eq!(grid["columns"].as_array().unwrap().len(), 9);
1086        // Nothing seen, nothing set: no table.
1087        let (none, more) = by_time(&sioul_core::attention::Attention::usual(), &Ledger::default(), &Choices::default());
1088        assert!(none["rows"].as_array().unwrap().is_empty() && !more);
1089    }
1090
1091    /// The listener's line in the phone's log: how many are held and how many
1092    /// come now, by the kind of their channel, why (the matrix's column and
1093    /// layers, or Always through), and the next release; never a name, a
1094    /// number, an app nor a conversation, whatever the ledger holds.
1095    #[test]
1096    fn the_log_line_says_kinds_counts_and_times_only() {
1097        let now: Zoned = "2026-10-11T21:30:00+02:00[Europe/Paris]".parse().unwrap();
1098        let stamp = now.timestamp().as_second();
1099        let held = |app: &str, label: &str, via: &str, who: &str, conversation: &str, kind: &str, until: i64| appnotes::Held { app: app.into(), label: label.into(), kind: kind.into(), via: via.into(), who: who.into(), conversation: conversation.into(), since: stamp - 3_600, until, ..Default::default() };
1100        let ledger = [
1101            held("org.example.chat", "Chat de Noa", "messages", "safe", "c0ffee12", "people", stamp + 30 * 60),
1102            held("org.example.chat", "Chat de Noa", "messages", "neutral", "c0ffee13", "people", stamp + 9 * 3_600 + 30 * 60),
1103            held("org.example.mail", "Courrier 01 99 00", "mail", "stranger", "", "people", stamp + 10 * 3_600),
1104            held("org.example.news", "Actualités", "", "", "", "gathered", stamp + 11 * 3_600),
1105            held("org.example.spam", "Bloqué", "messages", "blocked", "", "never", stamp + 7 * 86_400),
1106        ];
1107        let counted: Vec<Counted> = ledger.iter().map(|h| Counted::held(h, h.until)).collect();
1108        let review = log_line("review (files)", &counted, &["leisure", "dnd"], &now);
1109        assert_eq!(review, "Notes: review (files): held 5 (messages 3, mail 1, other apps 1); released 0; why: leisure + dnd; next release 22:00.");
1110        // One come back by Always through, the rest still held; the next release tomorrow (held for good aside).
1111        let mut released = vec![Counted { kind: "messages", until: None, why: Why::Always }];
1112        released.extend(counted.iter().skip(1).copied());
1113        let release = log_line("release", &released, &["sleep"], &now);
1114        assert_eq!(release, "Notes: release: held 4 (messages 2, mail 1, other apps 1); released 1 (messages 1); why: Always through; next release tomorrow 07:00.");
1115        // A review that lets one come at its time (a pause over): released now, the column why.
1116        let mut over = counted.clone();
1117        over[3].until = Some(stamp);
1118        assert!(log_line("review (files)", &over, &["leisure"], &now).contains("released 1 (other apps 1); why: leisure;"));
1119        // Nothing held: said so, with the columns now.
1120        assert_eq!(log_line("review (connected)", &[], &["work", "admin"], &now), "Notes: review (connected): held 0; released 0; why: work + admin; next release none.");
1121        // Never a name, a number, an app, a conversation nor who wrote.
1122        for line in [review, release] {
1123            for private in ["org.example", "Noa", "Courrier", "01 99 00", "Actualités", "Bloqué", "c0ffee", "safe", "neutral", "stranger", "blocked"] {
1124                assert!(!line.contains(private), "{line} says {private}");
1125            }
1126        }
1127        // Do-not-disturb with what holds it, Free time's "Nothing at all".
1128        let switch = Gated { until: None, focus: false, switch: true };
1129        let columns = column_words(&[Column::Free, Column::Dnd], true, Some(&switch));
1130        assert_eq!(columns, ["free (nothing at all)", "dnd (switch)"]);
1131        assert_eq!(column_words(&[Column::Leisure, Column::Dnd], true, None), ["leisure", "dnd"], "nothing at all says nothing outside Free time");
1132    }
1133
1134    /// The listener's line at each hold (decision 2 of the overrides, 18
1135    /// October 2026): the kind of its channel, until when it waits or for
1136    /// good, and what decided it, never who: the proposal's case (a
1137    /// contact's text at work held by a focus session), the switch and both,
1138    /// a text named only, an automaton in the night, an app's and a
1139    /// conversation's own rows, what it is for, held for good, held again.
1140    /// Then a real hold, decided and said: no name, number, app, conversation
1141    /// nor list in it, whatever the notification said.
1142    #[test]
1143    fn the_hold_line_says_what_decided_it_and_names_no_one() {
1144        let now: Zoned = "2026-10-14T10:20:00+02:00[Europe/Paris]".parse().unwrap();
1145        let stamp = now.timestamp().as_second();
1146        let focus = Gated { until: Some(stamp + 30 * 60), focus: true, switch: false };
1147        let both = Gated { until: None, focus: true, switch: true };
1148        let line = |event: &str, kind: &str, until: Option<i64>, why: Why, cause: Cause, gated: Option<&Gated>| hold_line(event, kind, &Decision { until, why }, Some(&cause), gated, &now);
1149        let columns = |columns: &[Column]| Cause { columns: columns.to_vec(), ..Cause::default() };
1150        let in_minutes = |m: i64| Some(stamp + m * 60);
1151        assert_eq!(line("held", "messages", in_minutes(30), Why::Waiting, columns(&[Column::Dnd]), Some(&focus)), "Notes: held: messages 1 until 10:50; why: dnd (focus session).");
1152        assert_eq!(line("held", "calls", in_minutes(30), Why::Waiting, columns(&[Column::Dnd]), Some(&both)), "Notes: held: calls 1 until 10:50; why: dnd (switch and focus session).");
1153        assert_eq!(line("held", "messages", in_minutes(100), Why::Gathered, Cause { gathered: Some(Gathering::Named), ..Cause::default() }, None), "Notes: held: messages 1 until 12:00; why: gathered (a sender named only).");
1154        assert_eq!(line("held", "messages", in_minutes(100), Why::Gathered, Cause { gathered: Some(Gathering::Machine), ..Cause::default() }, None), "Notes: held: messages 1 until 12:00; why: gathered (a number kept for machines).");
1155        assert_eq!(line("held", "other apps", Some(stamp + 22 * 3600 + 40 * 60), Why::Gathered, Cause { gathered: Some(Gathering::Automaton), columns: vec![Column::Sleep], ..Cause::default() }, None), "Notes: held: other apps 1 until tomorrow 09:00; why: gathered, sleep.");
1156        assert_eq!(line("held", "messages", in_minutes(400), Why::Times, Cause { own: Some(Own::App), columns: vec![Column::Work, Column::Admin], ..Cause::default() }, None), "Notes: held: messages 1 until 17:00; why: its app's row, work + admin.");
1157        assert_eq!(line("held", "messages", in_minutes(400), Why::Times, Cause { own: Some(Own::Conversation), gathered: None, columns: vec![Column::Work], ..Cause::default() }, None), "Notes: held: messages 1 until 17:00; why: its conversation's row, work.");
1158        // A person's own row (review of part 6, F6): never "its app's row", never who.
1159        assert_eq!(line("held", "messages", in_minutes(400), Why::Times, Cause { own: Some(Own::Person), columns: vec![Column::Work], ..Cause::default() }, None), "Notes: held: messages 1 until 17:00; why: their own row, work.");
1160        assert_eq!(line("held", "messages", in_minutes(100), Why::Gathered, Cause { gathered: Some(Gathering::Conversation), ..Cause::default() }, None), "Notes: held: messages 1 until 12:00; why: gathered (its conversation).");
1161        assert_eq!(line("held", "other apps", in_minutes(100), Why::Gathered, Cause { gathered: Some(Gathering::App), ..Cause::default() }, None), "Notes: held: other apps 1 until 12:00; why: gathered (its app).");
1162        assert_eq!(line("held", "mail", in_minutes(400), Why::Waiting, Cause { area: Some(Area::LEISURE), ..Cause::default() }, None), "Notes: held: mail 1 until 17:00; why: area (leisure).");
1163        assert_eq!(line("held", "messages", Some(stamp + 7 * 86_400), Why::Never, Cause { for_good: true, ..Cause::default() }, None), "Notes: held: messages 1 for good; why: never.");
1164        assert_eq!(line("held again", "messages", Some(stamp + 3 * 86_400), Why::Waiting, columns(&[Column::Pause]), None), "Notes: held again: messages 1 until 2026-10-17 10:20; why: pause.");
1165        assert_eq!(hold_line("held", "messages", &Decision { until: in_minutes(5), why: Why::Waiting }, None, None, &now), "Notes: held: messages 1 until 10:25; why: unknown.");
1166        // A real one: a contact's text at work, held by a focus session, decided and said as the listener does.
1167        struct Work;
1168        impl appnotes::TimeSource for Work {
1169            fn mode(&self, _: &Zoned) -> sioul_core::quiet::Mode {
1170                let week = sioul_core::areas::Week { work_hours: true, admin_hours: true, meals: true, sleep: true };
1171                sioul_core::quiet::Mode { quiet: false, time: sioul_core::areas::Time::Work, week, reason: sioul_core::quiet::Reason::Working, until: None, back: None, label: String::new() }
1172            }
1173            fn starts(&self, _: &Zoned, _: &Zoned) -> Vec<Zoned> {
1174                Vec::new()
1175            }
1176        }
1177        let posted: Posted = serde_json::from_str(r#"{"key":"0|org.example.chat|7|c0ffee12|10100","package":"org.example.chat","app":"Chat de Noa","category":"msg","template":"android.app.Notification$MessagingStyle","title":"Noa Martin","shortcut":"c0ffee12","messages":[{"sender":{"name":"Noa Martin","uri":"tel:%2B33199001234"},"text":"Tu passes ce soir ?"}]}"#).unwrap();
1178        let choices = Choices::default();
1179        let kind = appnotes::classify(&posted, &choices, sioul_core::words::Words::builtin_ref());
1180        let attention = sioul_core::attention::Attention::usual();
1181        let gathered = vec!["09:00".to_string(), "12:00".into(), "15:00".into(), "18:00".into()];
1182        let never = |_: &Zoned| false;
1183        let sources = sioul_core::attention::source_rows(&posted.package, "");
1184        let ask = Ask { now: &now, clock: &Work, attention: &attention, gathered: &gathered, area: None, gate: Some(Gate { until: focus.until }), slot_at: &never, nothing: false, also: &[], sources: &sources, reachable: None, silenced: false };
1185        let decision = appnotes::decide(&kind, Some(Who::Neutral), false, &choices, &ask);
1186        assert_eq!(decision.until, focus.until, "held until the session's end");
1187        let cause = appnotes::cause(&posted.package, &kind, Some(Who::Neutral), false, &choices, &ask);
1188        let said = hold_line("held", kind_word(&kind), &decision, cause.as_ref(), Some(&focus), &now);
1189        assert_eq!(said, "Notes: held: messages 1 until 10:50; why: dnd (focus session).");
1190        for private in ["org.example", "Noa", "Martin", "01 99 00", "+33", "Chat", "c0ffee", "ce soir", "safe", "neutral", "stranger", "blocked", "restricted"] {
1191            assert!(!said.contains(private), "{said} says {private}");
1192        }
1193    }
1194
1195    /// A text from a root ARCEP keeps for machines is an automaton's, while
1196    /// the call screening sends automated callers to voicemail: 01 63, a
1197    /// root for machines that ARCEP had attributed to no operator yet (so the
1198    /// number is nobody's); a fiction number is no machine's. The numbers'
1199    /// country set, as a French phone's language says it (with no locale, as
1200    /// on CI, it would be Britain's, where 01 63 is no machine's).
1201    #[test]
1202    fn a_machine_s_text_is_an_automaton_s() {
1203        let now: Zoned = "2026-10-20T10:00:00+02:00[Europe/Paris]".parse().unwrap();
1204        let config: Config = toml::from_str("[contacts]\nregion = \"FR\"\n").unwrap();
1205        let machine = Sender::Known { numbers: vec!["01 63 12 34 56".into()], emails: Vec::new(), contact: false };
1206        assert!(machine_number(&machine, &config, &now));
1207        assert!(!machine_number(&Sender::Known { numbers: vec!["01 99 00 12 34".into()], emails: Vec::new(), contact: false }, &config, &now));
1208        assert!(!machine_number(&Sender::Named { name: "AMELI".into(), book: true }, &config, &now));
1209        let as_unknown: Config = toml::from_str("[contacts]\nregion = \"FR\"\n[screening]\nautomated = \"unknown\"\n").unwrap();
1210        assert!(!machine_number(&machine, &as_unknown, &now), "set as any number nobody knows");
1211    }
1212
1213    #[test]
1214    fn nothing_reaches_java_on_a_computer() {
1215        assert_eq!(java("access", "{}"), Value::Null);
1216        assert_eq!(access_here(), None, "a computer says nothing of a phone's access");
1217        assert_eq!(times_in_words(&["18:00".into(), "9:00".into(), "13:00".into()]), format!("09:00, 13:00 {} 18:00", tr().text("word-and", None)));
1218    }
1219}