1use crate::backend::{load_config, tr};
38use sioul_core::config::Config;
39use std::path::Path;
40use std::sync::Mutex;
41use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
42
43static IN_SERVICE: AtomicBool = AtomicBool::new(false);
45pub(crate) const MAIL_EVERY: i64 = 15 * 60;
47pub(crate) const STEP_IN_USE: i64 = 2 * 60;
49pub(crate) const STEP_AWAKE: i64 = 5 * 60;
50pub(crate) const STEP_ASLEEP: i64 = 15 * 60;
51
52pub(crate) fn in_service() -> bool {
54 IN_SERVICE.load(Ordering::Relaxed)
55}
56
57#[cfg(target_os = "android")]
58unsafe extern "C" {
60 fn sioul_android_steps(verb: *const std::ffi::c_char, json: *const std::ffi::c_char) -> *mut std::ffi::c_char;
62 fn sioul_android_dnd_free(text: *mut std::ffi::c_char);
64 fn sioul_android_ask_contacts();
66}
67
68pub(crate) fn java(verb: &str, json: &str) -> serde_json::Value {
70 #[cfg(target_os = "android")]
71 {
72 use std::ffi::{CStr, CString};
73 let (Ok(verb), Ok(json)) = (CString::new(verb), CString::new(json)) else { return serde_json::Value::Null };
74 let answer = unsafe { sioul_android_steps(verb.as_ptr(), json.as_ptr()) };
76 if answer.is_null() {
77 return serde_json::Value::Null;
78 }
79 let text = unsafe { CStr::from_ptr(answer) }.to_string_lossy().to_string();
81 unsafe { sioul_android_dnd_free(answer) };
83 serde_json::from_str(&text).unwrap_or(serde_json::Value::Null)
84 }
85 #[cfg(not(target_os = "android"))]
86 {
87 let _ = (verb, json);
88 serde_json::Value::Null
89 }
90}
91
92pub(crate) fn ask_contacts() {
94 #[cfg(target_os = "android")]
95 unsafe {
97 sioul_android_ask_contacts()
98 };
99}
100
101pub(crate) fn next(at: Option<i64>) {
105 if !cfg!(target_os = "android") || in_service() {
106 return;
107 }
108 static GIVEN: AtomicI64 = AtomicI64::new(-1);
109 let millis = at.map_or(0, |a| a.saturating_mul(1000));
110 if GIVEN.swap(millis, Ordering::Relaxed) != millis {
111 java("next", &serde_json::json!({ "at": millis }).to_string());
112 }
113 follow_setting_once();
114}
115
116fn words() -> serde_json::Value {
118 serde_json::json!({ "title": tr().text("dnd-steps-note", None), "channel": tr().text("dnd-steps-channel", None) })
119}
120
121static FOLLOWED: Mutex<Option<(bool, i64)>> = Mutex::new(None);
123
124fn follow_setting_once() {
129 follow(false);
130}
131
132pub(crate) fn follow_setting() {
134 follow(true);
135}
136
137fn follow(now: bool) {
138 if !cfg!(target_os = "android") || in_service() {
139 return;
140 }
141 let config = load_config();
142 let on = config.dnd.background && crate::share::vault().is_some_and(|(_, vault)| vault.is_some());
143 let at = jiff::Timestamp::now().as_second();
144 {
145 let mut last = FOLLOWED.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
146 if !now && last.is_some_and(|(was, when)| was == on && (!on || at - when < 600)) {
147 return;
148 }
149 *last = Some((on, at));
150 }
151 java(if on { "start" } else { "stop" }, &words().to_string());
152}
153
154pub(crate) fn set_background(on: bool) -> Result<(), String> {
157 let config = load_config();
158 sioul_core::settings::apply(&crate::backend::config_path(), &config, "dnd.background", &sioul_core::config::SettingValue::Bool(on))?;
159 follow_setting();
160 Ok(())
161}
162
163pub(crate) fn setup(config: &Config) -> serde_json::Value {
165 let sharing = crate::share::vault().is_some_and(|(_, vault)| vault.is_some());
166 let running = java("running", "{}") == true;
167 let battery = java("battery", "{}") == true;
168 serde_json::json!({
169 "on": config.dnd.background,
170 "running": running,
171 "sharing": sharing,
172 "battery": battery,
173 "line": tr().text(if config.dnd.background && running { "dnd-steps-on" } else { "dnd-steps-off" }, None),
174 "battery_line": tr().text(if battery { "dnd-steps-battery-ok" } else { "dnd-steps-battery" }, None),
175 })
176}
177
178static POKED: Mutex<(String, String)> = Mutex::new((String::new(), String::new()));
182static MAILED: AtomicI64 = AtomicI64::new(0);
184
185fn next_step(in_use: bool, asleep: bool) -> i64 {
189 if in_use {
190 STEP_IN_USE
191 } else if asleep {
192 STEP_ASLEEP
193 } else {
194 STEP_AWAKE
195 }
196}
197
198fn mail_due(config: &Config, now: i64, last: i64, asleep: bool, paused: bool) -> bool {
204 use sioul_core::attention::{Attention, Column, Level, Row, persons};
205 use sioul_core::reach::Channel;
206 let attention = Attention::of(config);
207 let held = |column: Column| !persons(Channel::Mail).iter().any(|p| attention.cell(Row::People(Channel::Mail, *p), column) == Level::Now);
208 let resting = (asleep && held(Column::Sleep)) || (paused && held(Column::Pause));
209 config.reminders.mail && !resting && now - last >= MAIL_EVERY - 30 && config.accounts.iter().any(sioul_core::config::Account::syncs)
210}
211
212fn fetch_mail(config: &Config) -> bool {
220 let any = std::sync::atomic::AtomicBool::new(false);
221 std::thread::scope(|scope| {
222 for account in config.accounts.iter().filter(|a| a.syncs()) {
223 let any = &any;
224 scope.spawn(move || {
225 let Ok(password) = sioul_sync::secret::password(account) else { return };
226 match sioul_sync::inbox(account, &password) {
227 Ok(report) => {
228 let mut came = !report.new.is_empty();
229 crate::spam::after_fetch(account, &report.new, report.first);
230 let filtered = crate::filters::after_fetch(account, &report.new, report.first);
234 if !filtered.problem.is_empty() {
235 eprintln!("sioul: steps: {}: {}", account.id, filtered.problem);
236 }
237 crate::mailnote::arrived(&account.id, &report.new, report.first);
238 crate::mailnote::unfiltered(&account.id, &filtered.tell);
239 came |= !filtered.tell.is_empty();
240 any.fetch_or(came, Ordering::Relaxed);
241 }
242 Err(e) => eprintln!("sioul: steps: {}: {e:?}", account.id),
243 }
244 });
245 }
246 });
247 crate::mailnote::close_now();
249 let any = any.into_inner();
250 if any {
251 crate::homecard::mail_came();
252 }
253 any
254}
255
256fn brought_already(folder: &Path, names: &str) -> bool {
263 const SMALL: u64 = 64 << 10;
264 let Some(fetched) = sioul_sync::remote::overlay(folder) else { return false };
265 let names: Vec<&str> = names.split('/').filter(|n| !n.is_empty() && *n != "." && *n != "..").collect();
266 let same = |name: &str| {
267 let (here, there) = (folder.join(name), fetched.join(name));
268 match (std::fs::metadata(&here), std::fs::metadata(&there)) {
269 (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(),
270 _ => false,
271 }
272 };
273 !names.is_empty() && names.iter().all(|name| same(name))
274}
275
276fn rounds_wait() {
283 let memory = sioul_core::config::state_dir().join("share").join("memory.json");
284 sioul_sync::share::set_frugal(&memory, sioul_sync::remote::Sending::load(&memory).slow());
285}
286
287pub(crate) fn note() -> (serde_json::Value, Option<i64>) {
293 let (title, dnd, until) = crate::everywhere::note();
294 let calls = crate::calls::words();
295 let calls_line = match (calls["screening"] == true, calls["through"] == true) {
296 (false, _) => String::new(),
297 (true, true) => calls["line"].as_str().unwrap_or_default().to_string(),
298 (true, false) => tr().text("steps-note-calls-screened", None),
299 };
300 let text = [dnd, calls_line].into_iter().filter(|t| !t.is_empty()).collect::<Vec<_>>().join(" ");
301 let at = jiff::Timestamp::now().as_millisecond();
302 (serde_json::json!({ "at": at, "title": title, "text": text, "calls": calls }), until)
303}
304
305pub(crate) fn tell_note() {
314 if !cfg!(target_os = "android") || in_service() {
315 return;
316 }
317 java("note", ¬e().0.to_string());
318}
319
320fn step(reason: &str) -> serde_json::Value {
326 let config = load_config();
327 let Some((own, vault)) = crate::share::vault() else { return serde_json::json!({ "stop": true }) };
328 let Some((folder, _)) = vault else {
329 return serde_json::json!({ "stop": true });
331 };
332 if !config.dnd.background {
333 return serde_json::json!({ "stop": true });
334 }
335 let (reason, names) = reason.split_once(':').unwrap_or((reason, ""));
336 if reason == "folder" && brought_already(&folder, names) {
338 return serde_json::json!({ "skip": true });
339 }
340 let (asleep, paused) = crate::everywhere::rest_now();
345 let asleep = asleep && !paused;
346 let in_use = crate::devices::others_in_use();
347 let started = jiff::Timestamp::now().as_second();
351 let mail = mail_due(&config, started, MAILED.load(Ordering::Relaxed), asleep, paused).then(|| {
352 MAILED.store(started, Ordering::Relaxed);
353 std::thread::spawn(|| {
354 fetch_mail(&load_config());
355 crate::mailnote::tick();
356 })
357 });
358 let pressed = reason == "calls";
361 let calls = if pressed { Some(crate::calls::step(true)) } else { None };
362 let fetch_first = reason != "folder" && reason != "heard" && reason != "messages" && !pressed && (in_use || !asleep);
365 rounds_wait();
366 if let Some(Err(e)) = crate::share::exchange_here(fetch_first) {
367 eprintln!("sioul: steps: {e}");
368 }
369 if crate::texts::phone_step() {
372 rounds_wait();
373 if let Some(Err(e)) = crate::share::exchange_here(false) {
374 eprintln!("sioul: steps: {e}");
375 }
376 }
377 let wanted = std::thread::spawn(crate::share::fetch_wanted_here);
382 let calls = calls.unwrap_or_else(|| crate::calls::step(false));
384 let (signature, line) = crate::everywhere::wanted();
386 if let Ok(mut poked) = POKED.lock()
387 && (poked.0 != signature || poked.1 != line)
388 {
389 java("poke", "{}");
390 *poked = (signature, line.clone());
391 }
392 java("note", ¬e().0.to_string());
394 if let Some(mail) = mail
396 && mail.join().is_err()
397 {
398 eprintln!("sioul: steps: mail stopped short");
399 }
400 let until = std::time::Instant::now() + std::time::Duration::from_secs(25);
404 while !wanted.is_finished() && std::time::Instant::now() < until {
405 std::thread::sleep(std::time::Duration::from_millis(100));
406 }
407 let now = jiff::Timestamp::now().as_second();
410 let (note, until) = note();
411 let next = until.map(|u| u - now + 1).filter(|s| *s > 0).map_or(next_step(in_use, asleep), |s| s.min(next_step(in_use, asleep)));
412 let words = words();
413 serde_json::json!({
414 "next": next,
415 "folder": folder.display().to_string(),
416 "own": own,
417 "at": note["at"],
418 "title": note["title"],
419 "text": note["text"],
420 "channel": words["channel"],
421 "calls": calls,
422 "stop": false,
423 })
424}
425
426#[unsafe(no_mangle)]
432pub unsafe extern "C" fn sioul_steps_step(reason: *const std::ffi::c_char) -> *mut std::ffi::c_char {
433 let reason = if reason.is_null() { String::new() } else { unsafe { std::ffi::CStr::from_ptr(reason) }.to_string_lossy().to_string() };
435 let answer = std::panic::catch_unwind(|| step(&reason)).unwrap_or_else(|_| serde_json::json!({ "next": STEP_AWAKE }));
436 crate::alarms::handed(answer.to_string())
437}
438
439#[unsafe(no_mangle)]
443pub extern "C" fn sioul_steps_in_service() {
444 IN_SERVICE.store(true, Ordering::Relaxed);
445 sioul_sync::share::set_keeping(true);
448}
449
450#[unsafe(no_mangle)]
454pub extern "C" fn sioul_dnd_apply() -> *mut std::ffi::c_char {
455 let answer = std::panic::catch_unwind(|| {
456 crate::everywhere::apply();
457 crate::everywhere::moment().to_string()
458 })
459 .unwrap_or_default();
460 crate::alarms::handed(answer)
461}
462
463#[unsafe(no_mangle)]
471pub unsafe extern "C" fn sioul_dnd_heard(json: *const std::ffi::c_char) -> *mut std::ffi::c_char {
472 let json = if json.is_null() { String::new() } else { unsafe { std::ffi::CStr::from_ptr(json) }.to_string_lossy().to_string() };
474 let answer = std::panic::catch_unwind(|| {
475 let asked: serde_json::Value = serde_json::from_str(&json).unwrap_or_default();
476 if let Some(on) = asked["on"].as_bool() {
477 crate::everywhere::heard_from_system(on);
478 }
479 crate::everywhere::moment().to_string()
480 })
481 .unwrap_or_default();
482 crate::alarms::handed(answer)
483}
484
485#[unsafe(no_mangle)]
489pub extern "C" fn sioul_dnd_toggle() -> *mut std::ffi::c_char {
490 let answer = std::panic::catch_unwind(|| {
491 crate::everywhere::toggle_here();
492 crate::everywhere::moment().to_string()
493 })
494 .unwrap_or_default();
495 crate::alarms::handed(answer)
496}
497
498pub(crate) fn send_now() {
503 if !cfg!(target_os = "android") {
504 return;
505 }
506 if java("heard", "{}")["stepped"] != true
507 && let Some(Err(e)) = crate::share::exchange_here(false)
508 {
509 eprintln!("sioul: do-not-disturb: {e}");
510 }
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516
517 #[test]
518 fn the_rhythm_follows_the_other_devices_and_the_night() {
519 assert_eq!(next_step(true, false), 120);
520 assert_eq!(next_step(true, true), 120, "a device in use at night: its news stays fresh");
521 assert_eq!(next_step(false, true), 900);
522 assert_eq!(next_step(false, false), 300);
523 }
524
525 #[test]
526 fn mail_comes_at_its_rhythm_never_asleep_nor_paused() {
527 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();
528 assert!(config.accounts.iter().any(sioul_core::config::Account::syncs));
529 let now = 10_000;
530 assert!(mail_due(&config, now, 0, false, false));
531 assert!(!mail_due(&config, now, now - 60, false, false), "fetched a minute ago");
532 assert!(mail_due(&config, now, now - MAIL_EVERY, false, false));
533 assert!(!mail_due(&config, now, 0, true, false), "asleep");
534 assert!(!mail_due(&config, now, 0, false, true), "paused");
535 let mut told: Config = toml::from_str("[notify]\nmail = [\"sleep\"]\n").unwrap();
537 told.accounts = config.accounts.clone();
538 assert!(mail_due(&told, now, 0, true, false) && !mail_due(&told, now, 0, false, true));
539 config.reminders.mail = false;
540 assert!(!mail_due(&config, now, 0, false, false), "new mail not notified at all");
541 config.reminders.mail = true;
542 config.accounts.clear();
543 assert!(!mail_due(&config, now, 0, false, false), "no account");
544 }
545
546 #[test]
549 fn a_file_the_pull_brought_already_brings_no_step() {
550 let dir = std::env::temp_dir().join(format!("sioul-steps-brought-{}", std::process::id()));
551 let _ = std::fs::remove_dir_all(&dir);
552 let (folder, state) = (dir.join("Sioul"), dir.join("state").join("share"));
553 let fetched = state.join("remote");
554 for d in [&folder, &fetched] {
555 std::fs::create_dir_all(d).unwrap();
556 std::fs::write(d.join("seal.toml"), "version = 1\n").unwrap();
557 }
558 let mut remote = toml::Table::new();
559 remote.insert("folder".into(), folder.display().to_string().into());
560 std::fs::write(state.join("remote.toml"), toml::to_string(&remote).unwrap()).unwrap();
561 let both = |name: &str, here: &[u8], there: &[u8]| {
562 std::fs::write(folder.join(name), here).unwrap();
563 std::fs::write(fetched.join(name), there).unwrap();
564 };
565 both("7c41d09e-1.jsonl", b"line one\nline two\n", b"line one\nline two\n");
566 both("7c41d09e.toml", b"read = 1\n", b"read = 1\n");
567 assert!(!brought_already(&folder, "7c41d09e-1.jsonl"));
569 assert!(sioul_sync::remote::attach(&folder, &state.join("memory.json")).is_some());
570 assert!(brought_already(&folder, "7c41d09e-1.jsonl/7c41d09e.toml"));
571 both("7c41d09e.toml", b"read = 2\n", b"read = 1\n");
573 assert!(!brought_already(&folder, "7c41d09e-1.jsonl/7c41d09e.toml") && !brought_already(&folder, "7c41d09e.toml"));
574 both("7c41d09e-1.jsonl", b"line one\nline two\nline three\n", b"line one\nline two\n");
576 assert!(!brought_already(&folder, "7c41d09e-1.jsonl"));
577 both("7c41d09e-2.jsonl", &vec![b'a'; 70 << 10], &vec![b'b'; 70 << 10]);
579 assert!(brought_already(&folder, "7c41d09e-2.jsonl"));
580 std::fs::write(folder.join("f00d.toml"), b"x").unwrap();
582 assert!(!brought_already(&folder, "f00d.toml") && !brought_already(&folder, "") && !brought_already(&folder, ".."));
583 let _ = std::fs::remove_dir_all(&dir);
584 }
585
586 #[test]
589 fn sioul_never_writes_a_contact_nor_a_star() {
590 let package = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../android/package");
591 let manifest = std::fs::read_to_string(package.join("AndroidManifest.xml")).unwrap();
592 assert!(manifest.contains("android.permission.READ_CONTACTS"));
593 assert!(!manifest.contains("WRITE_CONTACTS"), "no permission to write contacts");
594 let contacts = std::fs::read_to_string(package.join("src/com/aurelienpierre/sioul/DndContacts.java")).unwrap();
595 for write in [".insert(", ".update(", ".delete(", "applyBatch", "ContentProviderOperation"] {
596 assert!(!contacts.contains(write), "DndContacts.java writes: {write}");
597 }
598 }
599
600 #[test]
601 fn nothing_reaches_java_on_a_computer() {
602 assert_eq!(java("running", "{}"), serde_json::Value::Null);
603 next(Some(1));
604 assert!(!in_service());
605 }
606}