Skip to main content

sioul_app/
everywhere.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Do-not-disturb on every device, as this device applies and says it
5//! (docs/do-not-disturb.md; the rules are `sioul_core::everywhere`). The one
6//! caller of the do-not-disturb layer (`crate::dnd`): the pauses
7//! (`pauses::apply`), the switch, the focus timer and the minute all come
8//! here, and every device reads the same shared files: `quiet.toml` (the
9//! pauses), `time/running.toml` (focus), Health (the night),
10//! `state/do-not-disturb.toml` (the switch) and the `[dnd]` settings.
11//!
12//! - **Apply**: the system's do-not-disturb on for each reason that holds,
13//!   off for each that does not; nothing redone when nothing changed (the
14//!   layer keeps what it did). On Android, only in the window's process (where
15//!   the layer's modes are kept): the background service asks it to
16//!   (`steps`), and an alarm comes back at the next end (`steps::next`).
17//! - **Say**: this device's table in the switch's file tells the others what
18//!   holds here and whether its system is silenced; the status line, the
19//!   phone's card and the service's notification say where it holds.
20//! - **The list**: Always through (« Passent toujours »), edited in
21//!   Settings ▸ Do not disturb; its people get through as its rows of the
22//!   matrix of what reaches you say (`sioul_core::attention`), and on a phone
23//!   Sioul says who is not starred there.
24//! - **Both ways**: a change of this device's own do-not-disturb, heard as it
25//!   happens, presses the switch when it disagrees with what holds here
26//!   (`heard_from_system`); Sioul's going off here turns the system's own off too, where
27//!   Sioul can (`apply`), and says what stays.
28
29use crate::backend::qobject::Sioul;
30use crate::backend::{QtThread, Shared, load_config, mode_json, say, tr};
31use crate::dnd::{Ask, Report, Which};
32use cxx_qt::Threading;
33use cxx_qt_lib::QString;
34use jiff::Zoned;
35use std::pin::Pin;
36use sioul_core::config::Config;
37use sioul_core::everywhere::{self as rules, Now, People, Person, Sources, Switch, Why};
38use sioul_core::quiet::Overrides;
39use std::sync::atomic::{AtomicBool, Ordering};
40use std::sync::{Arc, Mutex, PoisonError};
41use std::time::{Duration, Instant};
42
43/// This device's name in the sharing (share/here.toml), made once.
44fn here_id() -> String {
45    sioul_sync::share::Here::load(&sioul_core::config::state_dir()).id
46}
47
48/// What the reasons' sources say now (`rules::sources_at`, as every reader builds them).
49fn sources(overrides: &Overrides, blocks: &sioul_core::quiet::Blocks, now: &Zoned) -> Sources {
50    rules::sources_at(overrides, blocks, now)
51}
52
53/// When the devices gone were last noted (milliseconds; `note_gone`).
54static GONE_NOTED: Mutex<i64> = Mutex::new(0);
55
56/// A device forgotten, or its files removed, here: the devices gone noted
57/// again at the next exchange, not an hour later (review of part 3, I1).
58pub(crate) fn gone_soon() {
59    if let Ok(mut last) = GONE_NOTED.lock() {
60        *last = 0;
61    }
62}
63
64/// At the window's exchanges, at most once an hour (and at the one after a
65/// device was forgotten here: `gone_soon`): the devices the sharing has gone
66/// by a sealed fact written down (`sioul_sync::share::asking`), for the
67/// switch's reading. Never on a dose's path: the window's own exchanges only.
68pub(crate) fn note_gone(folder: &std::path::Path, key: &[u8; 32], here: &str) {
69    let now = jiff::Timestamp::now().as_millisecond();
70    {
71        let Ok(mut last) = GONE_NOTED.lock() else { return };
72        if now - *last < 3_600_000 {
73            return;
74        }
75        *last = now;
76    }
77    let (_, gone) = sioul_sync::share::asking(folder, key, &crate::share::memory_path(), here, now);
78    if gone != rules::gone_devices()
79        && let Ok(text) = serde_json::to_vec(&gone)
80    {
81        let path = rules::gone_path();
82        let fresh = path.with_extension(format!("json.{}", std::process::id()));
83        if std::fs::write(&fresh, text).and_then(|()| std::fs::rename(&fresh, &path)).is_err() {
84            let _ = std::fs::remove_file(&fresh);
85        }
86    }
87}
88
89/// The switch as this device reads it (`rules::read_switch`): the devices gone from the sharing read as As usual.
90fn switch_here(here: &str) -> Switch {
91    rules::read_switch(here)
92}
93
94/// Health's nights, naps and meals: as the window has them; in the background
95/// service's process, without the day's events (only the meals move for them,
96/// and reading every calendar at each step would cost a phone's battery).
97fn blocks(now: &Zoned) -> sioul_core::quiet::Blocks {
98    if crate::steps::in_service() { sioul_core::quiet::Blocks::read(now, &[]) } else { crate::hours::blocks(now) }
99}
100
101/// Asleep (the night from winding down, a nap), and paused, now: for the
102/// background service's rhythm, whatever the settings of do-not-disturb.
103pub(crate) fn rest_now() -> (bool, bool) {
104    let now = Zoned::now();
105    let paused = Overrides::load(&Overrides::default_path()).paused_from().is_some();
106    (blocks(&now).at(now.timestamp().as_second(), &["sleep", "nap"]).is_some(), paused)
107}
108
109/// What is read for one look: the settings, the pauses, the switch, why it
110/// holds, and what now is for (the time under do-not-disturb's layer).
111struct Look {
112    config: Config,
113    overrides: Overrides,
114    switch: Switch,
115    state: Now,
116    mode: sioul_core::quiet::Mode,
117    now: Zoned,
118}
119
120impl Look {
121    fn now() -> Look {
122        let config = load_config();
123        let now = Zoned::now();
124        let overrides = Overrides::load(&Overrides::default_path());
125        let switch = switch_here(&here_id());
126        let blocks = blocks(&now);
127        let state = rules::now(&config.dnd, &sources(&overrides, &blocks, &now), switch.latest().as_ref(), now.timestamp().as_second());
128        let mode = sioul_core::quiet::mode(&config.week_hours(), &config.time_off, &overrides, &blocks, &now);
129        Look { config, overrides, switch, state, mode, now }
130    }
131
132    /// What each mode of Sioul's asks of the system now; none: off. Whom it
133    /// lets through is the matrix of what reaches you's (`attention::silence_at`):
134    /// at the pause's column; at Free time's ("Nothing at all" holding all but
135    /// Always through); do-not-disturb's at sleep's, or at the time now under
136    /// the switch's or a focus session's layer, as the matrix reads a layer.
137    /// While this phone screens calls, every contact's call goes through the
138    /// mode, Sioul having declined the others already, and anyone's when the
139    /// call screening lets numbers nobody knows ring then; else the starred;
140    /// and anyone's while Expecting a call holds.
141    fn asks(&self) -> [(Which, Option<Ask>); 3] {
142        use sioul_core::attention::{Column, Now as Moment, Senders};
143        let (config, state) = (&self.config, &self.state);
144        let gnome = config.pause.gnome;
145        let paused = state.has(Why::Paused);
146        let free = state.has(Why::FreeTime) && !paused;
147        let attention = sioul_core::attention::Attention::of(config);
148        let phone = phone_does();
149        // While this phone screens calls: whether the call screening lets numbers nobody knows ring at each
150        // mode's moment by itself (`calls::unknown_rings`), so that the mode silences nothing it lets ring; the
151        // matrix then lets them through only where your contacts may ring (`attention::Phone::unknown`).
152        let phone_at = |moment: &Moment| sioul_core::attention::Phone { unknown: phone.screens && crate::calls::unknown_rings(config, moment.times.iter().any(|c| matches!(c, Column::Work | Column::Admin))), ..phone };
153        let mode = &self.mode;
154        let through = self.through();
155        let moment = |times: Vec<Column>, dnd: bool, nothing: bool| {
156            let mut moment = Moment::of(mode);
157            moment.times = times;
158            moment.dnd = dnd;
159            moment.nothing = nothing;
160            moment.through = through;
161            moment
162        };
163        let ask = |which: Which, moment: Moment| {
164            let silence = attention.silence_at(&moment, phone_at(&moment));
165            Ask { which, people: silence.calls != Senders::None, doses: silence.doses, desktop: gnome, silence: Some(silence), stack: true }
166        };
167        // The switch's mode adds nothing over this device's own do-not-disturb
168        // when that alone turned it on (docs/do-not-disturb.md, "Both ways").
169        let latest = self.switch.latest();
170        let from_system = latest.as_ref().is_some_and(|p| p.on && p.via == rules::VIA_SYSTEM);
171        let stack = !(from_system && rules::held_by_system(state, latest.as_ref(), &here_id()));
172        let global = if state.has(Why::Sleep) { vec![Column::Sleep] } else { Moment::of(mode).times };
173        [
174            (Which::Paused, paused.then(|| ask(Which::Paused, moment(vec![Column::Pause], false, false)))),
175            (Which::FreeTime, free.then(|| ask(Which::FreeTime, moment(vec![Column::Free], false, sioul_core::pause::nothing_now(&self.overrides, &config.free_time))))),
176            (Which::Global, state.global().then(|| Ask { stack, ..ask(Which::Global, moment(global, state.gates(), false)) })),
177        ]
178    }
179
180    /// Expecting a call (once "Let every call through") holds now, as this device knows it.
181    fn through(&self) -> bool {
182        crate::calls::through_now(&self.switch).is_some_and(|t| t.holds(self.now.timestamp().as_millisecond()))
183    }
184
185    /// What this device asks, in a few words: the background service compares
186    /// it with what was applied last, and asks the window's process only on a change.
187    fn signature(&self) -> String {
188        self.asks().iter().map(|(which, ask)| format!("{which:?}:{}", ask.as_ref().map_or_else(|| "-".to_string(), |a| format!("{}{}{}{}{}", u8::from(a.people), u8::from(a.doses), u8::from(a.desktop), u8::from(a.stack), a.silence.map(|s| format!("{:?}{:?}{}{}{}", s.calls, s.messages, u8::from(s.repeat), u8::from(s.conversations), u8::from(s.events))).unwrap_or_default())))).collect::<Vec<_>>().join(",")
189    }
190
191    /// When to look again by itself: a reason's end, the next night or nap
192    /// when sleep counts, the time's end while do-not-disturb's layer reads
193    /// it, the end of Expecting a call (Unix seconds).
194    fn next_change(&self) -> Option<i64> {
195        let stamp = self.now.timestamp().as_second();
196        let night = if self.config.dnd.sleep { blocks(&self.now).kept.iter().filter(|k| (k.kind == "sleep" || k.kind == "nap") && k.start > stamp).map(|k| k.start).min() } else { None };
197        let time = if self.state.gates() { self.mode.until.as_ref().map(|u| u.timestamp().as_second()) } else { None };
198        let through = crate::calls::through_now(&self.switch).filter(|t| t.holds(stamp * 1000) && t.until > 0).map(|t| (t.until + 999) / 1000);
199        let reachable = self.state.reachable.as_ref().and_then(|r| r.until);
200        [self.state.next_end(), night, time, through, reachable].into_iter().flatten().filter(|t| *t > stamp).min()
201    }
202}
203
204/// What this phone does itself, for whom Sioul's modes let through: it
205/// screens calls (Sioul holds the role). A computer nothing. Nothing of
206/// the listener, though it may hold other apps' notifications:
207/// Android plays a notification's sound before any listener hears of it
208/// (docs/android.md, "Notifications from other apps", Sound), so a contact's
209/// message let through the mode and then held by Sioul would ring once in a
210/// pause or Free time; the mode is what keeps it silent. Calls differ: the
211/// screening answers before the phone rings.
212fn phone_does() -> sioul_core::attention::Phone {
213    if !cfg!(target_os = "android") {
214        return sioul_core::attention::Phone::default();
215    }
216    sioul_core::attention::Phone { screens: crate::calls::screens_here(), unknown: false }
217}
218
219/// Why do-not-disturb holds now, on every device alike.
220pub(crate) fn now() -> Now {
221    Look::now().state
222}
223
224/// One file as a reading kept was read from: when it was last changed, its
225/// size, and on Unix its inode (each of these files is written beside and
226/// renamed: a new one at each change, even within the clock's tick).
227type FileSeen = Option<(std::time::SystemTime, u64, u64)>;
228
229fn seen(path: &std::path::Path) -> FileSeen {
230    let meta = std::fs::metadata(path).ok()?;
231    #[cfg(unix)]
232    let inode = std::os::unix::fs::MetadataExt::ino(&meta);
233    #[cfg(not(unix))]
234    let inode = 0;
235    Some((meta.modified().ok()?, meta.len(), inode))
236}
237
238/// What Reachable's reading is made of beside the time: the switch, the
239/// pauses, the devices gone from the sharing, Health's nights.
240fn reach_inputs() -> Vec<FileSeen> {
241    [Switch::default_path(), Overrides::default_path(), rules::gone_path(), sioul_core::health::Health::default_path(), sioul_core::needs::Days::default_path()].iter().map(|path| seen(path)).collect()
242}
243
244/// Reachable as last read, with what it was read from (`reach_inputs`);
245/// forgotten at a press here.
246static REACHABLE_KEPT: Mutex<Option<(Vec<FileSeen>, Option<rules::Reach>)>> = Mutex::new(None);
247
248/// Tests only: the tests that write the shared files of this device's state
249/// (the switch, the pauses, the listener's ledger and choices), one at a time.
250#[cfg(test)]
251pub(crate) static FILES: Mutex<()> = Mutex::new(());
252
253/// Tests only: those files taken away, for a test to start from none.
254#[cfg(test)]
255pub(crate) fn files_cleared() {
256    for path in [Switch::default_path(), Overrides::default_path(), rules::gone_path(), sioul_core::appnotes::Ledger::default_path(), sioul_core::appnotes::Choices::default_path()] {
257        let _ = std::fs::remove_file(path);
258    }
259    if let Ok(mut kept) = REACHABLE_KEPT.lock() {
260        *kept = None;
261    }
262}
263
264/// Reachable now, on every device alike (part 3 of "What reaches you"):
265/// since, until when, from which device. Asked at every notification: read
266/// again whenever one of its files changed or its end passed, never kept by
267/// time alone, so that a press brought from another device counts at once
268/// (review of part 3, F4).
269pub(crate) fn reachable() -> Option<rules::Reach> {
270    let inputs = reach_inputs();
271    let stamp = jiff::Timestamp::now().as_second();
272    if let Ok(kept) = REACHABLE_KEPT.lock()
273        && let Some((read_from, reach)) = kept.as_ref()
274        && *read_from == inputs
275        && reach.as_ref().is_none_or(|r| r.until.is_none_or(|until| stamp < until))
276    {
277        return reach.clone();
278    }
279    let reach = now().reachable;
280    if let Ok(mut kept) = REACHABLE_KEPT.lock() {
281        *kept = Some((inputs, reach.clone()));
282    }
283    reach
284}
285
286/// What the phone is asked to let through, by the device that asks
287/// (`rules::Switch::release_stamps`): each device's "Let what your phone
288/// holds through", and the Reachable press that holds.
289pub(crate) fn release_stamps() -> Vec<(String, i64)> {
290    switch_here(&here_id()).release_stamps()
291}
292
293/// The latest press of the switch known here (milliseconds): what a pause
294/// pressed now knew of (`pause::start_pause`), so that it ends that
295/// Reachable whatever the clocks (review of part 3, L2).
296pub(crate) fn latest_press() -> i64 {
297    switch_here(&here_id()).latest_known(None)
298}
299
300/// Whether the switch or a focus session holds do-not-disturb now: a layer
301/// of the matrix of what reaches you (`hours::attention_now`), which says
302/// what waits then (as usual, the sites' notifications and the suggestions
303/// to move; doses, codes asked for, events' reminders and Health's notices
304/// come, and Always through). Read again at most every five seconds: asked
305/// at every notification.
306pub(crate) fn holds_others() -> bool {
307    static KEPT: Mutex<Option<(Instant, bool)>> = Mutex::new(None);
308    if let Ok(kept) = KEPT.lock()
309        && let Some((at, holds)) = *kept
310        && at.elapsed() < Duration::from_secs(5)
311    {
312        return holds;
313    }
314    let holds = now().gates();
315    if let Ok(mut kept) = KEPT.lock() {
316        *kept = Some((Instant::now(), holds));
317    }
318    holds
319}
320
321/// Do-not-disturb holding from its switch or a focus session (`gated`):
322/// when it ends, when that is known, and which of the two holds.
323#[derive(Debug, Clone, Copy, PartialEq, Eq)]
324pub(crate) struct Gated {
325    pub(crate) until: Option<i64>,
326    pub(crate) focus: bool,
327    pub(crate) switch: bool,
328}
329
330/// While do-not-disturb holds from its switch or a focus session (a layer
331/// of the matrix of what reaches you): when it ends, when that is known
332/// (none: until turned off, or a session open-ended), and which of the two
333/// holds (the listener's line in the phone's log: "dnd (focus session)");
334/// none otherwise.
335pub(crate) fn gated() -> Option<Gated> {
336    gated_of(&now())
337}
338
339/// The same, of a reading already made (`now`).
340pub(crate) fn gated_of(state: &Now) -> Option<Gated> {
341    let gates: Vec<&sioul_core::everywhere::Held> = state.holds.iter().filter(|h| matches!(h.why, Why::Focus | Why::Manual)).collect();
342    let until = if gates.iter().any(|h| h.until.is_none()) { None } else { gates.iter().filter_map(|h| h.until).max() };
343    (!gates.is_empty()).then_some(Gated { until, focus: gates.iter().any(|h| h.why == Why::Focus), switch: gates.iter().any(|h| h.why == Why::Manual) })
344}
345
346// ---------------------------------------------------------------- applying
347
348/// Asked again while an apply runs: it goes once more, with what changed meanwhile.
349static AGAIN: AtomicBool = AtomicBool::new(false);
350static BUSY: Mutex<()> = Mutex::new(());
351
352/// The system's do-not-disturb as the reasons say now, on this device: on
353/// for each of Sioul's modes that holds, off for the others (the layer does
354/// nothing when nothing changed); this device's table written when what it
355/// says changed; the phone's card told; on Android, an alarm at the next end.
356/// Called at the start, each minute, at once on a press, and after an
357/// exchange brought a change. Never in the background service's process
358/// (Android), which asks the window's process instead (`steps`).
359pub(crate) fn apply() {
360    AGAIN.store(true, Ordering::SeqCst);
361    while AGAIN.load(Ordering::SeqCst) {
362        let Some(busy) = crate::backend::one_at_a_time(&BUSY) else { return };
363        if !AGAIN.swap(false, Ordering::SeqCst) {
364            return;
365        }
366        apply_once();
367        drop(busy);
368    }
369}
370
371fn apply_once() {
372    let look = Look::now();
373    let mut reports: Vec<(Which, Report)> = Vec::new();
374    for (which, ask) in look.asks() {
375        match ask {
376            Some(ask) => reports.push((which, crate::dnd::enter(&ask))),
377            None => {
378                crate::dnd::leave(which);
379            }
380        }
381    }
382    // This device's words: those of the mode of the reason said first.
383    let said = match look.state.why() {
384        Some(Why::Paused) => Which::Paused,
385        Some(Why::FreeTime) => Which::FreeTime,
386        _ => Which::Global,
387    };
388    let report = reports.iter().find(|(w, _)| *w == said).or(reports.first()).map(|(_, r)| r.clone()).unwrap_or_default();
389    let why = look.state.why().map(Why::id).unwrap_or_default().to_string();
390    // Both ways (docs/do-not-disturb.md): Sioul's do-not-disturb gone off here
391    // turns the system's own off too, once, where Sioul can; while it is off,
392    // what the system still holds here is said.
393    let on = look.state.on();
394    crate::dnd::tell_system(on);
395    let was = crate::dnd::on_here(on);
396    let acted = was && !on && crate::dnd::system_off(look.config.pause.gnome);
397    let still = if on || acted { None } else { crate::dnd::still_on() };
398    write_own(|own| {
399        // Sioul runs here (again): it follows.
400        own.closed = false;
401        own.why = why.clone();
402        own.silenced = report.on && !why.is_empty();
403        own.turned_off = report.turned_off && !why.is_empty();
404        own.system_on = still.is_some();
405        own.line = match &still {
406            _ if !why.is_empty() => report.line.clone(),
407            Some(still) => still.line.clone(),
408            None => String::new(),
409        };
410    });
411    // The background service's notification says the state now, within a second: told once
412    // this device's table is written (its words read it; before, they would say a state
413    // between two), before the card and the calls' table, which may take longer.
414    crate::steps::tell_note();
415    crate::homecard::dnd_seen(moment());
416    // On a phone, Android's alarm comes back at the next end, Sioul closed or not.
417    crate::steps::next(look.next_change());
418    // The calls' table follows the pauses, the times and the switch at once (`calls::refresh`:
419    // written again only when what it is made of changed, or every ten minutes).
420    crate::calls::refresh(false);
421}
422
423/// The background service's notification in words (`steps::note`): its
424/// title, what now is for and until when ("Leisure until 22:00"); then
425/// do-not-disturb here, as the status line says it while it holds, what this
426/// device's own still holds while Sioul's is off, else that it is off. And
427/// when what now is for ends (Unix seconds), for the service's next step.
428pub(crate) fn note() -> (String, String, Option<i64>) {
429    let look = Look::now();
430    let tr = tr();
431    let moment = moment_of(&look);
432    let dnd = if moment["on"] == true {
433        moment["line"].as_str().unwrap_or_default().to_string()
434    } else if moment["system_on"] == true {
435        moment["system_line"].as_str().unwrap_or_default().to_string()
436    } else {
437        String::new()
438    };
439    let dnd = if dnd.is_empty() { tr.text("steps-note-dnd-off", None) } else { dnd };
440    // What now is for as the window has it, meals moved past events, in every process alike
441    // (the service's own look leaves the events aside): the title never changes with the process.
442    let mode = crate::hours::mode_at(&look.now);
443    let times: Vec<String> = sioul_core::attention::Now::of(&mode).times.iter().map(|c| sioul_core::attention::column_label(tr, *c)).collect();
444    let time = match times.as_slice() {
445        [] => String::new(),
446        [one] => one.clone(),
447        [rest @ .., last] => format!("{} {} {}", rest.join(", "), tr.text("word-and", None), last.to_lowercase()),
448    };
449    let until = if mode.paused() { None } else { mode.until.clone() };
450    let mut args = sioul_core::i18n::args();
451    args.set("time", time);
452    let title = match &until {
453        Some(until) => {
454            args.set("until", sioul_core::quiet::until_text(tr, until, &look.now));
455            tr.text("steps-note-time", Some(&args))
456        }
457        None => tr.text("steps-note-time-open", Some(&args)),
458    };
459    (title, dnd, until.map(|u| u.timestamp().as_second()))
460}
461
462/// What this device asks of its system now, in a few words, and the status
463/// line: the background service pokes the window's process when either
464/// changed since it last did (`steps`).
465pub(crate) fn wanted() -> (String, String) {
466    let look = Look::now();
467    (look.signature(), moment_of(&look)["line"].as_str().unwrap_or_default().to_string())
468}
469
470/// This device's own table changed (its kind and name always set), under
471/// the file's lock; written only when it changed. Sharing off, it is kept
472/// here all the same: the switch works on this device alone.
473fn write_own(change: impl FnOnce(&mut rules::Device)) {
474    let here = here_id();
475    let now = jiff::Timestamp::now().as_millisecond();
476    let result = rules::change_own(&Switch::default_path(), &here, |switch| {
477        let after = switch.latest_known(None) + 1;
478        let own = switch.device.entry(here.clone()).or_default();
479        let before = own.clone();
480        change(own);
481        own.kind = crate::devices::kind().to_string();
482        own.name = if own.kind == "phone" { String::new() } else { crate::devices::name() };
483        let changed = *own != before;
484        if changed {
485            // Never before a press already known here: the others read it as written after it.
486            own.at = now.max(after);
487        }
488        changed
489    });
490    if let Err(e) = result {
491        eprintln!("sioul: do-not-disturb: {e}");
492    }
493}
494
495/// The switch pressed in the window: on for `minutes` (0: until turned off;
496/// -1: until what now is for ends), or off. Applied here, the status line
497/// shown again, and the other devices told at once.
498pub(crate) fn toggle(mut sioul: Pin<&mut Sioul>, on: bool, minutes: i32) {
499    let now = Zoned::now();
500    let until = match minutes {
501        m if m > 0 => Some(now.timestamp().as_second() + i64::from(m) * 60),
502        -1 => crate::hours::mode_at(&now).until.map(|u| u.timestamp().as_second()),
503        _ => None,
504    };
505    if let Err(e) = press(on, until.filter(|_| on)) {
506        sioul.as_mut().set_status(QString::from(&e));
507        return;
508    }
509    let (qt, shared) = (sioul.qt_thread(), sioul.shared());
510    std::thread::spawn(move || {
511        apply();
512        let _ = qt.queue(|mut sioul| sioul.as_mut().set_mode(QString::from(&mode_json())));
513        crate::share::exchange(&qt, &shared);
514    });
515}
516
517/// The switch pressed here: on until `until` (Unix seconds; none, until
518/// turned off), or off; on every device once the sharing carries it. Applied
519/// by the caller (`apply`).
520pub(crate) fn press(on: bool, until: Option<i64>) -> Result<(), String> {
521    press_via(on, until, "")
522}
523
524/// Tonight: when the night begins next, by day, as Health knows it (Unix
525/// seconds); none at night, or with no night known ("Work until tonight").
526pub(crate) fn tonight(now: &Zoned) -> Option<i64> {
527    match night_and_waking(now) {
528        (night, None) => night,
529        _ => None,
530    }
531}
532
533/// When the night begins next (by day) and when the night you are in ends
534/// (at night), as Health knows them (Unix seconds): for Reachable's ends.
535fn night_and_waking(now: &Zoned) -> (Option<i64>, Option<i64>) {
536    let stamp = now.timestamp().as_second();
537    let blocks = blocks(now);
538    let waking = blocks.at(stamp, &["sleep"]).map(|k| k.end);
539    let night = blocks.kept.iter().filter(|k| k.kind == "sleep" && k.start > stamp).map(|k| k.start).min();
540    (night, waking)
541}
542
543/// The ends Reachable may be pressed for now (Unix seconds; 0 none): the end
544/// of the hours you are in, the night's start (or waking, at night), and
545/// whether it is night.
546fn reachable_ends(now: &Zoned) -> (i64, i64, bool) {
547    let stamp = now.timestamp().as_second();
548    let (night, waking) = night_and_waking(now);
549    let mode = crate::hours::mode_at(now);
550    // The hours' end, never past the night's start: Reachable's latest end (F2).
551    let hours = mode.until.as_ref().map(|u| u.timestamp().as_second()).filter(|u| *u > stamp && waking.is_none()).map(|u| night.map_or(u, |night| u.min(night)));
552    (hours.unwrap_or(0), waking.or(night).unwrap_or(0), waking.is_some())
553}
554
555/// One of the three states pressed here (`rules::State`; part 3 of "What
556/// reaches you"): Reachable for `length` ("30", "60", "120" minutes,
557/// "hours" until the hours you are in end, "tonight", "off" until you turn
558/// it off: at the latest the night's start, or waking at night), As usual,
559/// or Do not disturb for `length` minutes ("0" until turned off, "-1" until
560/// what now is for ends). Applied here, the status line shown again, the
561/// other devices told at once; a Reachable press releases what the phone
562/// holds (`appnotes::review`, at its next look).
563pub(crate) fn choose(mut sioul: Pin<&mut Sioul>, state: &str, length: &str) {
564    let now = Zoned::now();
565    let stamp = now.timestamp().as_second();
566    let (state, until, open) = match rules::State::read(state) {
567        Some(rules::State::Reachable) => {
568            // Never during a pause (decision 9), by the pause itself, whatever do-not-disturb's
569            // settings say of pauses (review of part 3, F6): the pause's button ends it.
570            if Look::now().mode.paused() {
571                sioul.as_mut().set_status(QString::from(&tr().text("reach-not-in-pause", None)));
572                return;
573            }
574            let length = match length {
575                "hours" => rules::Length::HoursEnd,
576                "tonight" => rules::Length::Tonight,
577                "off" => rules::Length::Off,
578                minutes => rules::Length::Minutes(minutes.parse().unwrap_or(60)),
579            };
580            let (night, waking) = night_and_waking(&now);
581            let hours = reachable_ends(&now).0;
582            match rules::reachable_until(length, stamp, (hours > 0).then_some(hours), night, waking) {
583                Some((until, open)) => (rules::State::Reachable, Some(until), open),
584                None => {
585                    sioul.as_mut().set_status(QString::from(&tr().text("reach-no-end", None)));
586                    return;
587                }
588            }
589        }
590        Some(rules::State::Dnd) => {
591            let until = match length.parse::<i64>().unwrap_or(0) {
592                m if m > 0 => Some(stamp + m * 60),
593                -1 => crate::hours::mode_at(&now).until.map(|u| u.timestamp().as_second()),
594                _ => None,
595            };
596            (rules::State::Dnd, until, false)
597        }
598        _ => (rules::State::Usual, None, false),
599    };
600    if let Err(e) = press_state(state, until, open) {
601        sioul.as_mut().set_status(QString::from(&e));
602        return;
603    }
604    let (qt, shared) = (sioul.qt_thread(), sioul.shared());
605    std::thread::spawn(move || {
606        apply();
607        let _ = qt.queue(|mut sioul| sioul.as_mut().set_mode(QString::from(&mode_json())));
608        crate::backend::follow_reachable(&qt, &shared);
609        crate::share::exchange(&qt, &shared);
610    });
611}
612
613/// A press of one of the three states here, until `until` (Unix seconds),
614/// "until you turn it off" or not (`open`); applied by the caller.
615pub(crate) fn press_state(state: rules::State, until: Option<i64>, open: bool) -> Result<(), String> {
616    crate::devices::pressed();
617    if let Ok(mut kept) = REACHABLE_KEPT.lock() {
618        *kept = None;
619    }
620    let here = here_id();
621    let now = jiff::Timestamp::now().as_millisecond();
622    // What it carries of the press before it, as the readers read that press (a gone device's As usual
623    // holds nothing off), never as the file says it (review of part 3, R-5).
624    let held_off = rules::held_off_of(switch_here(&here).latest().as_ref(), state);
625    rules::change_own(&Switch::default_path(), &here, |switch| {
626        switch.press_state(&here, state, until.map_or(0, |u| u.saturating_mul(1000)), open, now, "");
627        let own = switch.device.entry(here.clone()).or_default();
628        own.held_off = held_off;
629        own.kind = crate::devices::kind().to_string();
630        own.name = if own.kind == "phone" { String::new() } else { crate::devices::name() };
631        true
632    })?;
633    Ok(())
634}
635
636/// "Let what your phone holds through", pressed on a computer (3.3): a
637/// release stamp in this device's table; the phone lets through what it held
638/// before it, once, and the computer never learns what it was. Sent at once.
639pub(crate) fn let_through(mut sioul: Pin<&mut Sioul>) {
640    let here = here_id();
641    let now = jiff::Timestamp::now().as_millisecond();
642    let written = rules::change_own(&Switch::default_path(), &here, |switch| {
643        switch.release(&here, now);
644        true
645    });
646    if let Err(e) = written {
647        eprintln!("sioul: do-not-disturb: {e}");
648        sioul.as_mut().set_status(QString::from(&e));
649        return;
650    }
651    sioul.as_mut().set_status(QString::from(&tr().text("reach-let-through-done", None)));
652    let (qt, shared) = (sioul.qt_thread(), sioul.shared());
653    crate::share::exchange(&qt, &shared);
654}
655
656/// A press here, saying where it came from (`rules::VIA_SYSTEM`: this
657/// device's own do-not-disturb, heard as it changed; "": the switch).
658fn press_via(on: bool, until: Option<i64>, via: &str) -> Result<(), String> {
659    // Someone at this computer, said in its entry among your devices at the next
660    // export (the press's own exchange carries it): a phone's steps follow it (`devices::pressed`).
661    crate::devices::pressed();
662    let here = here_id();
663    let now = jiff::Timestamp::now().as_millisecond();
664    rules::change_own(&Switch::default_path(), &here, |switch| {
665        switch.press_via(&here, on, until.map_or(0, |u| u.saturating_mul(1000)), now, via);
666        let own = switch.device.entry(here.clone()).or_default();
667        own.kind = crate::devices::kind().to_string();
668        own.name = if own.kind == "phone" { String::new() } else { crate::devices::name() };
669        true
670    })?;
671    Ok(())
672}
673
674// ---------------------------------------------------------------- both ways
675
676/// One heard change at a time: two words of the same change (Android's
677/// status and its filter, two of Plasma's) press once.
678static HEARING: Mutex<()> = Mutex::new(());
679
680/// A change of this device's own do-not-disturb, heard as it happened
681/// (Android's receiver, Plasma's `Inhibited`, GNOME's switch), whatever
682/// caused it: a press of the switch here when it disagrees with what holds
683/// (`rules::heard`), applied, shown and sent at once; nothing when it agrees,
684/// which is what Sioul's own changes look like when they come back
685/// (docs/do-not-disturb.md, "Both ways"). Whether it pressed. Any thread.
686pub(crate) fn heard_from_system(system_on: bool) -> bool {
687    let _one = HEARING.lock().unwrap_or_else(PoisonError::into_inner);
688    let Some(on) = rules::heard(now().on(), system_on) else { return false };
689    if let Err(e) = press_via(on, None, rules::VIA_SYSTEM) {
690        eprintln!("sioul: do-not-disturb: {e}");
691        return false;
692    }
693    apply();
694    told();
695    true
696}
697
698/// The window, when it runs in this process: what is heard or pressed
699/// outside it shows there at once, and is sent with its exchange.
700static WINDOW: Mutex<Option<(QtThread, Arc<Shared>)>> = Mutex::new(None);
701
702/// The window runs (at the start and each minute, from the pauses' tick):
703/// kept, and this system's own do-not-disturb followed (`dnd::listen`), once.
704pub(crate) fn started(qt: &QtThread, shared: &Arc<Shared>) {
705    let mut window = WINDOW.lock().unwrap_or_else(PoisonError::into_inner);
706    if window.is_none() {
707        *window = Some((qt.clone(), Arc::clone(shared)));
708        crate::dnd::listen(Arc::new(|on| {
709            std::thread::spawn(move || heard_from_system(on));
710        }));
711    }
712}
713
714/// A press made outside the window (a system's change heard, the phone's
715/// tile): the window's status line at once and the other devices told;
716/// without the window (a phone's receiver), the background service sends it.
717fn told() {
718    let window = WINDOW.lock().ok().and_then(|window| window.clone());
719    match window {
720        Some((qt, shared)) => {
721            let _ = qt.queue(|mut sioul| sioul.as_mut().set_mode(QString::from(&mode_json())));
722            crate::share::exchange(&qt, &shared);
723        }
724        None => crate::steps::send_now(),
725    }
726}
727
728/// Sioul quits on a computer (its window closed, the tray's Quit, SIGTERM):
729/// this device's table as it then holds, for the other devices to say
730/// "On laptop: Sioul is closed." and never "silenced" by an inhibition that
731/// ended with Sioul; sent by the exchange that follows (`share::closing`).
732/// Nothing on a phone, whose modes stay while Sioul is closed.
733pub(crate) fn closing() {
734    if cfg!(target_os = "android") {
735        return;
736    }
737    let still = crate::dnd::still_after_quit();
738    write_own(|own| own.closing(still));
739}
740
741/// The switch pressed from the phone's quick settings (Sioul's tile): on
742/// until turned off, or off, as it is not now; applied and sent at once.
743pub(crate) fn toggle_here() {
744    if let Err(e) = press(!now().on(), None) {
745        eprintln!("sioul: do-not-disturb: {e}");
746        return;
747    }
748    apply();
749    told();
750}
751
752/// "Silence this device again": each of Sioul's modes that holds here left
753/// and entered afresh, the person's earlier "off" in the system set aside. A
754/// switch "on" taken from this device's own system becomes the switch's own,
755/// so that Sioul's mode silences the device whatever the system does.
756pub(crate) fn again() {
757    if Look::now().switch.latest().is_some_and(|p| p.on && p.via == rules::VIA_SYSTEM)
758        && let Err(e) = press(true, None)
759    {
760        eprintln!("sioul: do-not-disturb: {e}");
761    }
762    for ask in Look::now().asks().into_iter().filter_map(|(_, ask)| ask) {
763        crate::dnd::again(&ask);
764    }
765    apply();
766    told();
767}
768
769// ---------------------------------------------------------------- saying
770
771/// A device named inside a sentence: "your phone", a computer's name, "another device".
772fn name_of(switch: &Switch, id: &str) -> String {
773    let (kind, name) = switch.device.get(id).filter(|d| !d.kind.is_empty()).map(|d| (d.kind.clone(), d.name.clone())).or_else(|| crate::devices::kind_and_name(id)).unwrap_or_default();
774    match kind.as_str() {
775        "phone" => tr().text("dnd-device-phone", None),
776        _ if !name.trim().is_empty() => name,
777        _ => tr().text("dnd-device-other", None),
778    }
779}
780
781/// The other devices sharing, and when each was last heard from (Unix seconds).
782fn heard(here: &str) -> Vec<(String, i64)> {
783    let Some((_, Some((folder, _)))) = crate::share::vault() else { return Vec::new() };
784    sioul_sync::share::others(&folder, here).into_iter().map(|o| (o.id, o.heard)).collect()
785}
786
787/// "17:00", "tomorrow at 07:00".
788fn when(stamp: i64, now: &Zoned) -> String {
789    jiff::Timestamp::from_second(stamp).map(|t| sioul_core::quiet::until_text(tr(), &t.to_zoned(now.time_zone().clone()), now)).unwrap_or_default()
790}
791
792/// Do-not-disturb as the status line, the phone's card and the background
793/// service's notification say it: {on, why, global, manual, until, until_at
794/// (0: until switched off), line, why_line, details, everywhere, here,
795/// button, end_time, end_at}.
796pub(crate) fn moment() -> serde_json::Value {
797    moment_of(&Look::now())
798}
799
800fn moment_of(look: &Look) -> serde_json::Value {
801    let state = &look.state;
802    let now = &look.now;
803    let stamp = now.timestamp().as_second();
804    // The end of what now is for, for "Until 17:00" in the switch's menu (the window's alone).
805    let end = if crate::steps::in_service() { None } else { crate::hours::mode_at(now).until.map(|u| u.timestamp().as_second()).filter(|u| *u > stamp && *u - stamp < 24 * 3600) };
806    // The three states (part 3): which holds, Reachable's end and words, and the ends it may be pressed for.
807    let (hours_end, night_end, at_night) = if crate::steps::in_service() { (0, 0, false) } else { reachable_ends(now) };
808    let reach = state.reachable.as_ref().map(|r| {
809        let until = r.until.map(|u| when(u, now)).unwrap_or_default();
810        let line = match (r.open, until.is_empty()) {
811            (_, true) => tr().text("reach-line-open-ended", None),
812            (true, false) => say("reach-line-open", &[("until", until.clone())]),
813            (false, false) => say("reach-line", &[("until", until.clone())]),
814        };
815        serde_json::json!({ "until": until, "until_at": r.until.unwrap_or(0), "open": r.open, "line": line, "from": name_of(&look.switch, &r.from) })
816    });
817    let capped: Vec<bool> = [30, 60, 120].iter().map(|minutes| night_end > 0 && stamp + minutes * 60 > night_end).collect();
818    let base = serde_json::json!({
819        "button": look.config.dnd.button,
820        "end_time": end.map(|e| when(e, now)).unwrap_or_default(),
821        "end_at": end.unwrap_or(0),
822        "state": if state.reachable.is_some() { "reachable" } else if state.on() { "dnd" } else { "usual" },
823        "reachable": reach.unwrap_or(serde_json::Value::Null),
824        // The pause itself, whatever do-not-disturb's settings say of pauses (F6).
825        "paused": look.mode.paused(),
826        // Each of the three minute lengths that meets Reachable's latest end, said so (F2).
827        "reach_capped": capped,
828        "reach_hours": hours_end,
829        "reach_hours_time": if hours_end > 0 { when(hours_end, now) } else { String::new() },
830        "reach_tonight": night_end,
831        "reach_tonight_time": if night_end > 0 { when(night_end, now) } else { String::new() },
832        "reach_at_night": at_night,
833    });
834    let here = here_id();
835    let own = look.switch.device.get(&here);
836    if !state.on() {
837        // Sioul's off, this device's own still on: said, with what may be opened (both ways).
838        let system_line = own.filter(|d| d.system_on && d.why.is_empty()).map(|d| d.line.clone()).unwrap_or_default();
839        let mut off = serde_json::json!({
840            "on": false, "why": "", "global": false, "manual": false, "until": "", "until_at": 0, "line": "", "why_line": "", "details": [], "everywhere": false, "here": false,
841            "here_off": false, "here_offers": [],
842            "system_on": !system_line.is_empty(), "system_offers": system_offers(&system_line), "system_line": system_line,
843            "last_off": last_off(look),
844        });
845        merge(&mut off, base);
846        return off;
847    }
848    // What this device's system said for the reason that holds; not applied yet: as Sioul's own alone.
849    let (silenced, line) = own.filter(|d| !d.why.is_empty()).map_or((false, String::new()), |d| (d.silenced, d.line.clone()));
850    // Since the reason said first began: a device's table older than that says what held before.
851    let since = state.holds.first().map_or(0, |h| h.since.saturating_mul(1000));
852    // A device with its entry in the devices' registry knows do-not-disturb; one without runs an older Sioul.
853    let others = rules::others(&look.switch, &here, &heard(&here), stamp, since, &|id| crate::devices::kind_and_name(id).is_some());
854    let until = state.until();
855    let until_words = until.map(|u| when(u, now)).unwrap_or_default();
856    let turned_off = own.is_some_and(|d| d.turned_off && !d.why.is_empty());
857    let said = rules::said(state, rules::Here { silenced, line: &line, turned_off }, &others, &until_words, &|id| name_of(&look.switch, id), tr());
858    let mut on = serde_json::json!({
859        "on": true,
860        "why": state.why().map(Why::id).unwrap_or_default(),
861        "global": state.global(),
862        "manual": state.has(Why::Manual),
863        "until": until_words,
864        "until_at": until.unwrap_or(0),
865        "line": said.line,
866        "why_line": said.why,
867        "details": said.details,
868        "everywhere": said.everywhere,
869        "here": said.here,
870        "here_off": turned_off,
871        "here_offers": system_offers(if turned_off { &line } else { "" }),
872        "system_on": false, "system_line": "", "system_offers": [], "last_off": "",
873    });
874    merge(&mut on, base);
875    on
876}
877
878/// The buttons beside a line of this device's own do-not-disturb: on a phone,
879/// Android's do-not-disturb settings, where the person turns it off or on again.
880fn system_offers(line: &str) -> serde_json::Value {
881    if line.is_empty() || !cfg!(target_os = "android") {
882        return serde_json::json!([]);
883    }
884    serde_json::json!([{ "key": "modes", "label": crate::dnd::offer_label("modes") }])
885}
886
887/// "Turned off on your phone at 14:02, outside Sioul.": the switch's latest
888/// press, when it is an "off" heard from a device's own do-not-disturb within
889/// the last twelve hours; "" otherwise.
890fn last_off(look: &Look) -> String {
891    let Some(press) = look.switch.latest().filter(|p| !p.on && p.via == rules::VIA_SYSTEM) else { return String::new() };
892    let at = press.at / 1000;
893    if look.now.timestamp().as_second() - at > 12 * 3600 {
894        return String::new();
895    }
896    let Ok(time) = jiff::Timestamp::from_second(at) else { return String::new() };
897    let time = time.to_zoned(look.now.time_zone().clone()).strftime("%H:%M").to_string();
898    say("dnd-last-off", &[("device", name_of(&look.switch, &press.from)), ("time", time)])
899}
900
901fn merge(into: &mut serde_json::Value, from: serde_json::Value) {
902    if let (Some(into), serde_json::Value::Object(from)) = (into.as_object_mut(), from) {
903        into.extend(from);
904    }
905}
906
907// ---------------------------------------------------------------- Settings ▸ Do not disturb
908
909/// The numbers' country, for numbers written without one.
910fn region(config: &Config) -> Option<&'static sioul_core::phones::Region> {
911    sioul_core::phones::chosen(config.contacts.region.as_deref(), &tr().text("qt-locale", None))
912}
913
914/// The tab's own part, as JSON: what this device can do (the layer's line
915/// and buttons, GNOME's consent, Plasma's word on the list's mail), the
916/// list, and on a phone who is starred and the background service.
917pub(crate) fn setup() -> String {
918    setup_with(String::new())
919}
920
921fn setup_with(said: String) -> String {
922    let config = load_config();
923    let mut here = crate::dnd::can().json();
924    let mut lines = Vec::new();
925    let offers: Vec<String> = here["offers"].as_array().map(|o| o.iter().filter_map(|x| x["key"].as_str().map(str::to_string)).collect()).unwrap_or_default();
926    if !cfg!(target_os = "android") && here["on"] == true {
927        // Plasma hiding even critical notifications: the list's mail neither shows meanwhile.
928        lines.push(tr().text(if offers.iter().any(|o| o == "plasma") { "dnd-setup-plasma-mail" } else { "dnd-setup-critical" }, None));
929    }
930    here["lines"] = serde_json::json!(lines);
931    let people = People::read(&People::default_path());
932    let problem = people.as_ref().err().map(|why| say("dnd-setup-unreadable", &[("why", why.clone())])).unwrap_or_default();
933    let people: Vec<serde_json::Value> = people
934        .unwrap_or_default()
935        .people
936        .iter()
937        .map(|p| {
938            let mut notes = Vec::new();
939            if p.phones.is_empty() {
940                notes.push(tr().text("dnd-setup-no-number", None));
941            }
942            if p.emails.is_empty() {
943                notes.push(tr().text("dnd-setup-no-email", None));
944            }
945            serde_json::json!({ "id": p.id, "name": p.name, "phones": p.phones, "emails": p.emails, "notes": notes })
946        })
947        .collect();
948    let mut setup = serde_json::json!({
949        "here": here,
950        "gnome": config.pause.gnome,
951        "people": people,
952        "problem": problem,
953        "said": said,
954        "android": cfg!(target_os = "android"),
955    });
956    if cfg!(target_os = "android") {
957        setup["stars"] = stars(&config);
958        setup["steps"] = crate::steps::setup(&config);
959    }
960    setup.to_string()
961}
962
963/// Who on the list is starred on this phone (READ_CONTACTS, never written):
964/// {permission, summary, people: [{id, name, state, contact}]}, the state
965/// "starred", "not-starred", "unknown" (no contact has their number),
966/// "no-number".
967fn stars(config: &Config) -> serde_json::Value {
968    let people = People::load(&People::default_path());
969    let asked: Vec<serde_json::Value> = people.people.iter().map(|p| serde_json::json!({ "id": p.id, "phones": p.phones })).collect();
970    let answer = crate::steps::java("stars", &serde_json::json!({ "people": asked }).to_string());
971    let _ = config;
972    stars_of(&people, &answer, tr())
973}
974
975/// Java's answer about the list's people, in words: who is not starred here.
976fn stars_of(people: &People, answer: &serde_json::Value, tr: &sioul_core::i18n::Translator) -> serde_json::Value {
977    if answer["permission"] != true {
978        return serde_json::json!({ "permission": false, "summary": tr.text("dnd-stars-permission", None), "people": [] });
979    }
980    let found = answer["people"].as_array().cloned().unwrap_or_default();
981    let mut missing = 0;
982    let listed: Vec<serde_json::Value> = people
983        .people
984        .iter()
985        .map(|p| {
986            let seen = found.iter().find(|f| f["id"] == p.id.as_str());
987            let state = if p.phones.is_empty() { "no-number" } else { seen.and_then(|f| f["state"].as_str()).unwrap_or("unknown") };
988            if state == "not-starred" || state == "unknown" {
989                missing += 1;
990            }
991            serde_json::json!({ "id": p.id, "name": if p.name.is_empty() { p.emails.first().cloned().unwrap_or_default() } else { p.name.clone() }, "state": state, "contact": seen.and_then(|f| f["contact"].as_str()).unwrap_or_default() })
992        })
993        .collect();
994    let summary = if missing == 0 {
995        tr.text("dnd-stars-all", None)
996    } else {
997        tr.text("dnd-stars-missing", Some(&tr.counted(missing)))
998    };
999    serde_json::json!({ "permission": true, "summary": summary, "missing": missing, "people": listed })
1000}
1001
1002/// An action of the tab: the list changed, a contact opened, a permission
1003/// asked… (`verb`, its JSON). Answers the tab again, with a line said, and
1004/// whether something to share changed (the list, this device's setting).
1005pub(crate) fn change(verb: &str, json: &str) -> (String, bool) {
1006    let changes = matches!(verb, "add-safe" | "add-contact" | "add" | "edit" | "remove" | "gnome");
1007    let answer = change_here(verb, json);
1008    if verb == "gnome" {
1009        std::thread::spawn(apply);
1010    }
1011    (answer, changes)
1012}
1013
1014fn change_here(verb: &str, json: &str) -> String {
1015    let asked: serde_json::Value = serde_json::from_str(json).unwrap_or_default();
1016    let config = load_config();
1017    let region = region(&config);
1018    let path = People::default_path();
1019    let text = |key: &str| asked[key].as_str().unwrap_or_default().trim().to_string();
1020    let lines = |key: &str| asked[key].as_str().unwrap_or_default().lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_string).collect::<Vec<_>>();
1021    let said = match verb {
1022        // Everyone on the Safe list: their cards' names and numbers; the
1023        // categories, cards and numbers on it too, as its lines write them.
1024        "add-safe" => {
1025            let list = sioul_core::porch::SenderList::load(&config.safe_senders_path());
1026            let entries: Vec<String> = list
1027                .entries()
1028                .into_iter()
1029                .chain(list.categories().iter().map(|c| format!("{}{c}", sioul_core::porch::CATEGORY)))
1030                .chain(list.cards().iter().map(|c| format!("{}{c}", sioul_core::porch::CONTACT)))
1031                .chain(list.numbers().iter().map(|n| format!("{}{n}", sioul_core::porch::TEL)))
1032                .collect();
1033            let (found, patterns) = rules::from_safe(&entries, &sioul_core::contacts::all());
1034            let mut added = 0;
1035            match rules::change_people(&path, |people| {
1036                for person in found {
1037                    let before = people.people.len();
1038                    people.add(person, region);
1039                    added += people.people.len() - before;
1040                }
1041            }) {
1042                Ok(_) => {
1043                    let mut said = tr().text("dnd-setup-added-safe", Some(&tr().counted(added)));
1044                    if patterns > 0 {
1045                        said = format!("{said} {}", tr().text("dnd-setup-patterns", Some(&tr().counted(patterns))));
1046                    }
1047                    said
1048                }
1049                Err(why) => say("dnd-setup-unreadable", &[("why", why)]),
1050            }
1051        }
1052        // A contact picked from the address books; off the blocked list (Q3).
1053        "add-contact" => {
1054            let uid = text("uid");
1055            match sioul_core::contacts::all().into_iter().find(|c| c.uid == uid) {
1056                Some(card) => {
1057                    let person = rules::from_contact(&card);
1058                    let unblocked = unblock(&config, &person);
1059                    let said = edit_people(&path, |people| {
1060                        people.add(person, region);
1061                    });
1062                    with_unblocked(said, unblocked)
1063                }
1064                None => String::new(),
1065            }
1066        }
1067        // By hand, or changed: a name, numbers and addresses, one per line.
1068        "add" | "edit" => {
1069            let person = Person { id: text("id"), name: text("name"), phones: lines("phones"), emails: lines("emails"), ..Person::default() };
1070            if person.name.is_empty() && person.phones.is_empty() && person.emails.is_empty() {
1071                String::new()
1072            } else {
1073                let unblocked = unblock(&config, &person);
1074                let said = edit_people(&path, |people| match people.people.iter_mut().find(|p| verb == "edit" && p.id == person.id) {
1075                    Some(known) => {
1076                        known.name = person.name;
1077                        known.phones = person.phones;
1078                        known.emails = person.emails;
1079                    }
1080                    None => {
1081                        people.add(Person { id: String::new(), ..person }, region);
1082                    }
1083                });
1084                with_unblocked(said, unblocked)
1085            }
1086        }
1087        "remove" => edit_people(&path, |people| {
1088            people.remove(&text("id"));
1089        }),
1090        // The contacts that match, to pick one (twenty at most).
1091        "search" => {
1092            let all = sioul_core::contacts::all();
1093            let query = text("query");
1094            let found: Vec<serde_json::Value> = sioul_core::contacts::search(&all, &query)
1095                .into_iter()
1096                .filter(|c| !c.phones.is_empty() || !c.emails.is_empty())
1097                .take(20)
1098                .map(|c| serde_json::json!({ "uid": c.uid, "name": c.name, "detail": c.phones.first().map(|p| p.value.clone()).or_else(|| c.emails.first().map(|e| e.value.clone())).unwrap_or_default() }))
1099                .collect();
1100            return serde_json::json!({ "found": found, "none": if found.is_empty() { tr().text("dnd-setup-no-match", None) } else { String::new() } }).to_string();
1101        }
1102        // A system page: Android's access, Plasma's settings, the starred list.
1103        "open" => {
1104            crate::dnd::open(&text("key"));
1105            String::new()
1106        }
1107        // "Silence this device again" (the switch's menu): the person's earlier "off" in the system set aside.
1108        "again" => {
1109            std::thread::spawn(again);
1110            String::new()
1111        }
1112        // On a phone: a person's contact (to star it), Android's form to add one, the permission.
1113        "open-contact" => {
1114            crate::steps::java("open-contact", &serde_json::json!({ "uri": text("contact") }).to_string());
1115            String::new()
1116        }
1117        "add-to-contacts" => {
1118            if let Some(p) = People::load(&path).people.into_iter().find(|p| p.id == text("id")) {
1119                crate::steps::java("add-contact", &serde_json::json!({ "name": p.name, "phones": p.phones, "emails": p.emails }).to_string());
1120            }
1121            String::new()
1122        }
1123        "allow-contacts" => {
1124            crate::steps::ask_contacts();
1125            String::new()
1126        }
1127        "battery" => {
1128            crate::steps::java("ask-battery", "{}");
1129            String::new()
1130        }
1131        // This phone kept in step in the background, or not (its own setting).
1132        "background" => crate::steps::set_background(asked["on"] == true).err().unwrap_or_default(),
1133        // GNOME's consent, the same one the pauses use.
1134        "gnome" => sioul_core::settings::apply(&crate::backend::config_path(), &config, "pause.gnome", &sioul_core::config::SettingValue::Bool(asked["on"] == true)).err().unwrap_or_default(),
1135        _ => String::new(),
1136    };
1137    setup_with(said)
1138}
1139
1140/// Someone put on Always through comes off the blocked list: blocked and
1141/// Always through exclude each other (docs/attention.md, Q3). Whether they were on it.
1142fn unblock(config: &Config, person: &Person) -> bool {
1143    sioul_core::porch::unblock_person(config, &person.emails, &person.phones, &person.contact).unwrap_or_else(|e| {
1144        eprintln!("sioul: {e}");
1145        false
1146    })
1147}
1148
1149/// What a change said, and that someone came off the blocked list, when they did.
1150fn with_unblocked(said: String, unblocked: bool) -> String {
1151    if !unblocked {
1152        return said;
1153    }
1154    [said, tr().text("attention-unblocked", None)].into_iter().filter(|s| !s.is_empty()).collect::<Vec<_>>().join(" ")
1155}
1156
1157/// The list changed; "" said, or why it could not be.
1158fn edit_people(path: &std::path::Path, change: impl FnOnce(&mut People)) -> String {
1159    match rules::change_people(path, change) {
1160        Ok(_) => String::new(),
1161        Err(why) => say("dnd-setup-unreadable", &[("why", why)]),
1162    }
1163}
1164
1165#[cfg(test)]
1166mod tests {
1167    use super::*;
1168
1169    #[test]
1170    fn the_signature_follows_what_is_asked() {
1171        let look = |paused: bool, manual: bool| {
1172            let config = Config::default();
1173            let press = manual.then(|| rules::Press { at: 1_000, on: true, until: 0, from: "x".into(), via: String::new(), state: rules::State::Dnd, open: false, held_off: 0, gone: false });
1174            let sources = Sources { paused: paused.then_some(1), ..Sources::default() };
1175            let state = rules::now(&config.dnd, &sources, press.as_ref(), 100);
1176            let now = Zoned::now();
1177            let mode = sioul_core::quiet::mode(&config.week_hours(), &config.time_off, &Overrides::default(), &sioul_core::quiet::Blocks::default(), &now);
1178            Look { config, overrides: Overrides::default(), switch: Switch::default(), state, mode, now }
1179        };
1180        let off = look(false, false).signature();
1181        assert_eq!(off, "Paused:-,FreeTime:-,Global:-");
1182        assert_ne!(look(false, true).signature(), off);
1183        assert_ne!(look(true, false).signature(), look(false, true).signature());
1184        assert_eq!(look(true, true).asks().iter().filter(|(_, a)| a.is_some()).count(), 2, "the pause and the switch, each its own mode");
1185        // Expecting a call pressed: anyone's call through the pause's mode, said in what it asks.
1186        let mut through = look(true, false);
1187        sioul_core::calls::press(&mut through.switch, "phone", true, 0, Zoned::now().timestamp().as_millisecond());
1188        let calls = |look: &Look| look.asks()[0].1.as_ref().and_then(|a| a.silence).map(|s| s.calls);
1189        assert_eq!((calls(&through), calls(&look(true, false))), (Some(sioul_core::attention::Senders::Anyone), Some(sioul_core::attention::Senders::Starred)));
1190        assert_ne!(through.signature(), look(true, false).signature());
1191    }
1192
1193    /// Part 3: the status line's moment says which of the three states
1194    /// holds, Reachable's end in its words, whether it is "until you turn it
1195    /// off", and a pause, during which Reachable neither holds nor is offered.
1196    #[test]
1197    fn the_moment_says_which_of_the_three_states_holds() {
1198        let look = |press: Option<rules::Press>, paused: bool| {
1199            let config = Config::default();
1200            let now = Zoned::now();
1201            let stamp = now.timestamp().as_second();
1202            let overrides = Overrides { paused_since: paused.then_some(stamp - 60), ..Overrides::default() };
1203            let sources = rules::sources_at(&overrides, &sioul_core::quiet::Blocks::default(), &now);
1204            let state = rules::now(&config.dnd, &sources, press.as_ref(), stamp);
1205            let mode = sioul_core::quiet::mode(&config.week_hours(), &config.time_off, &overrides, &sioul_core::quiet::Blocks::default(), &now);
1206            Look { config, overrides, switch: Switch::default(), state, mode, now }
1207        };
1208        let at = jiff::Timestamp::now().as_millisecond();
1209        let reach = rules::Press { at: at - 1_000, on: false, until: at + 3_600_000, from: "x".into(), via: String::new(), state: rules::State::Reachable, open: false, held_off: 0, gone: false };
1210        let said = moment_of(&look(Some(reach.clone()), false));
1211        assert_eq!((said["state"].as_str(), said["paused"].as_bool()), (Some("reachable"), Some(false)), "{said}");
1212        let line = said["reachable"]["line"].as_str().unwrap_or_default();
1213        assert!(!line.is_empty() && !line.starts_with("reach-"), "{said}");
1214        assert_eq!(said["reachable"]["until_at"], (at + 3_600_000) / 1000);
1215        let open = moment_of(&look(Some(rules::Press { open: true, ..reach.clone() }), false));
1216        assert_eq!(open["reachable"]["open"], true);
1217        assert_ne!(open["reachable"]["line"], said["reachable"]["line"], "until you turn it off: said so");
1218        let dnd = rules::Press { state: rules::State::Dnd, on: true, until: 0, ..reach.clone() };
1219        assert_eq!(moment_of(&look(Some(dnd), false))["state"], "dnd");
1220        assert_eq!(moment_of(&look(None, false))["state"], "usual");
1221        // Past its end: as usual.
1222        let over = rules::Press { until: at - 500, ..reach.clone() };
1223        assert_eq!(moment_of(&look(Some(over), false))["state"], "usual");
1224        // During a pause: no Reachable, and the moment says paused (the menu offers none of its lengths).
1225        let paused = moment_of(&look(Some(reach), true));
1226        assert!(paused["reachable"].is_null() && paused["paused"] == true, "{paused}");
1227    }
1228
1229    fn device(pressed: i64, state: rules::State, until: i64, seen: i64) -> rules::Device {
1230        rules::Device { kind: "computer".into(), pressed, on: state == rules::State::Dnd, mode: state.id().into(), until, seen, at: pressed, ..rules::Device::default() }
1231    }
1232
1233    fn switch_saved(devices: &[(&str, rules::Device)]) {
1234        let switch = Switch { device: devices.iter().map(|(id, d)| (id.to_string(), d.clone())).collect(), ..Switch::default() };
1235        switch.save(&Switch::default_path()).unwrap();
1236    }
1237
1238    /// Review of part 3, F1 and F7, in the files: the phone's Do not disturb
1239    /// until turned off three days ago, the old laptop's As usual two days
1240    /// ago having seen it, the old laptop gone from the sharing: off, in the
1241    /// window and in the files' reading (`sioul remind --watch`, `sioul
1242    /// watch`) alike; a gone device's own Do not disturb holds in neither; a
1243    /// pause pressed after Reachable ends it in the files' reading too, so
1244    /// that the focus session begun before holds again there.
1245    #[test]
1246    fn every_reading_reads_a_gone_device_and_a_pause_alike() {
1247        let _files = FILES.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1248        files_cleared();
1249        const DAY: i64 = 86_400_000;
1250        let ms = jiff::Timestamp::now().as_millisecond();
1251        std::fs::create_dir_all(rules::gone_path().parent().unwrap()).unwrap();
1252        std::fs::write(rules::gone_path(), r#"["old-laptop"]"#).unwrap();
1253        switch_saved(&[("phone-1", device(ms - 3 * DAY, rules::State::Dnd, 0, 0)), ("old-laptop", device(ms - 2 * DAY, rules::State::Usual, 0, ms - 3 * DAY))]);
1254        let mut config = Config::default();
1255        config.dnd.focus = true;
1256        assert!(!now().on(), "the window: the Do not disturb it ended does not come back");
1257        assert!(!sioul_core::attention::dnd_from_files(&config, ms / 1000), "the files' reading too");
1258        switch_saved(&[("old-laptop", device(ms - 60_000, rules::State::Dnd, 0, 0))]);
1259        assert!(!now().on() && !sioul_core::attention::dnd_from_files(&config, ms / 1000), "a gone device's Do not disturb holds nowhere");
1260        // A focus session since an hour; Reachable for an hour ten minutes ago; a pause from five to two minutes ago.
1261        let _ = std::fs::remove_file(rules::gone_path());
1262        let running = sioul_core::timelog::Running { task: String::new(), start: ms / 1000 - 3600, planned: 120, paused_at: None, paused: 0 };
1263        sioul_core::timelog::keep_running_in(&sioul_core::timelog::folder(), Some(&running)).unwrap();
1264        switch_saved(&[("laptop-1", device(ms - 600_000, rules::State::Reachable, ms + 3_000_000, 0))]);
1265        assert!(!sioul_core::attention::dnd_from_files(&config, ms / 1000), "Reachable holds the session off");
1266        let overrides = Overrides { paused_since: Some(ms / 1000 - 300), paused_ended: Some(ms / 1000 - 120), ..Overrides::default() };
1267        overrides.save(&Overrides::default_path()).unwrap();
1268        assert!(sioul_core::attention::dnd_from_files(&config, ms / 1000), "the pause ended Reachable: the session holds again there too");
1269        assert!(now().reachable.is_none());
1270        sioul_core::timelog::keep_running_in(&sioul_core::timelog::folder(), None).unwrap();
1271        files_cleared();
1272    }
1273
1274    /// Re-check of part 3, R-5: Reachable pressed after a gone device's As
1275    /// usual carries nothing of it, as the readers read that As usual.
1276    #[test]
1277    fn reachable_pressed_after_a_gone_device_s_as_usual_carries_nothing() {
1278        let _files = FILES.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1279        files_cleared();
1280        let ms = jiff::Timestamp::now().as_millisecond();
1281        std::fs::create_dir_all(rules::gone_path().parent().unwrap()).unwrap();
1282        std::fs::write(rules::gone_path(), r#"["old-laptop"]"#).unwrap();
1283        switch_saved(&[("old-laptop", device(ms - 600_000, rules::State::Usual, 0, 0))]);
1284        press_state(rules::State::Reachable, Some(ms / 1000 + 3600), false).unwrap();
1285        let switch = Switch::load(&Switch::default_path());
1286        assert_eq!(switch.device[&here_id()].held_off, 0);
1287        files_cleared();
1288    }
1289
1290    /// Review of part 3, F4: Reachable read anew as soon as its files
1291    /// change, never kept by time alone: As usual pressed elsewhere counts
1292    /// at once.
1293    #[test]
1294    fn reachable_is_read_anew_when_the_switch_changes() {
1295        let _files = FILES.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1296        files_cleared();
1297        let ms = jiff::Timestamp::now().as_millisecond();
1298        switch_saved(&[("laptop-1", device(ms - 5_000, rules::State::Reachable, ms + 3_600_000, 0))]);
1299        assert!(reachable().is_some());
1300        switch_saved(&[("laptop-1", device(ms - 1_000, rules::State::Usual, 0, ms - 5_000))]);
1301        assert!(reachable().is_none(), "As usual came: read at once");
1302        files_cleared();
1303    }
1304
1305    /// Review of part 3, F6: with "[dnd] pauses = false" a pause adds no
1306    /// do-not-disturb reason, yet the moment says paused (the menu then
1307    /// offers none of Reachable's lengths, nor the computer's release), by
1308    /// the pause itself.
1309    #[test]
1310    fn the_moment_says_a_pause_whatever_do_not_disturb_says_of_pauses() {
1311        let mut config = Config::default();
1312        config.dnd.pauses = false;
1313        let now = Zoned::now();
1314        let stamp = now.timestamp().as_second();
1315        let overrides = Overrides { paused_since: Some(stamp - 300), ..Overrides::default() };
1316        let state = rules::now(&config.dnd, &rules::sources_at(&overrides, &sioul_core::quiet::Blocks::default(), &now), None, stamp);
1317        assert!(!state.has(Why::Paused));
1318        let mode = sioul_core::quiet::mode(&config.week_hours(), &config.time_off, &overrides, &sioul_core::quiet::Blocks::default(), &now);
1319        let look = Look { config, overrides, switch: Switch::default(), state, mode, now };
1320        assert_eq!(moment_of(&look)["paused"], true);
1321    }
1322
1323    #[test]
1324    fn the_phone_says_who_on_the_list_is_not_starred_there() {
1325        let mut people = People::default();
1326        for (name, phone) in [("Alice", "+262639980001"), ("Bob", "+262639980002"), ("Carol", "+262639980003"), ("Dan", "")] {
1327            people.add(Person { name: name.into(), phones: if phone.is_empty() { Vec::new() } else { vec![phone.into()] }, emails: vec![format!("{name}@example.org")], ..Person::default() }, None);
1328        }
1329        let id = |name: &str| people.people.iter().find(|p| p.name == name).unwrap().id.clone();
1330        let answer = serde_json::json!({ "permission": true, "people": [
1331            { "id": id("Alice"), "state": "starred", "contact": "" },
1332            { "id": id("Bob"), "state": "not-starred", "contact": "content://com.android.contacts/contacts/lookup/x/1" },
1333            { "id": id("Carol"), "state": "unknown", "contact": "" },
1334        ] });
1335        let en = sioul_core::i18n::Translator::new("en");
1336        let said = stars_of(&people, &answer, &en);
1337        assert_eq!(said["missing"], 2);
1338        assert_eq!(said["summary"], "Two people on your list are not starred on this phone.");
1339        let states: Vec<&str> = said["people"].as_array().unwrap().iter().map(|p| p["state"].as_str().unwrap()).collect();
1340        assert_eq!(states, ["starred", "not-starred", "unknown", "no-number"]);
1341        assert_eq!(said["people"][1]["contact"], "content://com.android.contacts/contacts/lookup/x/1");
1342        // Everyone starred, or no permission: said so.
1343        let all = serde_json::json!({ "permission": true, "people": [{ "id": id("Alice"), "state": "starred" }, { "id": id("Bob"), "state": "starred" }, { "id": id("Carol"), "state": "starred" }] });
1344        assert_eq!(stars_of(&people, &all, &en)["summary"], "Everyone on your list with a number is starred on this phone: their calls and messages ring during do-not-disturb.");
1345        let refused = stars_of(&people, &serde_json::json!({ "permission": false }), &en);
1346        assert_eq!(refused["permission"], false);
1347        assert!(refused["summary"].as_str().unwrap().contains("read your contacts"));
1348    }
1349}