1use crate::backend::{load_config, tr};
35use jiff::Zoned;
36use serde_json::json;
37use sioul_core::calls::{self as rules, Held, Through};
38use sioul_core::config::Config;
39use sioul_core::everywhere::{self as switches, People, Switch};
40use sioul_core::porch::{Senders, Standing};
41use sioul_core::attention::{Attention, Person};
42use sioul_core::quiet::Overrides;
43use sioul_core::reach::{Clock, Who};
44use sioul_core::screening::{self, Screening};
45use sioul_core::voicemail::{self, Voicemail};
46use std::collections::BTreeMap;
47use std::path::{Path, PathBuf};
48use std::sync::Mutex;
49use std::time::{Duration, SystemTime};
50
51const REMAKE_EVERY: i64 = 10 * 60;
53const STATE_KEPT: i64 = 30;
55const SOUNDS_KEPT: usize = 8;
57
58fn here_id() -> String {
60 sioul_sync::share::Here::load(&sioul_core::config::state_dir()).id
61}
62
63fn phone() -> bool {
66 cfg!(target_os = "android") || std::env::var_os("SIOUL_CALLS").is_some()
67}
68
69fn path(name: &str) -> PathBuf {
70 rules::folder().join(name)
71}
72
73static STATE: Mutex<Option<(i64, serde_json::Value)>> = Mutex::new(None);
76
77fn state() -> serde_json::Value {
80 let now = jiff::Timestamp::now().as_second();
81 if let Ok(kept) = STATE.lock()
82 && let Some((at, state)) = kept.as_ref()
83 && (0..STATE_KEPT).contains(&(now - at))
84 {
85 return state.clone();
86 }
87 let state = crate::steps::java("calls-state", "{}");
88 if let Ok(mut kept) = STATE.lock() {
89 *kept = Some((now, state.clone()));
90 }
91 state
92}
93
94fn forget_state() {
96 if let Ok(mut kept) = STATE.lock() {
97 *kept = None;
98 }
99}
100
101pub(crate) fn screens_here() -> bool {
103 cfg!(target_os = "android") && state()["held"] == true
104}
105
106type Inputs = (jiff::civil::Date, Vec<Option<SystemTime>>);
111
112fn changed(path: &Path) -> Option<SystemTime> {
113 std::fs::metadata(path).and_then(|m| m.modified()).ok()
114}
115
116fn inputs(config: &Config, now: &Zoned) -> Inputs {
117 let files = [
118 crate::backend::config_path(),
119 config.safe_senders_path(),
120 config.neutral_senders_path(),
121 config.restricted_senders_path(),
122 config.blocked_senders_path(),
123 config.known_senders_path(),
124 People::default_path(),
125 Switch::default_path(),
126 sioul_core::everywhere::gone_path(),
128 Overrides::default_path(),
129 sioul_core::health::Health::default_path(),
130 sioul_core::needs::Days::default_path(),
131 sioul_core::attention::Slots::default_path(),
132 sioul_core::timelog::folder().join("running.toml"),
134 path(rules::THROUGH_HERE),
135 ];
136 (now.date(), files.iter().map(|f| changed(f)).collect())
137}
138
139static MADE: Mutex<Option<(Inputs, i64, String)>> = Mutex::new(None);
141static BUSY: Mutex<()> = Mutex::new(());
142
143pub(crate) fn refresh(now: bool) {
148 if !phone() {
149 return;
150 }
151 let busy = if now { Some(BUSY.lock().unwrap_or_else(std::sync::PoisonError::into_inner)) } else { crate::backend::one_at_a_time(&BUSY) };
152 let Some(_busy) = busy else { return };
153 if cfg!(target_os = "android") && !screens_here() {
154 let _ = rules::prune_called(&path(rules::CALLED), 0, 0);
156 let here = here_id();
157 if Switch::load(&Switch::default_path()).device.get(&here).is_some_and(|d| rules::part_of(d).screens) {
158 carry();
159 }
160 return;
161 }
162 let config = load_config();
163 let at = Zoned::now();
164 let stamp = at.timestamp().as_second();
165 let there = path(rules::TABLE).is_file();
166 let before = inputs(&config, &at);
167 let fresh = MADE.lock().ok().and_then(|made| made.as_ref().map(|(inputs, when, _)| *inputs == before && (0..REMAKE_EVERY).contains(&(stamp - when)))).unwrap_or(false);
168 if !now && there && fresh {
169 return;
170 }
171 carry();
172 let made_of = inputs(&config, &at);
175 let switch = switches::read_switch(&here_id());
178 let table = make(&config, &switch, &at);
179 if let Err(e) = rules::prune_called(&path(rules::CALLED), at.timestamp().as_millisecond(), table.screening.as_ref().map_or(0, |s| s.called_days)) {
181 eprintln!("sioul: calls: {}: {e}", rules::CALLED);
182 }
183 let content = rules::content(&table);
184 let same = there && MADE.lock().ok().and_then(|made| made.as_ref().map(|(_, _, said)| *said == content)).unwrap_or(false);
185 if !same && let Err(e) = rules::write(&path(rules::TABLE), &table) {
186 eprintln!("sioul: calls: {e}");
187 return;
188 }
189 if let Ok(mut made) = MADE.lock() {
191 *made = Some((made_of, stamp, content));
192 }
193}
194
195fn carry() {
200 let path = Switch::default_path();
201 let here = here_id();
202 if !cfg!(target_os = "android") || here.is_empty() {
203 return;
204 }
205 let local: Option<Through> = rules::read_json(&rules::folder().join(rules::THROUGH_HERE));
206 let screens = screens_here();
207 let result = switches::change_own(&path, &here, |switch| {
208 let mut changed = local.as_ref().is_some_and(|l| rules::merge_local(switch, &here, l));
209 changed |= rules::set_screens(switch, &here, screens);
210 if changed {
211 named(switch, &here);
212 }
213 changed
214 });
215 if let Err(e) = result {
216 eprintln!("sioul: calls: {e}");
217 }
218}
219
220fn named(switch: &mut Switch, here: &str) {
222 let own = switch.device.entry(here.to_string()).or_default();
223 own.kind = crate::devices::kind().to_string();
224 own.name = if own.kind == "phone" { String::new() } else { crate::devices::name() };
225}
226
227fn numbers() -> &'static screening::Numbers {
229 screening::shipped()
230}
231
232fn make(config: &Config, switch: &Switch, now: &Zoned) -> rules::Table {
239 let senders = Senders::load(config);
240 let attention = Attention::of(config);
241 let clock = if crate::steps::in_service() {
242 let needs = sioul_core::health::Health::load(&sioul_core::health::Health::default_path()).needs;
245 let days = sioul_core::needs::Days::load(&sioul_core::needs::Days::default_path());
246 Clock::new(config, Overrides::load(&Overrides::default_path()), needs, days, Vec::new())
247 } else {
248 Clock::load(config, now)
249 };
250 let from = now.date().to_zoned(now.time_zone().clone()).unwrap_or_else(|_| now.clone());
251 let until = from.checked_add(jiff::Span::new().days(rules::TABLE_DAYS)).unwrap_or_else(|_| now.clone());
252 let people = People::load(&People::default_path());
254 let always = rules::always_numbers(&people, senders.region(), &|key| senders.judge_number(key).who);
255 let (own_numbers, own_rows) = rules::own_rows(&attention, &senders);
256 rules::table(rules::Made {
257 made: now.timestamp().as_millisecond(),
258 region: senders.region(),
259 numbers: senders.numbers().into_iter().map(|(key, who)| (key, who.id().to_string())).collect(),
260 prefixes: senders.prefixes().into_iter().map(|(prefix, who)| (prefix, who.id().to_string())).collect(),
261 always,
262 own: own_numbers,
263 expecting: rules::through_of(switch),
264 screening: Some(screening::TablePart::of(&Screening::of(config), numbers(), now.date())),
265 frames: rules::frames_with(&attention, &clock, &layers(), &from, &until, &own_rows),
266 })
267}
268
269
270pub(crate) fn unknown_rings(config: &Config, hours: bool) -> bool {
276 let screening = Screening::of(config);
277 screening.rings(false, hours) || screening.rings(true, hours)
278}
279
280fn layers() -> rules::Layers {
285 let since = jiff::Timestamp::now().as_second();
286 let state = crate::everywhere::now();
289 let dnd = crate::everywhere::gated_of(&state).map(|g| (since, g.until));
290 let reachable = state.reachable.map(|r| (r.since, r.until));
291 rules::Layers { slots: crate::hours::slots().slots, dnd, reachable }
292}
293
294pub(crate) fn through_now(switch: &Switch) -> Option<Through> {
299 let shared = rules::through_of(switch);
300 let local: Option<Through> = if cfg!(target_os = "android") { rules::read_json(&path(rules::THROUGH_HERE)) } else { None };
301 match (shared, local) {
302 (Some(shared), Some(local)) => Some(if local.pressed > shared.pressed { local } else { shared }),
303 (shared, local) => shared.or(local),
304 }
305}
306
307fn words_at(ms: i64, now: &Zoned) -> String {
309 jiff::Timestamp::from_millisecond(ms).map(|t| sioul_core::quiet::until_text(tr(), &t.to_zoned(now.time_zone().clone()), now)).unwrap_or_default()
310}
311
312pub(crate) fn press(on: bool, minutes: i32) -> Result<(), String> {
317 let here = here_id();
318 let now = jiff::Timestamp::now().as_millisecond();
319 let until = if on && minutes > 0 { now + i64::from(minutes) * 60_000 } else { 0 };
320 switches::change_own(&Switch::default_path(), &here, |switch| {
321 rules::press(switch, &here, on, until, now);
322 named(switch, &here);
323 true
324 })?;
325 Ok(())
326}
327
328pub(crate) fn moment() -> serde_json::Value {
331 let switch = switches::read_switch(&here_id());
333 let shown = screens_here() || rules::screens_anywhere(&switch);
334 if !shown {
335 return json!({ "shown": false, "through": false, "line": "", "until_at": 0 });
336 }
337 let now = Zoned::now();
338 let now_ms = now.timestamp().as_millisecond();
339 let through = through_now(&switch).filter(|t| t.holds(now_ms));
340 let emergency = if cfg!(target_os = "android") { state()["emergency_at"].as_i64().unwrap_or(0) } else { 0 };
341 let line = rules::through_line(tr(), through.as_ref(), emergency, now_ms, &|ms| words_at(ms, &now));
342 json!({ "shown": true, "through": through.is_some(), "line": line, "until_at": through.map_or(0, |t| t.until) })
343}
344
345pub(crate) fn step(pressed: bool) -> serde_json::Value {
350 if pressed {
351 forget_state();
352 }
353 refresh(pressed);
354 words()
355}
356
357pub(crate) fn words() -> serde_json::Value {
362 if !screens_here() {
363 return json!({});
364 }
365 let said = moment();
366 json!({
367 "screening": true,
368 "through": said["through"],
369 "line": said["line"],
370 "hour": tr().text("calls-note-hour", None),
371 "off": tr().text("calls-note-off", None),
372 "again": tr().text("calls-note-again", None),
373 })
374}
375
376static TRIMMED: Mutex<i64> = Mutex::new(0);
380static COPIED: Mutex<Option<(u64, Option<SystemTime>)>> = Mutex::new(None);
382
383pub(crate) fn before_exchange() {
391 let here = here_id();
392 if here.is_empty() {
393 return;
394 }
395 let root = rules::folder();
396 let now = jiff::Timestamp::now().as_millisecond();
397 let due = TRIMMED.lock().map(|mut last| a_day_since(&mut last, now)).unwrap_or(false);
398 if due {
399 let done = rules::adopt_older_seen(&root, &here, now).and_then(|()| rules::trim_own(&root, &here, now)).and_then(|_| rules::trim_others(&root, &here, now));
400 if let Err(e) = done {
401 eprintln!("sioul: calls: {e}");
402 }
403 }
404 if !phone() {
405 return;
406 }
407 let java = path(rules::HELD);
408 let Some(stamp) = std::fs::metadata(&java).ok().map(|m| (m.len(), m.modified().ok())) else { return };
409 let mut copied = COPIED.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
410 if *copied == Some(stamp) {
411 return;
412 }
413 let region = sioul_core::reach::region(&load_config());
414 match rules::carry_into(&rules::own_file(&root, rules::LOG, &here), &rules::read_held(&java), region, now) {
415 Ok(_) => *copied = Some(stamp),
416 Err(e) => eprintln!("sioul: calls: {e}"),
417 }
418}
419
420fn a_day_since(last: &mut i64, now: i64) -> bool {
423 let due = now - *last > 86_400_000;
424 if due {
425 *last = now;
426 }
427 due
428}
429
430fn who_now(senders: &Senders, held: &Held) -> Option<Who> {
435 if held.hidden {
436 return None;
437 }
438 let who = senders.judge_number(&held.key).who;
439 Some(if who == Who::Stranger && !held.name.trim().is_empty() { Who::Neutral } else { who })
440}
441
442fn listed(root: &Path, here: &str, everywhere: bool, now_ms: i64) -> Vec<Held> {
445 let mut calls = rules::read_logs(root, now_ms);
446 if !everywhere {
447 calls.retain(|h| h.device == here);
448 }
449 calls
450}
451
452pub(crate) fn phone_names() -> Vec<(String, String)> {
455 type Kept = Option<(i64, Vec<(String, String)>)>;
456 static KEPT: Mutex<Kept> = Mutex::new(None);
457 let now = jiff::Timestamp::now().as_second();
458 if let Ok(kept) = KEPT.lock()
459 && let Some((at, names)) = kept.as_ref()
460 && (0..300).contains(&(now - at))
461 {
462 return names.clone();
463 }
464 let names: Vec<(String, String)> = match crate::share::vault() {
465 Some((_, Some((folder, key)))) => sioul_sync::devices::all(&folder, &key).0.into_iter().filter(|d| d.kind == sioul_sync::devices::PHONE).map(|d| (d.id, d.name)).collect(),
466 _ => Vec::new(),
467 };
468 if let Ok(mut kept) = KEPT.lock() {
469 *kept = Some((now, names.clone()));
470 }
471 names
472}
473
474pub(crate) fn link_apps() -> (bool, bool) {
478 static FOUND: std::sync::OnceLock<(bool, bool)> = std::sync::OnceLock::new();
479 *FOUND.get_or_init(|| {
480 if cfg!(target_os = "android") {
481 return (true, true);
482 }
483 let has = |scheme: &str| {
484 cfg!(target_os = "linux")
485 && std::process::Command::new("xdg-mime")
486 .args(["query", "default", &format!("x-scheme-handler/{scheme}")])
487 .output()
488 .ok()
489 .filter(|out| out.status.success())
490 .is_some_and(|out| !String::from_utf8_lossy(&out.stdout).trim().is_empty())
491 };
492 (has("tel"), has("sms"))
493 })
494}
495
496pub(crate) fn view() -> String {
502 let empty = || json!({ "lines": [] }).to_string();
503 let root = rules::folder();
504 if !phone() && !root.join(rules::LOG).is_dir() {
506 return empty();
507 }
508 if phone() {
509 before_exchange();
510 }
511 let config = load_config();
512 let here = here_id();
513 let now = Zoned::now();
514 let held = listed(&root, &here, config.porch.calls, now.timestamp().as_millisecond());
515 if held.is_empty() {
516 return empty();
517 }
518 let senders = Senders::load(&config);
519 let attention = Attention::of(&config);
520 let moment = crate::hours::attention_now();
521 let always = People::load(&People::default_path());
522 let shows = |h: &Held| match who_now(&senders, h) {
523 None => attention.listed(Person::Hidden, false, &moment),
524 Some(who) => attention.listed(Person::of(who), always.admits_number(&h.key, senders.region()), &moment),
525 };
526 let name_of = |key: &str| {
527 let judged = senders.judge_number(key);
528 (!judged.card.trim().is_empty()).then_some(judged.card)
529 };
530 let mails = voicemails(&config, senders.region());
531 let messages: BTreeMap<String, rules::Message> = voicemail::link(&held, &mails)
532 .into_iter()
533 .map(|(id, i)| (id, rules::Message { text: rules::message_words(tr(), mails[i].seconds), path: mails[i].path.display().to_string(), sound: mails[i].sound }))
534 .collect();
535 let seen = rules::read_seen(&root);
536 let phones = phone_names();
537 let words = |device: &str| rules::phone_words(tr(), device, &here, &phones, cfg!(target_os = "android"));
538 let lister = rules::Lister { now: &now, tr: tr(), region: senders.region(), name_of: &name_of, shows: &shows, phone: &words };
539 let (tel, sms) = link_apps();
540 json!({ "lines": rules::lines(&held, &seen, &messages, &lister), "phone": cfg!(target_os = "android"), "links": { "tel": tel, "sms": sms } }).to_string()
541}
542
543pub(crate) type CardCalls = Box<dyn Fn(&sioul_core::attention::Now, &Zoned) -> Vec<String>>;
545
546pub(crate) fn for_card() -> Option<CardCalls> {
552 let root = rules::folder();
553 if !phone() && !root.join(rules::LOG).is_dir() {
554 return None;
555 }
556 let config = load_config();
557 let here = here_id();
558 let held = listed(&root, &here, config.porch.calls, Zoned::now().timestamp().as_millisecond());
559 if held.is_empty() {
560 return None;
561 }
562 let senders = Senders::load(&config);
563 let attention = Attention::of(&config);
564 let always = People::load(&People::default_path());
565 let seen = rules::read_seen(&root);
566 let phones = phone_names();
567 Some(Box::new(move |moment, at| {
568 let shows = |h: &Held| match who_now(&senders, h) {
569 None => attention.listed(Person::Hidden, false, moment),
570 Some(who) => attention.listed(Person::of(who), always.admits_number(&h.key, senders.region()), moment),
571 };
572 let name_of = |key: &str| {
573 let judged = senders.judge_number(key);
574 (!judged.card.trim().is_empty()).then_some(judged.card)
575 };
576 let words = |device: &str| rules::phone_words(tr(), device, &here, &phones, cfg!(target_os = "android"));
577 let lister = rules::Lister { now: at, tr: tr(), region: senders.region(), name_of: &name_of, shows: &shows, phone: &words };
578 rules::lines(&held, &seen, &BTreeMap::new(), &lister).into_iter().map(|line| line.text).collect()
579 }))
580}
581
582pub(crate) fn history(numbers: &[String]) -> Vec<String> {
587 let root = rules::folder();
588 if numbers.is_empty() || !root.join(rules::LOG).is_dir() {
589 return Vec::new();
590 }
591 let config = load_config();
592 let region = sioul_core::reach::region(&config);
593 let keys: std::collections::BTreeSet<String> = numbers.iter().map(|n| sioul_core::phones::key(n.trim().trim_start_matches(sioul_core::porch::TEL), region)).filter(|k| sioul_core::phones::is_whole(k)).collect();
594 if keys.is_empty() {
595 return Vec::new();
596 }
597 let here = here_id();
598 let now = Zoned::now();
599 let calls = rules::read_logs(&root, now.timestamp().as_millisecond());
600 let phones = phone_names();
601 let words = |device: &str| rules::phone_words(tr(), device, &here, &phones, cfg!(target_os = "android"));
602 let (none, all) = (|_: &str| None, |_: &Held| true);
603 let lister = rules::Lister { now: &now, tr: tr(), region, name_of: &none, shows: &all, phone: &words };
604 rules::history(&calls, &keys, &lister)
605}
606
607fn voicemails(config: &Config, region: Option<&'static sioul_core::phones::Region>) -> Vec<Voicemail> {
613 type Folder = (Option<SystemTime>, Vec<Voicemail>);
614 static FOLDERS: Mutex<BTreeMap<PathBuf, Folder>> = Mutex::new(BTreeMap::new());
615 static LETTERS: Mutex<BTreeMap<String, Option<Voicemail>>> = Mutex::new(BTreeMap::new());
616 static READ_WITH: Mutex<Option<sioul_core::words::VoicemailWords>> = Mutex::new(None);
617 let words = sioul_core::words::Words::of(config).voicemail.clone();
618 if words.operators.iter().all(|d| d.trim().is_empty()) {
619 return Vec::new();
620 }
621 let zone = jiff::tz::TimeZone::system();
622 let oldest = SystemTime::now().checked_sub(Duration::from_secs(u64::try_from((rules::LISTED_DAYS + 1) * 86_400).unwrap_or(0))).unwrap_or(SystemTime::UNIX_EPOCH);
623 let mut folders = FOLDERS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
624 let mut letters = LETTERS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
625 let mut read_with = READ_WITH.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
627 if read_with.as_ref() != Some(&words) {
628 folders.clear();
629 letters.clear();
630 *read_with = Some(words.clone());
631 }
632 let mut found = Vec::new();
633 for source in config.mail_sources() {
634 for folder in [source.folder.join("new"), source.folder.join("cur")] {
635 let stamp = changed(&folder);
636 if let Some((known, kept)) = folders.get(&folder)
637 && *known == stamp
638 && stamp.is_some()
639 {
640 found.extend(kept.iter().cloned());
641 continue;
642 }
643 let mut here = Vec::new();
644 if let Ok(entries) = std::fs::read_dir(&folder) {
645 for entry in entries.flatten() {
646 let name = entry.file_name().to_string_lossy().to_string();
647 if name.starts_with('.') || entry.metadata().ok().filter(|m| m.is_file()).and_then(|m| m.modified().ok()).is_none_or(|m| m < oldest) {
648 continue;
649 }
650 let file = entry.path();
651 let unique = format!("{}/{}", source.account.as_deref().unwrap_or(""), sioul_core::maildir::unique_part(&name));
652 let read = letters.entry(unique).or_insert_with(|| read_voicemail(&words, &file, &source.trusted_ids, region, &zone));
653 if let Some(v) = read {
654 here.push(Voicemail { path: file.clone(), ..v.clone() });
655 }
656 }
657 }
658 found.extend(here.iter().cloned());
659 folders.insert(folder, (stamp, here));
660 }
661 }
662 let kept: std::collections::BTreeSet<PathBuf> = found.iter().map(|v| v.path.clone()).collect();
664 if letters.len() > 4096 {
665 letters.retain(|_, v| v.as_ref().is_some_and(|v| kept.contains(&v.path)));
666 }
667 found
668}
669
670fn read_voicemail(words: &sioul_core::words::VoicemailWords, file: &Path, trusted: &[String], region: Option<&'static sioul_core::phones::Region>, zone: &jiff::tz::TimeZone) -> Option<Voicemail> {
671 use std::io::Read;
672 let mut head = Vec::with_capacity(16 * 1024);
674 std::fs::File::open(file).ok()?.take(16 * 1024).read_to_end(&mut head).ok()?;
675 if !voicemail::may_be(&words.operators, &head) {
676 return None;
677 }
678 let raw = std::fs::read(file).ok()?;
679 voicemail::read(words, &raw, file, region, zone, trusted)
680}
681
682fn listen(mail: &str, sound: u32) -> Result<String, String> {
685 let config = load_config();
686 let file = PathBuf::from(mail);
687 let region = sioul_core::reach::region(&config);
688 if !voicemails(&config, region).iter().any(|v| v.path == file && v.sound == Some(sound)) {
689 return Err(tr().text("calls-listen-gone", None));
690 }
691 let (_, bytes) = sioul_core::reading::attachment(&file, sound).ok_or_else(|| tr().text("calls-listen-gone", None))?;
692 let dir = sioul_core::config::cache_dir().join(rules::FOLDER);
693 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
694 let name = format!("{}-{sound}.wav", sioul_core::maildir::unique_part(&file.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()).replace(['/', '\\', ':'], "_"));
696 let out = dir.join(name);
697 std::fs::write(&out, bytes).map_err(|e| e.to_string())?;
698 if let Ok(entries) = std::fs::read_dir(&dir) {
700 let mut sounds: Vec<(SystemTime, PathBuf)> = entries.flatten().filter_map(|e| Some((e.metadata().ok()?.modified().ok()?, e.path()))).collect();
701 sounds.sort();
702 let extra = sounds.len().saturating_sub(SOUNDS_KEPT);
703 for (_, old) in sounds.into_iter().take(extra) {
704 let _ = std::fs::remove_file(old);
705 }
706 }
707 Ok(format!("file://{}", out.display()))
708}
709
710pub(crate) fn act(verb: &str, json: &str) -> String {
716 let asked: serde_json::Value = serde_json::from_str(json).unwrap_or_default();
717 let text = |key: &str| asked[key].as_str().unwrap_or_default().trim().to_string();
718 let mut at = 0;
719 let (said, url, shared) = match verb {
720 "seen" => {
722 let ids: Vec<String> = asked["ids"].as_array().map(|a| a.iter().filter_map(|i| i.as_str().map(str::to_string)).collect()).unwrap_or_default();
723 match rules::mark_seen(&rules::folder(), &here_id(), &ids, jiff::Timestamp::now().as_millisecond()) {
724 Ok(pressed) => {
725 at = pressed;
726 (tr().text("calls-seen-said", None), String::new(), true)
727 }
728 Err(e) => (e, String::new(), false),
729 }
730 }
731 "unseen" => match rules::unmark_seen(&rules::folder(), &here_id(), asked["at"].as_i64().unwrap_or(0)) {
733 Ok(gone) => (String::new(), String::new(), gone),
734 Err(e) => (e, String::new(), false),
735 },
736 "add-contact" => {
738 crate::steps::java("add-contact", &json!({ "name": "", "phones": [text("number")], "emails": [] }).to_string());
739 (String::new(), String::new(), false)
740 }
741 "block" => {
743 let number = text("number");
744 if number.is_empty() {
745 (String::new(), String::new(), false)
746 } else {
747 match sioul_core::porch::set_standing(&load_config(), &format!("{}{number}", sioul_core::porch::TEL), Standing::Blocked) {
749 Ok(()) => (tr().text("calls-blocked-said", None), String::new(), true),
750 Err(e) => (e, String::new(), false),
751 }
752 }
753 }
754 "listen" => match listen(&text("path"), u32::try_from(asked["sound"].as_u64().unwrap_or(0)).unwrap_or(0)) {
755 Ok(url) => (String::new(), url, false),
756 Err(e) => (e, String::new(), false),
757 },
758 "open-blocked" => {
759 crate::steps::java("calls-open-blocked", "{}");
760 (String::new(), String::new(), false)
761 }
762 _ => (String::new(), String::new(), false),
763 };
764 json!({ "said": said, "url": url, "shared": shared, "at": at }).to_string()
765}
766
767pub(crate) fn setup() -> String {
773 forget_state();
774 let state = state();
775 if state["held"] == true && !path(rules::TABLE).is_file() {
776 std::thread::spawn(|| refresh(true));
777 }
778 json!({
779 "android": cfg!(target_os = "android"),
780 "state": state,
781 "moment": moment(),
782 "screening": screening_rows(&load_config(), &Zoned::now()),
783 })
784 .to_string()
785}
786
787fn row(key: &str, kind: &str, label: &str, help: &str, value: serde_json::Value, choices: &[(&str, String)]) -> serde_json::Value {
789 json!({
790 "key": format!("screening.{key}"),
791 "kind": kind,
792 "label": label,
793 "help": help,
794 "value": value,
795 "choices": choices.iter().map(|(value, label)| json!({ "value": value, "label": label })).collect::<Vec<_>>(),
796 })
797}
798
799fn screening_rows(config: &Config, now: &Zoned) -> serde_json::Value {
805 let tr = tr();
806 let text = |id: &str| tr.text(id, None);
807 let s = Screening::of(config);
808 let rules = [("voicemail", text("screening-rule-voicemail")), ("work", text("screening-rule-work")), ("ring", text("screening-rule-ring"))];
809 let spam = [("voicemail", text("screening-spam-voicemail")), ("unknown", text("screening-spam-unknown"))];
810 let today = now.date();
812 let unattributed = numbers().group.iter().find(|g| g.kind == "unattributed");
813 let unknown_help = match unattributed {
814 Some(g) => {
815 let day = |d: jiff::civil::Date| format!("{} {}", tr.day_month(d), d.year());
816 let read = g.checked.parse::<jiff::civil::Date>().ok();
817 let until = read.zip(g.valid_days).and_then(|(r, days)| r.checked_add(jiff::Span::new().days(days)).ok());
818 let mut args = sioul_core::i18n::args();
819 args.set("date", read.map(day).unwrap_or_default());
820 args.set("until", until.map(day).unwrap_or_default());
821 tr.text(if g.counts(today) { "screening-unknown-help" } else { "screening-unknown-help-old" }, Some(&args))
822 }
823 None => text("screening-unknown-help-old"),
824 };
825 json!({
826 "title": text("screening-title"),
827 "intro": text("screening-intro"),
828 "rows": [
829 row("on", "bool", &text("screening-on"), &text("screening-on-help"), json!(s.on), &[]),
830 row("hours", "choice", &text("screening-hours"), &text("screening-hours-help"), json!(s.hours.id()), &[("always", text("screening-hours-always")), ("outside-work", text("screening-hours-outside-work"))]),
831 row("unknown", "choice", &text("screening-unknown"), &unknown_help, json!(s.unknown.id()), &rules),
832 row("hidden", "choice", &text("screening-hidden"), &text("screening-hidden-help"), json!(s.hidden.id()), &rules),
833 row("automated", "choice", &text("screening-automated"), &text("screening-automated-help"), json!(s.automated.id()), &spam),
834 row("impossible", "choice", &text("screening-impossible"), &text("screening-impossible-help"), json!(s.impossible.id()), &spam),
835 row("unverified", "choice", &text("screening-unverified"), &text("screening-unverified-help"), json!(s.unverified.id()), &spam),
836 row("called", "bool", &text("screening-called"), &text("screening-called-help"), json!(s.called), &[]),
837 ],
838 "hears": text("screening-hears"),
839 "expecting": text("screening-expecting"),
840 })
841}
842
843pub(crate) fn setup_change(verb: &str, json: &str) -> String {
849 let asked: serde_json::Value = serde_json::from_str(json).unwrap_or_default();
850 match verb {
851 "set" => {
852 let key = asked["key"].as_str().unwrap_or_default();
853 let value = match &asked["value"] {
854 serde_json::Value::Bool(on) => sioul_core::config::SettingValue::Bool(*on),
855 other => sioul_core::config::SettingValue::Text(other.as_str().unwrap_or_default().to_string()),
856 };
857 match screening::set(&crate::backend::config_path(), key, &value) {
858 Ok(()) => {
859 std::thread::spawn(|| refresh(true));
860 }
861 Err(e) => eprintln!("sioul: calls: {e}"),
862 }
863 }
864 "ask-role" => {
865 crate::steps::java("calls-ask-role", "{}");
866 }
867 "open-roles" => {
868 crate::steps::java("calls-open-roles", "{}");
869 }
870 "allow-contacts" => crate::steps::ask_contacts(),
871 "open-blocked" => {
872 crate::steps::java("calls-open-blocked", "{}");
873 }
874 "dial" => {
876 let number = asked["number"].as_str().unwrap_or_default();
877 if ["*#61#", "*#62#", "*#67#"].contains(&number) {
878 crate::steps::java("calls-dial", &json!({ "number": number }).to_string());
879 }
880 }
881 _ => {}
882 }
883 setup()
884}
885
886#[cfg(test)]
887mod tests {
888 use super::*;
889
890 #[test]
894 fn nothing_is_written_nor_asked_on_a_computer() {
895 if phone() || rules::folder().join(rules::LOG).is_dir() {
896 return;
897 }
898 refresh(true);
899 assert_eq!(view(), json!({ "lines": [] }).to_string());
900 assert!(history(&["+33199001234".to_string()]).is_empty());
901 assert_eq!(step(false), json!({}));
902 assert_eq!(state(), serde_json::Value::Null);
903 assert!(!screens_here());
904 assert!(!path(rules::TABLE).exists());
905 }
906
907 #[test]
912 fn the_table_s_layers_read_the_switch_anew() {
913 let _files = crate::everywhere::FILES.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
914 crate::everywhere::files_cleared();
915 let ms = jiff::Timestamp::now().as_millisecond();
916 let device = |pressed: i64, state: switches::State, until: i64, seen: i64| switches::Device { kind: "computer".into(), pressed, on: state == switches::State::Dnd, mode: state.id().into(), until, seen, at: pressed, ..switches::Device::default() };
917 let save = |d: switches::Device| Switch { device: [("laptop-1".to_string(), d)].into_iter().collect(), ..Switch::default() }.save(&Switch::default_path()).unwrap();
918 save(device(ms - 60_000, switches::State::Reachable, ms + 3_600_000, 0));
919 assert!(crate::everywhere::reachable().is_some() && layers().reachable.is_some());
920 save(device(ms - 1_000, switches::State::Dnd, 0, ms - 60_000));
921 let now = layers();
922 assert!(now.reachable.is_none() && now.dnd.is_some(), "{now:?}");
923 crate::everywhere::files_cleared();
924 }
925
926 #[test]
933 fn a_gone_device_never_opens_the_screening() {
934 let folders = std::env::temp_dir().join(format!("sioul-calls-gone-{}", std::process::id()));
935 let _ = std::fs::remove_dir_all(&folders);
936 for dir in ["config", "data", "state", "cache"] {
937 std::fs::create_dir_all(folders.join(dir)).unwrap();
938 }
939 let ran = std::process::Command::new(std::env::current_exe().unwrap())
940 .args(["--exact", "calls::tests::gone_expecting_a_call_in_the_table", "--ignored", "--test-threads=1"])
941 .env("SIOUL_CALLS", "1")
942 .env("XDG_CONFIG_HOME", folders.join("config"))
943 .env("XDG_DATA_HOME", folders.join("data"))
944 .env("XDG_STATE_HOME", folders.join("state"))
945 .env("XDG_CACHE_HOME", folders.join("cache"))
946 .output()
947 .unwrap();
948 let said = String::from_utf8_lossy(&ran.stdout).to_string() + &String::from_utf8_lossy(&ran.stderr);
949 let _ = std::fs::remove_dir_all(&folders);
950 assert!(ran.status.success() && said.contains("1 passed"), "{said}");
951 }
952
953 #[test]
955 #[ignore]
956 fn gone_expecting_a_call_in_the_table() {
957 assert!(phone(), "SIOUL_CALLS");
958 const DAY: i64 = 86_400_000;
959 let ms = jiff::Timestamp::now().as_millisecond();
960 std::fs::create_dir_all(switches::gone_path().parent().unwrap()).unwrap();
961 std::fs::write(switches::gone_path(), r#"["old-laptop"]"#).unwrap();
962 let mut switch = Switch::default();
964 rules::set_screens(&mut switch, "phone-1", true);
965 rules::press(&mut switch, "old-laptop", true, 0, ms - 2 * DAY);
966 switch.save(&Switch::default_path()).unwrap();
967 refresh(true);
968 let table: rules::Table = rules::read_json(&path(rules::TABLE)).unwrap();
969 assert!(table.expecting.is_none_or(|e| !e.holds(ms)), "{:?}", table.expecting);
970 assert_eq!(moment()["through"], false);
971 let mut switch = Switch::default();
973 rules::set_screens(&mut switch, "phone-1", true);
974 rules::press(&mut switch, "phone-1", true, 0, ms - 3 * DAY);
975 rules::press(&mut switch, "old-laptop", false, 0, ms - 2 * DAY);
976 switch.save(&Switch::default_path()).unwrap();
977 refresh(true);
978 let table: rules::Table = rules::read_json(&path(rules::TABLE)).unwrap();
979 assert!(table.expecting.is_none_or(|e| !e.holds(ms)), "{:?}", table.expecting);
980 assert_eq!(moment()["through"], false);
981 }
982
983 #[test]
986 fn a_device_lists_the_phones_calls_as_the_setting_says() {
987 let root = std::env::temp_dir().join(format!("sioul-app-calls-listed-{}", std::process::id()));
988 let _ = std::fs::remove_dir_all(&root);
989 let now = jiff::Timestamp::now().as_millisecond();
990 let call = |at: i64, key: &str| Held { at, key: key.into(), number: key.into(), who: "stranger".into(), column: "sleep".into(), why: "matrix".into(), ..Held::default() };
991 rules::carry_into(&rules::own_file(&root, rules::LOG, "phone-a"), &[call(now - 3_600_000, "+33199001234")], None, now).unwrap();
992 rules::carry_into(&rules::own_file(&root, rules::LOG, "phone-b"), &[call(now - 1_800_000, "+33465710042")], None, now).unwrap();
993 assert_eq!(listed(&root, "desk", true, now).iter().map(|h| h.device.as_str()).collect::<Vec<_>>(), ["phone-a", "phone-b"]);
994 assert!(listed(&root, "desk", false, now).is_empty(), "the setting off: a computer lists none");
995 assert_eq!(listed(&root, "phone-a", false, now).len(), 1, "a phone its own");
996 let _ = std::fs::remove_dir_all(&root);
997 }
998
999 #[test]
1005 fn the_phone_writes_the_screening_into_its_table() {
1006 let now: Zoned = "2026-10-20T10:00:00+02:00[Europe/Paris]".parse().unwrap();
1007 let config = Config::default();
1008 let mut switch = Switch::default();
1009 rules::press(&mut switch, "desk", true, 0, now.timestamp().as_millisecond());
1010 let table = make(&config, &switch, &now);
1011 assert_eq!((table.v, table.through), (rules::VERSION, None));
1012 assert!(table.expecting.is_some_and(|e| e.on && e.holds(now.timestamp().as_millisecond())));
1013 let s = table.screening.clone().unwrap();
1014 assert_eq!((s.on, s.hours, s.unknown, s.hidden, s.called_days), (true, screening::Hours::Always, screening::Rule::Voicemail, screening::Rule::Work, screening::CALLED_DAYS));
1015 assert!(s.automated_prefixes.contains(&"0162".to_string()));
1017 assert_eq!(s.impossible_prefixes, vec!["089".to_string()]);
1018 assert!(table.frames.iter().all(|f| !f.ring.contains_key("stranger") && !f.ring.contains_key("hidden")));
1019 let later: Zoned = "2027-05-08T10:00:00+02:00[Europe/Paris]".parse().unwrap();
1020 assert_eq!(make(&config, &switch, &later).screening.unwrap().impossible_prefixes, vec!["089".to_string()]);
1021 let held: Config = toml::from_str("[attention]\n\"calls.hidden\" = [\"work:later\", \"admin:later\", \"leisure:later\", \"meals:later\", \"sleep:later\", \"pause:later\", \"free:later\", \"slot:later\", \"dnd:later\"]\n").unwrap();
1023 assert_eq!(make(&held, &switch, &now).screening.unwrap().hidden, screening::Rule::Voicemail);
1024 assert!(unknown_rings(&config, true) && !unknown_rings(&config, false));
1026 assert!(!unknown_rings(&held, true) && !unknown_rings(&held, false));
1027 let off: Config = toml::from_str("[screening]\non = false\n").unwrap();
1028 assert!(unknown_rings(&off, false));
1029 let rows = screening_rows(&held, &now);
1031 let keys: Vec<&str> = rows["rows"].as_array().unwrap().iter().map(|r| r["key"].as_str().unwrap()).collect();
1032 assert_eq!(keys, ["screening.on", "screening.hours", "screening.unknown", "screening.hidden", "screening.automated", "screening.impossible", "screening.unverified", "screening.called"]);
1033 assert_eq!((rows["rows"][0]["value"].as_bool(), rows["rows"][3]["value"].as_str(), rows["rows"][3]["choices"].as_array().unwrap().len()), (Some(true), Some("voicemail"), 3));
1034 assert!(rows["rows"].as_array().unwrap().iter().all(|r| !r["label"].as_str().unwrap().is_empty() && !r["label"].as_str().unwrap().starts_with("screening-")));
1035 let help = |rows: &serde_json::Value, at: usize| rows["rows"][at]["help"].as_str().unwrap().to_string();
1038 assert!(help(&rows, 2).contains("19") && help(&rows, 2).contains("17") && help(&rows, 2).contains("2027"), "{}", help(&rows, 2));
1039 let old = screening_rows(&held, &later);
1040 assert!(help(&old, 2).contains("19") && help(&old, 2) != help(&rows, 2), "{}", help(&old, 2));
1041 assert!(help(&rows, 5).contains("2026") && help(&rows, 5).contains("08 9"), "{}", help(&rows, 5));
1042 }
1043
1044 #[test]
1046 fn once_a_day() {
1047 let mut last = 0;
1048 let start = 1_791_346_200_000;
1049 assert!(a_day_since(&mut last, start), "the first time");
1050 assert!(!a_day_since(&mut last, start + 60_000) && !a_day_since(&mut last, start + 23 * 3_600_000), "not again the same day");
1051 assert!(a_day_since(&mut last, start + 86_400_001), "a day after the last");
1052 assert!(!a_day_since(&mut last, start + 86_400_001 + 60_000));
1053 }
1054}