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`; Sioul keeping the folder itself, `blobs/`
18//!    listed too when due, `remote::sealed_step`, and the contents of the
19//!    notes this step's exchange left unread asked for), for the window's
20//!    exchange to write, offline too; the spam filter's table, for the next
21//!    step's;
22//! 2. do-not-disturb: what this device should ask of its system, compared
23//!    with what was asked last; changed, Sioul's own process is asked to apply
24//!    it (`DndReceiver`), where Android's modes of Sioul's are kept;
25//! 3. mail at its rhythm, the inbox only, fetched from the step's start
26//!    beside the exchange, every account at once, so that the step's network
27//!    comes in one burst (on mobile data, the radio woken once): what your
28//!    own spam filter moves as you chose, into the Junk folder
29//!    (`spam::after_fetch`); what your mail filters do, on the server, to the
30//!    arrivals still unread (`filters::after_fetch`); the rest handed to the
31//!    new-mail notifications (`mailnote`), told as soon as every account is
32//!    fetched, which never tell what the spam filter flagged or moved, nor
33//!    what a mail filter moves out of the inbox or marks read;
34//! 4. the calls' table (`calls::step`): made again when what it is made of
35//!    changed, and the notification's Expecting a call;
36//! 5. when to look next: two minutes while the phone keeps close
37//!    (`keeps_close`, read again after the step's pull: another device in
38//!    use; while you sleep, a computer only with someone at it, or any while
39//!    a dose is due and not marked), five otherwise, fifteen while you sleep.
40//!
41//! On a computer nothing here runs: the window does all of it each minute.
42
43use 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
49/// In the background service's own process: set by Java before each step.
50static IN_SERVICE: AtomicBool = AtomicBool::new(false);
51/// Mail fetched in the background this often (seconds).
52pub(crate) const MAIL_EVERY: i64 = 15 * 60;
53/// Steps while another device is in use, while awake otherwise, while asleep (seconds).
54pub(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
58/// Whether this process is the background service's.
59pub(crate) fn in_service() -> bool {
60    IN_SERVICE.load(Ordering::Relaxed)
61}
62
63#[cfg(target_os = "android")]
64// SAFETY: declared as android/main.cpp defines them: extern "C", the same types.
65unsafe extern "C" {
66    /// StepService.call (android/main.cpp): a verb and its JSON, a JSON answer or null.
67    fn sioul_android_steps(verb: *const std::ffi::c_char, json: *const std::ffi::c_char) -> *mut std::ffi::c_char;
68    /// An answer of `sioul_android_steps` given back.
69    fn sioul_android_dnd_free(text: *mut std::ffi::c_char);
70    /// Android's question for reading contacts, with the window only.
71    fn sioul_android_ask_contacts();
72}
73
74/// Java's answer to `verb` (StepService.call); null elsewhere, or without one.
75pub(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        // SAFETY: two zero-terminated texts, valid for the call.
81        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        // SAFETY: a zero-terminated text from sioul_android_steps, read before it is given back.
86        let text = unsafe { CStr::from_ptr(answer) }.to_string_lossy().to_string();
87        // SAFETY: given back once, as main.cpp asks; not read after.
88        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
98/// Android's question for reading contacts (the window's). Nothing elsewhere.
99pub(crate) fn ask_contacts() {
100    #[cfg(target_os = "android")]
101    // SAFETY: no arguments; main.cpp asks Qt, with the window only.
102    unsafe {
103        sioul_android_ask_contacts()
104    };
105}
106
107/// On a phone, in Sioul's own process: Android's alarm back at `at` (Unix
108/// seconds) to apply do-not-disturb again then (DndReceiver), Sioul closed
109/// or not; none takes it away. Nothing elsewhere: the window's minute does it.
110pub(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
122/// The service's words, in the person's language.
123fn words() -> serde_json::Value {
124    serde_json::json!({ "title": tr().text("dnd-steps-note", None), "channel": tr().text("dnd-steps-channel", None) })
125}
126
127/// What was last asked of the service here (on), and when (Unix seconds).
128static FOLLOWED: Mutex<Option<(bool, i64)>> = Mutex::new(None);
129
130/// The service started or stopped as this device's setting says, asked again
131/// every ten minutes while it should run: Android lets an app start its
132/// service only while its window is on the screen (or at a restart), and
133/// may have stopped it meanwhile. Java does nothing when it runs already.
134fn follow_setting_once() {
135    follow(false);
136}
137
138/// The service started (with its words) or stopped as this device's setting says, now.
139pub(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
160/// The setting turned on or off here: written (this device's own, never
161/// shared), then the service follows.
162pub(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
169/// Settings ▸ Do not disturb, on a phone: {on, running, sharing, battery, line, battery_line}.
170pub(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
184// ---------------------------------------------------------------- a step
185
186/// What was last asked of Sioul's own process (`everywhere::wanted`); empty: nothing yet in this process.
187static POKED: Mutex<(String, String)> = Mutex::new((String::new(), String::new()));
188/// When mail was last fetched in the background (Unix seconds).
189static MAILED: AtomicI64 = AtomicI64::new(0);
190
191/// How long until the next step (seconds): two minutes while the phone keeps
192/// close (`keeps_close`: another device in use, its news fresh, the doses'
193/// too, and do-not-disturb pressed there comes within them), fifteen while
194/// you sleep, five otherwise.
195fn 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
205/// Whether mail is fetched at this step: at its rhythm, not while you sleep
206/// or pause unless the matrix of what reaches you tells some mail then (as
207/// usual, nothing is told then, your safe senders' and Always through's
208/// shown, not told; it comes when Sioul opens or after), and only when new
209/// mail is notified at all.
210fn 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
219/// The inbox of each account fetched, every account at once (each in a
220/// thread of its own, as the window's watchers fetch them: one connection
221/// each, the account's lock across processes), what your spam filter moves
222/// moved, what your mail filters do done, its arrivals handed to the new-mail
223/// notifications, told in one notification once every account is fetched
224/// (`mailnote::close_now`), and to the home screen's card when any came
225/// (`homecard::mail_came`); whether any came.
226fn 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                        // Your mail filters, as every device that fetches runs them (docs/client.md,
238                        // "Filters"): what they would take quietly is held back from the
239                        // notifications, and told as new mail if they could not act on it.
240                        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    // Every account's arrivals known: told now, in one notification.
255    crate::mailnote::close_now();
256    let any = any.into_inner();
257    if any {
258        crate::homecard::mail_came();
259    }
260    any
261}
262
263/// The files another device wrote that the sync app just put in the sharing
264/// folder (`StepService`'s watch: their names, "/" between them), each one
265/// the pull brought already, the same here as fetched (`remote::overlay`):
266/// whole for a small file, by its size for a larger one (a log's round, which
267/// only grows). Nothing new to read: the folder's step is left out. Not when
268/// nothing is fetched beside the folder, nor for a name not known.
269fn 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
283/// The sharing's full rounds wait while the connection is measured slow
284/// (`share::set_frugal`: a round restating the records, a megabyte or two
285/// sent whole, waits while this device appended less than four), as the
286/// window's exchanges have them. While a bulk import of texts goes on, none
287/// starts at all: `share::exchange_here` says so before each exchange, an
288/// alarm's too (`share::set_importing`).
289fn 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
294/// The background service's notification as things stand now (StepService;
295/// docs/android.md, "In the background"): {title, text, calls}. Its title,
296/// what now is for and until when; its text, do-not-disturb on or off, then
297/// the calls, screened or every call ringing; `calls`, the words of its
298/// buttons (`calls::words`). And when what now is for ends (Unix seconds).
299pub(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
312/// The notification's words told to the background service at once
313/// (StepService.call "note"; after each apply in Sioul's own process: a press
314/// of the switch, the phone's own do-not-disturb heard, the tile, a mode's
315/// end, another device's change, the calls' switch, the window's minute), so
316/// that it says the state now without waiting for its next step. Java shows
317/// them only when they changed, and never words older than those shown: no
318/// copy kept here, which the service's own steps would make stale. Nothing
319/// on a computer, nor from the service's own process, whose step says it.
320pub(crate) fn tell_note() {
321    if !cfg!(target_os = "android") || in_service() {
322        return;
323    }
324    java("note", &note().0.to_string());
325}
326
327/// Whether the phone keeps its two-minute steps (docs/android.md, "Its
328/// rhythm"), as the devices' entries read now (the folder and the last
329/// pull's copy): another device in use (`sioul_sync::devices::others_in_use`:
330/// while you are awake, a computer whose Sioul runs; while you sleep, only one
331/// someone is at); or, while you sleep, a dose due and not marked while
332/// another device runs (`health::dose_open_now`): a mark made there comes
333/// within the two minutes, not at the phone's next slow look (docs/health.md,
334/// "Knowing"). Read before the step's pull, for whether it pulls at once, and
335/// again after it, for when the next step comes (`step`).
336fn 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
341/// `keeps_close`, from what was read: the devices' entries, this phone's
342/// id, now, you asleep, and whether a dose is due and not marked (asked only
343/// when it decides).
344fn 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
349/// One step of the background service (see the module's words): its answer
350/// for Java, {next (seconds), folder, own, title, text, channel, calls, stop},
351/// or {skip} for a folder's step with nothing new (the alarm left as it is).
352/// `reason`: `alarm`, `folder:<names>` ("/" between them), `messages`,
353/// `heard`, `calls`, `start`, `restart`.
354fn 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        // Sharing off: nothing to keep in step; the service ends until it is on again.
359        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    // A file the sync app just wrote that the pull brought already: nothing new to read.
366    if reason == "folder" && brought_already(&folder, names) {
367        return serde_json::json!({ "skip": true });
368    }
369    // 1. The others' news: at an alarm, Sioul's own pull from the server while it
370    // works, else the sync app asked first and given twenty seconds
371    // (`share::exchange_here`), unless you sleep and the phone need not keep
372    // close (nothing is coming: the pull's own pace is enough); a file the sync
373    // app just wrote, read at once. Whether it keeps close, as the last pull left
374    // the others' entries: read again after this step's pull, for the next step.
375    let (asleep, paused) = crate::everywhere::rest_now();
376    let asleep = asleep && !paused;
377    let in_use = keeps_close(asleep);
378    // 3, begun now: mail at its rhythm, fetched beside the exchange, so that the
379    // step's network comes in one burst; then what waited and may come now
380    // ("The Porch opens"), at the mail's rhythm. Waited for before the answer.
381    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    // Expecting a call pressed on the notification: carried into the switch
390    // and the calls' table first, and sent at once, the sync app not waited for.
391    let pressed = reason == "calls";
392    let calls = if pressed { Some(crate::calls::step(true)) } else { None };
393    // A press heard from this phone's own do-not-disturb (DndReceiver): sent at once too; and
394    // a message's line for your computers (`phonemsgs`, the listener's "messages").
395    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    // texts and replies: the requests this exchange brought, decided and handed over now, their
401    // outcomes shared at once; a batch of the import too, the full rounds waiting while it goes on.
402    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    // The others' sealed notes and papers this phone waits for, fetched
410    // from the server by their names beside the mail (`share::fetch_wanted_here`;
411    // Sioul keeping the folder itself, `blobs/` listed too when due): the
412    // window's exchange writes them, offline too (this step's reads no notes);
413    // the spam filter's table, the next step's. Waited for before the answer,
414    // with the mail; never by a dose's alarm.
415    let wanted = std::thread::spawn(crate::share::fetch_wanted_here);
416    // The copies beside drafts that hold an encrypted message's text in
417    // clear, and their earlier versions, encrypted again, after this step's
418    // exchange and beside the mail: never on a dose's alarm (review of 19
419    // October 2026, F9). Waited for before the answer, as the mail is.
420    let look = std::thread::spawn(crate::crypto::seal_clear_drafts);
421    // The calls' table as your devices' news left it, and the notification's words for them.
422    let calls = calls.unwrap_or_else(|| crate::calls::step(false));
423    // 2. Do-not-disturb: Sioul's own process asked when what it should ask changed.
424    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    // The notification's words shown now, before mail ends, which may take a while.
432    java("note", &note().0.to_string());
433    // 3. Mail, done: every account fetched and its batch told (no wait after it).
434    if let Some(mail) = mail
435        && mail.join().is_err()
436    {
437        eprintln!("sioul: steps: mail stopped short");
438    }
439    // The sealed files waited for, twenty-five seconds more at most: a large
440    // paper on a slow line goes on without holding the step (asked again
441    // later if it is cut).
442    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    // Whether the phone keeps close, read again now that this step's pull brought
447    // the others' entries: someone back at a computer, a dose marked there, seen
448    // from this step on, not one slow step later (review of battery part C, F1).
449    let close = keeps_close(asleep);
450    // The notification's words now; the next step no later than the end of what now is for,
451    // so that its title changes with the time.
452    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/// Java's step (StepService, on its worker thread, the phone kept awake).
470///
471/// # Safety
472/// `reason` is null or a zero-terminated text valid for the call.
473// SAFETY: no other symbol of the program has this name.
474#[unsafe(no_mangle)]
475pub unsafe extern "C" fn sioul_steps_step(reason: *const std::ffi::c_char) -> *mut std::ffi::c_char {
476    // SAFETY: `reason` is not null here, and a C string valid for the call, as this function's contract says.
477    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/// Java says this process is the background service's: Android's modes are
483/// never touched from it (`everywhere::apply` runs in Sioul's own process).
484// SAFETY: no other symbol of the program has this name.
485#[unsafe(no_mangle)]
486pub extern "C" fn sioul_steps_in_service() {
487    IN_SERVICE.store(true, Ordering::Relaxed);
488    // It exchanges every few minutes for days: the sharing's memory and its
489    // logs of lines kept between exchanges, not read again at each (`share::set_keeping`).
490    sioul_sync::share::set_keeping(true);
491}
492
493/// Do-not-disturb applied in Sioul's own process (DndReceiver): asked by the
494/// service, at its next end, after a restart.
495// SAFETY: no other symbol of the program has this name.
496#[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/// A change of this phone's own do-not-disturb, heard as it happened
507/// (DndReceiver, in Sioul's own process): {on}, the system's state. A press of
508/// the switch when it disagrees with what holds (`everywhere::heard_from_system`).
509///
510/// # Safety
511/// `json` is null or a zero-terminated text valid for the call.
512// SAFETY: no other symbol of the program has this name.
513#[unsafe(no_mangle)]
514pub unsafe extern "C" fn sioul_dnd_heard(json: *const std::ffi::c_char) -> *mut std::ffi::c_char {
515    // SAFETY: `json` is not null here, and a C string valid for the call, as this function's contract says.
516    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/// Sioul's switch pressed on the phone's quick-settings tile (DndTile, through
529/// DndReceiver, in Sioul's own process).
530// SAFETY: no other symbol of the program has this name.
531#[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
541/// A press made on this phone outside the window (a system's change heard,
542/// the tile): sent at once by the background service, or, while it does not
543/// run, by this process itself (one exchange, the sync app not asked first).
544/// Nothing on a computer: the window sends its own.
545pub(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    /// The phone's id in the wake counts below.
571    const PHONE_ID: &str = "7c41d09e-0000-4000-8000-000000000001";
572    const DESK_ID: &str = "0b5e11a2-0000-4000-8000-000000000002";
573
574    /// What one hour of the background service does on the network, as its
575    /// steps pace it (`next_step`, `mail_due`, `remote::due`, `keeps_close_of`):
576    /// its alarm steps, the pulls among them, the mail rounds fetched in their
577    /// bursts (one burst a step: pull, send and mail together, on one
578    /// connection), the steps a message for your computers or a text brings
579    /// (twenty seconds after it, a burst of their own unless an alarm step
580    /// comes within those seconds), the bursts in all (the modem woken once
581    /// each), and the longest wait between two pulls (seconds): how late a
582    /// computer's do-not-disturb may come.
583    #[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    /// An hour from `start`, after an hour run the same way (its rhythm
594    /// settled), the desk as the server holds its entry at each moment
595    /// (`desk`; none: no computer running), you asleep or not, a dose due and
596    /// not marked or not, messages for your computers (or texts) heard at
597    /// `heard` (seconds into the hour). In `step`'s order: whether the phone
598    /// keeps close is read from the entry the last pull brought, before the
599    /// step's pull (whether it pulls at once), and again after it (when the
600    /// next step comes).
601    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        // The longest wait between two pulls in the hour, the wait from its last one to the next step included.
634        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        // A message's step, twenty seconds on (`StepService.SOON_MS`), the one
637        // after it joining it (`Later.of`); an alarm step within them takes it along.
638        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    /// The desk's entry as the server holds it at `at`: in use since well
654    /// before, someone at it or not (`active`; none: unsaid, as an older
655    /// Sioul's or where its system says nothing), written at each export,
656    /// each minute, whoever is at it.
657    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    /// The phone's network wakes in an hour, from the code, before and after
662    /// battery part C (13 October 2026, and its review): with no computer
663    /// running, a computer left on with nobody at it, a person at a computer,
664    /// and a dose due and not marked. "Before" is the desk's entry as it was,
665    /// saying nothing of anyone at it (as an older Sioul's still does): Sioul
666    /// running counted. Only the sleep column changed: awake, a computer whose
667    /// Sioul runs counts, as before.
668    #[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        // No computer running: unchanged, every five minutes awake, every fifteen asleep.
674        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        // A computer left on, nobody at it: thirty wakes an hour before, day and night alike;
677        // now thirty while you are awake, as before, and four while you sleep.
678        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        // A dose due and not marked, a computer running: two-minute steps, while you sleep too;
683        // with no other device, nothing to keep close to.
684        assert_eq!(an_hour(&nobody, true, true, &[]), wakes(&before, true));
685        assert_eq!(an_hour(&none, true, true, &[]), wakes(&none, true));
686        // Someone at a computer: as before, every two minutes, asleep too.
687        assert_eq!(wakes(&someone, false), wakes(&before, false));
688        assert_eq!(wakes(&someone, true), wakes(&before, true));
689        // A message for your computers (or a text) brings a step twenty seconds on, a burst of
690        // its own unless an alarm step comes within them; two heard together go as one.
691        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    /// One press at the desk at `press` (do-not-disturb's switch, or a dose
696    /// marked there), nobody at the desk before and someone from it on, its
697    /// entry reaching the server `upload` seconds after each export (each
698    /// minute, and at once at the press): as `step` runs it (`keeps_close_of`
699    /// before its pull and again after it, `remote::due`, `next_step`), how
700    /// long until the phone pulls it (`came`); how far on the step that pulled
701    /// it sets the next (`then`: how late a second press, made just after, may
702    /// come); from the press, when the phone's steps are two minutes apart
703    /// again (`two`); and how long, meanwhile, a dose due an hour before reads
704    /// known not marked elsewhere (`plain`: the card's plain line while the
705    /// mark waits at the desk). Adapted from the review of battery part C (F1, F2).
706    #[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                // Whether a dose waits: until the mark made at the desk is pulled.
731                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            // The card between looks: the dose marked at the desk at `press`, not pulled yet.
760            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    /// The worst of each measure over presses made every seven seconds across
775    /// two whole cycles of the phone's slowest steps, its rhythm settled hours before.
776    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    /// Do-not-disturb pressed at a desk after a while away from it reaches the
783    /// phone at its next look, and the step that brings it brings the
784    /// two-minute steps with it: the step reads the others' entries again
785    /// after its own pull (review of battery part C, F1; before, it went on at
786    /// the slow pace, and while you slept never came back to two minutes).
787    /// Awake, a computer whose Sioul runs counts whoever is at it: two minutes
788    /// throughout, as before.
789    #[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        // The step reads them again after its pull, for its next (read from the source).
800        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    /// A dose due and not marked, a computer running with nobody at it, you
807    /// asleep: the phone keeps its two-minute steps, so that a mark made at
808    /// the desk reaches it within them, and the card's plain line ("not marked
809    /// elsewhere") lasts two minutes at most past the mark, the desk's upload
810    /// aside, not the five that the doses' freshness allows (review of
811    /// battery part C, F2). Without a dose waiting, the night's pace, the
812    /// doses' rule bounding the plain line by `FRESH` all the same.
813    #[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        // New mail told while you sleep, as the notification matrix now says: fetched then too.
836        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    /// Calls are untouched by whether someone is at a computer: the calls'
847    /// table and its screening (calls.rs, Calls.java, CallScreen.java) read
848    /// neither the in-use rule nor what an entry says of it, and "Let every
849    /// call through" pressed on the notification still steps at once, its
850    /// exchange not waiting for a pull (read from the sources).
851    #[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    /// Mail at night: a round only when the matrix of what reaches you tells
865    /// some mail then (as usual it tells none while you sleep: no round, no
866    /// wake); your Always through set to come at night keeps its rhythm, a
867    /// round every fifteen minutes in the night's steps, and no IDLE.
868    #[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        // Its rounds fall in the night's steps (every fifteen minutes): four an hour, no wake of their own.
878        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    /// A file the sync app wrote that the pull brought already, the same
889    /// here as fetched, brings no step; anything else does.
890    #[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        // Nothing fetched beside the folder in this process yet: read as before.
910        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        // A small file the same size, otherwise: read.
914        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        // A round longer here (the sync app newer than the pull): read.
917        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        // A large round, the same size: brought (a round only grows).
920        both("7c41d09e-2.jsonl", &vec![b'a'; 70 << 10], &vec![b'b'; 70 << 10]);
921        assert!(brought_already(&folder, "7c41d09e-2.jsonl"));
922        // Not fetched, no name, or a name that is no file's: read.
923        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    /// The coordinator's rule (6 October 2026): the list's stars are said and
929    /// their contacts opened, never written. Checked in what the APK is made of.
930    #[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}