Skip to main content

sioul_app/
steps.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The phone in the background: "Sioul keeps your devices in step"
5//! (docs/android.md, "In the background"; StepService.java). A foreground
6//! service in a process of its own (":steps"), which holds Sioul's library
7//! without Qt's window, asks Rust for a step at each of its alarms and when
8//! the sync app writes another device's file into the sharing folder:
9//!
10//! 1. the other devices' news: at an alarm, Sioul's own pull from the server
11//!    while it works (else the sync app asked to look first, and waited for),
12//!    then a quick exchange (`share::exchange_here`, which also keeps this
13//!    device's entry among the devices up to date; the service never says the
14//!    phone is in use); a file the sync app wrote that the pull brought
15//!    already, left out (`brought_already`); then the sealed files of the
16//!    others' notes and papers this phone waits for, fetched by their names
17//!    (`share::fetch_wanted_here`), for the window's exchange to write;
18//! 2. do-not-disturb: what this device should ask of its system, compared
19//!    with what was asked last; changed, Sioul's own process is asked to apply
20//!    it (`DndReceiver`), where Android's modes of Sioul's are kept;
21//! 3. mail at its rhythm, the inbox only, fetched from the step's start
22//!    beside the exchange, every account at once, so that the step's network
23//!    comes in one burst (on mobile data, the radio woken once): what your
24//!    own spam filter moves as you chose, into the Junk folder
25//!    (`spam::after_fetch`); what your mail filters do, on the server, to the
26//!    arrivals still unread (`filters::after_fetch`); the rest handed to the
27//!    new-mail notifications (`mailnote`), told as soon as every account is
28//!    fetched, which never tell what the spam filter flagged or moved, nor
29//!    what a mail filter moves out of the inbox or marks read;
30//! 4. the calls' table (`calls::step`): made again when what it is made of
31//!    changed, and the notification's "Let every call through";
32//! 5. when to look next: two minutes while another device is in use, five
33//!    otherwise, fifteen while you sleep.
34//!
35//! On a computer nothing here runs: the window does all of it each minute.
36
37use 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
43/// In the background service's own process: set by Java before each step.
44static IN_SERVICE: AtomicBool = AtomicBool::new(false);
45/// Mail fetched in the background this often (seconds).
46pub(crate) const MAIL_EVERY: i64 = 15 * 60;
47/// Steps while another device is in use, while awake otherwise, while asleep (seconds).
48pub(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
52/// Whether this process is the background service's.
53pub(crate) fn in_service() -> bool {
54    IN_SERVICE.load(Ordering::Relaxed)
55}
56
57#[cfg(target_os = "android")]
58// SAFETY: declared as android/main.cpp defines them: extern "C", the same types.
59unsafe extern "C" {
60    /// StepService.call (android/main.cpp): a verb and its JSON, a JSON answer or null.
61    fn sioul_android_steps(verb: *const std::ffi::c_char, json: *const std::ffi::c_char) -> *mut std::ffi::c_char;
62    /// An answer of `sioul_android_steps` given back.
63    fn sioul_android_dnd_free(text: *mut std::ffi::c_char);
64    /// Android's question for reading contacts, with the window only.
65    fn sioul_android_ask_contacts();
66}
67
68/// Java's answer to `verb` (StepService.call); null elsewhere, or without one.
69pub(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        // SAFETY: two zero-terminated texts, valid for the call.
75        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        // SAFETY: a zero-terminated text from sioul_android_steps, read before it is given back.
80        let text = unsafe { CStr::from_ptr(answer) }.to_string_lossy().to_string();
81        // SAFETY: given back once, as main.cpp asks; not read after.
82        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
92/// Android's question for reading contacts (the window's). Nothing elsewhere.
93pub(crate) fn ask_contacts() {
94    #[cfg(target_os = "android")]
95    // SAFETY: no arguments; main.cpp asks Qt, with the window only.
96    unsafe {
97        sioul_android_ask_contacts()
98    };
99}
100
101/// On a phone, in Sioul's own process: Android's alarm back at `at` (Unix
102/// seconds) to apply do-not-disturb again then (DndReceiver), Sioul closed
103/// or not; none takes it away. Nothing elsewhere: the window's minute does it.
104pub(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
116/// The service's words, in the person's language.
117fn words() -> serde_json::Value {
118    serde_json::json!({ "title": tr().text("dnd-steps-note", None), "channel": tr().text("dnd-steps-channel", None) })
119}
120
121/// What was last asked of the service here (on), and when (Unix seconds).
122static FOLLOWED: Mutex<Option<(bool, i64)>> = Mutex::new(None);
123
124/// The service started or stopped as this device's setting says, asked again
125/// every ten minutes while it should run: Android lets an app start its
126/// service only while its window is on the screen (or at a restart), and
127/// may have stopped it meanwhile. Java does nothing when it runs already.
128fn follow_setting_once() {
129    follow(false);
130}
131
132/// The service started (with its words) or stopped as this device's setting says, now.
133pub(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
154/// The setting turned on or off here: written (this device's own, never
155/// shared), then the service follows.
156pub(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
163/// Settings ▸ Do not disturb, on a phone: {on, running, sharing, battery, line, battery_line}.
164pub(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
178// ---------------------------------------------------------------- a step
179
180/// What was last asked of Sioul's own process (`everywhere::wanted`); empty: nothing yet in this process.
181static POKED: Mutex<(String, String)> = Mutex::new((String::new(), String::new()));
182/// When mail was last fetched in the background (Unix seconds).
183static MAILED: AtomicI64 = AtomicI64::new(0);
184
185/// How long until the next step (seconds): two minutes while another device
186/// is in use (its news stays fresh, the doses' too), fifteen while you sleep,
187/// five otherwise.
188fn 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
198/// Whether mail is fetched at this step: at its rhythm, not while you sleep
199/// or pause unless the matrix of what reaches you tells some mail then (as
200/// usual, nothing is told then, your safe senders' and Always through's
201/// shown, not told; it comes when Sioul opens or after), and only when new
202/// mail is notified at all.
203fn 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
212/// The inbox of each account fetched, every account at once (each in a
213/// thread of its own, as the window's watchers fetch them: one connection
214/// each, the account's lock across processes), what your spam filter moves
215/// moved, what your mail filters do done, its arrivals handed to the new-mail
216/// notifications, told in one notification once every account is fetched
217/// (`mailnote::close_now`), and to the home screen's card when any came
218/// (`homecard::mail_came`); whether any came.
219fn 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                        // Your mail filters, as every device that fetches runs them (docs/client.md,
231                        // "Filters"): what they would take quietly is held back from the
232                        // notifications, and told as new mail if they could not act on it.
233                        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    // Every account's arrivals known: told now, in one notification.
248    crate::mailnote::close_now();
249    let any = any.into_inner();
250    if any {
251        crate::homecard::mail_came();
252    }
253    any
254}
255
256/// The files another device wrote that the sync app just put in the sharing
257/// folder (`StepService`'s watch: their names, "/" between them), each one
258/// the pull brought already, the same here as fetched (`remote::overlay`):
259/// whole for a small file, by its size for a larger one (a log's round, which
260/// only grows). Nothing new to read: the folder's step is left out. Not when
261/// nothing is fetched beside the folder, nor for a name not known.
262fn 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
276/// The sharing's full rounds wait while the connection is measured slow
277/// (`share::set_frugal`: a round restating the records, a megabyte or two
278/// sent whole, waits while this device appended less than four), as the
279/// window's exchanges have them. While a bulk import of texts goes on, none
280/// starts at all: `share::exchange_here` says so before each exchange, an
281/// alarm's too (`share::set_importing`).
282fn 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
287/// The background service's notification as things stand now (StepService;
288/// docs/android.md, "In the background"): {title, text, calls}. Its title,
289/// what now is for and until when; its text, do-not-disturb on or off, then
290/// the calls, screened or every call ringing; `calls`, the words of its
291/// buttons (`calls::words`). And when what now is for ends (Unix seconds).
292pub(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
305/// The notification's words told to the background service at once
306/// (StepService.call "note"; after each apply in Sioul's own process: a press
307/// of the switch, the phone's own do-not-disturb heard, the tile, a mode's
308/// end, another device's change, the calls' switch, the window's minute), so
309/// that it says the state now without waiting for its next step. Java shows
310/// them only when they changed, and never words older than those shown: no
311/// copy kept here, which the service's own steps would make stale. Nothing
312/// on a computer, nor from the service's own process, whose step says it.
313pub(crate) fn tell_note() {
314    if !cfg!(target_os = "android") || in_service() {
315        return;
316    }
317    java("note", &note().0.to_string());
318}
319
320/// One step of the background service (see the module's words): its answer
321/// for Java, {next (seconds), folder, own, title, text, channel, calls, stop},
322/// or {skip} for a folder's step with nothing new (the alarm left as it is).
323/// `reason`: `alarm`, `folder:<names>` ("/" between them), `messages`,
324/// `heard`, `calls`, `start`, `restart`.
325fn 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        // Sharing off: nothing to keep in step; the service ends until it is on again.
330        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    // A file the sync app just wrote that the pull brought already: nothing new to read.
337    if reason == "folder" && brought_already(&folder, names) {
338        return serde_json::json!({ "skip": true });
339    }
340    // 1. The others' news: at an alarm, Sioul's own pull from the server while it
341    // works, else the sync app asked first and given twenty seconds
342    // (`share::exchange_here`), unless you sleep and no other device is in use
343    // (nothing is coming: the pull's own pace is enough); a file the sync app just wrote, read at once.
344    let (asleep, paused) = crate::everywhere::rest_now();
345    let asleep = asleep && !paused;
346    let in_use = crate::devices::others_in_use();
347    // 3, begun now: mail at its rhythm, fetched beside the exchange, so that the
348    // step's network comes in one burst; then what waited and may come now
349    // ("The Porch opens"), at the mail's rhythm. Waited for before the answer.
350    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 every call through" pressed on the notification: carried into the switch
359    // and the calls' table first, and sent at once, the sync app not waited for.
360    let pressed = reason == "calls";
361    let calls = if pressed { Some(crate::calls::step(true)) } else { None };
362    // A press heard from this phone's own do-not-disturb (DndReceiver): sent at once too; and
363    // a message's line for your computers (`phonemsgs`, the listener's "messages").
364    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    // texts: the requests this exchange brought, decided and sent now, their outcomes shared at
370    // once; a batch of the import too, the full rounds waiting while it goes on.
371    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    // The others' sealed notes and papers this phone waits for, fetched
378    // from the server by their names beside the mail (`share::fetch_wanted_here`):
379    // the window's exchange, or the next step, writes them. Waited for before
380    // the answer, with the mail.
381    let wanted = std::thread::spawn(crate::share::fetch_wanted_here);
382    // The calls' table as your devices' news left it, and the notification's words for them.
383    let calls = calls.unwrap_or_else(|| crate::calls::step(false));
384    // 2. Do-not-disturb: Sioul's own process asked when what it should ask changed.
385    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    // The notification's words shown now, before mail ends, which may take a while.
393    java("note", &note().0.to_string());
394    // 3. Mail, done: every account fetched and its batch told (no wait after it).
395    if let Some(mail) = mail
396        && mail.join().is_err()
397    {
398        eprintln!("sioul: steps: mail stopped short");
399    }
400    // The sealed files waited for, twenty-five seconds more at most: a large
401    // paper on a slow line goes on without holding the step (asked again
402    // later if it is cut).
403    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    // The notification's words now; the next step no later than the end of what now is for,
408    // so that its title changes with the time.
409    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/// Java's step (StepService, on its worker thread, the phone kept awake).
427///
428/// # Safety
429/// `reason` is null or a zero-terminated text valid for the call.
430// SAFETY: no other symbol of the program has this name.
431#[unsafe(no_mangle)]
432pub unsafe extern "C" fn sioul_steps_step(reason: *const std::ffi::c_char) -> *mut std::ffi::c_char {
433    // SAFETY: `reason` is not null here, and a C string valid for the call, as this function's contract says.
434    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/// Java says this process is the background service's: Android's modes are
440/// never touched from it (`everywhere::apply` runs in Sioul's own process).
441// SAFETY: no other symbol of the program has this name.
442#[unsafe(no_mangle)]
443pub extern "C" fn sioul_steps_in_service() {
444    IN_SERVICE.store(true, Ordering::Relaxed);
445    // It exchanges every few minutes for days: the sharing's memory and its
446    // logs of lines kept between exchanges, not read again at each (`share::set_keeping`).
447    sioul_sync::share::set_keeping(true);
448}
449
450/// Do-not-disturb applied in Sioul's own process (DndReceiver): asked by the
451/// service, at its next end, after a restart.
452// SAFETY: no other symbol of the program has this name.
453#[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/// A change of this phone's own do-not-disturb, heard as it happened
464/// (DndReceiver, in Sioul's own process): {on}, the system's state. A press of
465/// the switch when it disagrees with what holds (`everywhere::heard_from_system`).
466///
467/// # Safety
468/// `json` is null or a zero-terminated text valid for the call.
469// SAFETY: no other symbol of the program has this name.
470#[unsafe(no_mangle)]
471pub unsafe extern "C" fn sioul_dnd_heard(json: *const std::ffi::c_char) -> *mut std::ffi::c_char {
472    // SAFETY: `json` is not null here, and a C string valid for the call, as this function's contract says.
473    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/// Sioul's switch pressed on the phone's quick-settings tile (DndTile, through
486/// DndReceiver, in Sioul's own process).
487// SAFETY: no other symbol of the program has this name.
488#[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
498/// A press made on this phone outside the window (a system's change heard,
499/// the tile): sent at once by the background service, or, while it does not
500/// run, by this process itself (one exchange, the sync app not asked first).
501/// Nothing on a computer: the window sends its own.
502pub(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        // New mail told while you sleep, as the notification matrix now says: fetched then too.
536        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    /// A file the sync app wrote that the pull brought already, the same
547    /// here as fetched, brings no step; anything else does.
548    #[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        // Nothing fetched beside the folder in this process yet: read as before.
568        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        // A small file the same size, otherwise: read.
572        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        // A round longer here (the sync app newer than the pull): read.
575        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        // A large round, the same size: brought (a round only grows).
578        both("7c41d09e-2.jsonl", &vec![b'a'; 70 << 10], &vec![b'b'; 70 << 10]);
579        assert!(brought_already(&folder, "7c41d09e-2.jsonl"));
580        // Not fetched, no name, or a name that is no file's: read.
581        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    /// The coordinator's rule (6 October 2026): the list's stars are said and
587    /// their contacts opened, never written. Checked in what the APK is made of.
588    #[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}