Skip to main content

sioul_app/
calls.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Calls screened on a phone, as this device writes and says them
5//! (docs/android.md, "Calls"; the rules are `sioul_core::calls`, the
6//! decision Java's: android/…/Calls.java).
7//!
8//! - **The table** Java decides from (`refresh`): written again when what it
9//!   is made of changed (the settings, the lists, the do-not-disturb list,
10//!   Expecting a call, the call screening, the pauses and the times, Health), and at
11//!   least every ten minutes while something of Sioul's runs: the window's
12//!   minute and each do-not-disturb applied (`everywhere::apply`), the
13//!   background service's step, Android's daily look for the events'
14//!   reminders. Its frames reach four days ahead: Sioul closed that long, the
15//!   calls ring.
16//! - **Expecting a call** (`press`; once "Let every call through"), from the
17//!   window on any device, or from the phone's notification (Java keeps that
18//!   press; it is carried into the shared switch here, `carry`); said in the
19//!   status line (`moment`) and the notification (`step`).
20//! - **The call screening** (`sioul_core::screening`): its settings in
21//!   Settings ▸ This phone ▸ Calls (`setup`, "set"), written into the table
22//!   with the numbers it knows by their kind (`make`); whether it lets numbers
23//!   nobody knows ring at a time, for the phone's modes (`unknown_rings`).
24//! - **The log**: on a phone, Java's calls copied into this phone's own log,
25//!   which the sharing carries to your other devices (`before_exchange`); on
26//!   every device, its own lines taken out after their month.
27//! - **The list** of calls declined, on the Porch of every device (`view`):
28//!   this phone's own, and every phone's while `[porch] calls` says so (the
29//!   default); with the voicemail Free mails linked to its call and played on
30//!   demand; Seen on any device, gone from all (`act`).
31//! - **A person's calls** of the month, on their sheet (`history`).
32//! - **Settings ▸ Calls** (`setup`, `setup_change`).
33
34use crate::backend::{load_config, tr};
35use jiff::Zoned;
36use serde_json::json;
37use sioul_core::calls::{self as rules, Held, Through};
38use sioul_core::config::Config;
39use sioul_core::everywhere::{self as switches, People, Switch};
40use sioul_core::porch::{Senders, Standing};
41use sioul_core::attention::{Attention, Person};
42use sioul_core::quiet::Overrides;
43use sioul_core::reach::{Clock, Who};
44use sioul_core::screening::{self, Screening};
45use sioul_core::voicemail::{self, Voicemail};
46use std::collections::BTreeMap;
47use std::path::{Path, PathBuf};
48use std::sync::Mutex;
49use std::time::{Duration, SystemTime};
50
51/// The table is made again at least this often while something runs (seconds), nothing changed.
52const REMAKE_EVERY: i64 = 10 * 60;
53/// Java's answer about the role, kept this long (seconds): the status line asks it each minute.
54const STATE_KEPT: i64 = 30;
55/// The voicemails' sounds kept to play, the newest.
56const SOUNDS_KEPT: usize = 8;
57
58/// This device's name in the sharing (share/here.toml), made once.
59fn here_id() -> String {
60    sioul_sync::share::Here::load(&sioul_core::config::state_dir()).id
61}
62
63/// Whether this device has calls to screen: a phone. SIOUL_CALLS makes a
64/// computer write its table and read its list too, to look at them.
65fn phone() -> bool {
66    cfg!(target_os = "android") || std::env::var_os("SIOUL_CALLS").is_some()
67}
68
69fn path(name: &str) -> PathBuf {
70    rules::folder().join(name)
71}
72
73// ---------------------------------------------------------------- what Android allows
74
75static STATE: Mutex<Option<(i64, serde_json::Value)>> = Mutex::new(None);
76
77/// What Android allows this phone (Calls.state): {api, available, held,
78/// contacts, table, emergency_at}; null elsewhere. Kept half a minute.
79fn state() -> serde_json::Value {
80    let now = jiff::Timestamp::now().as_second();
81    if let Ok(kept) = STATE.lock()
82        && let Some((at, state)) = kept.as_ref()
83        && (0..STATE_KEPT).contains(&(now - at))
84    {
85        return state.clone();
86    }
87    let state = crate::steps::java("calls-state", "{}");
88    if let Ok(mut kept) = STATE.lock() {
89        *kept = Some((now, state.clone()));
90    }
91    state
92}
93
94/// The role asked or changed: Android's answer read again next time.
95fn forget_state() {
96    if let Ok(mut kept) = STATE.lock() {
97        *kept = None;
98    }
99}
100
101/// Whether this phone screens calls now: Sioul holds the role.
102pub(crate) fn screens_here() -> bool {
103    cfg!(target_os = "android") && state()["held"] == true
104}
105
106// ---------------------------------------------------------------- the table
107
108/// What the table is made of, as the files' times say it, with the day: the
109/// same, nothing to make again before ten minutes.
110type Inputs = (jiff::civil::Date, Vec<Option<SystemTime>>);
111
112fn changed(path: &Path) -> Option<SystemTime> {
113    std::fs::metadata(path).and_then(|m| m.modified()).ok()
114}
115
116fn inputs(config: &Config, now: &Zoned) -> Inputs {
117    let files = [
118        crate::backend::config_path(),
119        config.safe_senders_path(),
120        config.neutral_senders_path(),
121        config.restricted_senders_path(),
122        config.blocked_senders_path(),
123        config.known_senders_path(),
124        People::default_path(),
125        Switch::default_path(),
126        // The devices gone from the sharing: read as As usual in the switch (F1).
127        sioul_core::everywhere::gone_path(),
128        Overrides::default_path(),
129        sioul_core::health::Health::default_path(),
130        sioul_core::needs::Days::default_path(),
131        sioul_core::attention::Slots::default_path(),
132        // A focus session: do-not-disturb while it counts, when set so (a layer of the frames).
133        sioul_core::timelog::folder().join("running.toml"),
134        path(rules::THROUGH_HERE),
135    ];
136    (now.date(), files.iter().map(|f| changed(f)).collect())
137}
138
139/// What was made last in this process: its inputs, when (seconds), what it said.
140static MADE: Mutex<Option<(Inputs, i64, String)>> = Mutex::new(None);
141static BUSY: Mutex<()> = Mutex::new(());
142
143/// The table written again when what it is made of changed, ten minutes went
144/// by, or `now` says so (a press, a number blocked, the role given). On a
145/// phone only; never two at once (one asked meanwhile, unforced, is let go:
146/// the next look makes it).
147pub(crate) fn refresh(now: bool) {
148    if !phone() {
149        return;
150    }
151    let busy = if now { Some(BUSY.lock().unwrap_or_else(std::sync::PoisonError::into_inner)) } else { crate::backend::one_at_a_time(&BUSY) };
152    let Some(_busy) = busy else { return };
153    if cfg!(target_os = "android") && !screens_here() {
154        // Not screening: no table to keep, no number called to keep; your other devices told so, once.
155        let _ = rules::prune_called(&path(rules::CALLED), 0, 0);
156        let here = here_id();
157        if Switch::load(&Switch::default_path()).device.get(&here).is_some_and(|d| rules::part_of(d).screens) {
158            carry();
159        }
160        return;
161    }
162    let config = load_config();
163    let at = Zoned::now();
164    let stamp = at.timestamp().as_second();
165    let there = path(rules::TABLE).is_file();
166    let before = inputs(&config, &at);
167    let fresh = MADE.lock().ok().and_then(|made| made.as_ref().map(|(inputs, when, _)| *inputs == before && (0..REMAKE_EVERY).contains(&(stamp - when)))).unwrap_or(false);
168    if !now && there && fresh {
169        return;
170    }
171    carry();
172    // What it is made of, read once carrying wrote the switch and before reading it: a
173    // change after this is a change the next refresh sees (F4).
174    let made_of = inputs(&config, &at);
175    // The switch as every reader reads it: a gone device's Expecting a call read as "Screen
176    // calls again" at its stamp, which opens nothing (review of part 3, R-1).
177    let switch = switches::read_switch(&here_id());
178    let table = make(&config, &switch, &at);
179    // The numbers called lately, kept only the days they count (none, the setting off).
180    if let Err(e) = rules::prune_called(&path(rules::CALLED), at.timestamp().as_millisecond(), table.screening.as_ref().map_or(0, |s| s.called_days)) {
181        eprintln!("sioul: calls: {}: {e}", rules::CALLED);
182    }
183    let content = rules::content(&table);
184    let same = there && MADE.lock().ok().and_then(|made| made.as_ref().map(|(_, _, said)| *said == content)).unwrap_or(false);
185    if !same && let Err(e) = rules::write(&path(rules::TABLE), &table) {
186        eprintln!("sioul: calls: {e}");
187        return;
188    }
189    // As read after carrying, which may have written the switch's file, and before making it.
190    if let Ok(mut made) = MADE.lock() {
191        *made = Some((made_of, stamp, content));
192    }
193}
194
195/// This phone's press on its notification (Java's file) carried into the
196/// shared switch once, and whether it screens calls said there, for your
197/// other devices: their window offers Expecting a call then. The file is
198/// written as it is; it is read as every reader reads it (`read_switch`).
199fn carry() {
200    let path = Switch::default_path();
201    let here = here_id();
202    if !cfg!(target_os = "android") || here.is_empty() {
203        return;
204    }
205    let local: Option<Through> = rules::read_json(&rules::folder().join(rules::THROUGH_HERE));
206    let screens = screens_here();
207    let result = switches::change_own(&path, &here, |switch| {
208        let mut changed = local.as_ref().is_some_and(|l| rules::merge_local(switch, &here, l));
209        changed |= rules::set_screens(switch, &here, screens);
210        if changed {
211            named(switch, &here);
212        }
213        changed
214    });
215    if let Err(e) = result {
216        eprintln!("sioul: calls: {e}");
217    }
218}
219
220/// This device's own table in the switch's file, its kind and name set.
221fn named(switch: &mut Switch, here: &str) {
222    let own = switch.device.entry(here.to_string()).or_default();
223    own.kind = crate::devices::kind().to_string();
224    own.name = if own.kind == "phone" { String::new() } else { crate::devices::name() };
225}
226
227/// The numbers the screening knows by their kind, as Sioul ships them: read once.
228fn numbers() -> &'static screening::Numbers {
229    screening::shipped()
230}
231
232/// The table: who each number is (the Always through list's as rows of their
233/// own, never a blocked one), the floors, Expecting a call, the call
234/// screening with the numbers it knows by their kind that count today, and
235/// the frames from today's midnight, four days on (from midnight, so that a
236/// table made a minute later says the same and is not written again), with
237/// the matrix's layers: today's slots of time for you, do-not-disturb.
238fn make(config: &Config, switch: &Switch, now: &Zoned) -> rules::Table {
239    let senders = Senders::load(config);
240    let attention = Attention::of(config);
241    let clock = if crate::steps::in_service() {
242        // The background service reads no calendar (a phone's battery): the meals
243        // are not pushed past the day's events there; the window's next look does it.
244        let needs = sioul_core::health::Health::load(&sioul_core::health::Health::default_path()).needs;
245        let days = sioul_core::needs::Days::load(&sioul_core::needs::Days::default_path());
246        Clock::new(config, Overrides::load(&Overrides::default_path()), needs, days, Vec::new())
247    } else {
248        Clock::load(config, now)
249    };
250    let from = now.date().to_zoned(now.time_zone().clone()).unwrap_or_else(|_| now.clone());
251    let until = from.checked_add(jiff::Span::new().days(rules::TABLE_DAYS)).unwrap_or_else(|_| now.clone());
252    // Always through: its people's numbers, read against who each number is; blocked beats it.
253    let people = People::load(&People::default_path());
254    let always = rules::always_numbers(&people, senders.region(), &|key| senders.judge_number(key).who);
255    let (own_numbers, own_rows) = rules::own_rows(&attention, &senders);
256    rules::table(rules::Made {
257        made: now.timestamp().as_millisecond(),
258        region: senders.region(),
259        numbers: senders.numbers().into_iter().map(|(key, who)| (key, who.id().to_string())).collect(),
260        prefixes: senders.prefixes().into_iter().map(|(prefix, who)| (prefix, who.id().to_string())).collect(),
261        always,
262        own: own_numbers,
263        expecting: rules::through_of(switch),
264        screening: Some(screening::TablePart::of(&Screening::of(config), numbers(), now.date())),
265        frames: rules::frames_with(&attention, &clock, &layers(), &from, &until, &own_rows),
266    })
267}
268
269
270/// Whether the call screening lets a number nobody knows, or a hidden one,
271/// ring at a moment by itself (off, outside its hours, its rules), work or
272/// admin time then or not (`hours`): the phone's modes then let anyone's call
273/// through, so that nothing it lets ring is silenced (`attention::Phone::unknown`).
274/// Expecting a call is said apart (`attention::Now::through`).
275pub(crate) fn unknown_rings(config: &Config, hours: bool) -> bool {
276    let screening = Screening::of(config);
277    screening.rings(false, hours) || screening.rings(true, hours)
278}
279
280/// What holds on top of the times, ahead: today's slots of time for you
281/// (`state/slots.toml`), and do-not-disturb from its switch or a focus
282/// session, from when, until when (none: until turned off; the table is
283/// written again at each change, `everywhere::apply`).
284fn layers() -> rules::Layers {
285    let since = jiff::Timestamp::now().as_second();
286    // Read now, once, both layers alike: never a reading kept by time, since the table
287    // made of it is kept until its inputs change (review of part 3, F4).
288    let state = crate::everywhere::now();
289    let dnd = crate::everywhere::gated_of(&state).map(|g| (since, g.until));
290    let reachable = state.reachable.map(|r| (r.since, r.until));
291    rules::Layers { slots: crate::hours::slots().slots, dnd, reachable }
292}
293
294// ---------------------------------------------------------------- Expecting a call
295
296/// Expecting a call (once "Let every call through") as this device knows it: the shared switch, and
297/// on the phone its notification's own press, the later of the two.
298pub(crate) fn through_now(switch: &Switch) -> Option<Through> {
299    let shared = rules::through_of(switch);
300    let local: Option<Through> = if cfg!(target_os = "android") { rules::read_json(&path(rules::THROUGH_HERE)) } else { None };
301    match (shared, local) {
302        (Some(shared), Some(local)) => Some(if local.pressed > shared.pressed { local } else { shared }),
303        (shared, local) => shared.or(local),
304    }
305}
306
307/// "15:30", "tomorrow at 09:31".
308fn words_at(ms: i64, now: &Zoned) -> String {
309    jiff::Timestamp::from_millisecond(ms).map(|t| sioul_core::quiet::until_text(tr(), &t.to_zoned(now.time_zone().clone()), now)).unwrap_or_default()
310}
311
312/// Pressed in the window, on any device: every call rings for `minutes` (0:
313/// until turned off), or calls are screened again; on every device once the
314/// sharing carries it. The caller writes the phone's table next (`refresh`,
315/// off the window's thread).
316pub(crate) fn press(on: bool, minutes: i32) -> Result<(), String> {
317    let here = here_id();
318    let now = jiff::Timestamp::now().as_millisecond();
319    let until = if on && minutes > 0 { now + i64::from(minutes) * 60_000 } else { 0 };
320    switches::change_own(&Switch::default_path(), &here, |switch| {
321        rules::press(switch, &here, on, until, now);
322        named(switch, &here);
323        true
324    })?;
325    Ok(())
326}
327
328/// The status line's part (main.qml, ReachCard.qml): {shown (a phone of
329/// yours screens calls), through, line, until_at}.
330pub(crate) fn moment() -> serde_json::Value {
331    // As every reader reads it (R-1): a gone phone screens nothing, a gone device expects no call.
332    let switch = switches::read_switch(&here_id());
333    let shown = screens_here() || rules::screens_anywhere(&switch);
334    if !shown {
335        return json!({ "shown": false, "through": false, "line": "", "until_at": 0 });
336    }
337    let now = Zoned::now();
338    let now_ms = now.timestamp().as_millisecond();
339    let through = through_now(&switch).filter(|t| t.holds(now_ms));
340    let emergency = if cfg!(target_os = "android") { state()["emergency_at"].as_i64().unwrap_or(0) } else { 0 };
341    let line = rules::through_line(tr(), through.as_ref(), emergency, now_ms, &|ms| words_at(ms, &now));
342    json!({ "shown": true, "through": through.is_some(), "line": line, "until_at": through.map_or(0, |t| t.until) })
343}
344
345/// At each step of the background service: the table made again if need be
346/// (at once after a press on the notification), and the notification's words
347/// for the calls (StepService): {screening, through, line, hour, off, again};
348/// nothing while this phone does not screen calls.
349pub(crate) fn step(pressed: bool) -> serde_json::Value {
350    if pressed {
351        forget_state();
352    }
353    refresh(pressed);
354    words()
355}
356
357/// The background service's notification's words for the calls, the table
358/// left as it is (`steps::note`, after each apply in Sioul's own process):
359/// {screening, through, line, hour, off, again}; nothing while this phone does
360/// not screen calls.
361pub(crate) fn words() -> serde_json::Value {
362    if !screens_here() {
363        return json!({});
364    }
365    let said = moment();
366    json!({
367        "screening": true,
368        "through": said["through"],
369        "line": said["line"],
370        "hour": tr().text("calls-note-hour", None),
371        "off": tr().text("calls-note-off", None),
372        "again": tr().text("calls-note-again", None),
373    })
374}
375
376// ---------------------------------------------------------------- the log
377
378/// When this device last took its old lines out (ms, its clock): once a day.
379static TRIMMED: Mutex<i64> = Mutex::new(0);
380/// Java's file as last copied in this process: its size and time.
381static COPIED: Mutex<Option<(u64, Option<SystemTime>)>> = Mutex::new(None);
382
383/// Before an exchange (the window's, a quick one: the background step, a
384/// dose's alarm) and before the list is read: on a phone, Java's new calls
385/// copied into its own log, which the sharing carries (`calls::carry_into`);
386/// on every device, once a day, its own lines taken out after their month,
387/// and the others' long after theirs (a phone gone for good never takes its
388/// own out), and a phone's older seen file made a line of its seen log. Cheap
389/// when nothing changed: Java's file is read again only when it changed.
390pub(crate) fn before_exchange() {
391    let here = here_id();
392    if here.is_empty() {
393        return;
394    }
395    let root = rules::folder();
396    let now = jiff::Timestamp::now().as_millisecond();
397    let due = TRIMMED.lock().map(|mut last| a_day_since(&mut last, now)).unwrap_or(false);
398    if due {
399        let done = rules::adopt_older_seen(&root, &here, now).and_then(|()| rules::trim_own(&root, &here, now)).and_then(|_| rules::trim_others(&root, &here, now));
400        if let Err(e) = done {
401            eprintln!("sioul: calls: {e}");
402        }
403    }
404    if !phone() {
405        return;
406    }
407    let java = path(rules::HELD);
408    let Some(stamp) = std::fs::metadata(&java).ok().map(|m| (m.len(), m.modified().ok())) else { return };
409    let mut copied = COPIED.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
410    if *copied == Some(stamp) {
411        return;
412    }
413    let region = sioul_core::reach::region(&load_config());
414    match rules::carry_into(&rules::own_file(&root, rules::LOG, &here), &rules::read_held(&java), region, now) {
415        Ok(_) => *copied = Some(stamp),
416        Err(e) => eprintln!("sioul: calls: {e}"),
417    }
418}
419
420/// Whether a day went by since `last` (ms); when it did, `last` becomes
421/// `now`, so that the next comes a day after this one.
422fn a_day_since(last: &mut i64, now: i64) -> bool {
423    let due = now - *last > 86_400_000;
424    if due {
425        *last = now;
426    }
427    due
428}
429
430// ---------------------------------------------------------------- the list
431
432/// Who a call's caller is now, as your lists and address books say (a number
433/// only in the phone's contacts: neutral); none for a hidden number.
434fn who_now(senders: &Senders, held: &Held) -> Option<Who> {
435    if held.hidden {
436        return None;
437    }
438    let who = senders.judge_number(&held.key).who;
439    Some(if who == Who::Stranger && !held.name.trim().is_empty() { Who::Neutral } else { who })
440}
441
442/// The calls a device lists, every phone's of the month (`everywhere`:
443/// `[porch] calls`, the default), or this device's own alone.
444fn listed(root: &Path, here: &str, everywhere: bool, now_ms: i64) -> Vec<Held> {
445    let mut calls = rules::read_logs(root, now_ms);
446    if !everywhere {
447        calls.retain(|h| h.device == here);
448    }
449    calls
450}
451
452/// Your phones in the sharing, each with its name (a phone's model), for the
453/// list's words ("your phone (GS290)"); read again five minutes on at most.
454pub(crate) fn phone_names() -> Vec<(String, String)> {
455    type Kept = Option<(i64, Vec<(String, String)>)>;
456    static KEPT: Mutex<Kept> = Mutex::new(None);
457    let now = jiff::Timestamp::now().as_second();
458    if let Ok(kept) = KEPT.lock()
459        && let Some((at, names)) = kept.as_ref()
460        && (0..300).contains(&(now - at))
461    {
462        return names.clone();
463    }
464    let names: Vec<(String, String)> = match crate::share::vault() {
465        Some((_, Some((folder, key)))) => sioul_sync::devices::all(&folder, &key).0.into_iter().filter(|d| d.kind == sioul_sync::devices::PHONE).map(|d| (d.id, d.name)).collect(),
466        _ => Vec::new(),
467    };
468    if let Ok(mut kept) = KEPT.lock() {
469        *kept = Some((now, names.clone()));
470    }
471    names
472}
473
474/// Whether this device has apps for `tel:` and `sms:` links: a phone always;
475/// a computer when its system names one (Linux: `xdg-mime`, KDE Connect,
476/// which hands them to your phone, or a softphone). Looked up once.
477pub(crate) fn link_apps() -> (bool, bool) {
478    static FOUND: std::sync::OnceLock<(bool, bool)> = std::sync::OnceLock::new();
479    *FOUND.get_or_init(|| {
480        if cfg!(target_os = "android") {
481            return (true, true);
482        }
483        let has = |scheme: &str| {
484            cfg!(target_os = "linux")
485                && std::process::Command::new("xdg-mime")
486                    .args(["query", "default", &format!("x-scheme-handler/{scheme}")])
487                    .output()
488                    .ok()
489                    .filter(|out| out.status.success())
490                    .is_some_and(|out| !String::from_utf8_lossy(&out.stdout).trim().is_empty())
491        };
492        (has("tel"), has("sms"))
493    })
494}
495
496/// The Porch's list of calls declined (CallsSection.qml): {lines, phone (this
497/// device is one), links: {tel, sms}}; each line shown at the times its
498/// caller's Calls row lets them through (a row that rings at no time at all,
499/// in work and admin time: `Attention::listed`), on every device: this
500/// phone's own calls, and every phone's while `[porch] calls` says so.
501pub(crate) fn view() -> String {
502    let empty = || json!({ "lines": [] }).to_string();
503    let root = rules::folder();
504    // No phone ever shared a call with this computer: nothing to read.
505    if !phone() && !root.join(rules::LOG).is_dir() {
506        return empty();
507    }
508    if phone() {
509        before_exchange();
510    }
511    let config = load_config();
512    let here = here_id();
513    let now = Zoned::now();
514    let held = listed(&root, &here, config.porch.calls, now.timestamp().as_millisecond());
515    if held.is_empty() {
516        return empty();
517    }
518    let senders = Senders::load(&config);
519    let attention = Attention::of(&config);
520    let moment = crate::hours::attention_now();
521    let always = People::load(&People::default_path());
522    let shows = |h: &Held| match who_now(&senders, h) {
523        None => attention.listed(Person::Hidden, false, &moment),
524        Some(who) => attention.listed(Person::of(who), always.admits_number(&h.key, senders.region()), &moment),
525    };
526    let name_of = |key: &str| {
527        let judged = senders.judge_number(key);
528        (!judged.card.trim().is_empty()).then_some(judged.card)
529    };
530    let mails = voicemails(&config, senders.region());
531    let messages: BTreeMap<String, rules::Message> = voicemail::link(&held, &mails)
532        .into_iter()
533        .map(|(id, i)| (id, rules::Message { text: rules::message_words(tr(), mails[i].seconds), path: mails[i].path.display().to_string(), sound: mails[i].sound }))
534        .collect();
535    let seen = rules::read_seen(&root);
536    let phones = phone_names();
537    let words = |device: &str| rules::phone_words(tr(), device, &here, &phones, cfg!(target_os = "android"));
538    let lister = rules::Lister { now: &now, tr: tr(), region: senders.region(), name_of: &name_of, shows: &shows, phone: &words };
539    let (tel, sms) = link_apps();
540    json!({ "lines": rules::lines(&held, &seen, &messages, &lister), "phone": cfg!(target_os = "android"), "links": { "tel": tel, "sms": sms } }).to_string()
541}
542
543/// What a moment lets through, and the time a list is seen from: the lines then.
544pub(crate) type CardCalls = Box<dyn Fn(&sioul_core::attention::Now, &Zoned) -> Vec<String>>;
545
546/// The calls declined as the phone's home card lists them (homecard.rs): the
547/// Porch's lines (`view`'s) at a moment and a time, each shown as that
548/// moment lets its caller through, its day said as seen from that time;
549/// without the voicemails, which the card does not say. Read once, for
550/// every frame of the card. None where no phone ever shared a call.
551pub(crate) fn for_card() -> Option<CardCalls> {
552    let root = rules::folder();
553    if !phone() && !root.join(rules::LOG).is_dir() {
554        return None;
555    }
556    let config = load_config();
557    let here = here_id();
558    let held = listed(&root, &here, config.porch.calls, Zoned::now().timestamp().as_millisecond());
559    if held.is_empty() {
560        return None;
561    }
562    let senders = Senders::load(&config);
563    let attention = Attention::of(&config);
564    let always = People::load(&People::default_path());
565    let seen = rules::read_seen(&root);
566    let phones = phone_names();
567    Some(Box::new(move |moment, at| {
568        let shows = |h: &Held| match who_now(&senders, h) {
569            None => attention.listed(Person::Hidden, false, moment),
570            Some(who) => attention.listed(Person::of(who), always.admits_number(&h.key, senders.region()), moment),
571        };
572        let name_of = |key: &str| {
573            let judged = senders.judge_number(key);
574            (!judged.card.trim().is_empty()).then_some(judged.card)
575        };
576        let words = |device: &str| rules::phone_words(tr(), device, &here, &phones, cfg!(target_os = "android"));
577        let lister = rules::Lister { now: at, tr: tr(), region: senders.region(), name_of: &name_of, shows: &shows, phone: &words };
578        rules::lines(&held, &seen, &BTreeMap::new(), &lister).into_iter().map(|line| line.text).collect()
579    }))
580}
581
582/// A person's calls of the last month (`numbers`: theirs, as written), for
583/// their sheet (`reaches::person`): a sentence each, newest first, from every
584/// phone, rang or declined; none where no phone shares its calls, and never a
585/// blocked caller's (their calls never leave their phone).
586pub(crate) fn history(numbers: &[String]) -> Vec<String> {
587    let root = rules::folder();
588    if numbers.is_empty() || !root.join(rules::LOG).is_dir() {
589        return Vec::new();
590    }
591    let config = load_config();
592    let region = sioul_core::reach::region(&config);
593    let keys: std::collections::BTreeSet<String> = numbers.iter().map(|n| sioul_core::phones::key(n.trim().trim_start_matches(sioul_core::porch::TEL), region)).filter(|k| sioul_core::phones::is_whole(k)).collect();
594    if keys.is_empty() {
595        return Vec::new();
596    }
597    let here = here_id();
598    let now = Zoned::now();
599    let calls = rules::read_logs(&root, now.timestamp().as_millisecond());
600    let phones = phone_names();
601    let words = |device: &str| rules::phone_words(tr(), device, &here, &phones, cfg!(target_os = "android"));
602    let (none, all) = (|_: &str| None, |_: &Held| true);
603    let lister = rules::Lister { now: &now, tr: tr(), region, name_of: &none, shows: &all, phone: &words };
604    rules::history(&calls, &keys, &lister)
605}
606
607/// The voicemails your operators mailed lately (`[words.voicemail]`: their
608/// domains, none until you name yours), from every account's inbox: each
609/// folder listed again only when it changed (a letter came, or was read),
610/// each letter read once (by its file's name) while the words stay the same;
611/// never the sound itself, kept in memory.
612fn voicemails(config: &Config, region: Option<&'static sioul_core::phones::Region>) -> Vec<Voicemail> {
613    type Folder = (Option<SystemTime>, Vec<Voicemail>);
614    static FOLDERS: Mutex<BTreeMap<PathBuf, Folder>> = Mutex::new(BTreeMap::new());
615    static LETTERS: Mutex<BTreeMap<String, Option<Voicemail>>> = Mutex::new(BTreeMap::new());
616    static READ_WITH: Mutex<Option<sioul_core::words::VoicemailWords>> = Mutex::new(None);
617    let words = sioul_core::words::Words::of(config).voicemail.clone();
618    if words.operators.iter().all(|d| d.trim().is_empty()) {
619        return Vec::new();
620    }
621    let zone = jiff::tz::TimeZone::system();
622    let oldest = SystemTime::now().checked_sub(Duration::from_secs(u64::try_from((rules::LISTED_DAYS + 1) * 86_400).unwrap_or(0))).unwrap_or(SystemTime::UNIX_EPOCH);
623    let mut folders = FOLDERS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
624    let mut letters = LETTERS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
625    // Other words: every letter read again.
626    let mut read_with = READ_WITH.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
627    if read_with.as_ref() != Some(&words) {
628        folders.clear();
629        letters.clear();
630        *read_with = Some(words.clone());
631    }
632    let mut found = Vec::new();
633    for source in config.mail_sources() {
634        for folder in [source.folder.join("new"), source.folder.join("cur")] {
635            let stamp = changed(&folder);
636            if let Some((known, kept)) = folders.get(&folder)
637                && *known == stamp
638                && stamp.is_some()
639            {
640                found.extend(kept.iter().cloned());
641                continue;
642            }
643            let mut here = Vec::new();
644            if let Ok(entries) = std::fs::read_dir(&folder) {
645                for entry in entries.flatten() {
646                    let name = entry.file_name().to_string_lossy().to_string();
647                    if name.starts_with('.') || entry.metadata().ok().filter(|m| m.is_file()).and_then(|m| m.modified().ok()).is_none_or(|m| m < oldest) {
648                        continue;
649                    }
650                    let file = entry.path();
651                    let unique = format!("{}/{}", source.account.as_deref().unwrap_or(""), sioul_core::maildir::unique_part(&name));
652                    let read = letters.entry(unique).or_insert_with(|| read_voicemail(&words, &file, &source.trusted_ids, region, &zone));
653                    if let Some(v) = read {
654                        here.push(Voicemail { path: file.clone(), ..v.clone() });
655                    }
656                }
657            }
658            found.extend(here.iter().cloned());
659            folders.insert(folder, (stamp, here));
660        }
661    }
662    // Letters older than the list forgotten.
663    let kept: std::collections::BTreeSet<PathBuf> = found.iter().map(|v| v.path.clone()).collect();
664    if letters.len() > 4096 {
665        letters.retain(|_, v| v.as_ref().is_some_and(|v| kept.contains(&v.path)));
666    }
667    found
668}
669
670fn read_voicemail(words: &sioul_core::words::VoicemailWords, file: &Path, trusted: &[String], region: Option<&'static sioul_core::phones::Region>, zone: &jiff::tz::TimeZone) -> Option<Voicemail> {
671    use std::io::Read;
672    // The head first: most letters are not an operator's, and some are large.
673    let mut head = Vec::with_capacity(16 * 1024);
674    std::fs::File::open(file).ok()?.take(16 * 1024).read_to_end(&mut head).ok()?;
675    if !voicemail::may_be(&words.operators, &head) {
676        return None;
677    }
678    let raw = std::fs::read(file).ok()?;
679    voicemail::read(words, &raw, file, region, zone, trusted)
680}
681
682/// A voicemail's sound written where the player reads it: its address
683/// ("file://…"). Only one found among your mail: never another file.
684fn listen(mail: &str, sound: u32) -> Result<String, String> {
685    let config = load_config();
686    let file = PathBuf::from(mail);
687    let region = sioul_core::reach::region(&config);
688    if !voicemails(&config, region).iter().any(|v| v.path == file && v.sound == Some(sound)) {
689        return Err(tr().text("calls-listen-gone", None));
690    }
691    let (_, bytes) = sioul_core::reading::attachment(&file, sound).ok_or_else(|| tr().text("calls-listen-gone", None))?;
692    let dir = sioul_core::config::cache_dir().join(rules::FOLDER);
693    std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
694    // A name of its own per message: a player keeps what it read at an address.
695    let name = format!("{}-{sound}.wav", sioul_core::maildir::unique_part(&file.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()).replace(['/', '\\', ':'], "_"));
696    let out = dir.join(name);
697    std::fs::write(&out, bytes).map_err(|e| e.to_string())?;
698    // The older sounds go: those of the newest messages stay.
699    if let Ok(entries) = std::fs::read_dir(&dir) {
700        let mut sounds: Vec<(SystemTime, PathBuf)> = entries.flatten().filter_map(|e| Some((e.metadata().ok()?.modified().ok()?, e.path()))).collect();
701        sounds.sort();
702        let extra = sounds.len().saturating_sub(SOUNDS_KEPT);
703        for (_, old) in sounds.into_iter().take(extra) {
704            let _ = std::fs::remove_file(old);
705        }
706    }
707    Ok(format!("file://{}", out.display()))
708}
709
710/// An action of the list (CallsSection.qml): "seen" {ids}, "unseen" {at}
711/// (Seen undone, within its ten seconds), "add-contact" {number} (Android's
712/// form; a computer opens Sioul's own), "block" {number}, "listen" {path,
713/// sound}, "open-blocked". Answers {said, url, shared, at} (`shared`: what
714/// travels changed, to share now; `at`: the Seen to undo).
715pub(crate) fn act(verb: &str, json: &str) -> String {
716    let asked: serde_json::Value = serde_json::from_str(json).unwrap_or_default();
717    let text = |key: &str| asked[key].as_str().unwrap_or_default().trim().to_string();
718    let mut at = 0;
719    let (said, url, shared) = match verb {
720        // In this device's own seen log, which the sharing carries: gone from every device's Porch.
721        "seen" => {
722            let ids: Vec<String> = asked["ids"].as_array().map(|a| a.iter().filter_map(|i| i.as_str().map(str::to_string)).collect()).unwrap_or_default();
723            match rules::mark_seen(&rules::folder(), &here_id(), &ids, jiff::Timestamp::now().as_millisecond()) {
724                Ok(pressed) => {
725                    at = pressed;
726                    (tr().text("calls-seen-said", None), String::new(), true)
727                }
728                Err(e) => (e, String::new(), false),
729            }
730        }
731        // This device's own Seen undone; another device's stays.
732        "unseen" => match rules::unmark_seen(&rules::folder(), &here_id(), asked["at"].as_i64().unwrap_or(0)) {
733            Ok(gone) => (String::new(), String::new(), gone),
734            Err(e) => (e, String::new(), false),
735        },
736        // Android's own form for a new contact, the number filled in: saved by you, or not.
737        "add-contact" => {
738            crate::steps::java("add-contact", &json!({ "name": "", "phones": [text("number")], "emails": [] }).to_string());
739            (String::new(), String::new(), false)
740        }
741        // On your blocked list, as a number (`tel:`): refused whenever they call, on every device's list.
742        "block" => {
743            let number = text("number");
744            if number.is_empty() {
745                (String::new(), String::new(), false)
746            } else {
747                // The table written again by the caller, off the window's thread.
748                match sioul_core::porch::set_standing(&load_config(), &format!("{}{number}", sioul_core::porch::TEL), Standing::Blocked) {
749                    Ok(()) => (tr().text("calls-blocked-said", None), String::new(), true),
750                    Err(e) => (e, String::new(), false),
751                }
752            }
753        }
754        "listen" => match listen(&text("path"), u32::try_from(asked["sound"].as_u64().unwrap_or(0)).unwrap_or(0)) {
755            Ok(url) => (String::new(), url, false),
756            Err(e) => (e, String::new(), false),
757        },
758        "open-blocked" => {
759            crate::steps::java("calls-open-blocked", "{}");
760            (String::new(), String::new(), false)
761        }
762        _ => (String::new(), String::new(), false),
763    };
764    json!({ "said": said, "url": url, "shared": shared, "at": at }).to_string()
765}
766
767// ---------------------------------------------------------------- Settings ▸ Calls
768
769/// Settings ▸ Calls (CallsSetup.qml): {android, state, moment}; the table
770/// made at once when the role was just given. Who rings when is What
771/// reaches you's (its Calls rows, Always through).
772pub(crate) fn setup() -> String {
773    forget_state();
774    let state = state();
775    if state["held"] == true && !path(rules::TABLE).is_file() {
776        std::thread::spawn(|| refresh(true));
777    }
778    json!({
779        "android": cfg!(target_os = "android"),
780        "state": state,
781        "moment": moment(),
782        "screening": screening_rows(&load_config(), &Zoned::now()),
783    })
784    .to_string()
785}
786
787/// A row of the call screening's settings, as `SettingRow.qml` shows it.
788fn row(key: &str, kind: &str, label: &str, help: &str, value: serde_json::Value, choices: &[(&str, String)]) -> serde_json::Value {
789    json!({
790        "key": format!("screening.{key}"),
791        "kind": kind,
792        "label": label,
793        "help": help,
794        "value": value,
795        "choices": choices.iter().map(|(value, label)| json!({ "value": value, "label": label })).collect::<Vec<_>>(),
796    })
797}
798
799/// Settings ▸ This phone ▸ Calls ▸ Call screening: {title, intro, rows,
800/// hears, expecting}; each row at its value now (`Screening::of`: the
801/// first values while unsaid); numbers nobody knows said with ARCEP's list of
802/// the blocks it gave to no operator, its day, and until when a call's line
803/// says it (it decides nothing: review of part 2, F1).
804fn screening_rows(config: &Config, now: &Zoned) -> serde_json::Value {
805    let tr = tr();
806    let text = |id: &str| tr.text(id, None);
807    let s = Screening::of(config);
808    let rules = [("voicemail", text("screening-rule-voicemail")), ("work", text("screening-rule-work")), ("ring", text("screening-rule-ring"))];
809    let spam = [("voicemail", text("screening-spam-voicemail")), ("unknown", text("screening-spam-unknown"))];
810    // The blocks attributed to nobody: until when Sioul's copy of ARCEP's list is said.
811    let today = now.date();
812    let unattributed = numbers().group.iter().find(|g| g.kind == "unattributed");
813    let unknown_help = match unattributed {
814        Some(g) => {
815            let day = |d: jiff::civil::Date| format!("{} {}", tr.day_month(d), d.year());
816            let read = g.checked.parse::<jiff::civil::Date>().ok();
817            let until = read.zip(g.valid_days).and_then(|(r, days)| r.checked_add(jiff::Span::new().days(days)).ok());
818            let mut args = sioul_core::i18n::args();
819            args.set("date", read.map(day).unwrap_or_default());
820            args.set("until", until.map(day).unwrap_or_default());
821            tr.text(if g.counts(today) { "screening-unknown-help" } else { "screening-unknown-help-old" }, Some(&args))
822        }
823        None => text("screening-unknown-help-old"),
824    };
825    json!({
826        "title": text("screening-title"),
827        "intro": text("screening-intro"),
828        "rows": [
829            row("on", "bool", &text("screening-on"), &text("screening-on-help"), json!(s.on), &[]),
830            row("hours", "choice", &text("screening-hours"), &text("screening-hours-help"), json!(s.hours.id()), &[("always", text("screening-hours-always")), ("outside-work", text("screening-hours-outside-work"))]),
831            row("unknown", "choice", &text("screening-unknown"), &unknown_help, json!(s.unknown.id()), &rules),
832            row("hidden", "choice", &text("screening-hidden"), &text("screening-hidden-help"), json!(s.hidden.id()), &rules),
833            row("automated", "choice", &text("screening-automated"), &text("screening-automated-help"), json!(s.automated.id()), &spam),
834            row("impossible", "choice", &text("screening-impossible"), &text("screening-impossible-help"), json!(s.impossible.id()), &spam),
835            row("unverified", "choice", &text("screening-unverified"), &text("screening-unverified-help"), json!(s.unverified.id()), &spam),
836            row("called", "bool", &text("screening-called"), &text("screening-called-help"), json!(s.called), &[]),
837        ],
838        "hears": text("screening-hears"),
839        "expecting": text("screening-expecting"),
840    })
841}
842
843/// An action of Settings ▸ Calls: "ask-role" (Android's question), "open-roles"
844/// (its default apps, to change or stop), "allow-contacts", "open-blocked",
845/// "dial" {number} (the phone app with a code typed in, never called), "set"
846/// {key, value} (a setting of the call screening, `screening::set`; the table
847/// made again at once, off the window's thread). The tab again.
848pub(crate) fn setup_change(verb: &str, json: &str) -> String {
849    let asked: serde_json::Value = serde_json::from_str(json).unwrap_or_default();
850    match verb {
851        "set" => {
852            let key = asked["key"].as_str().unwrap_or_default();
853            let value = match &asked["value"] {
854                serde_json::Value::Bool(on) => sioul_core::config::SettingValue::Bool(*on),
855                other => sioul_core::config::SettingValue::Text(other.as_str().unwrap_or_default().to_string()),
856            };
857            match screening::set(&crate::backend::config_path(), key, &value) {
858                Ok(()) => {
859                    std::thread::spawn(|| refresh(true));
860                }
861                Err(e) => eprintln!("sioul: calls: {e}"),
862            }
863        }
864        "ask-role" => {
865            crate::steps::java("calls-ask-role", "{}");
866        }
867        "open-roles" => {
868            crate::steps::java("calls-open-roles", "{}");
869        }
870        "allow-contacts" => crate::steps::ask_contacts(),
871        "open-blocked" => {
872            crate::steps::java("calls-open-blocked", "{}");
873        }
874        // Only the codes that read where calls go: Sioul dials nothing else.
875        "dial" => {
876            let number = asked["number"].as_str().unwrap_or_default();
877            if ["*#61#", "*#62#", "*#67#"].contains(&number) {
878                crate::steps::java("calls-dial", &json!({ "number": number }).to_string());
879            }
880        }
881        _ => {}
882    }
883    setup()
884}
885
886#[cfg(test)]
887mod tests {
888    use super::*;
889
890    /// On a computer, without SIOUL_CALLS and no phone's log here: nothing
891    /// listed, no table written, nothing asked of Java; nothing read but
892    /// whether the phones' logs are there.
893    #[test]
894    fn nothing_is_written_nor_asked_on_a_computer() {
895        if phone() || rules::folder().join(rules::LOG).is_dir() {
896            return;
897        }
898        refresh(true);
899        assert_eq!(view(), json!({ "lines": [] }).to_string());
900        assert!(history(&["+33199001234".to_string()]).is_empty());
901        assert_eq!(step(false), json!({}));
902        assert_eq!(state(), serde_json::Value::Null);
903        assert!(!screens_here());
904        assert!(!path(rules::TABLE).exists());
905    }
906
907    /// Review of part 3, F4: the calls' table's layers read the switch anew
908    /// each time: Do not disturb pressed on the laptop while its Reachable
909    /// lasted is the layer at once, Reachable gone from it, so that the
910    /// people you know never ring under it from a reading kept.
911    #[test]
912    fn the_table_s_layers_read_the_switch_anew() {
913        let _files = crate::everywhere::FILES.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
914        crate::everywhere::files_cleared();
915        let ms = jiff::Timestamp::now().as_millisecond();
916        let device = |pressed: i64, state: switches::State, until: i64, seen: i64| switches::Device { kind: "computer".into(), pressed, on: state == switches::State::Dnd, mode: state.id().into(), until, seen, at: pressed, ..switches::Device::default() };
917        let save = |d: switches::Device| Switch { device: [("laptop-1".to_string(), d)].into_iter().collect(), ..Switch::default() }.save(&Switch::default_path()).unwrap();
918        save(device(ms - 60_000, switches::State::Reachable, ms + 3_600_000, 0));
919        assert!(crate::everywhere::reachable().is_some() && layers().reachable.is_some());
920        save(device(ms - 1_000, switches::State::Dnd, 0, ms - 60_000));
921        let now = layers();
922        assert!(now.reachable.is_none() && now.dnd.is_some(), "{now:?}");
923        crate::everywhere::files_cleared();
924    }
925
926    /// Re-check of part 3, R-1, end to end as on a phone (`SIOUL_CALLS`: this
927    /// computer writes the calls' table as a phone does), in a process of its
928    /// own with folders of its own (`gone_expecting_a_call_in_the_table`): a
929    /// forgotten laptop's Expecting a call until turned off opens nothing,
930    /// in the table nor in the status line; its "Screen calls again" still
931    /// ends the phone's.
932    #[test]
933    fn a_gone_device_never_opens_the_screening() {
934        let folders = std::env::temp_dir().join(format!("sioul-calls-gone-{}", std::process::id()));
935        let _ = std::fs::remove_dir_all(&folders);
936        for dir in ["config", "data", "state", "cache"] {
937            std::fs::create_dir_all(folders.join(dir)).unwrap();
938        }
939        let ran = std::process::Command::new(std::env::current_exe().unwrap())
940            .args(["--exact", "calls::tests::gone_expecting_a_call_in_the_table", "--ignored", "--test-threads=1"])
941            .env("SIOUL_CALLS", "1")
942            .env("XDG_CONFIG_HOME", folders.join("config"))
943            .env("XDG_DATA_HOME", folders.join("data"))
944            .env("XDG_STATE_HOME", folders.join("state"))
945            .env("XDG_CACHE_HOME", folders.join("cache"))
946            .output()
947            .unwrap();
948        let said = String::from_utf8_lossy(&ran.stdout).to_string() + &String::from_utf8_lossy(&ran.stderr);
949        let _ = std::fs::remove_dir_all(&folders);
950        assert!(ran.status.success() && said.contains("1 passed"), "{said}");
951    }
952
953    /// The case itself, run by `a_gone_device_never_opens_the_screening` alone.
954    #[test]
955    #[ignore]
956    fn gone_expecting_a_call_in_the_table() {
957        assert!(phone(), "SIOUL_CALLS");
958        const DAY: i64 = 86_400_000;
959        let ms = jiff::Timestamp::now().as_millisecond();
960        std::fs::create_dir_all(switches::gone_path().parent().unwrap()).unwrap();
961        std::fs::write(switches::gone_path(), r#"["old-laptop"]"#).unwrap();
962        // A phone of yours screens calls; the old laptop expected a call until turned off, then was forgotten.
963        let mut switch = Switch::default();
964        rules::set_screens(&mut switch, "phone-1", true);
965        rules::press(&mut switch, "old-laptop", true, 0, ms - 2 * DAY);
966        switch.save(&Switch::default_path()).unwrap();
967        refresh(true);
968        let table: rules::Table = rules::read_json(&path(rules::TABLE)).unwrap();
969        assert!(table.expecting.is_none_or(|e| !e.holds(ms)), "{:?}", table.expecting);
970        assert_eq!(moment()["through"], false);
971        // The other way: the phone expected a call until turned off, the old laptop said screen again.
972        let mut switch = Switch::default();
973        rules::set_screens(&mut switch, "phone-1", true);
974        rules::press(&mut switch, "phone-1", true, 0, ms - 3 * DAY);
975        rules::press(&mut switch, "old-laptop", false, 0, ms - 2 * DAY);
976        switch.save(&Switch::default_path()).unwrap();
977        refresh(true);
978        let table: rules::Table = rules::read_json(&path(rules::TABLE)).unwrap();
979        assert!(table.expecting.is_none_or(|e| !e.holds(ms)), "{:?}", table.expecting);
980        assert_eq!(moment()["through"], false);
981    }
982
983    /// A device lists every phone's calls while `[porch] calls` says so (the
984    /// default); else only its own: a phone its own, a computer none.
985    #[test]
986    fn a_device_lists_the_phones_calls_as_the_setting_says() {
987        let root = std::env::temp_dir().join(format!("sioul-app-calls-listed-{}", std::process::id()));
988        let _ = std::fs::remove_dir_all(&root);
989        let now = jiff::Timestamp::now().as_millisecond();
990        let call = |at: i64, key: &str| Held { at, key: key.into(), number: key.into(), who: "stranger".into(), column: "sleep".into(), why: "matrix".into(), ..Held::default() };
991        rules::carry_into(&rules::own_file(&root, rules::LOG, "phone-a"), &[call(now - 3_600_000, "+33199001234")], None, now).unwrap();
992        rules::carry_into(&rules::own_file(&root, rules::LOG, "phone-b"), &[call(now - 1_800_000, "+33465710042")], None, now).unwrap();
993        assert_eq!(listed(&root, "desk", true, now).iter().map(|h| h.device.as_str()).collect::<Vec<_>>(), ["phone-a", "phone-b"]);
994        assert!(listed(&root, "desk", false, now).is_empty(), "the setting off: a computer lists none");
995        assert_eq!(listed(&root, "phone-a", false, now).len(), 1, "a phone its own");
996        let _ = std::fs::remove_dir_all(&root);
997    }
998
999    /// The table this phone writes: version 2, the call screening as the
1000    /// settings say (its first values while unsaid), the numbers it knows by
1001    /// their kind that count today, Expecting a call as your devices last
1002    /// pressed it; whether the screening lets numbers nobody knows ring, for
1003    /// the phone's modes; its settings' rows at their values.
1004    #[test]
1005    fn the_phone_writes_the_screening_into_its_table() {
1006        let now: Zoned = "2026-10-20T10:00:00+02:00[Europe/Paris]".parse().unwrap();
1007        let config = Config::default();
1008        let mut switch = Switch::default();
1009        rules::press(&mut switch, "desk", true, 0, now.timestamp().as_millisecond());
1010        let table = make(&config, &switch, &now);
1011        assert_eq!((table.v, table.through), (rules::VERSION, None));
1012        assert!(table.expecting.is_some_and(|e| e.on && e.holds(now.timestamp().as_millisecond())));
1013        let s = table.screening.clone().unwrap();
1014        assert_eq!((s.on, s.hours, s.unknown, s.hidden, s.called_days), (true, screening::Hours::Always, screening::Rule::Voicemail, screening::Rule::Work, screening::CALLED_DAYS));
1015        // What can never be a caller's: 089 alone; the blocks attributed to nobody on a day decide nothing (review of part 2, F1).
1016        assert!(s.automated_prefixes.contains(&"0162".to_string()));
1017        assert_eq!(s.impossible_prefixes, vec!["089".to_string()]);
1018        assert!(table.frames.iter().all(|f| !f.ring.contains_key("stranger") && !f.ring.contains_key("hidden")));
1019        let later: Zoned = "2027-05-08T10:00:00+02:00[Europe/Paris]".parse().unwrap();
1020        assert_eq!(make(&config, &switch, &later).screening.unwrap().impossible_prefixes, vec!["089".to_string()]);
1021        // The owner's hidden numbers, held at every time: voicemail at all hours, from his row.
1022        let held: Config = toml::from_str("[attention]\n\"calls.hidden\" = [\"work:later\", \"admin:later\", \"leisure:later\", \"meals:later\", \"sleep:later\", \"pause:later\", \"free:later\", \"slot:later\", \"dnd:later\"]\n").unwrap();
1023        assert_eq!(make(&held, &switch, &now).screening.unwrap().hidden, screening::Rule::Voicemail);
1024        // For the phone's modes: hidden numbers ring in work hours as usual; his never; all, the screening off.
1025        assert!(unknown_rings(&config, true) && !unknown_rings(&config, false));
1026        assert!(!unknown_rings(&held, true) && !unknown_rings(&held, false));
1027        let off: Config = toml::from_str("[screening]\non = false\n").unwrap();
1028        assert!(unknown_rings(&off, false));
1029        // The settings' rows: eight, each at its value, with its words.
1030        let rows = screening_rows(&held, &now);
1031        let keys: Vec<&str> = rows["rows"].as_array().unwrap().iter().map(|r| r["key"].as_str().unwrap()).collect();
1032        assert_eq!(keys, ["screening.on", "screening.hours", "screening.unknown", "screening.hidden", "screening.automated", "screening.impossible", "screening.unverified", "screening.called"]);
1033        assert_eq!((rows["rows"][0]["value"].as_bool(), rows["rows"][3]["value"].as_str(), rows["rows"][3]["choices"].as_array().unwrap().len()), (Some(true), Some("voicemail"), 3));
1034        assert!(rows["rows"].as_array().unwrap().iter().all(|r| !r["label"].as_str().unwrap().is_empty() && !r["label"].as_str().unwrap().starts_with("screening-")));
1035        // Numbers nobody knows: the day ARCEP's list of the blocks it gave to no operator was read, and until
1036        // when a call's line says it (17 January 2027); past it, said so. 089 with the plan's date.
1037        let help = |rows: &serde_json::Value, at: usize| rows["rows"][at]["help"].as_str().unwrap().to_string();
1038        assert!(help(&rows, 2).contains("19") && help(&rows, 2).contains("17") && help(&rows, 2).contains("2027"), "{}", help(&rows, 2));
1039        let old = screening_rows(&held, &later);
1040        assert!(help(&old, 2).contains("19") && help(&old, 2) != help(&rows, 2), "{}", help(&old, 2));
1041        assert!(help(&rows, 5).contains("2026") && help(&rows, 5).contains("08 9"), "{}", help(&rows, 5));
1042    }
1043
1044    /// Old lines taken out once a day, whatever the exchanges in between.
1045    #[test]
1046    fn once_a_day() {
1047        let mut last = 0;
1048        let start = 1_791_346_200_000;
1049        assert!(a_day_since(&mut last, start), "the first time");
1050        assert!(!a_day_since(&mut last, start + 60_000) && !a_day_since(&mut last, start + 23 * 3_600_000), "not again the same day");
1051        assert!(a_day_since(&mut last, start + 86_400_001), "a day after the last");
1052        assert!(!a_day_since(&mut last, start + 86_400_001 + 60_000));
1053    }
1054}