Skip to main content

sioul_core/
calls.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Calls screened on a phone (docs/android.md, "Calls";
5//! docs/research/call-screening.md, section 8).
6//!
7//! Android asks Sioul about each call, as its "Caller ID & spam app", and
8//! waits for the answer before the phone rings: the answer must come in
9//! milliseconds, from Java alone (android/…/Calls.java), never from Sioul's
10//! library. So Sioul writes ahead, in its state folder, a **table**
11//! (`calls/table.json`): who each number is (by `phones::key`), the floors
12//! that always ring, "Let every call through" as your devices last said it,
13//! and the coming days cut into **frames**, each with whether each row of the
14//! Calls rows of the matrix of what reaches you rings then (`attention`):
15//! the times, Free time's cells, and the layers (today's slots of time for
16//! you, do-not-disturb from its switch or a focus session). Java decides in
17//! this order: the floors (an emergency number or its callback; a day after
18//! you call an emergency number); your blocked numbers, refused; "Let every
19//! call through"; a second call within 15 minutes, which rings; then the
20//! frame for now. No table, or past its frames: it rings. Always through
21//! (`everywhere::People`) is no floor any more: its people's numbers are
22//! written as rows of their own ("always-safe", their own row read where
23//! Always through says "as their list"), never a blocked one: blocked beats
24//! Always through (docs/attention.md, Q3).
25//!
26//! A refused call goes to your voicemail. Java writes **every incoming call it
27//! screened**, declined or let ring (`rang`), in a file of its own
28//! (`calls/held.jsonl`); Rust copies each into this phone's own **log**,
29//! `calls/log/<device>.jsonl` (`carry_into`), which the sharing carries,
30//! sealed, a line at a time, to your other devices (the part "Calls"). Every
31//! device reads every phone's log (`read_logs`): the Porch lists the declined
32//! calls calmly, at the next time those callers may reach you, never counted
33//! (`lines`); a person's sheet shows their calls of the month (`history`).
34//! **Seen** is each device's own log of lines (`calls/seen/<device>.jsonl`,
35//! `mark_seen`), every device's read together (`read_seen`): seen on one,
36//! gone from all. A call is kept `KEPT_DAYS`: each device takes its own lines
37//! out after that (`trim_own`), and the sharing takes them out everywhere.
38//!
39//! **Let every call through** is pressed on any device: each device's own
40//! table in do-not-disturb's switch file (`state/do-not-disturb.toml`,
41//! shared) carries its last press, `[device.<id>.calls]`; the latest press
42//! anywhere holds. The phone's notification writes its press in a file of
43//! its own (`calls/through-here.json`) that Java reads at once and Rust
44//! carries into the switch (`merge_local`).
45
46use crate::attention::{self, Attention, Level, Person};
47use crate::everywhere::{Device, People, Switch};
48use crate::i18n::Translator;
49use crate::phones::{self, Region};
50use crate::reach::{Channel, Clock, Who};
51use jiff::Zoned;
52use serde::{Deserialize, Serialize};
53use std::collections::{BTreeMap, BTreeSet};
54use std::path::{Path, PathBuf};
55
56/// The calls' folder, in the state folder.
57pub const FOLDER: &str = "calls";
58/// The table Java decides from.
59pub const TABLE: &str = "table.json";
60/// The calls screened, declined or let ring, one JSON line each (Java's,
61/// this phone's alone: Rust copies them into its log, `LOG`).
62pub const HELD: &str = "held.jsonl";
63/// The folder of the phones' logs of the calls they screened, one file per
64/// phone (`<device>.jsonl`), written by that phone alone; shared.
65pub const LOG: &str = "log";
66/// The folder of the calls marked seen, one file per device, written by
67/// that device alone; shared.
68pub const SEEN_LOG: &str = "seen";
69/// This phone's press of "Let every call through" on the notification (Java's).
70pub const THROUGH_HERE: &str = "through-here.json";
71/// When this phone last called an emergency number (Java's).
72pub const EMERGENCY: &str = "emergency.json";
73/// The calls seen on the list, as this phone kept them before Seen was
74/// shared: read once into its own seen log, then gone (`adopt_older_seen`).
75pub const SEEN: &str = "seen.toml";
76/// The table's shape: Java reads this one only (another one: every call rings).
77pub const VERSION: u32 = 1;
78/// A second call within this many minutes rings (research 7.3).
79pub const REPEAT_MINUTES: i64 = 15;
80/// Every call rings this many hours after you call an emergency number (research 7.1).
81pub const AFTER_EMERGENCY_HOURS: i64 = 24;
82/// How many days the table's frames cover, from today's midnight: it is
83/// written again long before, at each change and each step.
84pub const TABLE_DAYS: i64 = 4;
85/// A call refused stays on the list this long at most (days), seen or not.
86pub const LISTED_DAYS: i64 = 14;
87/// A call is kept this long (days), on every device: its phone takes it out
88/// of its log after that, and the sharing takes it out everywhere.
89pub const KEPT_DAYS: i64 = 31;
90/// Another device's line kept this long past `KEPT_DAYS` (days) is taken out
91/// by any device: its phone, gone for good, never will (`trim_others`).
92pub const LATE_DAYS: i64 = 14;
93/// A day, in milliseconds.
94const DAY_MS: i64 = 86_400_000;
95/// The key of the calls' own part in a device's table of the switch's file.
96const DEVICE_PART: &str = "calls";
97/// The French plan's overseas numbers, by the three digits after the 0 (as `phones` reads them).
98const FRENCH_OVERSEAS: [(&str, &str); 14] = [
99    ("262", "262"),
100    ("263", "262"),
101    ("269", "262"),
102    ("639", "262"),
103    ("692", "262"),
104    ("693", "262"),
105    ("590", "590"),
106    ("690", "590"),
107    ("691", "590"),
108    ("594", "594"),
109    ("694", "594"),
110    ("596", "596"),
111    ("696", "596"),
112    ("697", "596"),
113];
114
115/// The calls' folder.
116pub fn folder() -> PathBuf {
117    crate::config::state_dir().join(FOLDER)
118}
119
120// ---------------------------------------------------------------- the table
121
122/// What Java reads (Calls.Table). Times are Unix milliseconds.
123#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
124pub struct Table {
125    pub v: u32,
126    pub made: i64,
127    /// How the country of numbers written without one writes them; none: as written.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub region: Option<RegionRules>,
130    /// The country codes whose numbers lose a trunk "0" written after them ("+33 06…").
131    #[serde(default)]
132    pub trunk_zero: Vec<String>,
133    /// Each number known, by its key: "safe", "neutral", "restricted", "blocked".
134    #[serde(default)]
135    pub numbers: BTreeMap<String, String>,
136    /// The prefixes on your lists (`tel:+3346571*`), for a number no card and no
137    /// entry of its own names: the longest first, then blocked before
138    /// restricted before neutral before safe (`porch::Senders::judge_number`).
139    #[serde(default)]
140    pub prefixes: Vec<Prefix>,
141    /// Who a number in the phone's own contacts is, when this table does not know it (research CS6).
142    pub phone_contacts: String,
143    pub floors: Floors,
144    /// "Let every call through", the latest press of your devices.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub through: Option<Through>,
147    pub frames: Vec<Frame>,
148    pub repeat_minutes: i64,
149    pub emergency_hours: i64,
150}
151
152/// A country's way of writing numbers, as Java needs it (`phones::Region`).
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154pub struct RegionRules {
155    pub code: String,
156    pub calling: String,
157    pub trunk: String,
158    pub international: String,
159    pub digits: [usize; 2],
160    #[serde(default)]
161    pub french: bool,
162    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
163    pub overseas: BTreeMap<String, String>,
164}
165
166impl RegionRules {
167    pub fn of(region: &Region) -> RegionRules {
168        RegionRules {
169            code: region.code.to_string(),
170            calling: region.calling.to_string(),
171            trunk: region.trunk.to_string(),
172            international: region.international.to_string(),
173            digits: [region.digits.0, region.digits.1],
174            french: region.french,
175            overseas: if region.french { FRENCH_OVERSEAS.iter().map(|(p, c)| (p.to_string(), c.to_string())).collect() } else { BTreeMap::new() },
176        }
177    }
178}
179
180/// A prefix on a list, and who a number starting with it is.
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182pub struct Prefix {
183    /// A key's start: "+3346571".
184    pub prefix: String,
185    pub who: String,
186}
187
188/// The numbers that always ring.
189#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
190pub struct Floors {
191    /// Emergency numbers and the emergency services' callback numbers, every country's.
192    pub emergency: Vec<String>,
193    /// Numbers that ring whatever else says, the blocked's aside: none written
194    /// since Always through has rows of its own; kept for the Java that reads it.
195    pub people: Vec<String>,
196}
197
198/// A press of "Let every call through": when (ms, the pressing device's
199/// hybrid clock), on or off, until when (ms; 0: until turned off).
200#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
201pub struct Through {
202    #[serde(default)]
203    pub pressed: i64,
204    #[serde(default)]
205    pub on: bool,
206    #[serde(default)]
207    pub until: i64,
208}
209
210impl Through {
211    /// Whether it lets every call through at `now` (ms).
212    pub fn holds(&self, now: i64) -> bool {
213        self.on && (self.until <= 0 || now < self.until)
214    }
215}
216
217/// A stretch of time and, for each row of the Calls matrix, whether it rings.
218#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
219pub struct Frame {
220    pub from: i64,
221    pub until: i64,
222    /// "work", "admin", "leisure", "meals", "sleep", "pause", "free"; a layer
223    /// above them, "slot", "dnd": for the list's words.
224    pub column: String,
225    /// `ROWS` → rings.
226    pub ring: BTreeMap<String, bool>,
227}
228
229/// The rows a frame says, as Java looks a caller up: the states, hidden
230/// numbers, and Always through read against each state ("always-neutral":
231/// someone on the list whose own row is neutral).
232pub const ROWS: [&str; 9] = ["safe", "neutral", "restricted", "stranger", "hidden", "always-safe", "always-neutral", "always-restricted", "always-stranger"];
233
234/// A frame's row: its person, on the Always through list or not.
235fn person_of(row: &str) -> (Person, bool) {
236    let (always, own) = match row.strip_prefix("always-") {
237        Some(own) => (true, own),
238        None => (false, row),
239    };
240    (Person::read(own).unwrap_or(Person::Stranger), always)
241}
242
243/// What holds on top of the clock's times, ahead (docs/attention.md, §1.6):
244/// today's slots of time for you (`attention::Slots`), and do-not-disturb
245/// from its switch or a focus session, from, and until when (none: until
246/// turned off; the table is written again when it changes).
247#[derive(Debug, Clone, Default, PartialEq, Eq)]
248pub struct Layers {
249    pub slots: Vec<(i64, i64)>,
250    pub dnd: Option<(i64, Option<i64>)>,
251}
252
253/// The column a moment is said by: a layer that holds, else the time's (the
254/// pause, sleep, Free time, the hours; work when work and admin are both
255/// open, or no hours are set).
256fn column_of(now: &attention::Now) -> &'static str {
257    if now.dnd {
258        "dnd"
259    } else if now.slot {
260        "slot"
261    } else {
262        now.times.first().map_or("", |c| c.id())
263    }
264}
265
266/// The frames from `from` to `until`, each with whether each row rings then
267/// (`ROWS`): the clock's own frames (`reach::Clock::frames`), cut where a
268/// layer begins or ends; two in a row that say the same are one.
269pub fn frames(attention: &Attention, clock: &Clock, layers: &Layers, from: &Zoned, until: &Zoned) -> Vec<Frame> {
270    let mut out: Vec<Frame> = Vec::new();
271    for f in clock.frames(from, until) {
272        let mut cuts = vec![f.start, f.end];
273        let edges = layers.slots.iter().flat_map(|&(a, b)| [a, b]).chain(layers.dnd.iter().flat_map(|&(a, b)| [Some(a), b]).flatten());
274        cuts.extend(edges.filter(|t| *t > f.start && *t < f.end));
275        cuts.sort_unstable();
276        cuts.dedup();
277        for pair in cuts.windows(2) {
278            let (start, end) = (pair[0], pair[1]);
279            let slot = layers.slots.iter().any(|&(a, b)| a <= start && start < b);
280            let dnd = layers.dnd.is_some_and(|(a, b)| a <= start && b.is_none_or(|b| start < b));
281            let now = attention::Now::of_moment(&f.moment).layers(slot, dnd);
282            let ring: BTreeMap<String, bool> = ROWS
283                .iter()
284                .map(|row| {
285                    let (person, always) = person_of(row);
286                    (row.to_string(), attention.person(Channel::Calls, person, always, &now) == Level::Now)
287                })
288                .collect();
289            let column = column_of(&now).to_string();
290            let (from_ms, until_ms) = (start.saturating_mul(1000), end.saturating_mul(1000));
291            match out.last_mut() {
292                Some(last) if last.until == from_ms && last.column == column && last.ring == ring => last.until = until_ms,
293                _ => out.push(Frame { from: from_ms, until: until_ms, column, ring }),
294            }
295        }
296    }
297    out
298}
299
300/// The numbers of the people Always through, each as a row of its own read
301/// against who the number is (`judge`: "always-safe"), whole numbers only;
302/// never a blocked number: blocked beats Always through.
303pub fn always_numbers(people: &People, region: Option<&Region>, judge: &dyn Fn(&str) -> Who) -> Vec<(String, String)> {
304    people_keys(people, region).into_iter().filter_map(|key| match judge(&key) {
305        Who::Blocked => None,
306        who => Some((key, format!("always-{}", who.id()))),
307    }).collect()
308}
309
310/// What a table is made of.
311pub struct Made {
312    /// When (ms).
313    pub made: i64,
314    pub region: Option<&'static Region>,
315    /// Every number known, by key, and who it is ("safe"…, "blocked"; `porch::Senders::numbers`).
316    pub numbers: Vec<(String, String)>,
317    /// The lists' prefixes ("+3346571*") and who they make a number (`porch::Senders::prefixes`), in the order ties are decided.
318    pub prefixes: Vec<(String, String)>,
319    /// The Always through list's numbers, as rows of their own (`always_numbers`): after `numbers`, they win.
320    pub always: Vec<(String, String)>,
321    pub through: Option<Through>,
322    pub frames: Vec<Frame>,
323}
324
325/// The table, as Java reads it.
326pub fn table(made: Made) -> Table {
327    let mut numbers = BTreeMap::new();
328    for (key, who) in made.numbers {
329        if !key.is_empty() && !who.is_empty() && who != "stranger" {
330            numbers.insert(key, who);
331        }
332    }
333    // Always through: its people's numbers, as their rows; the blocked kept blocked.
334    for (key, row) in made.always {
335        if !key.is_empty() && numbers.get(&key).is_none_or(|who| who != "blocked") {
336            numbers.insert(key, row);
337        }
338    }
339    // The longest first; at one length, as given (blocked before restricted before neutral before safe).
340    let mut prefixes: Vec<Prefix> = made.prefixes.iter().filter_map(|(p, who)| Some(Prefix { prefix: p.trim_end_matches('*').to_string(), who: who.clone() }).filter(|p| !p.prefix.is_empty() && !p.prefix.contains('*') && !p.who.is_empty() && p.who != "stranger")).collect();
341    prefixes.sort_by_key(|p| std::cmp::Reverse(p.prefix.len()));
342    Table {
343        v: VERSION,
344        made: made.made,
345        region: made.region.map(RegionRules::of),
346        trunk_zero: trunk_zero(),
347        numbers,
348        prefixes,
349        phone_contacts: "neutral".to_string(),
350        floors: Floors { emergency: emergency_keys(made.region), people: Vec::new() },
351        through: made.through,
352        frames: made.frames,
353        repeat_minutes: REPEAT_MINUTES,
354        emergency_hours: AFTER_EMERGENCY_HOURS,
355    }
356}
357
358/// The table as written, without its time: two tables that say the same are the same.
359pub fn content(table: &Table) -> String {
360    serde_json::to_string(&Table { made: 0, ..table.clone() }).unwrap_or_default()
361}
362
363/// The country codes whose numbers drop a trunk "0" (`phones`' table).
364pub fn trunk_zero() -> Vec<String> {
365    let mut codes: Vec<String> = phones::REGIONS.iter().filter(|r| r.trunk == "0").map(|r| r.calling.to_string()).collect();
366    codes.sort();
367    codes.dedup();
368    codes
369}
370
371/// Emergency numbers, crisis lines and callback numbers of every country
372/// Sioul knows (`data/crisis-lines.toml`), keyed as each country writes them,
373/// and as the person's country would.
374pub fn emergency_keys(region: Option<&Region>) -> Vec<String> {
375    let lines = crate::pause::CrisisLines::built_in();
376    let mut keys = BTreeSet::new();
377    for country in &lines.country {
378        let own = phones::region_named(&country.code);
379        for line in &country.line {
380            for key in [phones::key(&line.number, own.or(region)), phones::key(&line.number, region), line.number.chars().filter(char::is_ascii_digit).collect()] {
381                if !key.is_empty() {
382                    keys.insert(key);
383                }
384            }
385        }
386    }
387    keys.into_iter().collect()
388}
389
390/// The numbers of the people on the do-not-disturb list, whole numbers only (`People::admits_number`).
391pub fn people_keys(people: &People, region: Option<&Region>) -> Vec<String> {
392    let keys: BTreeSet<String> = people.people.iter().flat_map(|p| p.phones.iter()).map(|n| phones::key(n, region)).filter(|k| phones::is_whole(k)).collect();
393    keys.into_iter().collect()
394}
395
396/// Written beside, then put in place: Java never reads half a table.
397pub fn write(path: &Path, table: &Table) -> Result<(), String> {
398    let text = serde_json::to_string(table).map_err(|e| e.to_string())?;
399    write_whole(path, &text)
400}
401
402fn write_whole(path: &Path, text: &str) -> Result<(), String> {
403    let fail = |e: std::io::Error| format!("{}: {e}", path.display());
404    if let Some(parent) = path.parent() {
405        std::fs::create_dir_all(parent).map_err(fail)?;
406    }
407    let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
408    let fresh = path.with_file_name(format!(".{name}.{}.new", std::process::id()));
409    std::fs::write(&fresh, text).map_err(fail)?;
410    std::fs::rename(&fresh, path).map_err(|e| {
411        let _ = std::fs::remove_file(&fresh);
412        fail(e)
413    })
414}
415
416// ---------------------------------------------------------------- the decision, as Java makes it
417
418/// A call as Java sees it (Calls.decide), what it knows beside the table.
419#[derive(Debug, Clone, Default, PartialEq, Eq)]
420pub struct Incoming {
421    /// The caller ID as the network gave it; "" for none.
422    pub number: String,
423    /// No number shown (restricted, unknown, payphone, or no digits).
424    pub hidden: bool,
425    /// Placed by the person, not received.
426    pub outgoing: bool,
427    /// Now (ms).
428    pub now: i64,
429    /// When the person last called an emergency number (ms), as this phone saw it; 0 never.
430    pub emergency_at: i64,
431    /// This phone's own press of "Let every call through" (`through-here.json`).
432    pub local: Option<Through>,
433    /// The number's last call before this one (ms); 0 none.
434    pub last_call: i64,
435    /// The number is in the phone's own contacts (Android's), whatever Sioul's address books say.
436    pub phone_contact: bool,
437    /// Android's own list of emergency numbers has it (`TelephonyManager.isEmergencyNumber`).
438    pub system_emergency: bool,
439}
440
441/// What Java answers, and why.
442#[derive(Debug, Clone, PartialEq, Eq)]
443pub struct Decision {
444    pub refuse: bool,
445    /// "outgoing", "no-table", "emergency", "after-emergency", "through", "people", "blocked", "repeat", "no-frame", "no-row", "matrix".
446    pub why: &'static str,
447    /// The row decided by: "safe", "neutral", "restricted", "stranger", "hidden", "blocked"; "" before.
448    pub who: String,
449    pub column: String,
450}
451
452impl Table {
453    /// Who a prefix on your lists makes this number (the longest first).
454    pub fn by_prefix(&self, key: &str) -> Option<&str> {
455        self.prefixes.iter().find(|p| key.starts_with(&p.prefix)).map(|p| p.who.as_str())
456    }
457
458    /// The frame `now` (ms) falls in; none before the first or past the last.
459    pub fn frame_at(&self, now: i64) -> Option<&Frame> {
460        self.frames.iter().find(|f| f.from <= now && now < f.until)
461    }
462}
463
464/// Java's decision (Calls.decide), step for step: the order's reference,
465/// tested here. Outgoing: rings (to an emergency number, Java marks the
466/// moment). Incoming: what always rings (an emergency number, a day after
467/// you called one, the do-not-disturb list); blocked; "Let every call
468/// through"; a second call within 15 minutes; the frame for now. Nothing to
469/// decide from: it rings.
470pub fn decide(table: Option<&Table>, call: &Incoming) -> Decision {
471    let ring = |why: &'static str, who: &str, column: &str| Decision { refuse: false, why, who: who.to_string(), column: column.to_string() };
472    if call.outgoing {
473        return ring("outgoing", "", "");
474    }
475    let Some(table) = table else { return ring("no-table", "", "") };
476    let key = if call.hidden { String::new() } else { incoming_key(&call.number, table.region.as_ref(), &table.trunk_zero) };
477    // 1. What always rings.
478    if !call.hidden && (call.system_emergency || table.floors.emergency.contains(&key)) {
479        return ring("emergency", "", "");
480    }
481    if call.emergency_at > 0 && call.now >= call.emergency_at && call.now - call.emergency_at < table.emergency_hours * 3_600_000 {
482        return ring("after-emergency", "", "");
483    }
484    if !call.hidden && table.floors.people.contains(&key) {
485        return ring("people", "", "");
486    }
487    // Who: the number's own, a prefix of your lists, a contact of the phone's only, else a stranger.
488    let who = if call.hidden {
489        "hidden".to_string()
490    } else {
491        table.numbers.get(&key).cloned().filter(|w| !w.is_empty()).or_else(|| table.by_prefix(&key).map(str::to_string)).unwrap_or_else(|| if call.phone_contact { table.phone_contacts.clone() } else { "stranger".to_string() })
492    };
493    // 2. Blocked ("blocked entirely": every call let through but theirs).
494    if who == "blocked" {
495        return Decision { refuse: true, why: "blocked", who, column: String::new() };
496    }
497    // 3. Let every call through: the later press of this phone's and the table's.
498    let through = match (call.local, table.through) {
499        (Some(local), Some(shared)) => Some(if local.pressed > shared.pressed { local } else { shared }),
500        (local, shared) => local.or(shared),
501    };
502    if through.is_some_and(|t| t.holds(call.now)) {
503        return ring("through", &who, "");
504    }
505    // 4. A second call within 15 minutes.
506    if !call.hidden && call.last_call > 0 && call.now - call.last_call <= table.repeat_minutes * 60_000 {
507        return ring("repeat", &who, "");
508    }
509    // 5. The frame for now.
510    let Some(frame) = table.frame_at(call.now) else { return ring("no-frame", &who, "") };
511    match frame.ring.get(&who) {
512        Some(rings) => Decision { refuse: !rings, why: "matrix", who, column: frame.column.clone() },
513        None => ring("no-row", &who, &frame.column),
514    }
515}
516
517// ---------------------------------------------------------------- numbers as Java keys them
518
519/// A caller ID as Java keys it (Calls.key, the same steps): `phones::key`
520/// for what a caller ID holds (no extension, no "(0)"). The tests check that
521/// the two agree.
522pub fn incoming_key(raw: &str, region: Option<&RegionRules>, trunk_zero: &[String]) -> String {
523    let mut value = raw.trim_matches(|c: char| c.is_whitespace() || spacing(c));
524    if value.len() >= 4 && value.is_char_boundary(4) && value[..4].eq_ignore_ascii_case("tel:") {
525        value = value[4..].trim();
526    }
527    let as_written = || value.chars().filter(|c| !c.is_whitespace()).collect::<String>().to_lowercase();
528    let mut digits = String::new();
529    for (i, c) in value.chars().enumerate() {
530        match c {
531            '+' if i == 0 => digits.push('+'),
532            '0'..='9' => digits.push(c),
533            '(' | ')' => {}
534            c if spacing(c) => {}
535            _ => return as_written(),
536        }
537    }
538    if digits.is_empty() || digits == "+" {
539        return as_written();
540    }
541    let drop_trunk = |digits: &str| -> String {
542        for length in 1..=3usize.min(digits.len().saturating_sub(1)) {
543            let (calling, rest) = digits.split_at(length);
544            if rest.starts_with('0') && trunk_zero.iter().any(|t| t == calling) {
545                return format!("{calling}{}", &rest[1..]);
546            }
547        }
548        digits.to_string()
549    };
550    if let Some(international) = digits.strip_prefix('+') {
551        return format!("+{}", drop_trunk(international));
552    }
553    let Some(region) = region else { return digits };
554    for prefix in [region.international.as_str(), "00"] {
555        if prefix.is_empty() {
556            continue;
557        }
558        if let Some(rest) = digits.strip_prefix(prefix).filter(|rest| rest.len() >= 6 && !rest.starts_with('0')) {
559            return format!("+{}", drop_trunk(rest));
560        }
561    }
562    let length = digits.len();
563    if length < region.digits[0] || length > region.digits[1] {
564        return digits;
565    }
566    match region.trunk.as_str() {
567        "" => format!("+{}{digits}", region.calling),
568        "1" => match length {
569            10 if !digits.starts_with(['0', '1']) => format!("+1{digits}"),
570            11 if digits.starts_with('1') => format!("+{digits}"),
571            _ => digits,
572        },
573        trunk => match digits.strip_prefix(trunk) {
574            Some(national) if !national.is_empty() && !national.starts_with('0') => {
575                let calling = if region.french { national.get(..3).and_then(|p| region.overseas.get(p)).cloned().unwrap_or_else(|| region.calling.clone()) } else { region.calling.clone() };
576                format!("+{calling}{national}")
577            }
578            _ => digits,
579        },
580    }
581}
582
583/// The characters that only space a number out (as `phones` reads them).
584fn spacing(c: char) -> bool {
585    matches!(c, ' ' | '\t' | '.' | '-' | '/' | '\u{a0}' | '\u{202f}' | '\u{2009}' | '\u{2007}' | '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}')
586        || matches!(c, '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}' | '\u{feff}')
587}
588
589/// A key as shown: "01 99 00 12 34" for a number of your own country's
590/// ten-digit plan, else its international form.
591pub fn shown_number(key: &str, region: Option<&Region>) -> String {
592    let Some(region) = region else { return key.to_string() };
593    if region.french {
594        // France and its overseas departments share one ten-digit plan.
595        for calling in ["33", "262", "590", "594", "596"] {
596            if let Some(national) = key.strip_prefix('+').and_then(|k| k.strip_prefix(calling))
597                && national.len() == 9
598                && national.chars().all(|c| c.is_ascii_digit())
599                && (calling == "33" || FRENCH_OVERSEAS.iter().any(|(p, c)| *c == calling && national.starts_with(p)))
600            {
601                let digits = format!("0{national}");
602                return digits.as_bytes().chunks(2).map(|pair| String::from_utf8_lossy(pair).to_string()).collect::<Vec<_>>().join(" ");
603            }
604        }
605        return key.to_string();
606    }
607    match key.strip_prefix('+').and_then(|k| k.strip_prefix(region.calling)) {
608        Some(national) if !region.trunk.is_empty() && region.trunk != "1" && national.chars().all(|c| c.is_ascii_digit()) => format!("{}{national}", region.trunk),
609        _ => key.to_string(),
610    }
611}
612
613// ---------------------------------------------------------------- "Let every call through"
614
615/// The calls' part of one device's table in the switch's file.
616#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
617pub struct DevicePart {
618    /// This device's last press (ms, hybrid clock); 0 never.
619    #[serde(default)]
620    pub pressed: i64,
621    #[serde(default)]
622    pub on: bool,
623    /// Its end (ms); 0 until turned off.
624    #[serde(default)]
625    pub until: i64,
626    /// The last press of the phone's notification carried here (ms, Java's stamp).
627    #[serde(default)]
628    pub local: i64,
629    /// This device screens calls (a phone holding the role).
630    #[serde(default)]
631    pub screens: bool,
632}
633
634/// A device's calls part; nothing there: none pressed.
635pub fn part_of(device: &Device) -> DevicePart {
636    device.other.get(DEVICE_PART).and_then(|v| v.clone().try_into().ok()).unwrap_or_default()
637}
638
639/// A device's calls part written; a part with nothing to say is taken out.
640pub fn set_part(device: &mut Device, part: DevicePart) {
641    if part == DevicePart::default() {
642        device.other.remove(DEVICE_PART);
643        return;
644    }
645    if let Ok(value) = toml::Value::try_from(part) {
646        device.other.insert(DEVICE_PART.to_string(), value);
647    }
648}
649
650/// "Let every call through" as your devices say it: the latest press of any
651/// (an "on" wins a tie); none when none pressed.
652pub fn through_of(switch: &Switch) -> Option<Through> {
653    switch
654        .device
655        .iter()
656        .map(|(id, d)| (id, part_of(d)))
657        .filter(|(_, p)| p.pressed > 0)
658        .max_by_key(|(id, p)| (p.pressed, p.on, (*id).clone()))
659        .map(|(_, p)| Through { pressed: p.pressed, on: p.on, until: if p.on { p.until } else { 0 } })
660}
661
662/// Whether some device screens calls: the switch's tables say.
663pub fn screens_anywhere(switch: &Switch) -> bool {
664    switch.device.values().any(|d| part_of(d).screens)
665}
666
667/// A press here (ms): stamped after every press known, so that the latest
668/// wins everywhere whatever the clocks. Returns its stamp.
669pub fn press(switch: &mut Switch, here: &str, on: bool, until: i64, now: i64) -> i64 {
670    let latest = switch.device.values().map(|d| part_of(d).pressed).max().unwrap_or(0);
671    let stamp = now.max(latest + 1);
672    let own = switch.device.entry(here.to_string()).or_default();
673    let mut part = part_of(own);
674    part.pressed = stamp;
675    part.on = on;
676    part.until = if on { until.max(0) } else { 0 };
677    set_part(own, part);
678    stamp
679}
680
681/// The phone's own press, from its notification (`through-here.json`),
682/// carried into its table once: with Java's stamp, which came after every
683/// press the phone knew then. Whether anything changed.
684pub fn merge_local(switch: &mut Switch, here: &str, local: &Through) -> bool {
685    let own = switch.device.entry(here.to_string()).or_default();
686    let mut part = part_of(own);
687    if local.pressed <= part.local {
688        return false;
689    }
690    part.local = local.pressed;
691    if local.pressed > part.pressed {
692        part.pressed = local.pressed;
693        part.on = local.on;
694        part.until = if local.on { local.until.max(0) } else { 0 };
695    }
696    set_part(own, part);
697    true
698}
699
700/// This device screens calls, or no longer: written when it changed.
701pub fn set_screens(switch: &mut Switch, here: &str, screens: bool) -> bool {
702    let own = switch.device.entry(here.to_string()).or_default();
703    let mut part = part_of(own);
704    if part.screens == screens {
705        return false;
706    }
707    part.screens = screens;
708    set_part(own, part);
709    true
710}
711
712/// A small JSON file of Java's; none when it is not there or does not read.
713pub fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Option<T> {
714    serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
715}
716
717// ---------------------------------------------------------------- the calls screened
718
719/// A call screened, as Java wrote it (Calls.held) and as a phone's log keeps
720/// it: declined (held, sent to voicemail) or let ring (`rang`). The log's line
721/// leaves out what stays on the phone (the number as the network gave it, its
722/// presentation) and every empty field (`logged`).
723#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
724pub struct Held {
725    /// When (ms).
726    pub at: i64,
727    /// The number as Sioul keys it (`phones::key`); "" for a hidden number.
728    #[serde(default, skip_serializing_if = "String::is_empty")]
729    pub key: String,
730    /// As the network gave it; "" hidden. In Java's file only.
731    #[serde(default, skip_serializing_if = "String::is_empty")]
732    pub number: String,
733    #[serde(default, skip_serializing_if = "is_false")]
734    pub hidden: bool,
735    /// Android's presentation of the number. In Java's file only.
736    #[serde(default, skip_serializing_if = "is_zero")]
737    pub presentation: i32,
738    /// The row it was judged by: "safe", "neutral", "restricted", "stranger",
739    /// "hidden", "blocked", or "always-" and its own ("always-neutral"); ""
740    /// when it rang before anyone was judged (an emergency number).
741    #[serde(default, skip_serializing_if = "String::is_empty")]
742    pub who: String,
743    /// The phone's contacts' name for it, when Sioul's address books did not know it.
744    #[serde(default, skip_serializing_if = "String::is_empty")]
745    pub name: String,
746    /// The time of day: a column of the matrix ("work", "sleep"…) or a layer
747    /// ("slot", "dnd"); "" when no frame held (no table).
748    #[serde(default, skip_serializing_if = "String::is_empty")]
749    pub column: String,
750    /// Why it rang or not, as Java decided (`Decision::why`).
751    #[serde(default, skip_serializing_if = "String::is_empty")]
752    pub why: String,
753    /// Let ring; false, declined and sent to voicemail (a line of Java's from
754    /// before calls that rang were logged too was declined).
755    #[serde(default)]
756    pub rang: bool,
757    /// The network's verification of the number: "passed", "failed"; "" not said.
758    #[serde(default, skip_serializing_if = "String::is_empty")]
759    pub verified: String,
760    /// The phone that screened it: its name in the sharing, its log's file's
761    /// name; "" as read from Java's file. Never written in a line.
762    #[serde(skip)]
763    pub device: String,
764}
765
766fn is_false(value: &bool) -> bool {
767    !*value
768}
769
770fn is_zero(value: &i32) -> bool {
771    *value == 0
772}
773
774impl Held {
775    /// Its name on the list: when, and who.
776    pub fn id(&self) -> String {
777        format!("{}-{}", self.at, if self.hidden { "hidden" } else { self.key.as_str() })
778    }
779
780    /// The line a phone's log keeps of it: without the number as the network
781    /// gave it, nor its presentation; keyed as `region` writes numbers when
782    /// Java could not key it (it had no table then).
783    pub fn logged(&self, region: Option<&Region>) -> Held {
784        let key = if self.hidden || !self.key.is_empty() { self.key.clone() } else { phones::key(&self.number, region) };
785        Held { key, number: String::new(), presentation: 0, device: String::new(), ..self.clone() }
786    }
787}
788
789/// The calls of one file, oldest first; a line that does not read (half written) is passed over.
790pub fn read_held(path: &Path) -> Vec<Held> {
791    let mut held: Vec<Held> = read_lines::<Held>(path).into_iter().filter(|h| h.at > 0).collect();
792    held.sort_by_key(|h| h.at);
793    held
794}
795
796/// Every line of a file of JSON lines that reads; a line cut by a crash, or
797/// written by a later Sioul in a shape this one does not know, is passed over.
798pub(crate) fn read_lines<T: serde::de::DeserializeOwned>(path: &Path) -> Vec<T> {
799    std::fs::read_to_string(path).unwrap_or_default().lines().filter(|l| !l.trim().is_empty()).filter_map(|l| serde_json::from_str(l).ok()).collect()
800}
801
802/// A device's own file in one of the calls' shared folders (`LOG`,
803/// `SEEN_LOG`) under `root`, the calls' folder: `<folder>/<device>.jsonl`,
804/// its name in the sharing (a UUID made once).
805pub fn own_file(root: &Path, folder: &str, device: &str) -> PathBuf {
806    root.join(folder).join(format!("{}.jsonl", file_safe(device)))
807}
808
809/// A device's name as a file's: letters, digits, `-` and `_`.
810pub(crate) fn file_safe(device: &str) -> String {
811    let safe: String = device.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();
812    if safe.is_empty() { "this-device".to_string() } else { safe }
813}
814
815/// Every device's file of a shared folder (`*.jsonl`), each with its device's name, by name.
816pub(crate) fn device_files(folder: &Path) -> Vec<(String, PathBuf)> {
817    let mut files: Vec<(String, PathBuf)> = std::fs::read_dir(folder)
818        .into_iter()
819        .flatten()
820        .filter_map(Result::ok)
821        .map(|e| e.path())
822        .filter(|p| p.is_file() && p.extension().is_some_and(|e| e == "jsonl") && !p.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')))
823        .filter_map(|p| Some((p.file_stem()?.to_string_lossy().to_string(), p)))
824        .collect();
825    files.sort();
826    files
827}
828
829/// Every phone's log under `root` (the calls' folder), each call with its
830/// phone (`device`), oldest first: never a blocked caller's (their calls never
831/// leave their phone), never one kept past `KEPT_DAYS` before `now_ms` (a
832/// phone that stopped sharing never takes its own out).
833pub fn read_logs(root: &Path, now_ms: i64) -> Vec<Held> {
834    let mut calls: Vec<Held> = device_files(&root.join(LOG))
835        .into_iter()
836        .flat_map(|(device, path)| read_held(&path).into_iter().map(move |h| Held { device: device.clone(), ..h }))
837        .filter(|h| h.who != "blocked" && now_ms - h.at <= KEPT_DAYS * DAY_MS)
838        .collect();
839    calls.sort_by(|a, b| (a.at, &a.device).cmp(&(b.at, &b.device)));
840    calls
841}
842
843/// Java's new calls copied into this phone's own log (`own`), under its lock,
844/// read again there: those whose line is not in it yet (by its id), never a
845/// blocked caller's, nor one past `KEPT_DAYS` (taken out already, it would
846/// come back). Two processes copying at once never write a line twice.
847/// Returns how many lines were written.
848pub fn carry_into(own: &Path, java: &[Held], region: Option<&Region>, now_ms: i64) -> Result<usize, String> {
849    let fresh: Vec<Held> = java.iter().filter(|h| h.at > 0 && h.who != "blocked" && now_ms - h.at <= KEPT_DAYS * DAY_MS).map(|h| h.logged(region)).collect();
850    if fresh.is_empty() {
851        return Ok(0);
852    }
853    crate::filelock::with_lock(own, || {
854        let mut known: BTreeSet<String> = read_held(own).iter().map(Held::id).collect();
855        let mut text = String::new();
856        let mut written = 0;
857        for line in fresh {
858            if known.insert(line.id()) {
859                text.push_str(&serde_json::to_string(&line).map_err(|e| e.to_string())?);
860                text.push('\n');
861                written += 1;
862            }
863        }
864        if written > 0 {
865            append_private(own, &text)?;
866        }
867        Ok(written)
868    })
869}
870
871/// Adds lines at the end of a file of this device's own, made yours alone
872/// (0600, in a 0700 folder, on Unix). The caller holds its lock.
873pub(crate) fn append_private(path: &Path, text: &str) -> Result<(), String> {
874    use std::io::Write;
875    let fail = |e: std::io::Error| format!("{}: {e}", path.display());
876    if let Some(folder) = path.parent() {
877        let mut builder = std::fs::DirBuilder::new();
878        builder.recursive(true);
879        #[cfg(unix)]
880        std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
881        builder.create(folder).map_err(fail)?;
882    }
883    let mut options = std::fs::OpenOptions::new();
884    options.create(true).append(true);
885    #[cfg(unix)]
886    std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
887    options.open(path).and_then(|mut file| file.write_all(text.as_bytes())).map_err(fail)
888}
889
890/// A file of the calls' kept but for the lines `drop` names: written beside
891/// under a hidden name (yours alone), then put in place, under its lock.
892/// Returns how many lines went.
893pub(crate) fn rewrite_lines(path: &Path, drop: impl Fn(&str) -> bool) -> Result<usize, String> {
894    crate::filelock::with_lock(path, || {
895        let Ok(text) = std::fs::read_to_string(path) else { return Ok(0) };
896        let (kept, gone): (Vec<&str>, Vec<&str>) = text.lines().filter(|l| !l.trim().is_empty()).partition(|l| !drop(l));
897        if gone.is_empty() {
898            return Ok(0);
899        }
900        let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
901        let fresh = path.with_file_name(format!(".{name}.{}.new", std::process::id()));
902        let _ = std::fs::remove_file(&fresh);
903        let body: String = kept.iter().map(|l| format!("{l}\n")).collect();
904        append_private(&fresh, &body).and_then(|()| {
905            std::fs::rename(&fresh, path).map_err(|e| {
906                let _ = std::fs::remove_file(&fresh);
907                format!("{}: {e}", path.display())
908            })
909        })?;
910        Ok(gone.len())
911    })
912}
913
914/// This device's own lines kept past `KEPT_DAYS` taken out of its log and of
915/// its seen log under `root` (the calls' folder): the sharing then takes them
916/// out on every device. A line that does not read stays. Returns how many went.
917pub fn trim_own(root: &Path, device: &str, now_ms: i64) -> Result<usize, String> {
918    let old = older_than(KEPT_DAYS, now_ms);
919    Ok(rewrite_lines(&own_file(root, LOG, device), &old)? + rewrite_lines(&own_file(root, SEEN_LOG, device), &old)?)
920}
921
922/// Another device's lines kept `LATE_DAYS` past their month taken out here
923/// (`here`: this device): a phone gone for good never takes its own out, and
924/// its calls must not stay on your other devices for ever. While it shares,
925/// it took them out long before (`trim_own`), and nothing differs. Returns
926/// how many went.
927pub fn trim_others(root: &Path, here: &str, now_ms: i64) -> Result<usize, String> {
928    let old = older_than(KEPT_DAYS + LATE_DAYS, now_ms);
929    let mut gone = 0;
930    for folder in [LOG, SEEN_LOG] {
931        for (device, path) in device_files(&root.join(folder)) {
932            if device != file_safe(here) {
933                gone += rewrite_lines(&path, &old)?;
934            }
935        }
936    }
937    Ok(gone)
938}
939
940/// A line older than `days` before `now_ms`, by its `at`; a line that does not read is kept.
941fn older_than(days: i64, now_ms: i64) -> impl Fn(&str) -> bool {
942    move |line: &str| serde_json::from_str::<serde_json::Value>(line).ok().and_then(|v| v["at"].as_i64()).is_some_and(|at| now_ms - at > days * DAY_MS)
943}
944
945/// One press of Seen on a device: when (ms), and the calls of its line.
946#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
947pub struct SeenLine {
948    pub at: i64,
949    #[serde(default)]
950    pub ids: Vec<String>,
951}
952
953/// The calls seen, by id.
954#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
955pub struct Seen {
956    #[serde(default)]
957    pub seen: BTreeSet<String>,
958}
959
960impl Seen {
961    /// This phone's file from before Seen was shared (`SEEN`).
962    pub fn load(path: &Path) -> Seen {
963        std::fs::read_to_string(path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
964    }
965
966    /// Those calls marked seen; ids older than the list forgotten (`now` in ms).
967    pub fn add(&mut self, ids: &[String], now: i64) {
968        self.seen.extend(ids.iter().cloned());
969        let oldest = now - (LISTED_DAYS + 1) * DAY_MS;
970        self.seen.retain(|id| id.split('-').next().and_then(|at| at.parse::<i64>().ok()).is_some_and(|at| at >= oldest));
971    }
972}
973
974/// The calls seen on any device: every device's seen log under `root` (the
975/// calls' folder), read together. Seen on one device, gone from the Porch of
976/// every device.
977pub fn read_seen(root: &Path) -> Seen {
978    let mut seen = Seen::default();
979    for (_, path) in device_files(&root.join(SEEN_LOG)) {
980        for line in read_lines::<SeenLine>(&path) {
981            seen.seen.extend(line.ids);
982        }
983    }
984    seen
985}
986
987/// Calls marked seen on this device: one line in its own seen log under
988/// `root`, which the sharing carries to the others. Returns the line's time,
989/// to undo it (`unmark_seen`).
990pub fn mark_seen(root: &Path, device: &str, ids: &[String], now_ms: i64) -> Result<i64, String> {
991    let path = own_file(root, SEEN_LOG, device);
992    let text = serde_json::to_string(&SeenLine { at: now_ms, ids: ids.to_vec() }).map_err(|e| e.to_string())? + "\n";
993    crate::filelock::with_lock(&path, || append_private(&path, &text))?;
994    Ok(now_ms)
995}
996
997/// A press of Seen undone: this device's own line written at `at` taken out
998/// of its seen log, and so out of every device's once the sharing carries it.
999/// Another device's Seen of the same calls stays. Whether a line went.
1000pub fn unmark_seen(root: &Path, device: &str, at: i64) -> Result<bool, String> {
1001    Ok(rewrite_lines(&own_file(root, SEEN_LOG, device), |line| serde_json::from_str::<SeenLine>(line).is_ok_and(|s| s.at == at))? > 0)
1002}
1003
1004/// This phone's calls seen before Seen was shared (`SEEN`): one line of its
1005/// own seen log, then the older file goes. Nothing to do once it is gone.
1006pub fn adopt_older_seen(root: &Path, device: &str, now_ms: i64) -> Result<(), String> {
1007    let older = root.join(SEEN);
1008    if !older.exists() {
1009        return Ok(());
1010    }
1011    let ids: Vec<String> = Seen::load(&older).seen.into_iter().collect();
1012    if !ids.is_empty() {
1013        mark_seen(root, device, &ids, now_ms)?;
1014    }
1015    std::fs::remove_file(&older).map_err(|e| format!("{}: {e}", older.display()))
1016}
1017
1018/// A voicemail left, as the list says it (`voicemail::Voicemail`, linked).
1019#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1020pub struct Message {
1021    /// "They left a message (0:42)."
1022    pub text: String,
1023    /// The mail that carries it, and its sound's place among the attachments; none in a notice without it.
1024    pub path: String,
1025    pub sound: Option<u32>,
1026}
1027
1028/// One line of the list: a caller, in one context, one day, on one phone.
1029#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1030pub struct Line {
1031    /// The calls it says, to mark them seen.
1032    pub ids: Vec<String>,
1033    /// "While you slept, a number not in your contacts called at 09:30."; on
1034    /// another device, "…called your phone at 09:30."
1035    pub text: String,
1036    /// "01 99 00 12 34"; "" for a hidden number.
1037    pub number: String,
1038    /// What Call back and Text back dial ("+33199001234"); "" hidden.
1039    pub dial: String,
1040    /// In your address books or the phone's: no "Add to contacts".
1041    pub known: bool,
1042    pub hidden: bool,
1043    /// "They may have left a message.", when no voicemail is linked.
1044    pub doubt: String,
1045    pub message: Option<Message>,
1046    /// Why it went to voicemail, on request: "Numbers not in your contacts go to voicemail in your leisure time."
1047    pub why: String,
1048    /// "The same number called again at 06:48, and that call rang.": a call
1049    /// that rang from the same number after the line's last, the same day; "" none.
1050    pub again: String,
1051}
1052
1053/// What the list needs besides the calls.
1054pub struct Lister<'a> {
1055    pub now: &'a Zoned,
1056    pub tr: &'a Translator,
1057    pub region: Option<&'static Region>,
1058    /// The name your address books give a number's key; none: not in them.
1059    pub name_of: &'a dyn Fn(&str) -> Option<String>,
1060    /// Whether this call's caller may reach you now (by phone or in writing).
1061    pub shows: &'a dyn Fn(&Held) -> bool,
1062    /// The phone a call came to, as this device says it (`Held::device`): ""
1063    /// for this device's own ("called at"), else "your phone", "your other
1064    /// phone", "your phone (GS290)" (`phone_words`).
1065    pub phone: &'a dyn Fn(&str) -> String,
1066}
1067
1068/// The list, oldest first: the declined calls not seen yet on any device,
1069/// of the last two weeks, whose callers may reach you now; never the blocked;
1070/// one line per phone, caller, context and day. A call that rang is never a
1071/// line: it is said under the line of the declined calls it followed.
1072/// `calls` are every phone's (`read_logs`), oldest first; `messages` maps a
1073/// call's id to its voicemail.
1074pub fn lines(calls: &[Held], seen: &Seen, messages: &BTreeMap<String, Message>, l: &Lister) -> Vec<Line> {
1075    let now_ms = l.now.timestamp().as_millisecond();
1076    let mut shown: Vec<&Held> = calls.iter().filter(|h| !h.rang && h.who != "blocked" && !seen.seen.contains(&h.id()) && now_ms - h.at <= LISTED_DAYS * DAY_MS && h.at <= now_ms + 60_000 && (l.shows)(h)).collect();
1077    shown.sort_by_key(|h| h.at);
1078    // One line per phone, caller, context and day, in the order of their first call.
1079    let mut groups: Vec<(&str, String, &str, jiff::civil::Date, Vec<&Held>)> = Vec::new();
1080    for h in shown {
1081        let date = local(h.at, l.now).date();
1082        let caller = if h.hidden { "hidden".to_string() } else { h.key.clone() };
1083        match groups.iter_mut().find(|(d, c, col, day, _)| *d == h.device && *c == caller && *col == h.column && *day == date) {
1084            Some((_, _, _, _, group)) => group.push(h),
1085            None => groups.push((h.device.as_str(), caller, h.column.as_str(), date, vec![h])),
1086        }
1087    }
1088    groups.into_iter().map(|(device, _, column, date, group)| line(&group, calls, device, column, date, messages, l)).collect()
1089}
1090
1091pub(crate) fn local(at_ms: i64, now: &Zoned) -> Zoned {
1092    jiff::Timestamp::from_millisecond(at_ms).map_or_else(|_| now.clone(), |t| t.to_zoned(now.time_zone().clone()))
1093}
1094
1095/// The first call that rang from the same number after the last of `group`,
1096/// the same day, on any phone: none for a hidden number (two hidden calls
1097/// cannot be told apart).
1098fn rang_after<'a>(calls: &'a [Held], group: &[&Held], date: jiff::civil::Date, l: &Lister) -> Option<&'a Held> {
1099    let last = group.last()?;
1100    if last.hidden {
1101        return None;
1102    }
1103    let now_ms = l.now.timestamp().as_millisecond();
1104    calls.iter().filter(|h| h.rang && !h.hidden && h.key == last.key && h.at > last.at && h.at <= now_ms + 60_000).min_by_key(|h| h.at).filter(|h| local(h.at, l.now).date() == date)
1105}
1106
1107/// "Monday 5 October", "yesterday", "today": a day, as the lines say it before
1108/// a capital is put to the sentence.
1109pub(crate) fn day_words(tr: &Translator, date: jiff::civil::Date, today: jiff::civil::Date) -> String {
1110    if date == today {
1111        tr.text("calls-day-today", None)
1112    } else if today.yesterday().is_ok_and(|y| y == date) {
1113        tr.text("calls-day-yesterday", None)
1114    } else {
1115        format!("{} {}", tr.text(&format!("weekday-{}", date.weekday().to_monday_one_offset()), None), tr.day_month(date))
1116    }
1117}
1118
1119fn line(group: &[&Held], calls: &[Held], device: &str, column: &str, date: jiff::civil::Date, messages: &BTreeMap<String, Message>, l: &Lister) -> Line {
1120    let tr = l.tr;
1121    let first = group[0];
1122    let name = if first.hidden { None } else { (l.name_of)(&first.key).or_else(|| group.iter().map(|h| h.name.trim()).find(|n| !n.is_empty()).map(str::to_string)) };
1123    let who = if first.hidden {
1124        tr.text("calls-who-hidden", None)
1125    } else {
1126        name.clone().unwrap_or_else(|| tr.text("calls-who-stranger", None))
1127    };
1128    let context_words = context(tr, column);
1129    let today = l.now.date();
1130    let context_said = if date == today {
1131        context_words.clone()
1132    } else {
1133        let mut args = crate::i18n::args();
1134        args.set("day", day_words(tr, date, today));
1135        args.set("context", context_words.clone());
1136        tr.text("calls-context-day", Some(&args))
1137    };
1138    let time = |h: &Held| local(h.at, l.now).strftime("%H:%M").to_string();
1139    // This phone's own calls say "called at"; another phone's, which one it was.
1140    let phone = (l.phone)(device);
1141    let on = if phone.is_empty() { "" } else { "-on" };
1142    let mut args = crate::i18n::args();
1143    args.set("context", context_said);
1144    args.set("who", who);
1145    args.set("phone", phone.clone());
1146    let text = match group.len() {
1147        1 => {
1148            args.set("time", time(first));
1149            tr.text(&format!("calls-line-once{on}"), Some(&args))
1150        }
1151        2 => {
1152            args.set("first", time(first));
1153            args.set("second", time(group[1]));
1154            tr.text(&format!("calls-line-twice{on}"), Some(&args))
1155        }
1156        n => {
1157            args.set("count", tr.count(n, false, false));
1158            args.set("time", time(group[n - 1]));
1159            tr.text(&format!("calls-line-more{on}"), Some(&args))
1160        }
1161    };
1162    let rang = rang_after(calls, group, date, l);
1163    let again = rang
1164        .map(|h| {
1165            let mut args = crate::i18n::args();
1166            args.set("time", time(h));
1167            tr.text("calls-again-rang", Some(&args))
1168        })
1169        .unwrap_or_default();
1170    let ids: Vec<String> = group.iter().map(|h| h.id()).collect();
1171    // A message left after the declined calls, or after the call that rang next.
1172    let message = ids.iter().chain(rang.map(Held::id).iter()).find_map(|id| messages.get(id)).cloned();
1173    // Someone Always through, declined: their own row held them ("always-neutral": as the neutral).
1174    let row = if first.hidden { "hidden" } else if name.is_none() { "stranger" } else { first.who.trim_start_matches("always-") };
1175    let mut why_args = crate::i18n::args();
1176    why_args.set("context", context_words);
1177    let why = match row {
1178        "safe" | "neutral" | "restricted" | "stranger" | "hidden" => tr.text(&format!("calls-why-{row}"), Some(&why_args)),
1179        _ => String::new(),
1180    };
1181    Line {
1182        ids,
1183        text: capitalized(&text),
1184        number: if first.hidden { String::new() } else { shown_number(&first.key, l.region) },
1185        dial: if first.hidden { String::new() } else { first.key.clone() },
1186        known: name.is_some(),
1187        hidden: first.hidden,
1188        doubt: if message.is_none() { tr.text("calls-may-have-left", None) } else { String::new() },
1189        message,
1190        why,
1191        again,
1192    }
1193}
1194
1195/// "while you slept", "during work": what the time was, in a few words.
1196pub(crate) fn context(tr: &Translator, column: &str) -> String {
1197    match column {
1198        "work" | "admin" | "leisure" | "meals" | "sleep" | "pause" | "free" | "slot" | "dnd" => tr.text(&format!("calls-context-{column}"), None),
1199        _ => tr.text("calls-context-any", None),
1200    }
1201}
1202
1203/// The phone a call came to, said on another device: "your phone" when one
1204/// phone shares its calls with this device, "your other phone" on a phone
1205/// when one other does, else "your phone (GS290)" by its name among your
1206/// devices (`phones`: each phone's id with its name; "" for this device's own).
1207pub fn phone_words(tr: &Translator, device: &str, here: &str, phones: &[(String, String)], this_is_a_phone: bool) -> String {
1208    if device == here {
1209        return String::new();
1210    }
1211    let others: Vec<&(String, String)> = phones.iter().filter(|(id, _)| id != here).collect();
1212    let name = others.iter().find(|(id, _)| id == device).map(|(_, name)| name.trim()).unwrap_or("");
1213    if others.len() > 1 && !name.is_empty() {
1214        let mut args = crate::i18n::args();
1215        args.set("name", name.to_string());
1216        return tr.text("calls-phone-named", Some(&args));
1217    }
1218    tr.text(if this_is_a_phone { "calls-phone-other" } else { "calls-phone-yours" }, None)
1219}
1220
1221/// A person's calls of the last month (`keys`, their numbers as Sioul keys
1222/// them), newest first, from every phone, rang or declined: one sentence each,
1223/// for their sheet, never a count. Each says the phone only when several
1224/// phones share their calls. A blocked caller's never come: their calls never
1225/// leave their phone.
1226pub fn history(calls: &[Held], keys: &BTreeSet<String>, l: &Lister) -> Vec<String> {
1227    let now_ms = l.now.timestamp().as_millisecond();
1228    let several = calls.iter().map(|h| h.device.as_str()).collect::<BTreeSet<_>>().len() > 1;
1229    calls.iter().rev().filter(|h| !h.hidden && keys.contains(&h.key) && h.who != "blocked" && now_ms - h.at <= KEPT_DAYS * DAY_MS && h.at <= now_ms + 60_000).map(|h| history_line(h, several, l)).collect()
1230}
1231
1232/// "Tuesday 6 October at 23:10, while you slept: declined, sent to
1233/// voicemail.", "Today at 06:48, while you slept: it rang. A second call
1234/// within 15 minutes rings."
1235fn history_line(h: &Held, several: bool, l: &Lister) -> String {
1236    let tr = l.tr;
1237    let at = local(h.at, l.now);
1238    let mut args = crate::i18n::args();
1239    args.set("day", day_words(tr, at.date(), l.now.date()));
1240    args.set("time", at.strftime("%H:%M").to_string());
1241    let when = tr.text("calls-history-when", Some(&args));
1242    let head = if h.column.is_empty() {
1243        when
1244    } else {
1245        let mut args = crate::i18n::args();
1246        args.set("when", when);
1247        args.set("context", context(tr, &h.column));
1248        tr.text("calls-history-head", Some(&args))
1249    };
1250    let phone = if several {
1251        let said = (l.phone)(&h.device);
1252        if said.is_empty() { tr.text("calls-phone-this", None) } else { said }
1253    } else {
1254        String::new()
1255    };
1256    let outcome = if h.rang { "calls-history-rang" } else { "calls-history-declined" };
1257    let mut args = crate::i18n::args();
1258    args.set("phone", phone.clone());
1259    let outcome = tr.text(&format!("{outcome}{}", if phone.is_empty() { "" } else { "-on" }), Some(&args));
1260    let mut args = crate::i18n::args();
1261    args.set("head", head);
1262    args.set("outcome", outcome);
1263    let mut text = capitalized(&tr.text("calls-history-line", Some(&args)));
1264    if let Some(reason) = h.rang.then(|| rang_reason(tr, &h.why)).flatten() {
1265        text.push(' ');
1266        text.push_str(&reason);
1267    }
1268    text
1269}
1270
1271/// Why a call rang when its row alone would not say it: a second call, Let
1272/// every call through, an emergency, nothing to decide from.
1273fn rang_reason(tr: &Translator, why: &str) -> Option<String> {
1274    let key = match why {
1275        "repeat" => "calls-rang-repeat",
1276        "through" => "calls-rang-through",
1277        "emergency" => "calls-rang-emergency",
1278        "after-emergency" => "calls-rang-after-emergency",
1279        "no-table" | "no-frame" | "no-row" | "error" => "calls-rang-undecided",
1280        _ => return None,
1281    };
1282    Some(tr.text(key, None))
1283}
1284
1285/// The first letter in capital: a sentence that starts with a context's words.
1286pub fn capitalized(text: &str) -> String {
1287    let mut chars = text.chars();
1288    match chars.next() {
1289        Some(first) => first.to_uppercase().chain(chars).collect(),
1290        None => String::new(),
1291    }
1292}
1293
1294/// "0:42": a voicemail's length.
1295pub fn length(seconds: u32) -> String {
1296    format!("{}:{:02}", seconds / 60, seconds % 60)
1297}
1298
1299/// What the list says of a voicemail linked to a call.
1300pub fn message_words(tr: &Translator, seconds: Option<u32>) -> String {
1301    match seconds.filter(|s| *s > 0) {
1302        Some(seconds) => {
1303            let mut args = crate::i18n::args();
1304            args.set("length", length(seconds));
1305            tr.text("calls-left-message", Some(&args))
1306        }
1307        None => tr.text("calls-left-message-plain", None),
1308    }
1309}
1310
1311// ---------------------------------------------------------------- what is said of it
1312
1313/// "Let every call through" in words: "Every call rings until 15:30.",
1314/// "… until you turn it off.", or after an emergency call, "Every call rings
1315/// until tomorrow at 09:31: you called an emergency number."; "" otherwise.
1316/// `through` and `emergency_at` in ms; `when` words an instant (ms).
1317pub fn through_line(tr: &Translator, through: Option<&Through>, emergency_at: i64, now: i64, when: &dyn Fn(i64) -> String) -> String {
1318    let after = emergency_at + AFTER_EMERGENCY_HOURS * 3_600_000;
1319    if emergency_at > 0 && emergency_at <= now && now < after {
1320        let mut args = crate::i18n::args();
1321        args.set("until", when(after));
1322        return tr.text("calls-through-emergency", Some(&args));
1323    }
1324    match through.filter(|t| t.holds(now)) {
1325        Some(t) if t.until > 0 => {
1326            let mut args = crate::i18n::args();
1327            args.set("until", when(t.until));
1328            tr.text("calls-through-until", Some(&args))
1329        }
1330        Some(_) => tr.text("calls-through-on", None),
1331        None => String::new(),
1332    }
1333}
1334
1335#[cfg(test)]
1336mod tests {
1337    use super::*;
1338    use crate::everywhere::Person;
1339
1340    fn fr() -> Option<&'static Region> {
1341        phones::region_named("FR")
1342    }
1343
1344    #[test]
1345    fn java_keys_numbers_as_phones_does() {
1346        let zero = trunk_zero();
1347        // Numbers lent to fiction only: ARCEP's (01 99 00, 06 39 98), Ofcom's
1348        // drama ones (020 7946 0xxx, 07700 900xxx), North America's 555-01xx.
1349        // No country without a trunk prefix (Spain, Italy) lends any: a
1350        // country code nobody has (999, spare in E.164) reads them as they do,
1351        // the leading 0 kept as Italy's.
1352        let spare = Region { code: "XX", calling: "999", trunk: "", international: "00", digits: (9, 10), french: false };
1353        for (region, numbers) in [
1354            (phones::region_named("FR"), &["0199001234", "01 99 00 12 34", "+33199001234", "0033199001234", "+330199001234", "0639981234", "0692123456", "0590123456", "0696123456", "0594123456", "112", "15", "3114", "0800112112", "08 00 11 21 12", "+44 20 7946 0018", "0044 20 7946 0018", "+262692123456", "+262 6 39 98 12 34", "tel:+262639981234", "*#06#", "1-555-SIOUL", "639981234", "\u{202a}+33 1 99 00 12 34\u{202c}", "01\u{a0}99\u{202f}00 12 34"][..]),
1355            (phones::region_named("GB"), &["07700 900123", "020 7946 0018", "+44 20 7946 0018", "0044 7700 900123", "999"][..]),
1356            (phones::region_named("US"), &["(212) 555-0100", "1 212 555 0100", "011 33 6 39 98 12 34", "911"][..]),
1357            (Some(&spare), &["639 98 12 34", "0639 98 12 34", "+999 0639 98 12 34", "112"][..]),
1358        ] {
1359            let rules = region.map(RegionRules::of);
1360            let country = region.map_or("none", |r| r.code);
1361            for number in numbers {
1362                assert_eq!(incoming_key(number, rules.as_ref(), &zero), phones::key(number, region), "{country}: {number}");
1363            }
1364        }
1365        // The keys the branches give: a trunk 0 dropped, none to drop (the 0 kept), letters as written.
1366        assert_eq!(phones::key("07700 900123", phones::region_named("GB")), "+447700900123");
1367        assert_eq!((phones::key("639 98 12 34", Some(&spare)), phones::key("0639 98 12 34", Some(&spare))), ("+999639981234".to_string(), "+9990639981234".to_string()));
1368        assert_eq!(incoming_key("1-555-SIOUL", None, &zero), "1-555-sioul");
1369        // No country known: as written, as phones does.
1370        assert_eq!(incoming_key("06 39 98 12 34", None, &zero), phones::key("06 39 98 12 34", None));
1371        assert_eq!(incoming_key("+33 05 36 49 12 34", None, &zero), "+33536491234");
1372        // Every overseas prefix agrees with phones' own reading (Mayotte's 0639 98 is fiction's).
1373        let rules = RegionRules::of(fr().unwrap());
1374        for (prefix, calling) in FRENCH_OVERSEAS {
1375            let number = format!("0{prefix}981234");
1376            assert_eq!(phones::key(&number, fr()), format!("+{calling}{prefix}981234"), "{number}");
1377            assert_eq!(incoming_key(&number, Some(&rules), &zero), phones::key(&number, fr()));
1378        }
1379    }
1380
1381    /// A table as Java reads it: Monday 5 October 2026, work 09:00–17:00, then
1382    /// leisure, the night from 22:00; the usual Calls matrix. The carer and
1383    /// a blocked number are on the Always through list.
1384    fn decision_table() -> Table {
1385        let mut people = People::default();
1386        people.add(Person { name: "Carer".into(), phones: vec!["01 99 00 00 09".into(), "01 99 00 00 04".into()], ..Person::default() }, fr());
1387        let blocked = |key: &str| if key == "+33199000004" { Who::Blocked } else { Who::Stranger };
1388        let work = (ms("2026-10-05T09:00[Europe/Paris]"), ms("2026-10-05T17:00[Europe/Paris]"));
1389        let leisure = (work.1, ms("2026-10-05T22:00[Europe/Paris]"));
1390        let sleep = (leisure.1, ms("2026-10-06T07:00[Europe/Paris]"));
1391        table(Made {
1392            made: 0,
1393            region: fr(),
1394            numbers: vec![
1395                ("+33199000001".into(), "safe".into()),
1396                ("+33199000002".into(), "neutral".into()),
1397                ("+33199000003".into(), "restricted".into()),
1398                ("+33199000004".into(), "blocked".into()),
1399            ],
1400            prefixes: vec![("+3346571*".into(), "blocked".into())],
1401            always: always_numbers(&people, fr(), &blocked),
1402            through: None,
1403            frames: vec![
1404                frame(ms("2026-10-05T00:00[Europe/Paris]"), work.0, "sleep", &["always-stranger"]),
1405                frame(work.0, work.1, "work", &["safe", "neutral", "restricted", "hidden", "always-stranger"]),
1406                frame(leisure.0, leisure.1, "leisure", &["safe", "always-stranger"]),
1407                frame(sleep.0, sleep.1, "sleep", &["always-stranger"]),
1408            ],
1409        })
1410    }
1411
1412    fn call(number: &str, at: &str) -> Incoming {
1413        Incoming { number: number.into(), hidden: number.is_empty(), now: ms(&format!("2026-10-05T{at}[Europe/Paris]")), ..Incoming::default() }
1414    }
1415
1416    #[test]
1417    fn who_rings_when_and_what_always_rings() {
1418        let t = decision_table();
1419        let said = |c: &Incoming| {
1420            let d = decide(Some(&t), c);
1421            (d.refuse, d.why, d.who)
1422        };
1423        // Who × time: the frame's row, as the matrix said it ahead.
1424        for (number, at, refused, who) in [
1425            ("01 99 00 00 01", "10:00", false, "safe"),
1426            ("01 99 00 00 01", "18:00", false, "safe"),
1427            ("01 99 00 00 01", "23:00", true, "safe"),
1428            ("01 99 00 00 02", "10:00", false, "neutral"),
1429            ("01 99 00 00 02", "18:00", true, "neutral"),
1430            ("+33 1 99 00 00 03", "10:00", false, "restricted"),
1431            ("0199000003", "18:00", true, "restricted"),
1432            ("01 99 00 55 55", "10:00", true, "stranger"),
1433            ("", "10:00", false, "hidden"),
1434            ("", "18:00", true, "hidden"),
1435        ] {
1436            let (refuse, why, w) = said(&call(number, at));
1437            assert_eq!((refuse, why, w.as_str()), (refused, "matrix", who), "{number} at {at}");
1438        }
1439        // Blocked: by its number, or a prefix on a list, at every time, a second call too.
1440        assert_eq!(said(&call("01 99 00 00 04", "10:00")), (true, "blocked", "blocked".to_string()));
1441        assert_eq!(said(&call("04 65 71 12 34", "10:00")), (true, "blocked", "blocked".to_string()));
1442        let again = Incoming { last_call: ms("2026-10-05T09:55[Europe/Paris]"), ..call("01 99 00 00 04", "10:00") };
1443        assert_eq!(said(&again).1, "blocked");
1444        // A contact of the phone's only: neutral.
1445        assert_eq!(said(&Incoming { phone_contact: true, ..call("01 99 00 55 55", "10:00") }), (false, "matrix", "neutral".to_string()));
1446        assert_eq!(said(&Incoming { phone_contact: true, ..call("01 99 00 55 55", "18:00") }), (true, "matrix", "neutral".to_string()));
1447        // A second call within 15 minutes rings; after 15, the matrix again; hidden numbers never count.
1448        let first = ms("2026-10-05T18:00[Europe/Paris]");
1449        assert_eq!(said(&Incoming { last_call: first, ..call("01 99 00 55 55", "18:10") }).1, "repeat");
1450        assert_eq!(said(&Incoming { last_call: first, ..call("01 99 00 55 55", "18:16") }), (true, "matrix", "stranger".to_string()));
1451        assert_eq!(said(&Incoming { last_call: first, ..call("", "18:10") }), (true, "matrix", "hidden".to_string()));
1452        // The floors: emergency numbers and callbacks, as the table or Android says; the do-not-disturb list.
1453        for number in ["112", "15", "0 800 112 112", "+33800112112"] {
1454            assert_eq!(said(&call(number, "23:00")).1, "emergency", "{number}");
1455        }
1456        assert_eq!(said(&Incoming { system_emergency: true, ..call("17", "23:00") }).1, "emergency");
1457        assert_eq!(said(&call("17", "23:00")).0, true, "a short code Android does not call an emergency number: a stranger");
1458        // Always through: their own row, at night too; blocked beats it (before: the list's floor rang it).
1459        assert_eq!(said(&call("01 99 00 00 09", "23:00")), (false, "matrix", "always-stranger".to_string()));
1460        assert_eq!(said(&call("01 99 00 00 04", "23:00")).1, "blocked");
1461        assert!(t.floors.people.is_empty(), "no floor before the blocked any more");
1462        // A day after you call an emergency number, every call rings, the blocked too; not after.
1463        let called = ms("2026-10-05T03:00[Europe/Paris]");
1464        assert_eq!(said(&Incoming { emergency_at: called, ..call("01 99 00 00 04", "23:00") }).1, "after-emergency");
1465        assert_eq!(said(&Incoming { emergency_at: called - 86_400_000, ..call("01 99 00 55 55", "23:00") }).1, "matrix");
1466        // Let every call through: the later press of this phone's and the table's.
1467        let on = Through { pressed: 10, on: true, until: 0 };
1468        let mut through = t.clone();
1469        through.through = Some(on);
1470        assert_eq!(decide(Some(&through), &call("01 99 00 55 55", "23:00")).why, "through");
1471        assert_eq!(decide(Some(&through), &call("01 99 00 00 04", "23:00")).why, "blocked", "blocked entirely: never let through");
1472        let off_here = Through { pressed: 11, on: false, until: 0 };
1473        assert!(decide(Some(&through), &Incoming { local: Some(off_here), ..call("01 99 00 55 55", "23:00") }).refuse, "turned off on the phone after");
1474        let ended = Through { pressed: 12, on: true, until: ms("2026-10-05T22:30[Europe/Paris]") };
1475        assert!(decide(Some(&t), &Incoming { local: Some(ended), ..call("01 99 00 55 55", "23:00") }).refuse, "an hour that ended");
1476        // Nothing to decide from: it rings.
1477        assert_eq!(decide(None, &call("01 99 00 55 55", "23:00")).why, "no-table");
1478        assert_eq!(said(&Incoming { now: ms("2026-10-06T08:00[Europe/Paris]"), ..call("01 99 00 55 55", "23:00") }), (false, "no-frame", "stranger".to_string()));
1479        assert_eq!(said(&Incoming { outgoing: true, ..call("112", "23:00") }).1, "outgoing");
1480    }
1481
1482    #[test]
1483    fn frames_follow_the_day_and_its_change_of_hour() {
1484        use crate::needs::{Days, Needs};
1485        let config: crate::config::Config = toml::from_str(
1486            &["monday", "tuesday", "wednesday", "thursday", "friday"].iter().map(|d| format!("[[window]]\nday = \"{d}\"\nstart = \"09:00\"\nend = \"17:00\"\n\n")).collect::<String>(),
1487        )
1488        .unwrap();
1489        let needs = Needs { meals_on: true, naps_on: true, sleep_on: true, ..Needs::default() };
1490        let clock = Clock::new(&config, crate::quiet::Overrides::default(), needs, Days::default(), Vec::new());
1491        let attention = Attention::usual();
1492        let none = Layers::default();
1493        let from = at("2026-10-05T00:00[Europe/Paris]");
1494        let until = at("2026-10-06T00:00[Europe/Paris]");
1495        let frames = frames(&attention, &clock, &none, &from, &until);
1496        // From midnight to midnight, end to end.
1497        assert_eq!(frames.first().unwrap().from, from.timestamp().as_millisecond());
1498        assert_eq!(frames.last().unwrap().until, until.timestamp().as_millisecond());
1499        assert!(frames.windows(2).all(|w| w[0].until == w[1].from), "{frames:?}");
1500        let table = Table { frames, ..table(Made { made: 0, region: fr(), numbers: Vec::new(), prefixes: Vec::new(), always: Vec::new(), through: None, frames: Vec::new() }) };
1501        let at_ = |time: &str| table.frame_at(ms(&format!("2026-10-05T{time}[Europe/Paris]"))).unwrap();
1502        // The usual Calls matrix: safe all but sleep and the pause; neutral work, admin, leisure and meals; restricted work; strangers never; hidden work and admin.
1503        for (time, column, ringing) in [
1504            ("03:00", "sleep", vec![]),
1505            ("08:00", "meals", vec!["safe", "neutral"]),
1506            ("10:00", "work", vec!["safe", "neutral", "restricted", "hidden"]),
1507            ("12:30", "meals", vec!["safe", "neutral"]),
1508            ("14:10", "sleep", vec![]),
1509            ("18:00", "leisure", vec!["safe", "neutral"]),
1510            ("22:30", "sleep", vec![]),
1511        ] {
1512            let f = at_(time);
1513            assert_eq!(f.column, column, "{time}");
1514            let rings: Vec<&str> = ["safe", "neutral", "restricted", "stranger", "hidden"].into_iter().filter(|r| f.ring[*r]).collect();
1515            assert_eq!(rings, ringing, "{time}");
1516            assert!(f.ring["always-stranger"], "Always through rings at any time: {time}");
1517        }
1518        // The layers (Q11): do-not-disturb's switch from 18:00 to 19:00 sends the safe to voicemail, Always through rings;
1519        // a slot of time for you holds no call.
1520        let (dnd_from, dnd_until) = (at("2026-10-05T18:00[Europe/Paris]").timestamp().as_second(), at("2026-10-05T19:00[Europe/Paris]").timestamp().as_second());
1521        let layers = Layers { slots: vec![(dnd_until + 600, dnd_until + 1800)], dnd: Some((dnd_from, Some(dnd_until))) };
1522        let table = Table { frames: super::frames(&attention, &clock, &layers, &from, &until), ..table.clone() };
1523        let at_ = |time: &str| table.frame_at(ms(&format!("2026-10-05T{time}[Europe/Paris]"))).unwrap();
1524        assert_eq!((at_("18:30").column.as_str(), at_("18:30").ring["safe"], at_("18:30").ring["always-safe"]), ("dnd", false, true));
1525        assert_eq!((at_("17:30").column.as_str(), at_("17:30").ring["safe"]), ("leisure", true));
1526        assert_eq!((at_("19:15").column.as_str(), at_("19:15").ring["safe"]), ("slot", true));
1527        // Free time as cells: the safe ring in it, the others not; "Nothing at all", only Always through.
1528        let free = crate::quiet::Overrides { free_since: Some(at("2026-10-05T10:00[Europe/Paris]").timestamp().as_second()), free_nothing: Some(true), ..crate::quiet::Overrides::default() };
1529        let clock_free = Clock::new(&config, free, Needs { meals_on: true, naps_on: true, sleep_on: true, ..Needs::default() }, Days::default(), Vec::new());
1530        let table = Table { frames: super::frames(&attention, &clock_free, &none, &from, &until), ..table.clone() };
1531        let f = table.frame_at(ms("2026-10-05T11:00[Europe/Paris]")).unwrap();
1532        assert_eq!((f.column.as_str(), f.ring["safe"], f.ring["always-neutral"]), ("free", false, true));
1533        // The night the clocks go back (25 October 2026): no gap, no overlap, 25 hours that day.
1534        let from = at("2026-10-25T00:00[Europe/Paris]");
1535        let until = at("2026-10-26T00:00[Europe/Paris]");
1536        let frames = super::frames(&attention, &clock, &none, &from, &until);
1537        assert!(frames.windows(2).all(|w| w[0].until == w[1].from));
1538        assert_eq!(frames.last().unwrap().until - frames.first().unwrap().from, 25 * 3_600_000);
1539        // In the pause: the pause's column to the end, nobody ringing.
1540        let paused = crate::quiet::Overrides { paused_since: Some(from.timestamp().as_second()), ..crate::quiet::Overrides::default() };
1541        let clock = Clock::new(&config, paused, Needs::default(), Days::default(), Vec::new());
1542        let frames = super::frames(&attention, &clock, &none, &from, &until);
1543        assert!(frames.iter().all(|f| f.column == "pause" && f.ring.iter().all(|(row, r)| *r == row.starts_with("always-"))), "{frames:?}");
1544        assert_eq!(frames.last().unwrap().until, until.timestamp().as_millisecond());
1545    }
1546
1547    #[test]
1548    fn numbers_shown_as_people_write_them() {
1549        assert_eq!(shown_number("+33199001234", fr()), "01 99 00 12 34");
1550        assert_eq!(shown_number("+262692123456", fr()), "06 92 12 34 56");
1551        assert_eq!(shown_number("+442079460018", fr()), "+442079460018");
1552        assert_eq!(shown_number("112", fr()), "112");
1553        assert_eq!(shown_number("+442079460018", phones::region_named("GB")), "02079460018");
1554        assert_eq!(shown_number("+33199001234", None), "+33199001234");
1555    }
1556
1557    #[test]
1558    fn the_floors_hold_emergency_numbers_and_the_list_s_people() {
1559        let keys = emergency_keys(fr());
1560        for number in ["112", "15", "114", "3114", "+33800112112", "999", "911", "988"] {
1561            assert!(keys.iter().any(|k| k == number), "{number} in {keys:?}");
1562        }
1563        let mut people = People::default();
1564        people.add(Person { name: "Alice".into(), phones: vec!["01 99 00 00 01".into(), "3114".into()], ..Person::default() }, fr());
1565        assert_eq!(people_keys(&people, fr()), vec!["+33199000001".to_string()], "a short code is nobody's");
1566        assert_eq!(always_numbers(&people, fr(), &|_| Who::Neutral), vec![("+33199000001".to_string(), "always-neutral".to_string())]);
1567        assert!(always_numbers(&people, fr(), &|_| Who::Blocked).is_empty(), "blocked beats Always through");
1568        // 0639 is Mayotte's plan: its own country code, as phones reads it.
1569        assert_eq!(phones::key("06 39 98 00 01", fr()), "+262639980001");
1570    }
1571
1572    fn frame(from: i64, until: i64, column: &str, ringing: &[&str]) -> Frame {
1573        Frame { from, until, column: column.into(), ring: ROWS.iter().map(|r| (r.to_string(), ringing.contains(r))).collect() }
1574    }
1575
1576    #[test]
1577    fn the_table_says_what_java_needs_and_nothing_twice() {
1578        let mut people = People::default();
1579        people.add(Person { name: "Alice".into(), phones: vec!["01 99 00 00 01".into()], ..Person::default() }, fr());
1580        let made = |at: i64| Made {
1581            made: at,
1582            region: fr(),
1583            numbers: vec![("+262639980002".into(), "safe".into()), ("+33199001234".into(), "blocked".into()), ("+33199009999".into(), "stranger".into()), (String::new(), "safe".into())],
1584            prefixes: vec![("+33465711*".into(), "blocked".into()), ("+3346571*".into(), "safe".into()), ("+334657112*".into(), "neutral".into()), ("*".into(), "blocked".into())],
1585            always: always_numbers(&people, fr(), &|_| Who::Safe),
1586            through: Some(Through { pressed: 5, on: true, until: 0 }),
1587            frames: vec![frame(0, 1_000, "work", &["safe", "neutral", "restricted", "hidden"]), frame(1_000, 2_000, "sleep", &[])],
1588        };
1589        let table = table(made(42));
1590        assert_eq!(table.v, VERSION);
1591        assert_eq!(table.numbers.len(), 3, "strangers and empty keys are not written: {:?}", table.numbers);
1592        assert_eq!((table.numbers["+33199001234"].as_str(), table.numbers["+33199000001"].as_str()), ("blocked", "always-safe"));
1593        // Prefixes without their star, the longest first; a bare star is nothing.
1594        assert_eq!(table.prefixes.iter().map(|p| (p.prefix.as_str(), p.who.as_str())).collect::<Vec<_>>(), [("+334657112", "neutral"), ("+33465711", "blocked"), ("+3346571", "safe")]);
1595        assert!(table.floors.people.is_empty());
1596        assert!(table.floors.emergency.contains(&"+33800112112".to_string()));
1597        assert_eq!(table.region.as_ref().map(|r| (r.calling.as_str(), r.trunk.as_str(), r.digits)), Some(("33", "0", [10, 10])));
1598        assert_eq!(table.region.as_ref().unwrap().overseas.get("692").map(String::as_str), Some("262"));
1599        assert!(table.trunk_zero.contains(&"33".to_string()) && !table.trunk_zero.contains(&"39".to_string()));
1600        assert_eq!((table.repeat_minutes, table.emergency_hours, table.phone_contacts.as_str()), (15, 24, "neutral"));
1601        // Made at another time but saying the same: the same content, not written again.
1602        assert_eq!(content(&table), content(&self::table(made(43))));
1603        // As Java reads it: the keys it looks for.
1604        let json: serde_json::Value = serde_json::from_str(&serde_json::to_string(&table).unwrap()).unwrap();
1605        for key in ["v", "made", "region", "trunk_zero", "numbers", "prefixes", "phone_contacts", "floors", "through", "frames", "repeat_minutes", "emergency_hours"] {
1606            assert!(json.get(key).is_some(), "{key}");
1607        }
1608        assert_eq!(json["frames"][0]["ring"]["hidden"], true);
1609        assert_eq!(json["frames"][1]["column"], "sleep");
1610        assert_eq!(json["through"]["on"], true);
1611        // Written whole, read back the same.
1612        let dir = std::env::temp_dir().join(format!("sioul-calls-table-{}", std::process::id()));
1613        let path = dir.join(TABLE);
1614        write(&path, &table).unwrap();
1615        let back: Table = read_json(&path).unwrap();
1616        assert_eq!(back, table);
1617        let _ = std::fs::remove_dir_all(&dir);
1618    }
1619
1620    #[test]
1621    fn the_latest_press_anywhere_lets_calls_through() {
1622        let mut switch = Switch::default();
1623        assert_eq!(through_of(&switch), None);
1624        // The computer: every call for an hour.
1625        let stamp = press(&mut switch, "desk", true, 1_000 + 3_600_000, 1_000);
1626        assert_eq!(stamp, 1_000);
1627        let t = through_of(&switch).unwrap();
1628        assert!(t.holds(2_000) && !t.holds(1_000 + 3_600_000));
1629        // The phone's clock is behind: its "off" still comes after the press it knew of.
1630        let stamp = press(&mut switch, "phone", false, 0, 500);
1631        assert_eq!(stamp, 1_001);
1632        assert!(!through_of(&switch).unwrap().holds(2_000));
1633        // Until turned off.
1634        press(&mut switch, "desk", true, 0, 9_000);
1635        assert!(through_of(&switch).unwrap().holds(i64::MAX - 1));
1636        // The DND part of the device's table is untouched; the calls part reads back.
1637        assert!(switch.device["desk"].pressed == 0 && !switch.device["desk"].on);
1638        let text = toml::to_string(&switch).unwrap();
1639        assert!(text.contains("[device.desk.calls]"), "{text}");
1640        let back: Switch = toml::from_str(&text).unwrap();
1641        assert_eq!(through_of(&back), through_of(&switch));
1642    }
1643
1644    #[test]
1645    fn the_phone_s_own_press_is_carried_once_with_its_own_time() {
1646        let mut switch = Switch::default();
1647        // The phone's notification pressed at 10:00; the computer turned it off at 10:30.
1648        let local = Through { pressed: 36_000_000, on: true, until: 36_000_000 + 3_600_000 };
1649        press(&mut switch, "desk", false, 0, 37_800_000);
1650        assert!(merge_local(&mut switch, "phone", &local));
1651        assert!(!merge_local(&mut switch, "phone", &local), "once");
1652        // Carried with its own time: the later "off" still holds.
1653        let held = through_of(&switch).unwrap();
1654        assert!(!held.on && held.pressed == 37_800_000);
1655        // A press after it, from the phone again: it holds.
1656        let later = Through { pressed: 38_000_000, on: true, until: 0 };
1657        assert!(merge_local(&mut switch, "phone", &later));
1658        assert!(through_of(&switch).unwrap().holds(40_000_000));
1659        // Which device screens: said in its part.
1660        assert!(!screens_anywhere(&switch));
1661        assert!(set_screens(&mut switch, "phone", true) && !set_screens(&mut switch, "phone", true));
1662        assert!(screens_anywhere(&switch));
1663    }
1664
1665    fn held(at: i64, key: &str, who: &str, column: &str) -> Held {
1666        Held { at, key: key.into(), number: key.into(), hidden: key.is_empty(), who: if key.is_empty() { "hidden".into() } else { who.into() }, column: column.into(), why: "matrix".into(), ..Held::default() }
1667    }
1668
1669    fn at(text: &str) -> Zoned {
1670        text.parse().unwrap()
1671    }
1672
1673    fn ms(text: &str) -> i64 {
1674        at(text).timestamp().as_millisecond()
1675    }
1676
1677    #[test]
1678    fn the_list_says_who_called_when_calmly() {
1679        let now = at("2026-10-07T10:00[Europe/Paris]");
1680        let calls = vec![
1681            held(ms("2026-10-07T06:10[Europe/Paris]"), "+33199001234", "stranger", "sleep"),
1682            held(ms("2026-10-07T06:21[Europe/Paris]"), "+33199001234", "stranger", "sleep"),
1683            held(ms("2026-10-07T07:00[Europe/Paris]"), "", "hidden", "sleep"),
1684            held(ms("2026-10-06T23:10[Europe/Paris]"), "+262639980002", "neutral", "sleep"),
1685            held(ms("2026-10-07T09:30[Europe/Paris]"), "+33199005555", "blocked", "work"),
1686            held(ms("2026-10-05T12:30[Europe/Paris]"), "+33199007777", "stranger", "meals"),
1687            held(ms("2026-10-05T12:31[Europe/Paris]"), "+33199007777", "stranger", "meals"),
1688            held(ms("2026-10-05T12:40[Europe/Paris]"), "+33199007777", "stranger", "meals"),
1689        ];
1690        let names = |key: &str| (key == "+262639980002").then(|| "Dr Martin's office".to_string());
1691        let all = |_: &Held| true;
1692        let here = |_: &str| String::new();
1693        for (language, expected) in [
1694            (
1695                "en",
1696                [
1697                    "Monday 5 October, during a meal, a number not in your contacts called three times, the last at 12:40.",
1698                    "Yesterday, while you slept, Dr Martin's office called at 23:10.",
1699                    "While you slept, a number not in your contacts called twice, at 06:10 and 06:21.",
1700                    "While you slept, a hidden number called at 07:00.",
1701                ],
1702            ),
1703            (
1704                "fr",
1705                [
1706                    "Lundi 5 octobre, pendant un repas, un numéro absent de vos contacts a appelé trois fois, la dernière à 12:40.",
1707                    "Hier, pendant votre sommeil, Dr Martin's office a appelé à 23:10.",
1708                    "Pendant votre sommeil, un numéro absent de vos contacts a appelé deux fois, à 06:10 et à 06:21.",
1709                    "Pendant votre sommeil, un numéro masqué a appelé à 07:00.",
1710                ],
1711            ),
1712        ] {
1713            let tr = Translator::new(language);
1714            let l = Lister { now: &now, tr: &tr, region: fr(), name_of: &names, shows: &all, phone: &here };
1715            let lines = lines(&calls, &Seen::default(), &BTreeMap::new(), &l);
1716            assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), expected, "{language}");
1717            // Never the blocked.
1718            assert!(lines.iter().all(|l| !l.dial.contains("5555")));
1719            // The doubt said, never "no message".
1720            assert!(lines.iter().all(|l| !l.doubt.is_empty()));
1721        }
1722        let tr = Translator::new("en");
1723        let l = Lister { now: &now, tr: &tr, region: fr(), name_of: &names, shows: &all, phone: &here };
1724        let lines_en = lines(&calls, &Seen::default(), &BTreeMap::new(), &l);
1725        let stranger = &lines_en[2];
1726        assert_eq!((stranger.number.as_str(), stranger.dial.as_str(), stranger.known, stranger.hidden), ("01 99 00 12 34", "+33199001234", false, false));
1727        assert_eq!(stranger.doubt, "They may have left a message.");
1728        assert_eq!(stranger.why, "Numbers not in your contacts go to voicemail while you slept.");
1729        assert_eq!(stranger.ids.len(), 2);
1730        let hidden = &lines_en[3];
1731        assert!(hidden.hidden && hidden.number.is_empty() && hidden.dial.is_empty());
1732        assert!(lines_en[1].known);
1733        assert_eq!(lines_en[1].why, "Calls from your neutral contacts go to voicemail while you slept.");
1734        // Someone Always through, declined (their row as their own, which held them then): said as their own row.
1735        let always = [held(ms("2026-10-07T06:40[Europe/Paris]"), "+262639980002", "always-neutral", "sleep")];
1736        assert_eq!(lines(&always, &Seen::default(), &BTreeMap::new(), &l)[0].why, "Calls from your neutral contacts go to voicemail while you slept.");
1737        // Seen: gone; the others stay.
1738        let mut seen = Seen::default();
1739        seen.add(&stranger.ids, now.timestamp().as_millisecond());
1740        assert_eq!(lines(&calls, &seen, &BTreeMap::new(), &l).len(), 3);
1741        // Shown only when their callers may reach you now.
1742        let contacts_only = |h: &Held| h.who == "neutral";
1743        let l2 = Lister { shows: &contacts_only, ..l };
1744        assert_eq!(lines(&calls, &Seen::default(), &BTreeMap::new(), &l2).len(), 1);
1745        // A voicemail linked: said, and no doubt.
1746        let mut messages = BTreeMap::new();
1747        messages.insert(stranger.ids[1].clone(), Message { text: message_words(&tr, Some(42)), path: "/mail/1".into(), sound: Some(0) });
1748        let with = lines(&calls, &Seen::default(), &messages, &l);
1749        assert_eq!(with[2].message.as_ref().map(|m| m.text.as_str()), Some("They left a message (0:42)."));
1750        assert!(with[2].doubt.is_empty());
1751    }
1752
1753    #[test]
1754    fn old_calls_leave_the_list_and_seen_ids_are_forgotten_with_them() {
1755        let now = at("2026-10-30T10:00[Europe/Paris]");
1756        let calls = vec![held(ms("2026-10-07T06:10[Europe/Paris]"), "+33199001234", "stranger", "sleep")];
1757        let tr = Translator::new("en");
1758        let all = |_: &Held| true;
1759        let none = |_: &str| None;
1760        let here = |_: &str| String::new();
1761        let l = Lister { now: &now, tr: &tr, region: fr(), name_of: &none, shows: &all, phone: &here };
1762        assert!(lines(&calls, &Seen::default(), &BTreeMap::new(), &l).is_empty(), "past two weeks");
1763        let mut seen = Seen::default();
1764        seen.add(&["1000-+33199001234".to_string(), format!("{}-x", now.timestamp().as_millisecond())], now.timestamp().as_millisecond());
1765        assert_eq!(seen.seen.len(), 1);
1766    }
1767
1768    #[test]
1769    fn the_held_file_reads_whatever_java_left_in_it() {
1770        let dir = std::env::temp_dir().join(format!("sioul-calls-held-{}", std::process::id()));
1771        let _ = std::fs::remove_dir_all(&dir);
1772        std::fs::create_dir_all(&dir).unwrap();
1773        let path = dir.join(HELD);
1774        std::fs::write(&path, "{\"at\":2000,\"key\":\"+33199001234\",\"number\":\"0199001234\",\"hidden\":false,\"presentation\":1,\"who\":\"stranger\",\"name\":\"\",\"column\":\"sleep\",\"why\":\"matrix\",\"verified\":\"\",\"later\":1}\n{\"at\":1000,\"key\":\"\",\"hidden\":true,\"who\":\"hidden\"}\n{\"at\":30").unwrap();
1775        let held = read_held(&path);
1776        assert_eq!(held.iter().map(|h| h.at).collect::<Vec<_>>(), vec![1000, 2000], "oldest first, the half line passed over");
1777        assert_eq!(held[0].id(), "1000-hidden");
1778        assert_eq!(held[1].id(), "2000-+33199001234");
1779        let _ = std::fs::remove_dir_all(&dir);
1780    }
1781
1782    fn scratch(name: &str) -> PathBuf {
1783        let dir = std::env::temp_dir().join(format!("sioul-calls-{name}-{}", std::process::id()));
1784        let _ = std::fs::remove_dir_all(&dir);
1785        std::fs::create_dir_all(&dir).unwrap();
1786        dir
1787    }
1788
1789    /// A call that rang, or was declined, on a phone (`device`).
1790    fn call_on(device: &str, at: i64, key: &str, who: &str, column: &str, why: &str, rang: bool) -> Held {
1791        Held { device: device.into(), rang, why: why.into(), ..held(at, key, who, column) }
1792    }
1793
1794    /// The line a phone's log keeps: what Java wrote, less the number as the
1795    /// network gave it and its presentation, every empty field left out; a
1796    /// line of Java's from before calls that rang were logged was declined.
1797    #[test]
1798    fn a_logged_line_reads_back() {
1799        let java: Held = serde_json::from_str(r#"{"at":1791346200000,"key":"+33199001234","number":"01 99 00 12 34","hidden":false,"presentation":1,"who":"stranger","name":"","column":"sleep","why":"matrix","verified":""}"#).unwrap();
1800        assert!(!java.rang, "declined, as every line was before");
1801        let logged = java.logged(fr());
1802        assert_eq!(serde_json::to_string(&logged).unwrap(), r#"{"at":1791346200000,"key":"+33199001234","who":"stranger","column":"sleep","why":"matrix","rang":false}"#);
1803        assert_eq!(logged.id(), java.id());
1804        // Java had no table to key it with: keyed as the country writes numbers.
1805        let unkeyed = Held { key: String::new(), why: "no-table".into(), rang: true, ..java.clone() };
1806        assert_eq!(unkeyed.logged(fr()).key, "+33199001234");
1807        // A hidden number keeps no key; a name and a verification are kept.
1808        let hidden = Held { at: 5, hidden: true, who: "hidden".into(), why: "matrix".into(), ..Held::default() }.logged(fr());
1809        assert_eq!(serde_json::to_string(&hidden).unwrap(), r#"{"at":5,"hidden":true,"who":"hidden","why":"matrix","rang":false}"#);
1810        let named = Held { name: "Cabinet du Dr Martin".into(), verified: "passed".into(), ..java }.logged(fr());
1811        let back: Held = serde_json::from_str(&serde_json::to_string(&named).unwrap()).unwrap();
1812        assert_eq!((back.name.as_str(), back.verified.as_str(), back.number.as_str()), ("Cabinet du Dr Martin", "passed", ""));
1813    }
1814
1815    /// The lines Java writes (android/jvm-checks/LogCheck.java, where
1816    /// SIOUL_CALLS_SAMPLE says), read as Java's file is read here: declined; a second call that
1817    /// rang, its time of day said; hidden; no table, keyed here. Skipped
1818    /// without the sample.
1819    #[test]
1820    fn java_s_lines_read_here() {
1821        let Some(sample) = std::env::var_os("SIOUL_CALLS_SAMPLE") else { return };
1822        let calls = read_held(Path::new(&sample));
1823        assert_eq!(calls.iter().map(|h| (h.rang, h.why.as_str(), h.column.as_str(), h.hidden)).collect::<Vec<_>>(), [(false, "matrix", "sleep", false), (true, "repeat", "sleep", false), (false, "matrix", "sleep", true), (true, "no-table", "", false)]);
1824        assert_eq!((calls[0].key.as_str(), calls[0].who.as_str()), ("+33199001234", "stranger"));
1825        assert_eq!(calls[3].logged(fr()).key, "+33465710042");
1826        assert_eq!(calls[0].logged(fr()).number, "", "the number as given stays on the phone");
1827    }
1828
1829    /// Every phone's log read together, each call with its phone, oldest
1830    /// first; never a blocked caller's, never one past the month.
1831    #[test]
1832    fn every_phone_s_log_is_read() {
1833        let root = scratch("logs");
1834        let now = ms("2026-10-07T10:00[Europe/Paris]");
1835        let write = |device: &str, lines: &[Held]| {
1836            let text: String = lines.iter().map(|h| serde_json::to_string(&h.logged(fr())).unwrap() + "\n").collect();
1837            std::fs::create_dir_all(root.join(LOG)).unwrap();
1838            std::fs::write(own_file(&root, LOG, device), text).unwrap();
1839        };
1840        write("phone-a", &[held(ms("2026-10-07T06:10[Europe/Paris]"), "+33199001234", "stranger", "sleep"), held(ms("2026-10-07T09:00[Europe/Paris]"), "+33199005555", "blocked", "work")]);
1841        write("phone-b", &[held(ms("2026-10-07T06:00[Europe/Paris]"), "+33465710042", "safe", "sleep"), held(ms("2026-09-01T09:00[Europe/Paris]"), "+33465710042", "safe", "work")]);
1842        std::fs::write(root.join(LOG).join(".phone-c.jsonl.lock"), "").unwrap();
1843        let calls = read_logs(&root, now);
1844        assert_eq!(calls.iter().map(|h| (h.device.as_str(), h.key.as_str())).collect::<Vec<_>>(), [("phone-b", "+33465710042"), ("phone-a", "+33199001234")]);
1845        let _ = std::fs::remove_dir_all(&root);
1846    }
1847
1848    /// On the phone that screened, a line says "called at"; on another
1849    /// device, which phone: "your phone", "your other phone" on a phone, or
1850    /// its name when several phones share their calls.
1851    #[test]
1852    fn the_list_names_the_phone_elsewhere() {
1853        let now = at("2026-10-07T10:00[Europe/Paris]");
1854        let calls = vec![call_on("phone-a", ms("2026-10-07T06:10[Europe/Paris]"), "+33199001234", "stranger", "sleep", "matrix", false)];
1855        let none = |_: &str| None;
1856        let all = |_: &Held| true;
1857        let one = [("phone-a".to_string(), "A12".to_string())];
1858        let two = [("phone-a".to_string(), "A12".to_string()), ("phone-b".to_string(), "B7".to_string())];
1859        for (language, own, computer, other, named) in [
1860            (
1861                "en",
1862                "While you slept, a number not in your contacts called at 06:10.",
1863                "While you slept, a number not in your contacts called your phone at 06:10.",
1864                "While you slept, a number not in your contacts called your other phone at 06:10.",
1865                "While you slept, a number not in your contacts called your phone (A12) at 06:10.",
1866            ),
1867            (
1868                "fr",
1869                "Pendant votre sommeil, un numéro absent de vos contacts a appelé à 06:10.",
1870                "Pendant votre sommeil, un numéro absent de vos contacts a appelé votre téléphone à 06:10.",
1871                "Pendant votre sommeil, un numéro absent de vos contacts a appelé votre autre téléphone à 06:10.",
1872                "Pendant votre sommeil, un numéro absent de vos contacts a appelé votre téléphone (A12) à 06:10.",
1873            ),
1874        ] {
1875            let tr = Translator::new(language);
1876            let said = |here: &str, phones: &[(String, String)], phone: bool| {
1877                let words = |device: &str| phone_words(&tr, device, here, phones, phone);
1878                let l = Lister { now: &now, tr: &tr, region: fr(), name_of: &none, shows: &all, phone: &words };
1879                lines(&calls, &Seen::default(), &BTreeMap::new(), &l)[0].text.clone()
1880            };
1881            assert_eq!(said("phone-a", &one, true), own, "{language}: on the phone that screened");
1882            assert_eq!(said("desk", &one, false), computer, "{language}: on a computer");
1883            assert_eq!(said("desk", &[], false), computer, "{language}: without the devices' list");
1884            assert_eq!(said("phone-b", &two, true), other, "{language}: on the other phone");
1885            assert_eq!(said("desk", &two, false), named, "{language}: two phones, from a computer");
1886        }
1887    }
1888
1889    /// A call that rang is never a line of its own; after declined calls of
1890    /// the same number the same day, it is said under their line; never for
1891    /// a hidden number. A message left after it goes with the line.
1892    #[test]
1893    fn a_call_that_rang_is_never_listed_but_said() {
1894        let now = at("2026-10-07T10:00[Europe/Paris]");
1895        let calls = vec![
1896            call_on("phone-a", ms("2026-10-07T06:10[Europe/Paris]"), "+33199001234", "stranger", "sleep", "matrix", false),
1897            call_on("phone-a", ms("2026-10-07T06:40[Europe/Paris]"), "+33199001234", "stranger", "sleep", "matrix", false),
1898            call_on("phone-a", ms("2026-10-07T06:48[Europe/Paris]"), "+33199001234", "stranger", "sleep", "repeat", true),
1899            call_on("phone-a", ms("2026-10-07T07:00[Europe/Paris]"), "", "hidden", "sleep", "matrix", false),
1900            call_on("phone-a", ms("2026-10-07T07:05[Europe/Paris]"), "", "hidden", "sleep", "repeat", true),
1901            call_on("phone-a", ms("2026-10-07T09:15[Europe/Paris]"), "+33465710042", "safe", "work", "matrix", true),
1902        ];
1903        let none = |_: &str| None;
1904        let all = |_: &Held| true;
1905        let here = |_: &str| String::new();
1906        for (language, first, again) in [
1907            ("en", "While you slept, a number not in your contacts called twice, at 06:10 and 06:40.", "The same number called again at 06:48, and that call rang."),
1908            ("fr", "Pendant votre sommeil, un numéro absent de vos contacts a appelé deux fois, à 06:10 et à 06:40.", "Le même numéro a rappelé à 06:48, et cet appel a sonné."),
1909        ] {
1910            let tr = Translator::new(language);
1911            let l = Lister { now: &now, tr: &tr, region: fr(), name_of: &none, shows: &all, phone: &here };
1912            let said = lines(&calls, &Seen::default(), &BTreeMap::new(), &l);
1913            assert_eq!(said.len(), 2, "{language}: the stranger's and the hidden number's; never a call that rang");
1914            assert_eq!((said[0].text.as_str(), said[0].again.as_str()), (first, again), "{language}");
1915            assert_eq!(said[1].again, "", "{language}: two hidden calls cannot be told apart");
1916        }
1917        // The message left after the call that rang goes with the line.
1918        let tr = Translator::new("en");
1919        let l = Lister { now: &now, tr: &tr, region: fr(), name_of: &none, shows: &all, phone: &here };
1920        let mut messages = BTreeMap::new();
1921        messages.insert(calls[2].id(), Message { text: message_words(&tr, Some(12)), path: "/mail/2".into(), sound: Some(0) });
1922        let said = lines(&calls, &Seen::default(), &messages, &l);
1923        assert_eq!((said[0].message.as_ref().map(|m| m.text.as_str()), said[0].doubt.as_str()), (Some("They left a message (0:12)."), ""));
1924        // The next day's call that rang is no longer said under that line.
1925        let later = [calls[0].clone(), call_on("phone-a", ms("2026-10-08T08:00[Europe/Paris]"), "+33199001234", "stranger", "work", "matrix", true)];
1926        let tomorrow = at("2026-10-08T10:00[Europe/Paris]");
1927        let l = Lister { now: &tomorrow, ..l };
1928        assert_eq!(lines(&later, &Seen::default(), &BTreeMap::new(), &l)[0].again, "");
1929    }
1930
1931    /// Seen on one device, seen on all: each device's own log, every
1932    /// device's read together; Undo takes out this device's own line only.
1933    /// The phone's file from before Seen was shared becomes a line of its own log.
1934    #[test]
1935    fn seen_anywhere_is_seen_everywhere() {
1936        let root = scratch("seen");
1937        let a = "1791346200000-+33199001234".to_string();
1938        let b = "1791349200000-hidden".to_string();
1939        let pressed = mark_seen(&root, "desk", std::slice::from_ref(&a), 1_000).unwrap();
1940        mark_seen(&root, "phone-a", &[a.clone(), b.clone()], 2_000).unwrap();
1941        assert_eq!(read_seen(&root).seen, [a.clone(), b.clone()].into());
1942        // Undo on the desk: the phone's Seen stays.
1943        assert!(unmark_seen(&root, "desk", pressed).unwrap());
1944        assert!(!unmark_seen(&root, "desk", pressed).unwrap(), "once");
1945        assert_eq!(read_seen(&root).seen, [a.clone(), b.clone()].into());
1946        assert!(unmark_seen(&root, "phone-a", 2_000).unwrap());
1947        assert!(read_seen(&root).seen.is_empty());
1948        // The older file, this phone's alone: a line of its own log, then gone.
1949        std::fs::write(root.join(SEEN), "seen = [\"1791346200000-+33199001234\"]\n").unwrap();
1950        adopt_older_seen(&root, "phone-a", 3_000).unwrap();
1951        assert!(!root.join(SEEN).exists());
1952        assert_eq!(read_seen(&root).seen, [a].into());
1953        adopt_older_seen(&root, "phone-a", 4_000).unwrap();
1954        assert_eq!(std::fs::read_to_string(own_file(&root, SEEN_LOG, "phone-a")).unwrap().lines().count(), 1, "nothing twice");
1955        #[cfg(unix)]
1956        {
1957            use std::os::unix::fs::PermissionsExt;
1958            assert_eq!(std::fs::metadata(own_file(&root, SEEN_LOG, "phone-a")).unwrap().permissions().mode() & 0o777, 0o600, "yours alone");
1959        }
1960        let _ = std::fs::remove_dir_all(&root);
1961    }
1962
1963    /// A person's calls of the month on their sheet: newest first, rang or
1964    /// declined, why a call rang when its row alone would not say it, the
1965    /// phone named only when several share their calls; never a blocked
1966    /// caller's, nothing past the month.
1967    #[test]
1968    fn a_person_s_calls_of_the_month() {
1969        let now = at("2026-10-07T10:30[Europe/Paris]");
1970        let calls = vec![
1971            call_on("phone-a", ms("2026-09-01T09:00[Europe/Paris]"), "+262639980002", "neutral", "work", "matrix", true),
1972            call_on("phone-a", ms("2026-10-06T23:10[Europe/Paris]"), "+262639980002", "neutral", "sleep", "matrix", false),
1973            call_on("phone-a", ms("2026-10-07T06:48[Europe/Paris]"), "+262639980002", "neutral", "sleep", "repeat", true),
1974            call_on("phone-a", ms("2026-10-07T08:00[Europe/Paris]"), "+33199005555", "blocked", "work", "blocked", false),
1975            call_on("phone-a", ms("2026-10-07T10:05[Europe/Paris]"), "+262639980002", "neutral", "work", "matrix", true),
1976        ];
1977        let keys: BTreeSet<String> = ["+262639980002".to_string(), "+33199005555".to_string()].into();
1978        let none = |_: &str| None;
1979        let all = |_: &Held| true;
1980        for (language, expected) in [
1981            ("en", ["Today at 10:05, during work: it rang.", "Today at 06:48, while you slept: it rang. A second call within 15 minutes rings.", "Yesterday at 23:10, while you slept: declined, sent to voicemail."]),
1982            ("fr", ["Aujourd’hui à 10:05, pendant le travail\u{202f}: l’appel a sonné.", "Aujourd’hui à 06:48, pendant votre sommeil\u{202f}: l’appel a sonné. Un deuxième appel en moins de 15 minutes sonne.", "Hier à 23:10, pendant votre sommeil\u{202f}: refusé, envoyé sur votre messagerie."]),
1983        ] {
1984            let tr = Translator::new(language);
1985            let words = |device: &str| phone_words(&tr, device, "desk", &[("phone-a".to_string(), "A12".to_string())], false);
1986            let l = Lister { now: &now, tr: &tr, region: fr(), name_of: &none, shows: &all, phone: &words };
1987            assert_eq!(history(&calls, &keys, &l), expected, "{language}");
1988        }
1989        // Two phones: each line names its phone.
1990        let mut both = calls.clone();
1991        both.push(call_on("phone-b", ms("2026-10-07T09:00[Europe/Paris]"), "+262639980002", "neutral", "work", "matrix", false));
1992        both.sort_by_key(|h| h.at);
1993        let tr = Translator::new("en");
1994        let phones = [("phone-a".to_string(), "A12".to_string()), ("phone-b".to_string(), "B7".to_string())];
1995        let words = |device: &str| phone_words(&tr, device, "desk", &phones, false);
1996        let l = Lister { now: &now, tr: &tr, region: fr(), name_of: &none, shows: &all, phone: &words };
1997        let said = history(&both, &keys, &l);
1998        assert_eq!(&said[..2], ["Today at 10:05, during work: it rang on your phone (A12).", "Today at 09:00, during work: declined on your phone (B7), sent to voicemail."]);
1999    }
2000
2001    /// Java's calls copied into this phone's own log once, whoever copies;
2002    /// never a blocked caller's, nor one past the month; this device's own
2003    /// lines taken out after the month, and never copied back.
2004    #[test]
2005    fn old_lines_leave_with_their_writer() {
2006        let root = scratch("carry");
2007        let own = own_file(&root, LOG, "phone-a");
2008        let now = ms("2026-10-07T10:00[Europe/Paris]");
2009        let java = vec![
2010            Held { number: "01 99 00 12 34".into(), ..held(ms("2026-10-07T06:10[Europe/Paris]"), "+33199001234", "stranger", "sleep") },
2011            Held { key: String::new(), number: "0465710042".into(), rang: true, why: "no-table".into(), ..held(ms("2026-10-07T09:15[Europe/Paris]"), "x", "", "") },
2012            held(ms("2026-10-07T09:30[Europe/Paris]"), "+33199005555", "blocked", "work"),
2013            held(ms("2026-08-20T09:30[Europe/Paris]"), "+33199007777", "stranger", "work"),
2014        ];
2015        assert_eq!(carry_into(&own, &java, fr(), now).unwrap(), 2);
2016        assert_eq!(carry_into(&own, &java, fr(), now).unwrap(), 0, "once");
2017        let logged = read_held(&own);
2018        assert_eq!(logged.iter().map(|h| (h.key.as_str(), h.rang, h.number.as_str())).collect::<Vec<_>>(), [("+33199001234", false, ""), ("+33465710042", true, "")]);
2019        // A month and a few days later: taken out of this device's own files, a line that does not read kept.
2020        mark_seen(&root, "phone-a", &[logged[0].id()], now).unwrap();
2021        std::fs::write(&own, std::fs::read_to_string(&own).unwrap() + "{\"at\":\n").unwrap();
2022        let later = now + 33 * DAY_MS;
2023        assert_eq!(trim_own(&root, "phone-a", later).unwrap(), 3);
2024        assert_eq!(std::fs::read_to_string(&own).unwrap(), "{\"at\":\n");
2025        assert!(read_seen(&root).seen.is_empty());
2026        assert_eq!(carry_into(&own, &java, fr(), later).unwrap(), 0, "never copied back");
2027        // Nobody else's file is touched, until a line of theirs is long past its month (its phone gone for good).
2028        std::fs::write(own_file(&root, LOG, "phone-b"), serde_json::to_string(&java[0].logged(fr())).unwrap() + "\n").unwrap();
2029        trim_own(&root, "phone-a", later).unwrap();
2030        assert_eq!(trim_others(&root, "phone-a", later).unwrap(), 0);
2031        assert_eq!(read_held(&own_file(&root, LOG, "phone-b")).len(), 1);
2032        assert_eq!(trim_others(&root, "phone-a", now + (KEPT_DAYS + LATE_DAYS + 1) * DAY_MS).unwrap(), 1);
2033        assert!(read_held(&own_file(&root, LOG, "phone-b")).is_empty());
2034        assert_eq!(std::fs::read_to_string(&own).unwrap(), "{\"at\":\n", "its own left to trim_own");
2035        let _ = std::fs::remove_dir_all(&root);
2036    }
2037
2038    #[test]
2039    fn letting_calls_through_is_said_with_its_end() {
2040        let now = 10_000_000;
2041        let when = |ms: i64| format!("<{ms}>");
2042        for (language, hour, open, emergency) in [
2043            ("en", "Every call rings until <13600000>.", "Every call rings until you turn it off.", "Every call rings until <92810000>: you called an emergency number."),
2044            ("fr", "Tous les appels sonnent jusqu’à <13600000>.", "Tous les appels sonnent jusqu’à ce que vous l’arrêtiez.", "Tous les appels sonnent jusqu’à <92810000>\u{202f}: vous avez appelé un numéro d’urgence."),
2045        ] {
2046            let tr = Translator::new(language);
2047            let on = Through { pressed: 1, on: true, until: now + 3_600_000 };
2048            assert_eq!(through_line(&tr, Some(&on), 0, now, &when), hour);
2049            assert_eq!(through_line(&tr, Some(&Through { until: 0, ..on }), 0, now, &when), open);
2050            assert_eq!(through_line(&tr, Some(&Through { on: false, ..on }), 0, now, &when), "");
2051            assert_eq!(through_line(&tr, None, now - 3_590_000, now, &when), emergency);
2052            assert_eq!(through_line(&tr, None, now - 25 * 3_600_000, now, &when), "", "a day after");
2053        }
2054    }
2055
2056    #[test]
2057    fn every_word_has_both_languages() {
2058        for language in ["en", "fr"] {
2059            let tr = Translator::new(language);
2060            let mut keys: Vec<String> = [
2061                "calls-who-hidden", "calls-who-stranger", "calls-context-day", "calls-day-yesterday", "calls-day-today", "calls-line-once", "calls-line-twice", "calls-line-more", "calls-line-once-on", "calls-line-twice-on", "calls-line-more-on", "calls-may-have-left", "calls-left-message", "calls-left-message-plain", "calls-through-emergency", "calls-through-until", "calls-through-on", "calls-context-any",
2062                "calls-phone-yours", "calls-phone-other", "calls-phone-named", "calls-phone-this", "calls-again-rang", "calls-history-title", "calls-history-when", "calls-history-head", "calls-history-line", "calls-history-declined", "calls-history-rang", "calls-history-declined-on", "calls-history-rang-on",
2063                "calls-rang-repeat", "calls-rang-through", "calls-rang-emergency", "calls-rang-after-emergency", "calls-rang-undecided",
2064            ]
2065            .iter()
2066            .map(|k| k.to_string())
2067            .collect();
2068            keys.extend(["work", "admin", "leisure", "meals", "sleep", "pause", "free", "slot", "dnd"].iter().map(|c| format!("calls-context-{c}")));
2069            keys.extend(["safe", "neutral", "restricted", "stranger", "hidden"].iter().map(|r| format!("calls-why-{r}")));
2070            for key in keys {
2071                let mut args = crate::i18n::args();
2072                for name in ["context", "day", "who", "time", "first", "second", "count", "length", "until", "phone", "name", "when", "head", "outcome"] {
2073                    args.set(name, "X");
2074                }
2075                let words = tr.text(&key, Some(&args));
2076                assert!(!words.is_empty() && words != key && !words.contains('{'), "{language}: {key}: {words}");
2077                // French typography: a narrow no-break space before « : ; ? ! », the apostrophe ’.
2078                if language == "fr" {
2079                    assert!(![" :", " ;", " ?", " !", "'"].iter().any(|bad| words.contains(bad)), "{key}: {words}");
2080                }
2081            }
2082        }
2083    }
2084}