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.
49fn sources(overrides: &Overrides, blocks: &sioul_core::quiet::Blocks, now: &Zoned) -> Sources {
50    let stamp = now.timestamp().as_second();
51    let free = overrides.free_from().map(|since| (since, sioul_core::pause::free_until(since, blocks, now.time_zone())));
52    let sleep = blocks.at(stamp, &["sleep", "nap"]).map(|k| (k.start, k.end));
53    let focus = sioul_core::timelog::running().and_then(|r| rules::focus(&r, stamp));
54    Sources { paused: overrides.paused_from(), free, sleep, focus }
55}
56
57/// Health's nights, naps and meals: as the window has them; in the background
58/// service's process, without the day's events (only the meals move for them,
59/// and reading every calendar at each step would cost a phone's battery).
60fn blocks(now: &Zoned) -> sioul_core::quiet::Blocks {
61    if crate::steps::in_service() { sioul_core::quiet::Blocks::read(now, &[]) } else { crate::hours::blocks(now) }
62}
63
64/// Asleep (the night from winding down, a nap), and paused, now: for the
65/// background service's rhythm, whatever the settings of do-not-disturb.
66pub(crate) fn rest_now() -> (bool, bool) {
67    let now = Zoned::now();
68    let paused = Overrides::load(&Overrides::default_path()).paused_from().is_some();
69    (blocks(&now).at(now.timestamp().as_second(), &["sleep", "nap"]).is_some(), paused)
70}
71
72/// What is read for one look: the settings, the pauses, the switch, why it
73/// holds, and what now is for (the time under do-not-disturb's layer).
74struct Look {
75    config: Config,
76    overrides: Overrides,
77    switch: Switch,
78    state: Now,
79    mode: sioul_core::quiet::Mode,
80    now: Zoned,
81}
82
83impl Look {
84    fn now() -> Look {
85        let config = load_config();
86        let now = Zoned::now();
87        let overrides = Overrides::load(&Overrides::default_path());
88        let switch = Switch::load(&Switch::default_path());
89        let blocks = blocks(&now);
90        let state = rules::now(&config.dnd, &sources(&overrides, &blocks, &now), switch.latest().as_ref(), now.timestamp().as_second());
91        let mode = sioul_core::quiet::mode(&config.week_hours(), &config.time_off, &overrides, &blocks, &now);
92        Look { config, overrides, switch, state, mode, now }
93    }
94
95    /// What each mode of Sioul's asks of the system now; none: off. Whom it
96    /// lets through is the matrix of what reaches you's (`attention::silence_at`):
97    /// at the pause's column; at Free time's ("Nothing at all" holding all but
98    /// Always through); do-not-disturb's at sleep's, or at the time now under
99    /// the switch's or a focus session's layer, as the matrix reads a layer.
100    /// While this phone screens calls, every contact's call goes through the
101    /// mode, Sioul having declined the others already, else the starred; and
102    /// anyone's while "Let every call through" holds.
103    fn asks(&self) -> [(Which, Option<Ask>); 3] {
104        use sioul_core::attention::{Column, Now as Moment, Senders};
105        let (config, state) = (&self.config, &self.state);
106        let gnome = config.pause.gnome;
107        let paused = state.has(Why::Paused);
108        let free = state.has(Why::FreeTime) && !paused;
109        let attention = sioul_core::attention::Attention::of(config);
110        let phone = phone_does();
111        let mode = &self.mode;
112        let through = self.through();
113        let moment = |times: Vec<Column>, dnd: bool, nothing: bool| {
114            let mut moment = Moment::of(mode);
115            moment.times = times;
116            moment.dnd = dnd;
117            moment.nothing = nothing;
118            moment.through = through;
119            moment
120        };
121        let ask = |which: Which, moment: Moment| {
122            let silence = attention.silence_at(&moment, phone);
123            Ask { which, people: silence.calls != Senders::None, doses: silence.doses, desktop: gnome, silence: Some(silence), stack: true }
124        };
125        // The switch's mode adds nothing over this device's own do-not-disturb
126        // when that alone turned it on (docs/do-not-disturb.md, "Both ways").
127        let latest = self.switch.latest();
128        let from_system = latest.as_ref().is_some_and(|p| p.on && p.via == rules::VIA_SYSTEM);
129        let stack = !(from_system && rules::held_by_system(state, latest.as_ref(), &here_id()));
130        let global = if state.has(Why::Sleep) { vec![Column::Sleep] } else { Moment::of(mode).times };
131        [
132            (Which::Paused, paused.then(|| ask(Which::Paused, moment(vec![Column::Pause], false, false)))),
133            (Which::FreeTime, free.then(|| ask(Which::FreeTime, moment(vec![Column::Free], false, sioul_core::pause::nothing_now(&self.overrides, &config.free_time))))),
134            (Which::Global, state.global().then(|| Ask { stack, ..ask(Which::Global, moment(global, state.gates(), false)) })),
135        ]
136    }
137
138    /// "Let every call through" holds now, as this device knows it.
139    fn through(&self) -> bool {
140        crate::calls::through_now(&self.switch).is_some_and(|t| t.holds(self.now.timestamp().as_millisecond()))
141    }
142
143    /// What this device asks, in a few words: the background service compares
144    /// it with what was applied last, and asks the window's process only on a change.
145    fn signature(&self) -> String {
146        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(",")
147    }
148
149    /// When to look again by itself: a reason's end, the next night or nap
150    /// when sleep counts, the time's end while do-not-disturb's layer reads
151    /// it, the end of "Let every call through" (Unix seconds).
152    fn next_change(&self) -> Option<i64> {
153        let stamp = self.now.timestamp().as_second();
154        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 };
155        let time = if self.state.gates() { self.mode.until.as_ref().map(|u| u.timestamp().as_second()) } else { None };
156        let through = crate::calls::through_now(&self.switch).filter(|t| t.holds(stamp * 1000) && t.until > 0).map(|t| (t.until + 999) / 1000);
157        [self.state.next_end(), night, time, through].into_iter().flatten().filter(|t| *t > stamp).min()
158    }
159}
160
161/// What this phone does itself, for whom Sioul's modes let through: it
162/// screens calls (Sioul holds the role). A computer nothing. Nothing of
163/// the listener, though it may hold other apps' notifications:
164/// Android plays a notification's sound before any listener hears of it
165/// (docs/android.md, "Notifications from other apps", Sound), so a contact's
166/// message let through the mode and then held by Sioul would ring once in a
167/// pause or Free time; the mode is what keeps it silent. Calls differ: the
168/// screening answers before the phone rings.
169fn phone_does() -> sioul_core::attention::Phone {
170    if !cfg!(target_os = "android") {
171        return sioul_core::attention::Phone::default();
172    }
173    sioul_core::attention::Phone { screens: crate::calls::screens_here() }
174}
175
176/// Why do-not-disturb holds now, on every device alike.
177pub(crate) fn now() -> Now {
178    Look::now().state
179}
180
181/// Whether the switch or a focus session holds do-not-disturb now: a layer
182/// of the matrix of what reaches you (`hours::attention_now`), which says
183/// what waits then (as usual, the sites' notifications and the suggestions
184/// to move; doses, codes asked for, events' reminders and Health's notices
185/// come, and Always through). Read again at most every five seconds: asked
186/// at every notification.
187pub(crate) fn holds_others() -> bool {
188    static KEPT: Mutex<Option<(Instant, bool)>> = Mutex::new(None);
189    if let Ok(kept) = KEPT.lock()
190        && let Some((at, holds)) = *kept
191        && at.elapsed() < Duration::from_secs(5)
192    {
193        return holds;
194    }
195    let holds = now().gates();
196    if let Ok(mut kept) = KEPT.lock() {
197        *kept = Some((Instant::now(), holds));
198    }
199    holds
200}
201
202/// While do-not-disturb holds from its switch or a focus session (a layer
203/// of the matrix of what reaches you): when it ends, when that is known
204/// (none: until turned off, or a session open-ended); none otherwise.
205pub(crate) fn gate() -> Option<Option<i64>> {
206    let state = now();
207    let gates: Vec<&sioul_core::everywhere::Held> = state.holds.iter().filter(|h| matches!(h.why, Why::Focus | Why::Manual)).collect();
208    let until = if gates.iter().any(|h| h.until.is_none()) { None } else { gates.iter().filter_map(|h| h.until).max() };
209    (!gates.is_empty()).then_some(until)
210}
211
212// ---------------------------------------------------------------- applying
213
214/// Asked again while an apply runs: it goes once more, with what changed meanwhile.
215static AGAIN: AtomicBool = AtomicBool::new(false);
216static BUSY: Mutex<()> = Mutex::new(());
217
218/// The system's do-not-disturb as the reasons say now, on this device: on
219/// for each of Sioul's modes that holds, off for the others (the layer does
220/// nothing when nothing changed); this device's table written when what it
221/// says changed; the phone's card told; on Android, an alarm at the next end.
222/// Called at the start, each minute, at once on a press, and after an
223/// exchange brought a change. Never in the background service's process
224/// (Android), which asks the window's process instead (`steps`).
225pub(crate) fn apply() {
226    AGAIN.store(true, Ordering::SeqCst);
227    while AGAIN.load(Ordering::SeqCst) {
228        let Some(busy) = crate::backend::one_at_a_time(&BUSY) else { return };
229        if !AGAIN.swap(false, Ordering::SeqCst) {
230            return;
231        }
232        apply_once();
233        drop(busy);
234    }
235}
236
237fn apply_once() {
238    let look = Look::now();
239    let mut reports: Vec<(Which, Report)> = Vec::new();
240    for (which, ask) in look.asks() {
241        match ask {
242            Some(ask) => reports.push((which, crate::dnd::enter(&ask))),
243            None => {
244                crate::dnd::leave(which);
245            }
246        }
247    }
248    // This device's words: those of the mode of the reason said first.
249    let said = match look.state.why() {
250        Some(Why::Paused) => Which::Paused,
251        Some(Why::FreeTime) => Which::FreeTime,
252        _ => Which::Global,
253    };
254    let report = reports.iter().find(|(w, _)| *w == said).or(reports.first()).map(|(_, r)| r.clone()).unwrap_or_default();
255    let why = look.state.why().map(Why::id).unwrap_or_default().to_string();
256    // Both ways (docs/do-not-disturb.md): Sioul's do-not-disturb gone off here
257    // turns the system's own off too, once, where Sioul can; while it is off,
258    // what the system still holds here is said.
259    let on = look.state.on();
260    crate::dnd::tell_system(on);
261    let was = crate::dnd::on_here(on);
262    let acted = was && !on && crate::dnd::system_off(look.config.pause.gnome);
263    let still = if on || acted { None } else { crate::dnd::still_on() };
264    write_own(|own| {
265        // Sioul runs here (again): it follows.
266        own.closed = false;
267        own.why = why.clone();
268        own.silenced = report.on && !why.is_empty();
269        own.turned_off = report.turned_off && !why.is_empty();
270        own.system_on = still.is_some();
271        own.line = match &still {
272            _ if !why.is_empty() => report.line.clone(),
273            Some(still) => still.line.clone(),
274            None => String::new(),
275        };
276    });
277    // The background service's notification says the state now, within a second: told once
278    // this device's table is written (its words read it; before, they would say a state
279    // between two), before the card and the calls' table, which may take longer.
280    crate::steps::tell_note();
281    crate::homecard::dnd_seen(moment());
282    // On a phone, Android's alarm comes back at the next end, Sioul closed or not.
283    crate::steps::next(look.next_change());
284    // The calls' table follows the pauses, the times and the switch at once (`calls::refresh`:
285    // written again only when what it is made of changed, or every ten minutes).
286    crate::calls::refresh(false);
287}
288
289/// The background service's notification in words (`steps::note`): its
290/// title, what now is for and until when ("Leisure until 22:00"); then
291/// do-not-disturb here, as the status line says it while it holds, what this
292/// device's own still holds while Sioul's is off, else that it is off. And
293/// when what now is for ends (Unix seconds), for the service's next step.
294pub(crate) fn note() -> (String, String, Option<i64>) {
295    let look = Look::now();
296    let tr = tr();
297    let moment = moment_of(&look);
298    let dnd = if moment["on"] == true {
299        moment["line"].as_str().unwrap_or_default().to_string()
300    } else if moment["system_on"] == true {
301        moment["system_line"].as_str().unwrap_or_default().to_string()
302    } else {
303        String::new()
304    };
305    let dnd = if dnd.is_empty() { tr.text("steps-note-dnd-off", None) } else { dnd };
306    // What now is for as the window has it, meals moved past events, in every process alike
307    // (the service's own look leaves the events aside): the title never changes with the process.
308    let mode = crate::hours::mode_at(&look.now);
309    let times: Vec<String> = sioul_core::attention::Now::of(&mode).times.iter().map(|c| sioul_core::attention::column_label(tr, *c)).collect();
310    let time = match times.as_slice() {
311        [] => String::new(),
312        [one] => one.clone(),
313        [rest @ .., last] => format!("{} {} {}", rest.join(", "), tr.text("word-and", None), last.to_lowercase()),
314    };
315    let until = if mode.paused() { None } else { mode.until.clone() };
316    let mut args = sioul_core::i18n::args();
317    args.set("time", time);
318    let title = match &until {
319        Some(until) => {
320            args.set("until", sioul_core::quiet::until_text(tr, until, &look.now));
321            tr.text("steps-note-time", Some(&args))
322        }
323        None => tr.text("steps-note-time-open", Some(&args)),
324    };
325    (title, dnd, until.map(|u| u.timestamp().as_second()))
326}
327
328/// What this device asks of its system now, in a few words, and the status
329/// line: the background service pokes the window's process when either
330/// changed since it last did (`steps`).
331pub(crate) fn wanted() -> (String, String) {
332    let look = Look::now();
333    (look.signature(), moment_of(&look)["line"].as_str().unwrap_or_default().to_string())
334}
335
336/// This device's own table changed (its kind and name always set), under
337/// the file's lock; written only when it changed. Sharing off, it is kept
338/// here all the same: the switch works on this device alone.
339fn write_own(change: impl FnOnce(&mut rules::Device)) {
340    let here = here_id();
341    let now = jiff::Timestamp::now().as_millisecond();
342    let result = rules::change_own(&Switch::default_path(), &here, |switch| {
343        let after = switch.latest_known(None) + 1;
344        let own = switch.device.entry(here.clone()).or_default();
345        let before = own.clone();
346        change(own);
347        own.kind = crate::devices::kind().to_string();
348        own.name = if own.kind == "phone" { String::new() } else { crate::devices::name() };
349        let changed = *own != before;
350        if changed {
351            // Never before a press already known here: the others read it as written after it.
352            own.at = now.max(after);
353        }
354        changed
355    });
356    if let Err(e) = result {
357        eprintln!("sioul: do-not-disturb: {e}");
358    }
359}
360
361/// The switch pressed in the window: on for `minutes` (0: until turned off;
362/// -1: until what now is for ends), or off. Applied here, the status line
363/// shown again, and the other devices told at once.
364pub(crate) fn toggle(mut sioul: Pin<&mut Sioul>, on: bool, minutes: i32) {
365    let now = Zoned::now();
366    let until = match minutes {
367        m if m > 0 => Some(now.timestamp().as_second() + i64::from(m) * 60),
368        -1 => crate::hours::mode_at(&now).until.map(|u| u.timestamp().as_second()),
369        _ => None,
370    };
371    if let Err(e) = press(on, until.filter(|_| on)) {
372        sioul.as_mut().set_status(QString::from(&e));
373        return;
374    }
375    let (qt, shared) = (sioul.qt_thread(), sioul.shared());
376    std::thread::spawn(move || {
377        apply();
378        let _ = qt.queue(|mut sioul| sioul.as_mut().set_mode(QString::from(&mode_json())));
379        crate::share::exchange(&qt, &shared);
380    });
381}
382
383/// The switch pressed here: on until `until` (Unix seconds; none, until
384/// turned off), or off; on every device once the sharing carries it. Applied
385/// by the caller (`apply`).
386pub(crate) fn press(on: bool, until: Option<i64>) -> Result<(), String> {
387    press_via(on, until, "")
388}
389
390/// A press here, saying where it came from (`rules::VIA_SYSTEM`: this
391/// device's own do-not-disturb, heard as it changed; "": the switch).
392fn press_via(on: bool, until: Option<i64>, via: &str) -> Result<(), String> {
393    let here = here_id();
394    let now = jiff::Timestamp::now().as_millisecond();
395    rules::change_own(&Switch::default_path(), &here, |switch| {
396        switch.press_via(&here, on, until.map_or(0, |u| u.saturating_mul(1000)), now, via);
397        let own = switch.device.entry(here.clone()).or_default();
398        own.kind = crate::devices::kind().to_string();
399        own.name = if own.kind == "phone" { String::new() } else { crate::devices::name() };
400        true
401    })?;
402    Ok(())
403}
404
405// ---------------------------------------------------------------- both ways
406
407/// One heard change at a time: two words of the same change (Android's
408/// status and its filter, two of Plasma's) press once.
409static HEARING: Mutex<()> = Mutex::new(());
410
411/// A change of this device's own do-not-disturb, heard as it happened
412/// (Android's receiver, Plasma's `Inhibited`, GNOME's switch), whatever
413/// caused it: a press of the switch here when it disagrees with what holds
414/// (`rules::heard`), applied, shown and sent at once; nothing when it agrees,
415/// which is what Sioul's own changes look like when they come back
416/// (docs/do-not-disturb.md, "Both ways"). Whether it pressed. Any thread.
417pub(crate) fn heard_from_system(system_on: bool) -> bool {
418    let _one = HEARING.lock().unwrap_or_else(PoisonError::into_inner);
419    let Some(on) = rules::heard(now().on(), system_on) else { return false };
420    if let Err(e) = press_via(on, None, rules::VIA_SYSTEM) {
421        eprintln!("sioul: do-not-disturb: {e}");
422        return false;
423    }
424    apply();
425    told();
426    true
427}
428
429/// The window, when it runs in this process: what is heard or pressed
430/// outside it shows there at once, and is sent with its exchange.
431static WINDOW: Mutex<Option<(QtThread, Arc<Shared>)>> = Mutex::new(None);
432
433/// The window runs (at the start and each minute, from the pauses' tick):
434/// kept, and this system's own do-not-disturb followed (`dnd::listen`), once.
435pub(crate) fn started(qt: &QtThread, shared: &Arc<Shared>) {
436    let mut window = WINDOW.lock().unwrap_or_else(PoisonError::into_inner);
437    if window.is_none() {
438        *window = Some((qt.clone(), Arc::clone(shared)));
439        crate::dnd::listen(Arc::new(|on| {
440            std::thread::spawn(move || heard_from_system(on));
441        }));
442    }
443}
444
445/// A press made outside the window (a system's change heard, the phone's
446/// tile): the window's status line at once and the other devices told;
447/// without the window (a phone's receiver), the background service sends it.
448fn told() {
449    let window = WINDOW.lock().ok().and_then(|window| window.clone());
450    match window {
451        Some((qt, shared)) => {
452            let _ = qt.queue(|mut sioul| sioul.as_mut().set_mode(QString::from(&mode_json())));
453            crate::share::exchange(&qt, &shared);
454        }
455        None => crate::steps::send_now(),
456    }
457}
458
459/// Sioul quits on a computer (its window closed, the tray's Quit, SIGTERM):
460/// this device's table as it then holds, for the other devices to say
461/// "On laptop: Sioul is closed." and never "silenced" by an inhibition that
462/// ended with Sioul; sent by the exchange that follows (`share::closing`).
463/// Nothing on a phone, whose modes stay while Sioul is closed.
464pub(crate) fn closing() {
465    if cfg!(target_os = "android") {
466        return;
467    }
468    let still = crate::dnd::still_after_quit();
469    write_own(|own| own.closing(still));
470}
471
472/// The switch pressed from the phone's quick settings (Sioul's tile): on
473/// until turned off, or off, as it is not now; applied and sent at once.
474pub(crate) fn toggle_here() {
475    if let Err(e) = press(!now().on(), None) {
476        eprintln!("sioul: do-not-disturb: {e}");
477        return;
478    }
479    apply();
480    told();
481}
482
483/// "Silence this device again": each of Sioul's modes that holds here left
484/// and entered afresh, the person's earlier "off" in the system set aside. A
485/// switch "on" taken from this device's own system becomes the switch's own,
486/// so that Sioul's mode silences the device whatever the system does.
487pub(crate) fn again() {
488    if Look::now().switch.latest().is_some_and(|p| p.on && p.via == rules::VIA_SYSTEM)
489        && let Err(e) = press(true, None)
490    {
491        eprintln!("sioul: do-not-disturb: {e}");
492    }
493    for ask in Look::now().asks().into_iter().filter_map(|(_, ask)| ask) {
494        crate::dnd::again(&ask);
495    }
496    apply();
497    told();
498}
499
500// ---------------------------------------------------------------- saying
501
502/// A device named inside a sentence: "your phone", a computer's name, "another device".
503fn name_of(switch: &Switch, id: &str) -> String {
504    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();
505    match kind.as_str() {
506        "phone" => tr().text("dnd-device-phone", None),
507        _ if !name.trim().is_empty() => name,
508        _ => tr().text("dnd-device-other", None),
509    }
510}
511
512/// The other devices sharing, and when each was last heard from (Unix seconds).
513fn heard(here: &str) -> Vec<(String, i64)> {
514    let Some((_, Some((folder, _)))) = crate::share::vault() else { return Vec::new() };
515    sioul_sync::share::others(&folder, here).into_iter().map(|o| (o.id, o.heard)).collect()
516}
517
518/// "17:00", "tomorrow at 07:00".
519fn when(stamp: i64, now: &Zoned) -> String {
520    jiff::Timestamp::from_second(stamp).map(|t| sioul_core::quiet::until_text(tr(), &t.to_zoned(now.time_zone().clone()), now)).unwrap_or_default()
521}
522
523/// Do-not-disturb as the status line, the phone's card and the background
524/// service's notification say it: {on, why, global, manual, until, until_at
525/// (0: until switched off), line, why_line, details, everywhere, here,
526/// button, end_time, end_at}.
527pub(crate) fn moment() -> serde_json::Value {
528    moment_of(&Look::now())
529}
530
531fn moment_of(look: &Look) -> serde_json::Value {
532    let state = &look.state;
533    let now = &look.now;
534    let stamp = now.timestamp().as_second();
535    // The end of what now is for, for "Until 17:00" in the switch's menu (the window's alone).
536    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) };
537    let base = serde_json::json!({
538        "button": look.config.dnd.button,
539        "end_time": end.map(|e| when(e, now)).unwrap_or_default(),
540        "end_at": end.unwrap_or(0),
541    });
542    let here = here_id();
543    let own = look.switch.device.get(&here);
544    if !state.on() {
545        // Sioul's off, this device's own still on: said, with what may be opened (both ways).
546        let system_line = own.filter(|d| d.system_on && d.why.is_empty()).map(|d| d.line.clone()).unwrap_or_default();
547        let mut off = serde_json::json!({
548            "on": false, "why": "", "global": false, "manual": false, "until": "", "until_at": 0, "line": "", "why_line": "", "details": [], "everywhere": false, "here": false,
549            "here_off": false, "here_offers": [],
550            "system_on": !system_line.is_empty(), "system_offers": system_offers(&system_line), "system_line": system_line,
551            "last_off": last_off(look),
552        });
553        merge(&mut off, base);
554        return off;
555    }
556    // What this device's system said for the reason that holds; not applied yet: as Sioul's own alone.
557    let (silenced, line) = own.filter(|d| !d.why.is_empty()).map_or((false, String::new()), |d| (d.silenced, d.line.clone()));
558    // Since the reason said first began: a device's table older than that says what held before.
559    let since = state.holds.first().map_or(0, |h| h.since.saturating_mul(1000));
560    // A device with its entry in the devices' registry knows do-not-disturb; one without runs an older Sioul.
561    let others = rules::others(&look.switch, &here, &heard(&here), stamp, since, &|id| crate::devices::kind_and_name(id).is_some());
562    let until = state.until();
563    let until_words = until.map(|u| when(u, now)).unwrap_or_default();
564    let turned_off = own.is_some_and(|d| d.turned_off && !d.why.is_empty());
565    let said = rules::said(state, rules::Here { silenced, line: &line, turned_off }, &others, &until_words, &|id| name_of(&look.switch, id), tr());
566    let mut on = serde_json::json!({
567        "on": true,
568        "why": state.why().map(Why::id).unwrap_or_default(),
569        "global": state.global(),
570        "manual": state.has(Why::Manual),
571        "until": until_words,
572        "until_at": until.unwrap_or(0),
573        "line": said.line,
574        "why_line": said.why,
575        "details": said.details,
576        "everywhere": said.everywhere,
577        "here": said.here,
578        "here_off": turned_off,
579        "here_offers": system_offers(if turned_off { &line } else { "" }),
580        "system_on": false, "system_line": "", "system_offers": [], "last_off": "",
581    });
582    merge(&mut on, base);
583    on
584}
585
586/// The buttons beside a line of this device's own do-not-disturb: on a phone,
587/// Android's do-not-disturb settings, where the person turns it off or on again.
588fn system_offers(line: &str) -> serde_json::Value {
589    if line.is_empty() || !cfg!(target_os = "android") {
590        return serde_json::json!([]);
591    }
592    serde_json::json!([{ "key": "modes", "label": crate::dnd::offer_label("modes") }])
593}
594
595/// "Turned off on your phone at 14:02, outside Sioul.": the switch's latest
596/// press, when it is an "off" heard from a device's own do-not-disturb within
597/// the last twelve hours; "" otherwise.
598fn last_off(look: &Look) -> String {
599    let Some(press) = look.switch.latest().filter(|p| !p.on && p.via == rules::VIA_SYSTEM) else { return String::new() };
600    let at = press.at / 1000;
601    if look.now.timestamp().as_second() - at > 12 * 3600 {
602        return String::new();
603    }
604    let Ok(time) = jiff::Timestamp::from_second(at) else { return String::new() };
605    let time = time.to_zoned(look.now.time_zone().clone()).strftime("%H:%M").to_string();
606    say("dnd-last-off", &[("device", name_of(&look.switch, &press.from)), ("time", time)])
607}
608
609fn merge(into: &mut serde_json::Value, from: serde_json::Value) {
610    if let (Some(into), serde_json::Value::Object(from)) = (into.as_object_mut(), from) {
611        into.extend(from);
612    }
613}
614
615// ---------------------------------------------------------------- Settings ▸ Do not disturb
616
617/// The numbers' country, for numbers written without one.
618fn region(config: &Config) -> Option<&'static sioul_core::phones::Region> {
619    sioul_core::phones::chosen(config.contacts.region.as_deref(), &tr().text("qt-locale", None))
620}
621
622/// The tab's own part, as JSON: what this device can do (the layer's line
623/// and buttons, GNOME's consent, Plasma's word on the list's mail), the
624/// list, and on a phone who is starred and the background service.
625pub(crate) fn setup() -> String {
626    setup_with(String::new())
627}
628
629fn setup_with(said: String) -> String {
630    let config = load_config();
631    let mut here = crate::dnd::can().json();
632    let mut lines = Vec::new();
633    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();
634    if !cfg!(target_os = "android") && here["on"] == true {
635        // Plasma hiding even critical notifications: the list's mail neither shows meanwhile.
636        lines.push(tr().text(if offers.iter().any(|o| o == "plasma") { "dnd-setup-plasma-mail" } else { "dnd-setup-critical" }, None));
637    }
638    here["lines"] = serde_json::json!(lines);
639    let people = People::read(&People::default_path());
640    let problem = people.as_ref().err().map(|why| say("dnd-setup-unreadable", &[("why", why.clone())])).unwrap_or_default();
641    let people: Vec<serde_json::Value> = people
642        .unwrap_or_default()
643        .people
644        .iter()
645        .map(|p| {
646            let mut notes = Vec::new();
647            if p.phones.is_empty() {
648                notes.push(tr().text("dnd-setup-no-number", None));
649            }
650            if p.emails.is_empty() {
651                notes.push(tr().text("dnd-setup-no-email", None));
652            }
653            serde_json::json!({ "id": p.id, "name": p.name, "phones": p.phones, "emails": p.emails, "notes": notes })
654        })
655        .collect();
656    let mut setup = serde_json::json!({
657        "here": here,
658        "gnome": config.pause.gnome,
659        "people": people,
660        "problem": problem,
661        "said": said,
662        "android": cfg!(target_os = "android"),
663    });
664    if cfg!(target_os = "android") {
665        setup["stars"] = stars(&config);
666        setup["steps"] = crate::steps::setup(&config);
667    }
668    setup.to_string()
669}
670
671/// Who on the list is starred on this phone (READ_CONTACTS, never written):
672/// {permission, summary, people: [{id, name, state, contact}]}, the state
673/// "starred", "not-starred", "unknown" (no contact has their number),
674/// "no-number".
675fn stars(config: &Config) -> serde_json::Value {
676    let people = People::load(&People::default_path());
677    let asked: Vec<serde_json::Value> = people.people.iter().map(|p| serde_json::json!({ "id": p.id, "phones": p.phones })).collect();
678    let answer = crate::steps::java("stars", &serde_json::json!({ "people": asked }).to_string());
679    let _ = config;
680    stars_of(&people, &answer, tr())
681}
682
683/// Java's answer about the list's people, in words: who is not starred here.
684fn stars_of(people: &People, answer: &serde_json::Value, tr: &sioul_core::i18n::Translator) -> serde_json::Value {
685    if answer["permission"] != true {
686        return serde_json::json!({ "permission": false, "summary": tr.text("dnd-stars-permission", None), "people": [] });
687    }
688    let found = answer["people"].as_array().cloned().unwrap_or_default();
689    let mut missing = 0;
690    let listed: Vec<serde_json::Value> = people
691        .people
692        .iter()
693        .map(|p| {
694            let seen = found.iter().find(|f| f["id"] == p.id.as_str());
695            let state = if p.phones.is_empty() { "no-number" } else { seen.and_then(|f| f["state"].as_str()).unwrap_or("unknown") };
696            if state == "not-starred" || state == "unknown" {
697                missing += 1;
698            }
699            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() })
700        })
701        .collect();
702    let summary = if missing == 0 {
703        tr.text("dnd-stars-all", None)
704    } else {
705        tr.text("dnd-stars-missing", Some(&tr.counted(missing)))
706    };
707    serde_json::json!({ "permission": true, "summary": summary, "missing": missing, "people": listed })
708}
709
710/// An action of the tab: the list changed, a contact opened, a permission
711/// asked… (`verb`, its JSON). Answers the tab again, with a line said, and
712/// whether something to share changed (the list, this device's setting).
713pub(crate) fn change(verb: &str, json: &str) -> (String, bool) {
714    let changes = matches!(verb, "add-safe" | "add-contact" | "add" | "edit" | "remove" | "gnome");
715    let answer = change_here(verb, json);
716    if verb == "gnome" {
717        std::thread::spawn(apply);
718    }
719    (answer, changes)
720}
721
722fn change_here(verb: &str, json: &str) -> String {
723    let asked: serde_json::Value = serde_json::from_str(json).unwrap_or_default();
724    let config = load_config();
725    let region = region(&config);
726    let path = People::default_path();
727    let text = |key: &str| asked[key].as_str().unwrap_or_default().trim().to_string();
728    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<_>>();
729    let said = match verb {
730        // Everyone on the Safe list: their cards' names and numbers; the
731        // categories, cards and numbers on it too, as its lines write them.
732        "add-safe" => {
733            let list = sioul_core::porch::SenderList::load(&config.safe_senders_path());
734            let entries: Vec<String> = list
735                .entries()
736                .into_iter()
737                .chain(list.categories().iter().map(|c| format!("{}{c}", sioul_core::porch::CATEGORY)))
738                .chain(list.cards().iter().map(|c| format!("{}{c}", sioul_core::porch::CONTACT)))
739                .chain(list.numbers().iter().map(|n| format!("{}{n}", sioul_core::porch::TEL)))
740                .collect();
741            let (found, patterns) = rules::from_safe(&entries, &sioul_core::contacts::all());
742            let mut added = 0;
743            match rules::change_people(&path, |people| {
744                for person in found {
745                    let before = people.people.len();
746                    people.add(person, region);
747                    added += people.people.len() - before;
748                }
749            }) {
750                Ok(_) => {
751                    let mut said = tr().text("dnd-setup-added-safe", Some(&tr().counted(added)));
752                    if patterns > 0 {
753                        said = format!("{said} {}", tr().text("dnd-setup-patterns", Some(&tr().counted(patterns))));
754                    }
755                    said
756                }
757                Err(why) => say("dnd-setup-unreadable", &[("why", why)]),
758            }
759        }
760        // A contact picked from the address books; off the blocked list (Q3).
761        "add-contact" => {
762            let uid = text("uid");
763            match sioul_core::contacts::all().into_iter().find(|c| c.uid == uid) {
764                Some(card) => {
765                    let person = rules::from_contact(&card);
766                    let unblocked = unblock(&config, &person);
767                    let said = edit_people(&path, |people| {
768                        people.add(person, region);
769                    });
770                    with_unblocked(said, unblocked)
771                }
772                None => String::new(),
773            }
774        }
775        // By hand, or changed: a name, numbers and addresses, one per line.
776        "add" | "edit" => {
777            let person = Person { id: text("id"), name: text("name"), phones: lines("phones"), emails: lines("emails"), ..Person::default() };
778            if person.name.is_empty() && person.phones.is_empty() && person.emails.is_empty() {
779                String::new()
780            } else {
781                let unblocked = unblock(&config, &person);
782                let said = edit_people(&path, |people| match people.people.iter_mut().find(|p| verb == "edit" && p.id == person.id) {
783                    Some(known) => {
784                        known.name = person.name;
785                        known.phones = person.phones;
786                        known.emails = person.emails;
787                    }
788                    None => {
789                        people.add(Person { id: String::new(), ..person }, region);
790                    }
791                });
792                with_unblocked(said, unblocked)
793            }
794        }
795        "remove" => edit_people(&path, |people| {
796            people.remove(&text("id"));
797        }),
798        // The contacts that match, to pick one (twenty at most).
799        "search" => {
800            let all = sioul_core::contacts::all();
801            let query = text("query");
802            let found: Vec<serde_json::Value> = sioul_core::contacts::search(&all, &query)
803                .into_iter()
804                .filter(|c| !c.phones.is_empty() || !c.emails.is_empty())
805                .take(20)
806                .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() }))
807                .collect();
808            return serde_json::json!({ "found": found, "none": if found.is_empty() { tr().text("dnd-setup-no-match", None) } else { String::new() } }).to_string();
809        }
810        // A system page: Android's access, Plasma's settings, the starred list.
811        "open" => {
812            crate::dnd::open(&text("key"));
813            String::new()
814        }
815        // "Silence this device again" (the switch's menu): the person's earlier "off" in the system set aside.
816        "again" => {
817            std::thread::spawn(again);
818            String::new()
819        }
820        // On a phone: a person's contact (to star it), Android's form to add one, the permission.
821        "open-contact" => {
822            crate::steps::java("open-contact", &serde_json::json!({ "uri": text("contact") }).to_string());
823            String::new()
824        }
825        "add-to-contacts" => {
826            if let Some(p) = People::load(&path).people.into_iter().find(|p| p.id == text("id")) {
827                crate::steps::java("add-contact", &serde_json::json!({ "name": p.name, "phones": p.phones, "emails": p.emails }).to_string());
828            }
829            String::new()
830        }
831        "allow-contacts" => {
832            crate::steps::ask_contacts();
833            String::new()
834        }
835        "battery" => {
836            crate::steps::java("ask-battery", "{}");
837            String::new()
838        }
839        // This phone kept in step in the background, or not (its own setting).
840        "background" => crate::steps::set_background(asked["on"] == true).err().unwrap_or_default(),
841        // GNOME's consent, the same one the pauses use.
842        "gnome" => sioul_core::settings::apply(&crate::backend::config_path(), &config, "pause.gnome", &sioul_core::config::SettingValue::Bool(asked["on"] == true)).err().unwrap_or_default(),
843        _ => String::new(),
844    };
845    setup_with(said)
846}
847
848/// Someone put on Always through comes off the blocked list: blocked and
849/// Always through exclude each other (docs/attention.md, Q3). Whether they were on it.
850fn unblock(config: &Config, person: &Person) -> bool {
851    sioul_core::porch::unblock_person(config, &person.emails, &person.phones, &person.contact).unwrap_or_else(|e| {
852        eprintln!("sioul: {e}");
853        false
854    })
855}
856
857/// What a change said, and that someone came off the blocked list, when they did.
858fn with_unblocked(said: String, unblocked: bool) -> String {
859    if !unblocked {
860        return said;
861    }
862    [said, tr().text("attention-unblocked", None)].into_iter().filter(|s| !s.is_empty()).collect::<Vec<_>>().join(" ")
863}
864
865/// The list changed; "" said, or why it could not be.
866fn edit_people(path: &std::path::Path, change: impl FnOnce(&mut People)) -> String {
867    match rules::change_people(path, change) {
868        Ok(_) => String::new(),
869        Err(why) => say("dnd-setup-unreadable", &[("why", why)]),
870    }
871}
872
873#[cfg(test)]
874mod tests {
875    use super::*;
876
877    #[test]
878    fn the_signature_follows_what_is_asked() {
879        let look = |paused: bool, manual: bool| {
880            let config = Config::default();
881            let press = manual.then(|| rules::Press { at: 1_000, on: true, until: 0, from: "x".into(), via: String::new() });
882            let sources = Sources { paused: paused.then_some(1), ..Sources::default() };
883            let state = rules::now(&config.dnd, &sources, press.as_ref(), 100);
884            let now = Zoned::now();
885            let mode = sioul_core::quiet::mode(&config.week_hours(), &config.time_off, &Overrides::default(), &sioul_core::quiet::Blocks::default(), &now);
886            Look { config, overrides: Overrides::default(), switch: Switch::default(), state, mode, now }
887        };
888        let off = look(false, false).signature();
889        assert_eq!(off, "Paused:-,FreeTime:-,Global:-");
890        assert_ne!(look(false, true).signature(), off);
891        assert_ne!(look(true, false).signature(), look(false, true).signature());
892        assert_eq!(look(true, true).asks().iter().filter(|(_, a)| a.is_some()).count(), 2, "the pause and the switch, each its own mode");
893        // "Let every call through" pressed: anyone's call through the pause's mode, said in what it asks.
894        let mut through = look(true, false);
895        sioul_core::calls::press(&mut through.switch, "phone", true, 0, Zoned::now().timestamp().as_millisecond());
896        let calls = |look: &Look| look.asks()[0].1.as_ref().and_then(|a| a.silence).map(|s| s.calls);
897        assert_eq!((calls(&through), calls(&look(true, false))), (Some(sioul_core::attention::Senders::Anyone), Some(sioul_core::attention::Senders::Starred)));
898        assert_ne!(through.signature(), look(true, false).signature());
899    }
900
901    #[test]
902    fn the_phone_says_who_on_the_list_is_not_starred_there() {
903        let mut people = People::default();
904        for (name, phone) in [("Alice", "+262639980001"), ("Bob", "+262639980002"), ("Carol", "+262639980003"), ("Dan", "")] {
905            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);
906        }
907        let id = |name: &str| people.people.iter().find(|p| p.name == name).unwrap().id.clone();
908        let answer = serde_json::json!({ "permission": true, "people": [
909            { "id": id("Alice"), "state": "starred", "contact": "" },
910            { "id": id("Bob"), "state": "not-starred", "contact": "content://com.android.contacts/contacts/lookup/x/1" },
911            { "id": id("Carol"), "state": "unknown", "contact": "" },
912        ] });
913        let en = sioul_core::i18n::Translator::new("en");
914        let said = stars_of(&people, &answer, &en);
915        assert_eq!(said["missing"], 2);
916        assert_eq!(said["summary"], "Two people on your list are not starred on this phone.");
917        let states: Vec<&str> = said["people"].as_array().unwrap().iter().map(|p| p["state"].as_str().unwrap()).collect();
918        assert_eq!(states, ["starred", "not-starred", "unknown", "no-number"]);
919        assert_eq!(said["people"][1]["contact"], "content://com.android.contacts/contacts/lookup/x/1");
920        // Everyone starred, or no permission: said so.
921        let all = serde_json::json!({ "permission": true, "people": [{ "id": id("Alice"), "state": "starred" }, { "id": id("Bob"), "state": "starred" }, { "id": id("Carol"), "state": "starred" }] });
922        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.");
923        let refused = stars_of(&people, &serde_json::json!({ "permission": false }), &en);
924        assert_eq!(refused["permission"], false);
925        assert!(refused["summary"].as_str().unwrap().contains("read your contacts"));
926    }
927}