1use 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
46const REMAKE_EVERY: i64 = 10 * 60;
48const STATE_KEPT: i64 = 30;
50const SOUNDS_KEPT: usize = 8;
52
53fn here_id() -> String {
55 sioul_sync::share::Here::load(&sioul_core::config::state_dir()).id
56}
57
58fn 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
68static STATE: Mutex<Option<(i64, serde_json::Value)>> = Mutex::new(None);
71
72fn 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
89fn forget_state() {
91 if let Ok(mut kept) = STATE.lock() {
92 *kept = None;
93 }
94}
95
96pub(crate) fn screens_here() -> bool {
98 cfg!(target_os = "android") && state()["held"] == true
99}
100
101type 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 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
132static MADE: Mutex<Option<(Inputs, i64, String)>> = Mutex::new(None);
134static BUSY: Mutex<()> = Mutex::new(());
135
136pub(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 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 if let Ok(mut made) = MADE.lock() {
173 *made = Some((inputs(&config, &at), stamp, content));
174 }
175}
176
177fn 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
202fn 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
209fn 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 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 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
242fn 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
251pub(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
264fn 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
269pub(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
285pub(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
301pub(crate) fn step(pressed: bool) -> serde_json::Value {
306 if pressed {
307 forget_state();
308 }
309 refresh(pressed);
310 words()
311}
312
313pub(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
332static TRIMMED: Mutex<i64> = Mutex::new(0);
336static COPIED: Mutex<Option<(u64, Option<SystemTime>)>> = Mutex::new(None);
338
339pub(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
376fn 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
386fn 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
398fn 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
408pub(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
430pub(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
452pub(crate) fn view() -> String {
458 let empty = || json!({ "lines": [] }).to_string();
459 let root = rules::folder();
460 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
499pub(crate) type CardCalls = Box<dyn Fn(&sioul_core::attention::Now, &Zoned) -> Vec<String>>;
501
502pub(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
538pub(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
563fn 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 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 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 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
638fn 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 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 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
666pub(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 "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 "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 "add-contact" => {
694 crate::steps::java("add-contact", &json!({ "name": "", "phones": [text("number")], "emails": [] }).to_string());
695 (String::new(), String::new(), false)
696 }
697 "block" => {
699 let number = text("number");
700 if number.is_empty() {
701 (String::new(), String::new(), false)
702 } else {
703 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
723pub(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
742pub(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 "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 #[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 #[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 #[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}