1use 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
43fn here_id() -> String {
45 sioul_sync::share::Here::load(&sioul_core::config::state_dir()).id
46}
47
48fn sources(overrides: &Overrides, blocks: &sioul_core::quiet::Blocks, now: &Zoned) -> Sources {
50 rules::sources_at(overrides, blocks, now)
51}
52
53static GONE_NOTED: Mutex<i64> = Mutex::new(0);
55
56pub(crate) fn gone_soon() {
59 if let Ok(mut last) = GONE_NOTED.lock() {
60 *last = 0;
61 }
62}
63
64pub(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
89fn switch_here(here: &str) -> Switch {
91 rules::read_switch(here)
92}
93
94fn 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
101pub(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
109struct 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 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 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 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 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 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 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
204fn 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
219pub(crate) fn now() -> Now {
221 Look::now().state
222}
223
224type 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
238fn 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
244static REACHABLE_KEPT: Mutex<Option<(Vec<FileSeen>, Option<rules::Reach>)>> = Mutex::new(None);
247
248#[cfg(test)]
251pub(crate) static FILES: Mutex<()> = Mutex::new(());
252
253#[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
264pub(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
286pub(crate) fn release_stamps() -> Vec<(String, i64)> {
290 switch_here(&here_id()).release_stamps()
291}
292
293pub(crate) fn latest_press() -> i64 {
297 switch_here(&here_id()).latest_known(None)
298}
299
300pub(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#[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
330pub(crate) fn gated() -> Option<Gated> {
336 gated_of(&now())
337}
338
339pub(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
346static AGAIN: AtomicBool = AtomicBool::new(false);
350static BUSY: Mutex<()> = Mutex::new(());
351
352pub(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 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 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 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 crate::steps::tell_note();
415 crate::homecard::dnd_seen(moment());
416 crate::steps::next(look.next_change());
418 crate::calls::refresh(false);
421}
422
423pub(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 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
462pub(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
470fn 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 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
495pub(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
517pub(crate) fn press(on: bool, until: Option<i64>) -> Result<(), String> {
521 press_via(on, until, "")
522}
523
524pub(crate) fn tonight(now: &Zoned) -> Option<i64> {
527 match night_and_waking(now) {
528 (night, None) => night,
529 _ => None,
530 }
531}
532
533fn 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
543fn 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 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
555pub(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 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
613pub(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 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
636pub(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
656fn press_via(on: bool, until: Option<i64>, via: &str) -> Result<(), String> {
659 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
674static HEARING: Mutex<()> = Mutex::new(());
679
680pub(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
698static WINDOW: Mutex<Option<(QtThread, Arc<Shared>)>> = Mutex::new(None);
701
702pub(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
714fn 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
728pub(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
741pub(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
752pub(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
769fn 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
781fn 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
787fn 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
792pub(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 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 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 "paused": look.mode.paused(),
826 "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 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 let (silenced, line) = own.filter(|d| !d.why.is_empty()).map_or((false, String::new()), |d| (d.silenced, d.line.clone()));
850 let since = state.holds.first().map_or(0, |h| h.since.saturating_mul(1000));
852 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
878fn 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
887fn 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
907fn 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
914pub(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 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
963fn 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
975fn 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
1002pub(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 "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 "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 "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 "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 "open" => {
1104 crate::dnd::open(&text("key"));
1105 String::new()
1106 }
1107 "again" => {
1109 std::thread::spawn(again);
1110 String::new()
1111 }
1112 "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 "background" => crate::steps::set_background(asked["on"] == true).err().unwrap_or_default(),
1133 "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
1140fn 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
1149fn 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
1157fn 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 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 #[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 let over = rules::Press { until: at - 500, ..reach.clone() };
1223 assert_eq!(moment_of(&look(Some(over), false))["state"], "usual");
1224 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 #[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 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 #[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 #[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 #[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 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}