1use crate::backend::{load_config, tr};
44use sioul_core::config::Config;
45use std::path::Path;
46use std::sync::Mutex;
47use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
48
49static IN_SERVICE: AtomicBool = AtomicBool::new(false);
51pub(crate) const MAIL_EVERY: i64 = 15 * 60;
53pub(crate) const STEP_IN_USE: i64 = 2 * 60;
55pub(crate) const STEP_AWAKE: i64 = 5 * 60;
56pub(crate) const STEP_ASLEEP: i64 = 15 * 60;
57
58pub(crate) fn in_service() -> bool {
60 IN_SERVICE.load(Ordering::Relaxed)
61}
62
63#[cfg(target_os = "android")]
64unsafe extern "C" {
66 fn sioul_android_steps(verb: *const std::ffi::c_char, json: *const std::ffi::c_char) -> *mut std::ffi::c_char;
68 fn sioul_android_dnd_free(text: *mut std::ffi::c_char);
70 fn sioul_android_ask_contacts();
72}
73
74pub(crate) fn java(verb: &str, json: &str) -> serde_json::Value {
76 #[cfg(target_os = "android")]
77 {
78 use std::ffi::{CStr, CString};
79 let (Ok(verb), Ok(json)) = (CString::new(verb), CString::new(json)) else { return serde_json::Value::Null };
80 let answer = unsafe { sioul_android_steps(verb.as_ptr(), json.as_ptr()) };
82 if answer.is_null() {
83 return serde_json::Value::Null;
84 }
85 let text = unsafe { CStr::from_ptr(answer) }.to_string_lossy().to_string();
87 unsafe { sioul_android_dnd_free(answer) };
89 serde_json::from_str(&text).unwrap_or(serde_json::Value::Null)
90 }
91 #[cfg(not(target_os = "android"))]
92 {
93 let _ = (verb, json);
94 serde_json::Value::Null
95 }
96}
97
98pub(crate) fn ask_contacts() {
100 #[cfg(target_os = "android")]
101 unsafe {
103 sioul_android_ask_contacts()
104 };
105}
106
107pub(crate) fn next(at: Option<i64>) {
111 if !cfg!(target_os = "android") || in_service() {
112 return;
113 }
114 static GIVEN: AtomicI64 = AtomicI64::new(-1);
115 let millis = at.map_or(0, |a| a.saturating_mul(1000));
116 if GIVEN.swap(millis, Ordering::Relaxed) != millis {
117 java("next", &serde_json::json!({ "at": millis }).to_string());
118 }
119 follow_setting_once();
120}
121
122fn words() -> serde_json::Value {
124 serde_json::json!({ "title": tr().text("dnd-steps-note", None), "channel": tr().text("dnd-steps-channel", None) })
125}
126
127static FOLLOWED: Mutex<Option<(bool, i64)>> = Mutex::new(None);
129
130fn follow_setting_once() {
135 follow(false);
136}
137
138pub(crate) fn follow_setting() {
140 follow(true);
141}
142
143fn follow(now: bool) {
144 if !cfg!(target_os = "android") || in_service() {
145 return;
146 }
147 let config = load_config();
148 let on = config.dnd.background && crate::share::vault().is_some_and(|(_, vault)| vault.is_some());
149 let at = jiff::Timestamp::now().as_second();
150 {
151 let mut last = FOLLOWED.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
152 if !now && last.is_some_and(|(was, when)| was == on && (!on || at - when < 600)) {
153 return;
154 }
155 *last = Some((on, at));
156 }
157 java(if on { "start" } else { "stop" }, &words().to_string());
158}
159
160pub(crate) fn set_background(on: bool) -> Result<(), String> {
163 let config = load_config();
164 sioul_core::settings::apply(&crate::backend::config_path(), &config, "dnd.background", &sioul_core::config::SettingValue::Bool(on))?;
165 follow_setting();
166 Ok(())
167}
168
169pub(crate) fn setup(config: &Config) -> serde_json::Value {
171 let sharing = crate::share::vault().is_some_and(|(_, vault)| vault.is_some());
172 let running = java("running", "{}") == true;
173 let battery = java("battery", "{}") == true;
174 serde_json::json!({
175 "on": config.dnd.background,
176 "running": running,
177 "sharing": sharing,
178 "battery": battery,
179 "line": tr().text(if config.dnd.background && running { "dnd-steps-on" } else { "dnd-steps-off" }, None),
180 "battery_line": tr().text(if battery { "dnd-steps-battery-ok" } else { "dnd-steps-battery" }, None),
181 })
182}
183
184static POKED: Mutex<(String, String)> = Mutex::new((String::new(), String::new()));
188static MAILED: AtomicI64 = AtomicI64::new(0);
190
191fn next_step(in_use: bool, asleep: bool) -> i64 {
196 if in_use {
197 STEP_IN_USE
198 } else if asleep {
199 STEP_ASLEEP
200 } else {
201 STEP_AWAKE
202 }
203}
204
205fn mail_due(config: &Config, now: i64, last: i64, asleep: bool, paused: bool) -> bool {
211 use sioul_core::attention::{Attention, Column, Level, Row, persons};
212 use sioul_core::reach::Channel;
213 let attention = Attention::of(config);
214 let held = |column: Column| !persons(Channel::Mail).iter().any(|p| attention.cell(Row::People(Channel::Mail, *p), column) == Level::Now);
215 let resting = (asleep && held(Column::Sleep)) || (paused && held(Column::Pause));
216 config.reminders.mail && !resting && now - last >= MAIL_EVERY - 30 && config.accounts.iter().any(sioul_core::config::Account::syncs)
217}
218
219fn fetch_mail(config: &Config) -> bool {
227 let any = std::sync::atomic::AtomicBool::new(false);
228 std::thread::scope(|scope| {
229 for account in config.accounts.iter().filter(|a| a.syncs()) {
230 let any = &any;
231 scope.spawn(move || {
232 let Ok(password) = sioul_sync::secret::password(account) else { return };
233 match sioul_sync::inbox(account, &password) {
234 Ok(report) => {
235 let mut came = !report.new.is_empty();
236 crate::spam::after_fetch(account, &report.new, report.first);
237 let filtered = crate::filters::after_fetch(account, &report.new, report.first);
241 if !filtered.problem.is_empty() {
242 eprintln!("sioul: steps: {}: {}", account.id, filtered.problem);
243 }
244 crate::mailnote::arrived(&account.id, &report.new, report.first);
245 crate::mailnote::unfiltered(&account.id, &filtered.tell);
246 came |= !filtered.tell.is_empty();
247 any.fetch_or(came, Ordering::Relaxed);
248 }
249 Err(e) => eprintln!("sioul: steps: {}: {e:?}", account.id),
250 }
251 });
252 }
253 });
254 crate::mailnote::close_now();
256 let any = any.into_inner();
257 if any {
258 crate::homecard::mail_came();
259 }
260 any
261}
262
263fn brought_already(folder: &Path, names: &str) -> bool {
270 const SMALL: u64 = 64 << 10;
271 let Some(fetched) = sioul_sync::remote::overlay(folder) else { return false };
272 let names: Vec<&str> = names.split('/').filter(|n| !n.is_empty() && *n != "." && *n != "..").collect();
273 let same = |name: &str| {
274 let (here, there) = (folder.join(name), fetched.join(name));
275 match (std::fs::metadata(&here), std::fs::metadata(&there)) {
276 (Ok(a), Ok(b)) if a.is_file() && b.is_file() && a.len() == b.len() => a.len() > SMALL || std::fs::read(&here).ok() == std::fs::read(&there).ok(),
277 _ => false,
278 }
279 };
280 !names.is_empty() && names.iter().all(|name| same(name))
281}
282
283fn rounds_wait() {
290 let memory = sioul_core::config::state_dir().join("share").join("memory.json");
291 sioul_sync::share::set_frugal(&memory, sioul_sync::remote::Sending::load(&memory).slow());
292}
293
294pub(crate) fn note() -> (serde_json::Value, Option<i64>) {
300 let (title, dnd, until) = crate::everywhere::note();
301 let calls = crate::calls::words();
302 let calls_line = match (calls["screening"] == true, calls["through"] == true) {
303 (false, _) => String::new(),
304 (true, true) => calls["line"].as_str().unwrap_or_default().to_string(),
305 (true, false) => tr().text("steps-note-calls-screened", None),
306 };
307 let text = [dnd, calls_line].into_iter().filter(|t| !t.is_empty()).collect::<Vec<_>>().join(" ");
308 let at = jiff::Timestamp::now().as_millisecond();
309 (serde_json::json!({ "at": at, "title": title, "text": text, "calls": calls }), until)
310}
311
312pub(crate) fn tell_note() {
321 if !cfg!(target_os = "android") || in_service() {
322 return;
323 }
324 java("note", ¬e().0.to_string());
325}
326
327fn keeps_close(asleep: bool) -> bool {
337 let Some((own, entries)) = crate::devices::entries() else { return false };
338 keeps_close_of(&entries, &own, jiff::Timestamp::now().as_second(), asleep, crate::health::dose_open_now)
339}
340
341fn keeps_close_of(entries: &[sioul_sync::devices::Entry], own: &str, now: i64, asleep: bool, dose_open: impl FnOnce() -> bool) -> bool {
345 use sioul_sync::devices::others_in_use;
346 others_in_use(entries, own, now, asleep) || (asleep && others_in_use(entries, own, now, false) && dose_open())
347}
348
349fn step(reason: &str) -> serde_json::Value {
355 let config = load_config();
356 let Some((own, vault)) = crate::share::vault() else { return serde_json::json!({ "stop": true }) };
357 let Some((folder, _)) = vault else {
358 return serde_json::json!({ "stop": true });
360 };
361 if !config.dnd.background {
362 return serde_json::json!({ "stop": true });
363 }
364 let (reason, names) = reason.split_once(':').unwrap_or((reason, ""));
365 if reason == "folder" && brought_already(&folder, names) {
367 return serde_json::json!({ "skip": true });
368 }
369 let (asleep, paused) = crate::everywhere::rest_now();
376 let asleep = asleep && !paused;
377 let in_use = keeps_close(asleep);
378 let started = jiff::Timestamp::now().as_second();
382 let mail = mail_due(&config, started, MAILED.load(Ordering::Relaxed), asleep, paused).then(|| {
383 MAILED.store(started, Ordering::Relaxed);
384 std::thread::spawn(|| {
385 fetch_mail(&load_config());
386 crate::mailnote::tick();
387 })
388 });
389 let pressed = reason == "calls";
392 let calls = if pressed { Some(crate::calls::step(true)) } else { None };
393 let fetch_first = reason != "folder" && reason != "heard" && reason != "messages" && !pressed && (in_use || !asleep);
396 rounds_wait();
397 if let Some(Err(e)) = crate::share::exchange_here(fetch_first) {
398 eprintln!("sioul: steps: {e}");
399 }
400 let replied = crate::replies::phone_step();
403 if crate::texts::phone_step() || replied {
404 rounds_wait();
405 if let Some(Err(e)) = crate::share::exchange_here(false) {
406 eprintln!("sioul: steps: {e}");
407 }
408 }
409 let wanted = std::thread::spawn(crate::share::fetch_wanted_here);
416 let look = std::thread::spawn(crate::crypto::seal_clear_drafts);
421 let calls = calls.unwrap_or_else(|| crate::calls::step(false));
423 let (signature, line) = crate::everywhere::wanted();
425 if let Ok(mut poked) = POKED.lock()
426 && (poked.0 != signature || poked.1 != line)
427 {
428 java("poke", "{}");
429 *poked = (signature, line.clone());
430 }
431 java("note", ¬e().0.to_string());
433 if let Some(mail) = mail
435 && mail.join().is_err()
436 {
437 eprintln!("sioul: steps: mail stopped short");
438 }
439 let until = std::time::Instant::now() + std::time::Duration::from_secs(25);
443 while !(wanted.is_finished() && look.is_finished()) && std::time::Instant::now() < until {
444 std::thread::sleep(std::time::Duration::from_millis(100));
445 }
446 let close = keeps_close(asleep);
450 let now = jiff::Timestamp::now().as_second();
453 let (note, until) = note();
454 let next = until.map(|u| u - now + 1).filter(|s| *s > 0).map_or(next_step(close, asleep), |s| s.min(next_step(close, asleep)));
455 let words = words();
456 serde_json::json!({
457 "next": next,
458 "folder": folder.display().to_string(),
459 "own": own,
460 "at": note["at"],
461 "title": note["title"],
462 "text": note["text"],
463 "channel": words["channel"],
464 "calls": calls,
465 "stop": false,
466 })
467}
468
469#[unsafe(no_mangle)]
475pub unsafe extern "C" fn sioul_steps_step(reason: *const std::ffi::c_char) -> *mut std::ffi::c_char {
476 let reason = if reason.is_null() { String::new() } else { unsafe { std::ffi::CStr::from_ptr(reason) }.to_string_lossy().to_string() };
478 let answer = std::panic::catch_unwind(|| step(&reason)).unwrap_or_else(|_| serde_json::json!({ "next": STEP_AWAKE }));
479 crate::alarms::handed(answer.to_string())
480}
481
482#[unsafe(no_mangle)]
486pub extern "C" fn sioul_steps_in_service() {
487 IN_SERVICE.store(true, Ordering::Relaxed);
488 sioul_sync::share::set_keeping(true);
491}
492
493#[unsafe(no_mangle)]
497pub extern "C" fn sioul_dnd_apply() -> *mut std::ffi::c_char {
498 let answer = std::panic::catch_unwind(|| {
499 crate::everywhere::apply();
500 crate::everywhere::moment().to_string()
501 })
502 .unwrap_or_default();
503 crate::alarms::handed(answer)
504}
505
506#[unsafe(no_mangle)]
514pub unsafe extern "C" fn sioul_dnd_heard(json: *const std::ffi::c_char) -> *mut std::ffi::c_char {
515 let json = if json.is_null() { String::new() } else { unsafe { std::ffi::CStr::from_ptr(json) }.to_string_lossy().to_string() };
517 let answer = std::panic::catch_unwind(|| {
518 let asked: serde_json::Value = serde_json::from_str(&json).unwrap_or_default();
519 if let Some(on) = asked["on"].as_bool() {
520 crate::everywhere::heard_from_system(on);
521 }
522 crate::everywhere::moment().to_string()
523 })
524 .unwrap_or_default();
525 crate::alarms::handed(answer)
526}
527
528#[unsafe(no_mangle)]
532pub extern "C" fn sioul_dnd_toggle() -> *mut std::ffi::c_char {
533 let answer = std::panic::catch_unwind(|| {
534 crate::everywhere::toggle_here();
535 crate::everywhere::moment().to_string()
536 })
537 .unwrap_or_default();
538 crate::alarms::handed(answer)
539}
540
541pub(crate) fn send_now() {
546 if !cfg!(target_os = "android") {
547 return;
548 }
549 if java("heard", "{}")["stepped"] != true
550 && let Some(Err(e)) = crate::share::exchange_here(false)
551 {
552 eprintln!("sioul: do-not-disturb: {e}");
553 }
554}
555
556#[cfg(test)]
557mod tests {
558 use super::*;
559
560 #[test]
561 fn the_rhythm_follows_the_other_devices_and_the_night() {
562 assert_eq!(next_step(true, false), 120);
563 assert_eq!(next_step(true, true), 120, "a device in use at night: its news stays fresh");
564 assert_eq!(next_step(false, true), 900);
565 assert_eq!(next_step(false, false), 300);
566 }
567
568 use sioul_sync::devices::{COMPUTER, Entry};
569
570 const PHONE_ID: &str = "7c41d09e-0000-4000-8000-000000000001";
572 const DESK_ID: &str = "0b5e11a2-0000-4000-8000-000000000002";
573
574 #[derive(Debug, Default, PartialEq, Eq)]
584 struct Hour {
585 steps: u32,
586 pulls: u32,
587 mail: u32,
588 messages: u32,
589 wakes: u32,
590 longest: i64,
591 }
592
593 fn an_hour(desk: &dyn Fn(i64) -> Option<Entry>, asleep: bool, dose: bool, heard: &[i64]) -> Hour {
602 use sioul_sync::remote::{Pace, State, due};
603 let config: Config = toml::from_str("[reminders]\nmail = true\n[[account]]\nid = \"a\"\nkind = \"imap\"\naddress = \"jane@example.org\"\nhost = \"imap.example.org\"\n").unwrap();
604 let start = 1_800_000_000;
605 let warm = start - 3_600;
606 let mut state = State { confirmed: warm - 86_400, tried: warm - 3_600, last: warm - 3_600, ..State::default() };
607 let (mut hour, mut mailed, mut at) = (Hour::default(), warm - 3_600, warm);
608 let (mut known, mut pulls, mut alarms): (Vec<Entry>, Vec<i64>, Vec<i64>) = (Vec::new(), Vec::new(), Vec::new());
609 while at < start + 3_600 {
610 let close = keeps_close_of(&known, PHONE_ID, at, asleep, || dose);
611 let fetch_first = close || !asleep;
612 let in_use = sioul_sync::devices::others_in_use(&known, PHONE_ID, at, asleep);
613 let pace = Pace { phone: true, shown: false, in_use, urgent: fetch_first, step: if asleep { STEP_ASLEEP } else { STEP_AWAKE } };
614 let pulled = due(&state, at, pace);
615 if pulled {
616 (state.tried, state.last) = (at, at);
617 known = desk(at).into_iter().collect();
618 pulls.push(at);
619 }
620 let mail = mail_due(&config, at, mailed, asleep, false);
621 if mail {
622 mailed = at;
623 }
624 if at >= start {
625 hour.steps += 1;
626 hour.pulls += u32::from(pulled);
627 hour.mail += u32::from(mail);
628 hour.wakes += u32::from(pulled || mail);
629 alarms.push(at);
630 }
631 at += next_step(keeps_close_of(&known, PHONE_ID, at, asleep, || dose), asleep);
632 }
633 pulls.push(at);
635 hour.longest = pulls.windows(2).filter(|w| w[1] > start).map(|w| w[1] - w[0]).max().unwrap_or(0);
636 let mut waiting: Option<i64> = None;
639 for heard in heard.iter().map(|h| start + h) {
640 if waiting.is_some_and(|at| heard <= at) {
641 continue;
642 }
643 let at = heard + 20;
644 waiting = Some(at);
645 if !alarms.iter().any(|alarm| (heard..=at).contains(alarm)) {
646 hour.messages += 1;
647 hour.wakes += 1;
648 }
649 }
650 hour
651 }
652
653 fn desk(active: Option<bool>) -> impl Fn(i64) -> Option<Entry> {
658 move |at: i64| Some(Entry { id: DESK_ID.into(), kind: COMPUTER.into(), working: true, started: at - 86_400, exported: at - at.rem_euclid(60), active, ..Entry::default() })
659 }
660
661 #[test]
669 fn the_phone_wakes_as_often_as_someone_may_be_at_a_computer() {
670 let none = |_: i64| None;
671 let (before, nobody, someone) = (desk(None), desk(Some(false)), desk(Some(true)));
672 let wakes = |desk: &dyn Fn(i64) -> Option<Entry>, asleep: bool| an_hour(desk, asleep, false, &[]);
673 assert_eq!(wakes(&none, false), Hour { steps: 12, pulls: 12, mail: 4, messages: 0, wakes: 12, longest: 300 });
675 assert_eq!(wakes(&none, true), Hour { steps: 4, pulls: 4, mail: 0, messages: 0, wakes: 4, longest: 900 });
676 assert_eq!(wakes(&before, true), Hour { steps: 30, pulls: 30, mail: 0, messages: 0, wakes: 30, longest: 120 });
679 assert_eq!(wakes(&before, false), Hour { steps: 30, pulls: 30, mail: 4, messages: 0, wakes: 30, longest: 120 });
680 assert_eq!(wakes(&nobody, false), wakes(&before, false), "awake: Sioul running counts, as before");
681 assert_eq!(wakes(&nobody, true), wakes(&none, true), "asleep: as with no computer running");
682 assert_eq!(an_hour(&nobody, true, true, &[]), wakes(&before, true));
685 assert_eq!(an_hour(&none, true, true, &[]), wakes(&none, true));
686 assert_eq!(wakes(&someone, false), wakes(&before, false));
688 assert_eq!(wakes(&someone, true), wakes(&before, true));
689 assert_eq!(an_hour(&none, true, false, &[100, 110, 1_000]).wakes, 4 + 2);
692 assert_eq!(an_hour(&someone, true, false, &[100, 110]).wakes, 30, "taken along by the alarm step at 120 s");
693 }
694
695 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
707 struct Press {
708 came: i64,
709 then: i64,
710 two: i64,
711 plain: i64,
712 }
713
714 fn a_press(asleep: bool, dose: bool, press: i64, upload: i64) -> Press {
715 use sioul_core::health::{Peer, doubts_now};
716 use sioul_sync::remote::{Pace, State, due};
717 let server = |t: i64| {
718 let at = t - upload;
719 let (active, exported) = if at >= press { (Some(true), (at - at.rem_euclid(60)).max(press)) } else { (Some(false), at - at.rem_euclid(60)) };
720 Entry { id: DESK_ID.into(), kind: COMPUTER.into(), working: true, started: -86_400, exported, active, doses: true, ..Entry::default() }
721 };
722 let first = 0;
723 let mut state = State { confirmed: first - 86_400, tried: first - 3_600, last: first - 3_600, ..State::default() };
724 let mut known: Vec<Entry> = Vec::new();
725 let (mut seen, mut next_at) = (first, first);
726 let mut out = Press { came: -1, then: -1, two: -1, plain: 0 };
727 let mut t = first;
728 while t < press + 6 * 3_600 && (out.came < 0 || out.two < 0) {
729 if t == next_at {
730 let open = dose && out.came < 0;
732 let close = keeps_close_of(&known, PHONE_ID, t, asleep, || open);
733 let fetch_first = close || !asleep;
734 let in_use = sioul_sync::devices::others_in_use(&known, PHONE_ID, t, asleep);
735 let pace = Pace { phone: true, shown: false, in_use, urgent: fetch_first, step: if asleep { STEP_ASLEEP } else { STEP_AWAKE } };
736 let mut brought = false;
737 if due(&state, t, pace) {
738 (state.tried, state.last) = (t, t);
739 let fresh = server(t);
740 let state_of = |e: &Entry| (e.started, e.closed, e.working, e.exported, e.left);
741 if known.first().is_none_or(|k| state_of(k) != state_of(&fresh)) {
742 seen = t;
743 }
744 known = vec![fresh];
745 if out.came < 0 && t - upload >= press {
746 out.came = t - press;
747 brought = true;
748 }
749 }
750 let next = next_step(keeps_close_of(&known, PHONE_ID, t, asleep, || dose && out.came < 0), asleep);
751 if brought {
752 out.then = next;
753 }
754 if out.came >= 0 && out.two < 0 && next == STEP_IN_USE {
755 out.two = t + next - press;
756 }
757 next_at = t + next;
758 }
759 if t >= press
761 && out.came < 0
762 && let Some(entry) = known.first()
763 {
764 let peer = Peer { id: DESK_ID.into(), name: "desk".into(), said: Some(entry.said()), complete: true, seen, heard: entry.exported, ..Peer::default() };
765 if doubts_now(press - 3_600, t, None, std::slice::from_ref(&peer)).is_empty() {
766 out.plain += 1;
767 }
768 }
769 t += 1;
770 }
771 out
772 }
773
774 fn worst(asleep: bool, dose: bool, upload: i64) -> Press {
777 let presses: Vec<Press> = (4 * 3_600..4 * 3_600 + 2 * 900).step_by(7).map(|p| a_press(asleep, dose, p, upload)).collect();
778 let max = |f: &dyn Fn(&Press) -> i64| presses.iter().map(f).max().unwrap();
779 Press { came: max(&|p| p.came), then: max(&|p| p.then), two: max(&|p| p.two), plain: max(&|p| p.plain) }
780 }
781
782 #[test]
790 fn a_press_at_a_desk_brings_the_two_minute_steps_from_the_look_that_reads_it() {
791 for upload in [0, 30] {
792 let awake = worst(false, false, upload);
793 assert!(awake.came <= STEP_IN_USE + upload && awake.then == STEP_IN_USE && awake.two <= 2 * STEP_IN_USE + upload, "awake: {awake:?}");
794 let asleep = worst(true, false, upload);
795 assert!(asleep.came <= STEP_ASLEEP + upload, "asleep: within the night's step: {asleep:?}");
796 assert_eq!(asleep.then, STEP_IN_USE, "asleep: the step that brings it brings the two-minute steps: {asleep:?}");
797 assert!(asleep.two >= 0 && asleep.two <= STEP_ASLEEP + upload + STEP_IN_USE, "asleep: {asleep:?}");
798 }
799 let source = std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/steps.rs")).unwrap();
801 let body = source.split("\nfn step(reason: &str)").nth(1).and_then(|rest| rest.split("\n}\n").next()).unwrap();
802 let (pull, again, next) = (body.find("exchange_here(fetch_first)").unwrap(), body.find("let close = keeps_close(asleep);").unwrap(), body.find("next_step(close, asleep)").unwrap());
803 assert!(pull < again && again < next, "the next step from what this step's pull brought");
804 }
805
806 #[test]
814 fn a_dose_due_keeps_the_phone_close_while_a_computer_runs() {
815 use sioul_core::health::FRESH;
816 for upload in [0, 30] {
817 let watched = worst(true, true, upload);
818 assert!(watched.came <= STEP_IN_USE + upload, "{watched:?}");
819 assert!(watched.plain <= STEP_IN_USE + upload, "the card's plain line past the mark: {watched:?}");
820 let night = worst(true, false, upload);
821 assert!(night.plain <= FRESH + upload, "{night:?}");
822 }
823 }
824
825 #[test]
826 fn mail_comes_at_its_rhythm_never_asleep_nor_paused() {
827 let mut config: Config = toml::from_str("[reminders]\nmail = true\n[[account]]\nid = \"a\"\nkind = \"imap\"\naddress = \"jane@example.org\"\nhost = \"imap.example.org\"\n").unwrap();
828 assert!(config.accounts.iter().any(sioul_core::config::Account::syncs));
829 let now = 10_000;
830 assert!(mail_due(&config, now, 0, false, false));
831 assert!(!mail_due(&config, now, now - 60, false, false), "fetched a minute ago");
832 assert!(mail_due(&config, now, now - MAIL_EVERY, false, false));
833 assert!(!mail_due(&config, now, 0, true, false), "asleep");
834 assert!(!mail_due(&config, now, 0, false, true), "paused");
835 let mut told: Config = toml::from_str("[notify]\nmail = [\"sleep\"]\n").unwrap();
837 told.accounts = config.accounts.clone();
838 assert!(mail_due(&told, now, 0, true, false) && !mail_due(&told, now, 0, false, true));
839 config.reminders.mail = false;
840 assert!(!mail_due(&config, now, 0, false, false), "new mail not notified at all");
841 config.reminders.mail = true;
842 config.accounts.clear();
843 assert!(!mail_due(&config, now, 0, false, false), "no account");
844 }
845
846 #[test]
852 fn calls_are_untouched_by_whether_someone_is_at_a_computer() {
853 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
854 for path in ["crates/sioul-app/src/calls.rs", "android/package/src/com/aurelienpierre/sioul/Calls.java", "android/package/src/com/aurelienpierre/sioul/CallScreen.java"] {
855 let source = std::fs::read_to_string(root.join(path)).unwrap();
856 for word in ["others_in_use", "keeps_close", "present_of", "present_since", ".active"] {
857 assert!(!source.contains(word), "{path} reads {word}");
858 }
859 }
860 let step = std::fs::read_to_string(root.join("crates/sioul-app/src/steps.rs")).unwrap();
861 assert!(step.contains("let pressed = reason == \"calls\";") && step.contains("&& !pressed && (in_use || !asleep);"), "a press on the calls' notification: at once, no pull waited for");
862 }
863
864 #[test]
869 fn mail_at_night_only_when_the_matrix_tells_some_then() {
870 let accounts = "[reminders]\nmail = true\n[[account]]\nid = \"a\"\nkind = \"imap\"\naddress = \"jane@example.org\"\nhost = \"imap.example.org\"\n";
871 let usual: Config = toml::from_str(accounts).unwrap();
872 let now = 1_800_000_000;
873 assert!(!mail_due(&usual, now, 0, true, false), "asleep, as usual: no round");
874 let always: Config = toml::from_str(&format!("{accounts}[attention]\n\"mail.always\" = [\"sleep\"]\n")).unwrap();
875 assert!(mail_due(&always, now, 0, true, false), "Always through told at night: fetched");
876 assert!(!mail_due(&always, now, now - 60, true, false) && mail_due(&always, now, now - MAIL_EVERY, true, false), "at its rhythm");
877 let mut mailed = now - 3_600;
879 let mut rounds = 0;
880 for at in (0..4).map(|n| now + n * STEP_ASLEEP) {
881 if mail_due(&always, at, mailed, true, false) {
882 (mailed, rounds) = (at, rounds + 1);
883 }
884 }
885 assert_eq!(rounds, 4);
886 }
887
888 #[test]
891 fn a_file_the_pull_brought_already_brings_no_step() {
892 let dir = std::env::temp_dir().join(format!("sioul-steps-brought-{}", std::process::id()));
893 let _ = std::fs::remove_dir_all(&dir);
894 let (folder, state) = (dir.join("Sioul"), dir.join("state").join("share"));
895 let fetched = state.join("remote");
896 for d in [&folder, &fetched] {
897 std::fs::create_dir_all(d).unwrap();
898 std::fs::write(d.join("seal.toml"), "version = 1\n").unwrap();
899 }
900 let mut remote = toml::Table::new();
901 remote.insert("folder".into(), folder.display().to_string().into());
902 std::fs::write(state.join("remote.toml"), toml::to_string(&remote).unwrap()).unwrap();
903 let both = |name: &str, here: &[u8], there: &[u8]| {
904 std::fs::write(folder.join(name), here).unwrap();
905 std::fs::write(fetched.join(name), there).unwrap();
906 };
907 both("7c41d09e-1.jsonl", b"line one\nline two\n", b"line one\nline two\n");
908 both("7c41d09e.toml", b"read = 1\n", b"read = 1\n");
909 assert!(!brought_already(&folder, "7c41d09e-1.jsonl"));
911 assert!(sioul_sync::remote::attach(&folder, &state.join("memory.json")).is_some());
912 assert!(brought_already(&folder, "7c41d09e-1.jsonl/7c41d09e.toml"));
913 both("7c41d09e.toml", b"read = 2\n", b"read = 1\n");
915 assert!(!brought_already(&folder, "7c41d09e-1.jsonl/7c41d09e.toml") && !brought_already(&folder, "7c41d09e.toml"));
916 both("7c41d09e-1.jsonl", b"line one\nline two\nline three\n", b"line one\nline two\n");
918 assert!(!brought_already(&folder, "7c41d09e-1.jsonl"));
919 both("7c41d09e-2.jsonl", &vec![b'a'; 70 << 10], &vec![b'b'; 70 << 10]);
921 assert!(brought_already(&folder, "7c41d09e-2.jsonl"));
922 std::fs::write(folder.join("f00d.toml"), b"x").unwrap();
924 assert!(!brought_already(&folder, "f00d.toml") && !brought_already(&folder, "") && !brought_already(&folder, ".."));
925 let _ = std::fs::remove_dir_all(&dir);
926 }
927
928 #[test]
931 fn sioul_never_writes_a_contact_nor_a_star() {
932 let package = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../android/package");
933 let manifest = std::fs::read_to_string(package.join("AndroidManifest.xml")).unwrap();
934 assert!(manifest.contains("android.permission.READ_CONTACTS"));
935 assert!(!manifest.contains("WRITE_CONTACTS"), "no permission to write contacts");
936 let contacts = std::fs::read_to_string(package.join("src/com/aurelienpierre/sioul/DndContacts.java")).unwrap();
937 for write in [".insert(", ".update(", ".delete(", "applyBatch", "ContentProviderOperation"] {
938 assert!(!contacts.contains(write), "DndContacts.java writes: {write}");
939 }
940 }
941
942 #[test]
943 fn nothing_reaches_java_on_a_computer() {
944 assert_eq!(java("running", "{}"), serde_json::Value::Null);
945 next(Some(1));
946 assert!(!in_service());
947 }
948}