Skip to main content

sioul_app/
calls.rs

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