Skip to main content

sioul_app/
share.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Sharing with your other computers, for the window (docs/database.md): set
5//! up on the Parameters page, one exchange a minute off the window's thread,
6//! the pages read again when another computer's changes came in. Each part
7//! switched on or off on this device; the versions kept before other devices'
8//! changes listed, and put back.
9
10use crate::backend::{QtThread, Shared, json, load_config, say, tr};
11use cxx_qt_lib::QString;
12use serde::Serialize;
13use sioul_core::config::{Config, expand_home, state_dir};
14use sioul_sync::share;
15use std::path::{Path, PathBuf};
16use std::sync::{Arc, Mutex};
17
18/// The key made from the passphrase, once read from the keyring.
19static KEY: Mutex<Option<[u8; 32]>> = Mutex::new(None);
20/// One exchange at a time.
21static BUSY: Mutex<()> = Mutex::new(());
22/// Set while a hurried exchange (a dose's alarm, a button pressed, a switch
23/// changed) waits for the one running: its work on notes and papers stops
24/// where it is, and goes on at the next.
25static HURRY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
26/// This device's sharing file written one change at a time.
27static HERE: Mutex<()> = Mutex::new(());
28/// The last exchange: when it ended (Unix seconds), what went wrong.
29static LAST: Mutex<Option<(i64, Vec<String>)>> = Mutex::new(None);
30/// What an exchange did that you should know for longer than a minute, said
31/// for a day: files two devices changed, both versions kept (Unix seconds, the problem).
32static SAID: Mutex<Vec<(i64, String)>> = Mutex::new(Vec::new());
33
34fn hex(key: &[u8; 32]) -> String {
35    key.iter().map(|b| format!("{b:02x}")).collect()
36}
37
38fn unhex(text: &str) -> Option<[u8; 32]> {
39    let bytes: Option<Vec<u8>> = (0..text.len()).step_by(2).map(|i| text.get(i..i + 2).and_then(|b| u8::from_str_radix(b, 16).ok())).collect();
40    <[u8; 32]>::try_from(bytes?).ok()
41}
42
43fn key() -> Option<[u8; 32]> {
44    let mut held = KEY.lock().ok()?;
45    if held.is_none() {
46        *held = sioul_sync::secret::named(share::KEY_NAME).and_then(|k| unhex(&k));
47    }
48    *held
49}
50
51/// The sharing folder and its key, the key asked of the keyring only once a
52/// folder is set up: a device that does not share never asks it (on Linux a
53/// D-Bus call each time; the demo profile started the Secret Service at each
54/// start, review of 6 October 2026).
55fn sealed(folder: Option<PathBuf>) -> Option<(PathBuf, [u8; 32])> {
56    let folder = folder?;
57    Some((folder, key()?))
58}
59
60fn memory_path() -> PathBuf {
61    state_dir().join("share").join("memory.json")
62}
63
64/// This device's sharing (`share::Here`). The projects' switch was a setting
65/// every device followed (`share_projects`): the first time sharing is on
66/// here, what it said becomes this device's own choice.
67fn here() -> share::Here {
68    let state = state_dir();
69    let mut here = share::Here::load(&state);
70    if here.folder_path().is_some() && !here.parts.contains_key("projects") {
71        let _writing = HERE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
72        here = share::Here::load(&state);
73        if !here.parts.contains_key("projects") {
74            here.parts.insert("projects".into(), load_config().share_projects);
75            let _ = here.save(&state);
76        }
77    }
78    // An answer on the notes folder given before it was tied to the folder
79    // (`Here::notes_carried_for`): tied to the notes folder of now, once.
80    if here.notes_carried.is_some() && here.notes_carried_for.is_none() {
81        let _writing = HERE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
82        here = share::Here::load(&state);
83        if here.notes_carried.is_some() && here.notes_carried_for.is_none()
84            && let Some(notes) = load_config().notes_root_path()
85        {
86            here.notes_carried_for = Some(notes.display().to_string());
87            let _ = here.save(&state);
88        }
89    }
90    here
91}
92
93/// Whether notes and papers can be read whole here: on Android, only with
94/// "All files access" (without it, a folder lists only what Sioul made, and
95/// the rest would seem taken out).
96fn files_readable() -> bool {
97    #[cfg(target_os = "android")]
98    {
99        // SAFETY: declared as android/main.cpp defines them: extern "C", the same types.
100        unsafe extern "C" {
101            /// android/main.cpp's: whether Sioul may reach your files by their path.
102            fn sioul_android_files_access() -> bool;
103        }
104        // SAFETY: android/main.cpp's, asking Android through Java.
105        unsafe { sioul_android_files_access() }
106    }
107    #[cfg(not(target_os = "android"))]
108    true
109}
110
111/// `work` run while no exchange of this Sioul runs: the one running told to
112/// hurry (its notes and papers wait for the next), then waited for.
113fn quietly<T>(work: impl FnOnce() -> T) -> T {
114    use std::sync::atomic::Ordering;
115    HURRY.store(true, Ordering::Relaxed);
116    let busy = BUSY.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
117    HURRY.store(false, Ordering::Relaxed);
118    let done = work();
119    drop(busy);
120    done
121}
122
123/// The same, while no exchange runs on this computer at all (another Sioul,
124/// the command line): `share::exchange_lock` held too. Never around what takes that lock itself.
125fn between_exchanges<T>(work: impl FnOnce() -> T) -> T {
126    quietly(|| {
127        let running = share::exchange_lock(&memory_path(), true);
128        let done = work();
129        drop(running);
130        done
131    })
132}
133
134/// The notes folder, when a sync app carries it already: notes and papers then
135/// never travel through the sharing too, the two carriers would undo each
136/// other's changes. On a computer, from the sync apps' own settings and the
137/// folders named like theirs. On a phone, whose sync apps' settings Sioul
138/// cannot read, as you said there (`Here::notes_carried`, asked before notes
139/// or papers are switched on); until then guessed: a notes folder in the same
140/// top folder of its storage as the sharing folder (Documents…), which eDrive
141/// carries whole, whatever name the storage goes by.
142fn notes_carried(config: &Config, here: &share::Here) -> Option<PathBuf> {
143    let store = config.notes_root_path()?;
144    let carried = if cfg!(target_os = "android") { carried_on_phone(here.notes_said(&store), here.folder_path().is_some_and(|folder| same_top(&on_storage(&store), &on_storage(&folder), Path::new(STORAGE)))) } else { carried(&store, &synced_roots()) };
145    carried.then_some(store)
146}
147
148/// On a phone: what you said, else the guess.
149fn carried_on_phone(said: Option<bool>, guessed: bool) -> bool {
150    said.unwrap_or(guessed)
151}
152
153/// What you say of the notes folder on a phone: a sync app carries it, or
154/// none does (`Here::notes_carried`). Carried: notes and papers stop
155/// travelling through the sharing here. Returns what went wrong, else "".
156pub(crate) fn set_notes_carried(carried: bool) -> String {
157    between_exchanges(|| {
158        let _writing = HERE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
159        // Read as it is, never through `here()`, which takes this lock to mend it.
160        let mut here = share::Here::load(&state_dir());
161        here.notes_carried = Some(carried);
162        // Said of the notes folder of now: asked again once it moves.
163        here.notes_carried_for = load_config().notes_root_path().map(|notes| notes.display().to_string());
164        if carried {
165            for part in ["notes", "papers"] {
166                if here.parts.get(part) == Some(&true) {
167                    here.parts.insert(part.to_string(), false);
168                }
169            }
170        }
171        here.save(&state_dir()).err().unwrap_or_default()
172    })
173}
174
175/// A phone's shared storage.
176const STORAGE: &str = "/storage/emulated/0";
177
178/// A path of a phone's shared storage under its one name: "/sdcard/…",
179/// "/storage/self/primary/…", "/mnt/sdcard/…" are "/storage/emulated/0/…".
180fn on_storage(path: &Path) -> PathBuf {
181    ["/sdcard", "/storage/self/primary", "/mnt/sdcard", "/mnt/user/0/primary"].iter().find_map(|alias| path.strip_prefix(alias).ok()).map_or_else(|| path.to_path_buf(), |rest| Path::new(STORAGE).join(rest))
182}
183
184/// Two folders inside one folder at the top of `storage`.
185fn same_top(a: &Path, b: &Path, storage: &Path) -> bool {
186    let top = |path: &Path| path.strip_prefix(storage).ok().and_then(|rest| rest.components().next()).map(|c| c.as_os_str().to_owned());
187    top(a).is_some() && top(a) == top(b)
188}
189
190/// Why a part cannot be shared from here, when it cannot: notes and papers while a sync app carries the notes folder.
191fn refused(part: &str, config: &Config, here: &share::Here) -> Option<String> {
192    if !matches!(part, "notes" | "papers") {
193        return None;
194    }
195    notes_carried(config, here).map(|store| say("share-part-carried", &[("store", shorten(&store))]))
196}
197
198/// What this device shares: each part as switched here, else as before parts had switches.
199fn stores_here(here: &share::Here) -> Vec<share::Store> {
200    let config = load_config();
201    let carried = notes_carried(&config, here).is_some();
202    share::stores_of(&config, &share::Roots::here(), &|part| here.shares(part, &config) && !(carried && matches!(part, "notes" | "papers")))
203}
204
205/// A part's name and what it carries, in a line.
206fn part_words(part: &str) -> (String, String) {
207    let text = |id: &str| tr().text(id, None);
208    match part {
209        "settings" => (text("share-part-settings"), text("share-part-settings-carries")),
210        "senders" => (text("share-part-senders"), text("share-part-senders-carries")),
211        "calls" => (text("share-part-calls"), text("share-part-calls-carries")),
212        "phone-messages" => (text("share-part-phone-messages"), text("share-part-phone-messages-carries")),
213        // texts: SMS phase (b).
214        "texts" => (text("share-part-texts"), text("share-part-texts-carries")),
215        "spam" => (text("share-part-spam"), text("share-part-spam-carries")),
216        "health" => (text("share-part-health"), text("share-part-health-carries")),
217        "time" => (text("share-part-time"), text("share-part-time-carries")),
218        "drafts" => (text("share-part-drafts"), text("share-part-drafts-carries")),
219        "projects" => (text("share-part-projects"), text("share-part-projects-carries")),
220        "lists" => (text("share-part-lists"), text("share-part-lists-carries")),
221        "notes" => (text("share-part-notes"), text("share-part-notes-carries")),
222        "papers" => (text("share-part-papers"), text("share-part-papers-carries")),
223        // Your calendars' and contacts' versions set aside when an item changed here and on its server too.
224        sioul_sync::history::ACCOUNTS => (text("share-part-accounts"), String::new()),
225        _ => (part.to_string(), String::new()),
226    }
227}
228
229/// Folders a sync carries: those named so in your home, and Nextcloud's own list.
230fn synced_roots() -> Vec<PathBuf> {
231    let home = expand_home("~");
232    let mut roots: Vec<PathBuf> = ["Nextcloud", "Dropbox", "ownCloud", "Sync", "Syncthing", "OneDrive", "pCloudDrive", "Seafile"].iter().map(|n| home.join(n)).filter(|p| p.is_dir()).collect();
233    let data = |name: &str, file: &str| std::env::var_os(name).map(|a| PathBuf::from(a).join(file)).unwrap_or_default();
234    let read = |paths: &[PathBuf]| paths.iter().filter_map(|c| std::fs::read_to_string(c).ok()).collect::<Vec<_>>();
235    let mut found: Vec<PathBuf> = Vec::new();
236    for text in read(&[home.join(".config/Nextcloud/nextcloud.cfg"), home.join("Library/Preferences/Nextcloud/nextcloud.cfg"), data("APPDATA", "Nextcloud/nextcloud.cfg")]) {
237        found.extend(text.lines().filter_map(|line| line.split_once("localPath=")).map(|(_, path)| PathBuf::from(path.trim().trim_end_matches('/'))));
238    }
239    // Syncthing's folders, wherever they are (its config.xml).
240    for text in read(&[home.join(".config/syncthing/config.xml"), home.join(".local/state/syncthing/config.xml"), home.join("Library/Application Support/Syncthing/config.xml"), data("LOCALAPPDATA", "Syncthing/config.xml")]) {
241        found.extend(syncthing_folders(&text).iter().map(|path| expand_home(path)));
242    }
243    // Dropbox, wherever it was put (its info.json: the personal and business folders).
244    for text in read(&[home.join(".dropbox/info.json"), data("APPDATA", "Dropbox/info.json"), data("LOCALAPPDATA", "Dropbox/info.json")]) {
245        found.extend(dropbox_folders(&text).into_iter().map(PathBuf::from));
246    }
247    for path in found {
248        if path.is_dir() && !roots.contains(&path) {
249            roots.push(path);
250        }
251    }
252    roots
253}
254
255/// The folders a Syncthing configuration carries: each `<folder … path="…">`.
256fn syncthing_folders(xml: &str) -> Vec<String> {
257    xml.split("<folder ")
258        .skip(1)
259        .filter_map(|element| {
260            let tag = element.split('>').next()?;
261            let (_, rest) = tag.split_once(" path=\"").or_else(|| tag.strip_prefix("path=\"").map(|rest| ("", rest)))?;
262            let path = rest.split('"').next()?;
263            Some(path.replace("&amp;", "&").replace("&apos;", "'").replace("&quot;", "\"").replace("&lt;", "<").replace("&gt;", ">"))
264        })
265        .collect()
266}
267
268/// The folders Dropbox's info.json names: `{"personal": {"path": …}, "business": {"path": …}}`.
269fn dropbox_folders(json: &str) -> Vec<String> {
270    let Ok(serde_json::Value::Object(accounts)) = serde_json::from_str::<serde_json::Value>(json) else { return Vec::new() };
271    accounts.values().filter_map(|account| account.get("path").and_then(serde_json::Value::as_str).map(str::to_string)).collect()
272}
273
274/// Folders already shared through, by this device's other devices: those holding a seal
275/// (`seal.toml`) in the folders a sync carries here, or on Android in your files
276/// (where the sync app, eDrive, Syncthing, puts them). A few levels down, the big ones skipped.
277pub(crate) fn candidates() -> Vec<String> {
278    let roots: Vec<PathBuf> = if cfg!(target_os = "android") { vec![PathBuf::from("/storage/emulated/0")] } else { synced_roots() };
279    let mut found = Vec::new();
280    for root in &roots {
281        look_for_seals(root, 0, &mut found);
282    }
283    found.sort();
284    found.dedup();
285    found.into_iter().map(|path| shorten(&path)).collect()
286}
287
288/// A folder's own folders, for Sioul's folder browser (Android's picker
289/// refuses the phone's storage, Murena's among others): {"path", "parent",
290/// "folders": [{"name", "path", "sealed"}], "readable"}. Hidden folders left out;
291/// "" starts in the phone's storage (Android), else your home.
292pub(crate) fn folders_in(path: &str) -> String {
293    #[derive(Serialize)]
294    struct Folder {
295        name: String,
296        path: String,
297        /// Already shared through, by your other devices (it holds a seal).
298        sealed: bool,
299    }
300    #[derive(Serialize)]
301    struct Listing {
302        path: String,
303        parent: String,
304        folders: Vec<Folder>,
305        readable: bool,
306    }
307    let start = if cfg!(target_os = "android") { PathBuf::from("/storage/emulated/0") } else { expand_home("~") };
308    let dir = if path.trim().is_empty() { start } else { expand_home(path.trim()) };
309    let entries = std::fs::read_dir(&dir);
310    let readable = entries.is_ok();
311    let mut folders: Vec<Folder> = entries
312        .into_iter()
313        .flatten()
314        .filter_map(Result::ok)
315        .filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
316        .map(|e| (e.file_name().to_string_lossy().to_string(), e.path()))
317        .filter(|(name, _)| !name.starts_with('.'))
318        .map(|(name, path)| Folder { sealed: path.join("seal.toml").is_file(), path: path.display().to_string(), name })
319        .collect();
320    folders.sort_by_key(|f| f.name.to_lowercase());
321    // Up to the storage's top on Android: above it, nothing an app may read.
322    let top = cfg!(target_os = "android") && dir == Path::new("/storage/emulated/0");
323    let parent = dir.parent().filter(|_| !top).map(|p| p.display().to_string()).unwrap_or_default();
324    json(&Listing { path: dir.display().to_string(), parent, folders, readable })
325}
326
327fn look_for_seals(dir: &Path, depth: usize, found: &mut Vec<PathBuf>) {
328    // Deep enough for the Nextcloud app's own folder (Android/media/com.nextcloud.client/nextcloud/<account>/…).
329    if depth > 6 || found.len() >= 10 {
330        return;
331    }
332    if dir.join("seal.toml").is_file() {
333        found.push(dir.to_path_buf());
334        return;
335    }
336    let Ok(entries) = std::fs::read_dir(dir) else { return };
337    for entry in entries.filter_map(Result::ok) {
338        let name = entry.file_name().to_string_lossy().to_string();
339        let skip = name.starts_with('.') || matches!(name.as_str(), "DCIM" | "Pictures" | "Movies" | "Music" | "Podcasts" | "Ringtones" | "Alarms" | "Notifications" | "data" | "obb" | "node_modules" | "target");
340        if !skip && entry.file_type().is_ok_and(|t| t.is_dir()) {
341            look_for_seals(&entry.path(), depth + 1, found);
342        }
343    }
344}
345
346fn carried(path: &Path, roots: &[PathBuf]) -> bool {
347    roots.iter().any(|root| path.starts_with(root))
348}
349
350/// Where to share by default: inside a Documents folder a sync carries when
351/// there is one, since a phone's sync app may carry only some folders (Murena's
352/// eDrive: Documents, Pictures, Music…, not the cloud's root); else in the
353/// notes folder when a sync carries it, else in a synced folder.
354fn suggested(roots: &[PathBuf]) -> String {
355    let store = load_config().notes_root_path();
356    let documents = roots.iter().map(|root| root.join("Documents")).find(|d| d.is_dir());
357    let folder = match (&store, &documents, roots.first()) {
358        (Some(store), _, _) if carried(store, roots) && in_documents(store) => store.join("sioul-shared"),
359        (_, Some(documents), _) => documents.join("Sioul"),
360        (Some(store), _, _) if carried(store, roots) => store.join("sioul-shared"),
361        (_, _, Some(root)) => root.join("Sioul"),
362        (Some(store), _, None) => store.join("sioul-shared"),
363        (None, _, None) => return String::new(),
364    };
365    shorten(&folder)
366}
367
368/// Whether a folder is inside one named Documents, which phones' sync apps
369/// carry: Murena's eDrive carries it, and not the rest of the cloud.
370fn in_documents(path: &Path) -> bool {
371    path.components().any(|part| part.as_os_str().eq_ignore_ascii_case("Documents"))
372}
373
374/// A folder a sync carries, outside the Documents folder that sync has: a phone
375/// may not see it.
376fn beside_documents(path: &Path, roots: &[PathBuf]) -> bool {
377    roots.iter().any(|root| path.starts_with(root) && root.join("Documents").is_dir()) && !in_documents(path)
378}
379
380/// A path with your home written `~`.
381fn shorten(path: &Path) -> String {
382    let home = expand_home("~");
383    path.strip_prefix(&home).map_or_else(|_| path.display().to_string(), |rest| format!("~/{}", rest.display()))
384}
385
386/// A moment, said shortly: "14:05" today, "Fri 2 Oct 14:05" before.
387fn when(seconds: i64) -> String {
388    let Ok(at) = jiff::Timestamp::from_second(seconds) else { return String::new() };
389    let at = at.to_zoned(jiff::tz::TimeZone::system());
390    if at.date() == jiff::Zoned::now().date() { at.strftime("%H:%M").to_string() } else { tr().date(&at, true) }
391}
392
393#[derive(Serialize)]
394struct Status {
395    on: bool,
396    folder: String,
397    /// The folder already holds a seal: the passphrase is typed once, not twice.
398    sealed: bool,
399    /// Projects and budgets travel here too.
400    projects: bool,
401    lines: Vec<String>,
402    problems: Vec<String>,
403    /// What travels from this device, part by part.
404    parts: Vec<Part>,
405    /// Folders of notes or papers whose files went at once: held, said, to take out everywhere on a word.
406    vanished: Vec<Vanished>,
407    /// Your other devices, as this one knows them: in use, closed, silent, off as you said.
408    devices: Vec<crate::health::DeviceRow>,
409    /// The other devices' files fetched from the server too: where it stands, the switch, the place given by hand.
410    backup: Backup,
411    /// The accounts Sioul can keep the folder in step with itself (`remote::MIRROR`).
412    servers: Vec<ServerAccount>,
413    /// Sioul keeps the folder in step with a server itself here.
414    mirrored: bool,
415    /// This device's build, in words: "This device: Sioul 0.0.3 (eff8661abcde)."
416    build: String,
417    /// "Send everything again" shown: sharing is on (not found on a server, it says why nothing went).
418    can_again: bool,
419    /// What it did last, in words; "" before it was pressed.
420    again: String,
421    /// On a phone, before notes or papers are switched on: whether a sync app
422    /// there carries the notes folder, asked (Sioul cannot read its settings);
423    /// "" on a computer, or once you said.
424    carried_ask: String,
425}
426
427/// Files gone at once from a folder of notes or papers, held until you say.
428#[derive(Serialize)]
429struct Vanished {
430    /// The folder's part in the records: "files/notes/", "files/papers/".
431    store: String,
432    text: String,
433}
434
435/// A part of what is shared, as this device has it.
436#[derive(Serialize)]
437struct Part {
438    id: &'static str,
439    name: String,
440    /// What it carries, in a line.
441    carries: String,
442    on: bool,
443    /// Why it cannot be switched on here; "" when it can.
444    refused: String,
445    /// Refused on a phone by a guess (a sync app carries the notes folder),
446    /// which you can say is wrong there.
447    guessed: bool,
448    /// When it last sent and received a change here, said shortly; "" when not shared.
449    last: String,
450}
451
452/// What the Parameters page shows, as JSON; `folder` is the one being chosen, if any.
453pub(crate) fn status(folder: &str) -> String {
454    let here = here();
455    let roots = synced_roots();
456    let on = here.folder_path().is_some() && key().is_some();
457    // Shared: the folder in use. Not yet: the one being chosen, else where it was, else a suggestion.
458    let saved = here.folder.clone().filter(|f| !f.is_empty());
459    let chosen = match (&saved, folder.is_empty()) {
460        (Some(saved), _) if on => saved.clone(),
461        (_, false) => folder.to_string(),
462        (Some(saved), true) => saved.clone(),
463        (None, true) => suggested(&roots),
464    };
465    let path = expand_home(&chosen);
466    let mut lines = Vec::new();
467    let mut problems = Vec::new();
468    let mut vanished = Vec::new();
469    let mirror = mirrored();
470    // The format of what travels, as this device knows it (its devices listed below, every one holding it back among them).
471    let formats = if on { key().map(|key| share::formats(&path, &key, &memory_path(), &here.id, now_ms())) } else { None };
472    if on {
473        // Kept in step with a server by Sioul itself: its folder there said, not its copy here.
474        match &mirror {
475            Some(state) => {
476                lines.push(say("share-on-server", &[("host", state.host()), ("place", state.place.clone())]));
477                lines.extend(Some(mirror_line(state)).filter(|l| !l.is_empty()));
478            }
479            None => lines.push(say("share-on", &[("folder", chosen.clone())])),
480        }
481        attached(&here);
482        // Each heard of by its notes or its entry, the later (a phone put away restates its entry hourly).
483        let others = match key() {
484            Some(key) => share::others_heard(&path, &key, &here.id),
485            None => share::others(&path, &here.id),
486        };
487        match others.iter().map(|o| o.heard).max() {
488            Some(heard) => {
489                let mut args = sioul_core::i18n::args();
490                args.set("count", others.len() as i64);
491                args.set("when", when(heard));
492                lines.push(tr().text("share-others", Some(&args)));
493            }
494            None => lines.push(tr().text("share-alone", None)),
495        }
496        if let Some((at, said)) = LAST.lock().ok().and_then(|l| l.clone()) {
497            lines.push(say("share-last", &[("when", when(at))]));
498            problems.extend(said.iter().filter(|p| !kept_a_day(p) && !p.starts_with("share-vanished:") && !p.starts_with("share-busy") && !p.starts_with("share-newer:")).map(|p| problem_text(p)));
499            vanished.extend(said.iter().filter_map(|p| vanished_of(p)));
500        }
501        // The format of what travels (docs/database.md, "The format of what
502        // travels"): a part your other devices share in a newer form than
503        // this Sioul reads, said as long as it lasts; a device on an older
504        // Sioul holding the newer form back, or one appearing after it.
505        if let Some(formats) = &formats {
506            for (part, _, build) in &formats.newer {
507                let name = part_words(part).0;
508                problems.push(if build.is_empty() { say("share-newer-plain", &[("part", name)]) } else { say("share-newer", &[("part", name), ("build", build.clone())]) });
509            }
510            if formats.holders.iter().any(|(_, holds)| *holds != share::Holds::Unheard) {
511                lines.push(tr().text("share-format-held", None));
512            }
513            // A device that still counts (heard of in the last 180 days, not forgotten) on an older Sioul.
514            if !formats.older.is_empty() {
515                problems.push(tr().text("share-format-older", None));
516            }
517        }
518        // Files two devices changed, both kept: said for a day.
519        let day_ago = jiff::Timestamp::now().as_second() - 86_400;
520        if let Ok(mut said) = SAID.lock() {
521            said.retain(|(at, _)| *at > day_ago);
522            problems.extend(said.iter().map(|(_, p)| problem_text(p)));
523        }
524    } else if here.folder_path().is_some() {
525        problems.push(tr().text("share-key-missing", None));
526    } else {
527        lines.push(tr().text("share-off", None));
528    }
529    // A phone's sync app may carry only its cloud's Documents folder (Murena's eDrive).
530    if !cfg!(target_os = "android") && beside_documents(&path, &roots) {
531        lines.push(tr().text("share-phones", None));
532    }
533    // Notes travel by their own folder, unless this device shares them here:
534    // say so when no sync seems to carry it.
535    let config = load_config();
536    if let Some(store) = config.notes_root_path()
537        && !here.shares("notes", &config)
538        && carried(&path, &roots)
539        && !carried(&store, &roots)
540    {
541        problems.push(say("share-outside", &[("store", shorten(&store))]));
542    }
543    let traffic = share::traffic(&memory_path(), &here.id);
544    let parts = share::PARTS
545        .iter()
546        .map(|&id| {
547            let (name, carries) = part_words(id);
548            let refused = refused(id, &config, &here).unwrap_or_default();
549            let shared = here.shares(id, &config) && refused.is_empty();
550            let last = match traffic.get(id).copied().unwrap_or_default() {
551                _ if !on || !shared => String::new(),
552                (0, 0) => tr().text("share-part-quiet", None),
553                (sent, received) => [(sent, "share-part-sent"), (received, "share-part-received")].iter().filter(|(at, _)| *at > 0).map(|(at, id)| say(id, &[("when", when(*at))])).collect::<Vec<_>>().join(" "),
554            };
555            // texts: what this device keeps of them, as other parts say what they carry.
556            let last = match (id, last.is_empty()) {
557                ("texts", false) => [last, crate::texts::kept_words()].join(" ").trim().to_string(),
558                _ => last,
559            };
560            let guessed = cfg!(target_os = "android") && !refused.is_empty() && config.notes_root_path().is_none_or(|notes| here.notes_said(&notes).is_none());
561            Part { id, name, carries, on: shared, refused, guessed, last }
562        })
563        .collect();
564    // Every device holding the newer form back, or on an older Sioul once it travels, has its line.
565    let holders: Vec<(String, i64, bool)> = formats.as_ref().map(|f| f.holders.iter().map(|(id, _)| (id, true)).chain(f.older.iter().map(|id| (id, false))).map(|(id, holds)| (id.clone(), f.heard.get(id).copied().unwrap_or(0) / 1000, holds)).collect()).unwrap_or_default();
566    let devices = if on { crate::health::device_rows(&holders) } else { Vec::new() };
567    let backup = backup(on && mirror.is_none(), &path);
568    let build = say("share-build", &[("build", sioul_core::build::DESCRIBED.to_string())]);
569    // Pressed while the folder is not found on a server, it says why nothing went.
570    let can_again = on;
571    let again = if on { again_line(&sioul_sync::remote::Sending::load(&memory_path()).again, &sioul_sync::remote::State::load(&memory_path()).host()) } else { String::new() };
572    let carried_ask = match config.notes_root_path() {
573        Some(store) if cfg!(target_os = "android") && here.notes_said(&store).is_none() => say("share-carried-ask", &[("store", shorten(&store))]),
574        _ => String::new(),
575    };
576    json(&Status { on, folder: chosen, sealed: share::sealed(&path), projects: here.shares("projects", &config), lines, problems, parts, vanished, devices, backup, servers: server_accounts(&config), mirrored: mirror.is_some(), build, can_again, again, carried_ask })
577}
578
579fn problem_text(code: &str) -> String {
580    let file = |file: &str| share::shown(file).to_string();
581    match code.split_once(':') {
582        Some(("share-other-seal", _)) => tr().text("share-other-seal", None),
583        // Another device's records, by its name in the sharing (an id that says nothing to you).
584        Some((other @ ("share-other-gap" | "share-other-cut" | "share-other-line"), _)) => tr().text(other, None),
585        Some(("share-unreadable", path)) => say("share-unreadable", &[("file", path.to_string())]),
586        Some(("share-conflict", copy)) => say("share-conflict", &[("copy", copy.to_string())]),
587        Some(("share-conflict-gone", copy)) => say("share-conflict-gone", &[("copy", copy.to_string())]),
588        Some(("share-older-copy", copy)) => say("share-older-copy", &[("copy", copy.to_string())]),
589        Some(("share-damaged", name)) => say("share-damaged", &[("file", file(name))]),
590        Some(("share-too-big", name)) => say("share-too-big", &[("file", file(name))]),
591        Some(("share-missing", name)) => say("share-missing", &[("file", file(name))]),
592        Some(("share-no-room", name)) => say("share-no-room", &[("file", file(name))]),
593        Some(("share-name-clash", name)) => say("share-name-clash", &[("file", file(name))]),
594        Some(("share-refused", name)) => say("share-refused", &[("file", file(name))]),
595        Some(("share-not-text", name)) => say("share-not-text", &[("file", file(name))]),
596        Some(("share-emptied", name)) => say("share-emptied", &[("file", file(name))]),
597        Some(("share-format-wait", name)) => say("share-format-wait", &[("file", file(name))]),
598        None if code == "share-files-unreadable" => tr().text("share-files-unreadable", None),
599        None if matches!(code, "share-no-seal" | "share-sealed-otherwise" | "share-twin" | "share-own-ahead" | "share-own-cut") => tr().text(code, None),
600        _ => code.to_string(),
601    }
602}
603
604/// What is said for a day, not only until the next exchange: files two devices changed, an older copy put back by hand.
605fn kept_a_day(code: &str) -> bool {
606    code.starts_with("share-conflict") || code.starts_with("share-older-copy")
607}
608
609/// `share-vanished:<folder>:<count>`, said with a button: the folder of notes or papers it is in, and the sentence.
610fn vanished_of(code: &str) -> Option<Vanished> {
611    let (folder, count) = code.strip_prefix("share-vanished:")?.rsplit_once(':')?;
612    let store = if folder.starts_with("files/papers/") { "files/papers/" } else { "files/notes/" };
613    let shown = match share::shown(folder) {
614        "" => load_config().notes_root_path().map(|notes| shorten(&notes)).unwrap_or_default(),
615        inside => inside.trim_end_matches('/').to_string(),
616    };
617    Some(Vanished { store: store.to_string(), text: say("share-vanished", &[("folder", shown), ("count", count.to_string())]) })
618}
619
620/// Starts sharing through a folder; "" when it did, else why not.
621pub(crate) fn start(folder: &str, passphrase: &str, again: &str) -> String {
622    let folder = folder.trim();
623    if folder.is_empty() {
624        return tr().text("share-no-folder", None);
625    }
626    let path = expand_home(folder);
627    let first = !share::sealed(&path);
628    if passphrase.chars().count() < 12 {
629        return tr().text("share-short", None);
630    }
631    if first && passphrase != again {
632        return tr().text("share-differ", None);
633    }
634    let key = match share::key_for(&path, passphrase) {
635        Ok(key) => key,
636        Err(share::Refused::WrongPassphrase) => return tr().text("share-wrong", None),
637        Err(share::Refused::Other(e)) => return e,
638    };
639    if let Err(e) = sioul_sync::secret::save_named(share::KEY_NAME, &hex(&key)) {
640        return e.sentence(tr(), "Sioul");
641    }
642    if let Ok(mut held) = KEY.lock() {
643        *held = Some(key);
644    }
645    let state = state_dir();
646    let mut here = share::Here::load(&state);
647    here.folder = Some(folder.to_string());
648    here.save(&state).err().unwrap_or_default()
649}
650
651/// Projects and money shared from this device, or not; "" when kept, else why not.
652pub(crate) fn set_projects(on: bool) -> String {
653    set_part("projects", on)
654}
655
656/// A part shared from this device, or not (`share::PARTS`): this device's own
657/// choice, never shared. Notes and papers are refused while a sync app
658/// carries the notes folder. "" when kept, else why not.
659pub(crate) fn set_part(part: &str, on: bool) -> String {
660    let Some(part) = share::PARTS.iter().find(|p| **p == part) else { return format!("{part}?") };
661    if on && let Some(why) = refused(part, &load_config(), &here()) {
662        return why;
663    }
664    // Between two exchanges: one never reads half the parts as they were.
665    between_exchanges(|| {
666        let _writing = HERE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
667        // Read as it is, never through `here()`, which takes this lock to mend it.
668        let mut here = share::Here::load(&state_dir());
669        here.parts.insert((*part).to_string(), on);
670        here.save(&state_dir()).err().unwrap_or_default()
671    })
672}
673
674/// A version kept, as the window lists it.
675#[derive(Serialize)]
676struct Kept {
677    part: &'static str,
678    name: String,
679    files: Vec<KeptFile>,
680    /// The files not shown (a hundred at most are), said; "" when all are.
681    more: String,
682}
683
684#[derive(Serialize)]
685struct KeptFile {
686    file: String,
687    shown: String,
688    count: String,
689    versions: Vec<KeptVersion>,
690}
691
692#[derive(Serialize)]
693struct KeptVersion {
694    stamp: String,
695    when: String,
696    size: String,
697}
698
699/// The versions kept here before other devices' changes (`sioul_sync::history`),
700/// as JSON: each part's files whose name holds `filter` (any case), the one
701/// changed last first, a hundred at most (the others said), their versions.
702pub(crate) fn history(filter: &str) -> String {
703    let root = sioul_sync::history::root(&memory_path());
704    let needle = filter.trim().to_lowercase();
705    // The parts shared, and your calendars' and contacts' versions set aside (`dav`).
706    let kept: Vec<Kept> = share::PARTS
707        .iter()
708        .chain(std::iter::once(&sioul_sync::history::ACCOUNTS))
709        .filter_map(|&part| {
710            let all: Vec<_> = sioul_sync::history::files(&root, part).into_iter().filter(|(file, versions)| shown_kept(&root, part, file, versions).to_lowercase().contains(&needle)).collect();
711            let more = if all.len() > 100 { say("share-history-more", &[("count", (all.len() - 100).to_string())]) } else { String::new() };
712            let files: Vec<KeptFile> = all
713                .into_iter()
714                .take(100)
715                .map(|(file, versions)| KeptFile {
716                    shown: shown_kept(&root, part, &file, &versions),
717                    count: say("share-versions", &[("count", versions.len().to_string())]),
718                    versions: versions.into_iter().map(|v| KeptVersion { when: when(v.at / 1000), size: sioul_core::view::size(tr(), usize::try_from(v.size).unwrap_or(usize::MAX)), stamp: v.stamp }).collect(),
719                    file,
720                })
721                .collect();
722            (!files.is_empty()).then(|| Kept { part, name: part_words(part).0, files, more })
723        })
724        .collect();
725    json(&kept)
726}
727
728/// A file of the earlier versions, as the list names it: a calendar's event
729/// or a contact set aside by what it is ("Dentist", "Jane Doe"), from its
730/// newest version; any other by its name.
731fn shown_kept(root: &Path, part: &str, file: &str, versions: &[sioul_sync::history::Version]) -> String {
732    if part != sioul_sync::history::ACCOUNTS {
733        return share::shown(file).to_string();
734    }
735    let text = versions.first().and_then(|v| match sioul_sync::history::version(root, part, file, &v.stamp) {
736        Some(sioul_sync::history::Kept::Copy(path)) => std::fs::read_to_string(path).ok(),
737        _ => None,
738    });
739    let named = text.as_deref().and_then(|text| sioul_core::lines::unfold(text).iter().find(|l| matches!(sioul_core::lines::name(l).as_str(), "SUMMARY" | "FN")).map(|l| sioul_core::lines::value(l).trim().to_string())).filter(|n| !n.is_empty());
740    named.unwrap_or_else(|| share::shown(file).to_string())
741}
742
743/// Where an item of your calendars or contacts set aside belongs, from its
744/// name among the earlier versions (`data/calendars/<account>/…`): inside
745/// Sioul's calendars and address books only.
746fn account_item(file: &str) -> Option<PathBuf> {
747    let relative = file.strip_prefix("data/")?;
748    if relative.split('/').any(|piece| piece.is_empty() || piece == "." || piece == "..") {
749        return None;
750    }
751    let path = sioul_core::config::data_dir().join(relative);
752    crate::pim::account_of(&path).map(|_| path)
753}
754
755/// A calendar's item or a contact set aside put back (`history::ACCOUNTS`):
756/// its version written over the item, the item as it is then kept first; its
757/// account's sync sends it, over the server's version. Returns the account
758/// to sync, or what went wrong.
759pub(crate) fn put_back_item(file: &str, stamp: &str) -> Result<String, String> {
760    let root = sioul_sync::history::root(&memory_path());
761    let path = account_item(file).ok_or_else(|| format!("{file}?"))?;
762    let Some(sioul_sync::history::Kept::Copy(version)) = sioul_sync::history::version(&root, sioul_sync::history::ACCOUNTS, file, stamp) else { return Err(format!("{file}: {stamp}: not kept")) };
763    let bytes = std::fs::read(&version).map_err(|e| e.to_string())?;
764    if path.is_file() {
765        sioul_sync::history::keep(&root, sioul_sync::history::ACCOUNTS, file, &path, jiff::Timestamp::now().as_millisecond())?;
766    }
767    share::write_atomically(&path, &bytes)?;
768    crate::pim::account_of(&path).ok_or_else(|| format!("{file}?"))
769}
770
771/// A file put back as it was (`stamp`), the one there now kept in the list
772/// too; the next exchange sends it. Waits for an exchange running. Returns
773/// what went wrong, else "".
774pub(crate) fn put_back(part: &str, file: &str, stamp: &str) -> String {
775    let here = here();
776    let sealed = sealed(here.folder_path());
777    let vault = sealed.as_ref().map(|(folder, key)| (folder.as_path(), key));
778    // Every part, those switched off too: put back here only.
779    let stores = share::stores_of(&load_config(), &share::Roots::here(), &|_| true);
780    quietly(|| share::put_back(&memory_path(), vault, &stores, part, file, stamp, jiff::Timestamp::now().as_millisecond()).err().map(|e| problem_text(&e)).unwrap_or_default())
781}
782
783/// The store a file of the records is in, by name ("files/notes/", "config/config.toml"): for the pages to read again.
784pub(crate) fn store_of(file: &str) -> String {
785    share::stores_of(&load_config(), &share::Roots::here(), &|_| true).into_iter().filter(|s| if s.folder { file.starts_with(s.name.as_str()) } else { s.name == file }).map(|s| s.name).max_by_key(String::len).unwrap_or_default()
786}
787
788/// What putting back would do, said before it is done (`share::put_back_preview`).
789pub(crate) fn put_back_preview(part: &str, file: &str, stamp: &str) -> String {
790    let stores = share::stores_of(&load_config(), &share::Roots::here(), &|_| true);
791    let kept = sioul_sync::history::kept_at(stamp).map(|at| when(at / 1000)).unwrap_or_default();
792    let shown = share::shown(file).to_string();
793    // Your calendar's item or contact set aside: it goes back whole, sent to its server.
794    if part == sioul_sync::history::ACCOUNTS {
795        return say("share-put-back-whole", &[("file", shown), ("when", kept)]);
796    }
797    match share::put_back_preview(&memory_path(), &stores, part, file, stamp) {
798        Ok(back) if back.whole => say("share-put-back-whole", &[("file", shown), ("when", kept)]),
799        Ok(back) if back.changed + back.returning == 0 => say("share-put-back-nothing", &[("file", shown)]),
800        Ok(back) => say("share-put-back-entries", &[("file", shown), ("when", kept), ("changed", back.changed.to_string()), ("returning", back.returning.to_string()), ("kept", back.kept.to_string())]),
801        Err(e) => e,
802    }
803}
804
805/// Files gone at once from a folder of notes or papers ("files/notes/") taken out everywhere at the next exchange, as you said; "" when kept, else why not.
806pub(crate) fn confirm_gone(store: &str) -> String {
807    if !matches!(store, "files/notes/" | "files/papers/") {
808        return format!("{store}?");
809    }
810    let here = here();
811    quietly(|| share::confirm_gone(&memory_path(), &here.id, store).err().unwrap_or_default())
812}
813
814/// What switching Notes or Papers on would send from here, as JSON {"part", "text"}: how many files, how big, how many stay (too big).
815pub(crate) fn estimate(part: &str) -> String {
816    let stores = share::stores_of(&load_config(), &share::Roots::here(), &|p| p == part);
817    let (count, bytes, big) = share::estimate(&stores);
818    let size = sioul_core::view::size(tr(), usize::try_from(bytes).unwrap_or(usize::MAX));
819    let text = say("share-estimate", &[("count", count.to_string()), ("size", size), ("big", big.to_string())]);
820    json(&serde_json::json!({ "part": part, "text": text }))
821}
822
823/// What putting back did, for the status line: the file, and when the version was kept.
824pub(crate) fn put_back_said(file: &str, stamp: &str) -> String {
825    let kept = sioul_sync::history::kept_at(stamp).map(|at| when(at / 1000)).unwrap_or_default();
826    say("share-put-back-done", &[("file", share::shown(file).to_string()), ("when", kept)])
827}
828
829/// Stops sharing here: the key forgotten, the folder left as it is for the others.
830pub(crate) fn stop() -> String {
831    // Between two exchanges: one running never writes its memory back after.
832    between_exchanges(|| {
833        // The others count this device no more: said while its key is still known.
834        crate::devices::left();
835        if let Ok(mut held) = KEY.lock() {
836            *held = None;
837        }
838        let _ = sioul_sync::secret::forget_named(share::KEY_NAME);
839        let state = state_dir();
840        let _writing = HERE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
841        let mut here = share::Here::load(&state);
842        // What was fetched from the server, and where: forgotten with the folder.
843        // Sioul keeping the folder itself: this device's last word (it left)
844        // goes up first, off the window's thread, then its own copy goes.
845        if let Some(folder) = here.folder_path() {
846            if mirrored().is_some() {
847                let (memory, id) = (memory_path(), here.id.clone());
848                std::thread::spawn(move || {
849                    mirror_step_for(&folder, &id, false, false);
850                    sioul_sync::remote::forget(&memory, &folder);
851                    let _ = std::fs::remove_dir_all(sioul_sync::remote::mirror_of(&memory));
852                });
853            } else if sending_on(&here) {
854                // Beside a sync app: its entry saying it left sent too, then forgotten.
855                let (memory, leaving) = (memory_path(), here.clone());
856                std::thread::spawn(move || {
857                    send_to_server(&leaving, false);
858                    sioul_sync::remote::forget(&memory, &folder);
859                });
860            } else {
861                sioul_sync::remote::forget(&memory_path(), &folder);
862            }
863        }
864        here.folder = None;
865        // Starting again later reads the others first, as the first time.
866        let memory = memory_path();
867        let _ = std::fs::remove_file(memory.with_file_name("files.json"));
868        let _ = std::fs::remove_file(&memory);
869        here.save(&state).err().unwrap_or_default()
870    })
871}
872
873/// A device you said is gone (Forget this device), for the sharing too: it
874/// no longer holds the newer form back (`share::forget_device`), until it says
875/// anything newer. Sharing off: nothing to do.
876pub(crate) fn forget_device(id: &str) {
877    let here = here();
878    if let Some((folder, key)) = sealed(attached(&here))
879        && let Err(e) = share::forget_device(&folder, &key, &memory_path(), &here.id, id)
880    {
881        eprintln!("sioul: sharing: {e}");
882    }
883}
884
885/// Whether the doses' part is held here, met in a newer format than this
886/// Sioul reads (`share::holds`, from the sharing's memory on the disk: the
887/// same after a restart).
888pub(crate) fn holds_doses() -> bool {
889    on() && share::holds(&memory_path(), "health")
890}
891
892/// Whether this device's sharing writes format 2 (`share::FORMAT`), where
893/// each pinned site travels apart, and their order as its own setting.
894pub(crate) fn writes_format_2() -> bool {
895    on() && share::writes(&memory_path()) >= 2
896}
897
898/// Whether sharing is on here: a folder and its key.
899pub(crate) fn on() -> bool {
900    share::Here::load(&state_dir()).folder_path().is_some() && key().is_some()
901}
902
903/// The last exchange, when one ran since Sioul started: when (Unix seconds), and whether it went well.
904pub(crate) fn last_exchange() -> Option<(i64, bool)> {
905    LAST.lock().ok().and_then(|l| l.as_ref().map(|(at, problems)| (*at, problems.is_empty())))
906}
907
908/// Who keeps a part of the work among your computers (`sioul_sync::lease`),
909/// this computer's claim renewed; `active` is when you were last at it,
910/// `take` takes the part here on purpose. Sharing off: this computer alone.
911/// The second value: the folder could not be written, so the others may not know.
912pub(crate) fn keeper(part: &str, rule: sioul_sync::lease::Rule, active: i64, take: bool) -> (sioul_sync::lease::Keeper, bool) {
913    // Put away on a phone, its claims said closed (`closing`): renewed only
914    // once it is back (`back_here`), never by the minute's tick that runs
915    // meanwhile, which would say it open in a pocket (review of 5 October
916    // 2026, F9): who keeps the part, as the claims read.
917    if !renews(cfg!(target_os = "android"), crate::backend::AWAY.load(std::sync::atomic::Ordering::SeqCst)) {
918        return (looked(part, rule), false);
919    }
920    let _claiming = CLAIMING.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
921    let here = share::Here::load(&state_dir());
922    let alone = sioul_sync::lease::Keeper::alone(&here.id);
923    let Some((folder, key)) = sealed(attached(&here)) else { return (alone, false) };
924    // The claim says how far this computer wrote its records: read that far, the others know all it marked.
925    let wrote = share::written(&memory_path(), &here.id);
926    match sioul_sync::lease::renew(&folder, &key, part, &here.id, jiff::Timestamp::now().as_second(), active, take, rule, wrote) {
927        Ok(keeper) => (keeper, false),
928        Err(_) => (alone, true),
929    }
930}
931
932/// Whether this device's claims are renewed now: always, but on a phone with Sioul put away.
933fn renews(phone: bool, away: bool) -> bool {
934    !(phone && away)
935}
936
937/// Who keeps a part, as the claims read now, without claiming it. Sharing off: this computer alone.
938pub(crate) fn looked(part: &str, rule: sioul_sync::lease::Rule) -> sioul_sync::lease::Keeper {
939    let here = share::Here::load(&state_dir());
940    let Some((folder, key)) = sealed(attached(&here)) else { return sioul_sync::lease::Keeper::alone(&here.id) };
941    sioul_sync::lease::look(&folder, &key, part, &here.id, jiff::Timestamp::now().as_second(), rule)
942}
943
944/// Claims renewed or closed one at a time: a phone put away and back at once
945/// never ends with a "closed" written after it came back.
946static CLAIMING: Mutex<()> = Mutex::new(());
947
948/// The parts this computer claims (`keeper`): closed together.
949const PARTS: [&str; 3] = ["health", "notices", crate::projects::INVOICES];
950
951/// Sioul closes, or is put away on a phone: what was marked goes out at once
952/// (an exchange, waiting for one running), then its claims say it closed and
953/// how far it wrote, so the others know it marks nothing until it is back
954/// (docs/health.md, "Knowing").
955pub(crate) fn closing() {
956    // Over, unless a phone's Sioul is back already (put away a moment).
957    let ended = || !cfg!(target_os = "android") || crate::backend::AWAY.load(std::sync::atomic::Ordering::SeqCst);
958    let here = here();
959    let Some((folder, key)) = sealed(here.folder_path()) else {
960        crate::devices::window_closed(ended);
961        return;
962    };
963    let memory = memory_path();
964    {
965        // What was marked goes out; notes and papers wait for the next start.
966        HURRY.store(true, std::sync::atomic::Ordering::Relaxed);
967        let _busy = BUSY.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
968        HURRY.store(false, std::sync::atomic::Ordering::Relaxed);
969        let stores = stores_here(&here);
970        let sharing = share::Sharing { folder: &folder, computer: &here.id, key: &key, memory: &memory, files: false, hurry: None };
971        if let Ok(outcome) = share::exchange(&sharing, &stores, jiff::Timestamp::now().as_millisecond()) {
972            crate::devices::exported(&outcome);
973        }
974    }
975    {
976        let _claiming = CLAIMING.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
977        // Back already (a phone put away a moment): its claims stay open.
978        if ended() {
979            let wrote = share::written(&memory, &here.id);
980            for part in PARTS {
981                let _ = sioul_sync::lease::close(&folder, &key, part, &here.id, jiff::Timestamp::now().as_second(), wrote);
982            }
983        }
984    }
985    // The last step of the session, after its final export: down (docs/database.md, "Devices").
986    crate::devices::window_closed(ended);
987    // Sioul keeping the folder itself: that last word goes up, off the window's
988    // thread; a computer quitting waits a few seconds for it, a phone none
989    // (its background step sends it).
990    let going = if mirrored().is_some() {
991        Some(std::thread::spawn(move || mirror_step(&here, false, false)))
992    } else if sending_on(&here) {
993        // Beside a sync app: the same, sent to the server too.
994        Some(std::thread::spawn(move || {
995            send_to_server(&here, false);
996        }))
997    } else {
998        None
999    };
1000    if let Some(going) = going {
1001        let until = std::time::Instant::now() + std::time::Duration::from_secs(if cfg!(target_os = "android") { 0 } else { 5 });
1002        while !going.is_finished() && std::time::Instant::now() < until {
1003            std::thread::sleep(std::time::Duration::from_millis(50));
1004        }
1005    }
1006}
1007
1008/// What this device reads of the others, for the doses (`health::know`).
1009pub(crate) struct Others {
1010    /// Their claims on the doses (older Sioul say no more).
1011    pub claims: Vec<sioul_sync::lease::Claim>,
1012    /// How far their records were read here.
1013    pub heard: share::Heard,
1014    /// Every device sharing through the folder, and when it last exchanged.
1015    pub others: Vec<share::Other>,
1016    /// What each says of itself (`sioul_sync::devices`), and the ids of those whose entry does not read.
1017    pub entries: Vec<sioul_sync::devices::Entry>,
1018    pub unread: Vec<String>,
1019    /// Whether this device's own news flows now: its own pull from the
1020    /// server went through within the last two minutes (`Some(true)`), or
1021    /// failed, or is older (`Some(false)`); none when a sync app alone
1022    /// carries the folder, which says nothing of a pause (review of 10
1023    /// October 2026, R8: `health::note_wake`).
1024    pub news: Option<bool>,
1025}
1026
1027/// The others' claims on the doses, their entries in the devices' registry,
1028/// and what was read of their records: what this device knows of the doses
1029/// they marked (`health::know`). None when sharing is off.
1030pub(crate) fn others_on_health() -> Option<Others> {
1031    let here = share::Here::load(&state_dir());
1032    let Some((folder, key)) = sealed(attached(&here)) else { return None };
1033    let claims = sioul_sync::lease::claims(&folder, &key, "health").into_iter().filter(|c| c.computer != here.id).collect();
1034    let (mut entries, mut unread) = sioul_sync::devices::all(&folder, &key);
1035    entries.retain(|e| e.id != here.id);
1036    unread.retain(|id| *id != here.id);
1037    let pulls = sioul_sync::remote::State::load(&memory_path());
1038    let now = jiff::Timestamp::now().as_second();
1039    let news = (pulls.fetching() && pulls.confirmed > 0 && !pulls.url.is_empty()).then(|| pulls.said.is_empty() && (now - 2 * 60..=now).contains(&pulls.last));
1040    // Every device in the folder, heard of by its notes, its entry or its last record (`others_heard`).
1041    Some(Others { claims, heard: share::heard(&memory_path(), &here.id), others: share::others_heard(&folder, &key, &here.id), entries, unread, news })
1042}
1043
1044/// This device's id, and the folder and key when sharing is on: where its
1045/// entry in the devices' registry goes (`devices`).
1046pub(crate) fn vault() -> Option<(String, Option<(PathBuf, [u8; 32])>)> {
1047    let here = share::Here::load(&state_dir());
1048    if here.id.is_empty() {
1049        return None;
1050    }
1051    let vault = sealed(attached(&here));
1052    Some((here.id, vault))
1053}
1054
1055/// Whether this device shares a part (`share::PARTS`), as chosen here.
1056pub(crate) fn shares(part: &str) -> bool {
1057    here().shares(part, &load_config())
1058}
1059
1060/// The sync app asked to look now (`CARRIERS`), on a phone: after a reminder's
1061/// session went down, so that its lowered entry goes up (`devices::receiver`).
1062pub(crate) fn nudge_carriers() {
1063    ask_carriers();
1064}
1065
1066/// The doses' record read again from every computer's records at the next
1067/// exchange, this computer forgetting it held it: one lost or broken here,
1068/// started again empty, takes nothing out elsewhere. Waits for an exchange
1069/// running. Returns what went wrong, else "".
1070pub(crate) fn rebuild_health_record() -> String {
1071    let here = share::Here::load(&state_dir());
1072    let _busy = BUSY.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1073    share::rebuild(&memory_path(), &here.id, &["state/health-state.toml"]).err().unwrap_or_default()
1074}
1075
1076/// The same for the doses' records (`sioul_core::doses`): one lost or broken
1077/// here is read again from every device's records, nothing of it taken out elsewhere.
1078pub(crate) fn rebuild_dose_records() -> String {
1079    let here = share::Here::load(&state_dir());
1080    let _busy = BUSY.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1081    share::rebuild(&memory_path(), &here.id, &["state/health-doses.toml"]).err().unwrap_or_default()
1082}
1083
1084/// Sync apps that can be asked to look for changes now, on a phone: their
1085/// package, receiver and action. The sharing never depends on it (docs/database.md,
1086/// "What the sync app must do"): without one, what the other devices wrote
1087/// comes at the sync app's own pace, later, and doubts say so meanwhile.
1088/// Each is asked; one not installed hears nothing. Add others the same way,
1089/// with the source that shows their receiver (and its package in the
1090/// manifest's <queries>).
1091#[cfg(target_os = "android")]
1092const CARRIERS: &[(&str, &str, &str)] = &[
1093    // Murena's eDrive (/e/OS) looks every half hour; its "force scan" receiver
1094    // (receivers/DebugCmdReceiver.java, exported) looks now.
1095    ("foundation.e.drive", "foundation.e.drive.receivers.DebugCmdReceiver", "foundation.e.drive.action.FORCE_SCAN"),
1096];
1097
1098/// Every known sync app asked to look now (`CARRIERS`); elsewhere than a phone, nothing.
1099fn ask_carriers() {
1100    ask_carriers_with(false);
1101}
1102
1103/// `ask_carriers`; with `always`, the sync app asked even while Sioul's own
1104/// pull works (a dose's alarm whose pull did not go through falls back to it).
1105fn ask_carriers_with(always: bool) {
1106    // Sioul keeping the folder itself: what changed here goes up now, off the caller's thread.
1107    if mirrored().is_some() {
1108        std::thread::spawn(|| mirror_step(&here(), false, false));
1109        return;
1110    }
1111    // Beside a sync app: this device's files sent first, then the sync app
1112    // asked, off the caller's thread. Asked before, it could find the server
1113    // a version behind the phone's file and bring that older copy down
1114    // (eDrive: a size that differs, docs/database.md, "Sent to the server too").
1115    let here = here();
1116    if sending_on(&here) {
1117        std::thread::spawn(move || {
1118            send_to_server(&here, false);
1119            // Sioul's own pull bringing the others' news: the send was all that was needed.
1120            if always || !here.folder_path().is_some_and(|folder| pulls_alone(&folder)) {
1121                ask_carriers_only();
1122            }
1123        });
1124        return;
1125    }
1126    ask_carriers_only();
1127}
1128
1129/// On a phone, beside a sync app, Sioul's own pull from the server working
1130/// (`remote::State::pulls_well`: the backup on, the folder confirmed there,
1131/// the last pull through): the other devices' news come from it alone, the
1132/// sync app neither asked to look nor waited for, before or after a send
1133/// (docs/android.md, "In the background"). A pull that fails, or the backup
1134/// switched off, and the sync app is asked as before.
1135fn pulls_alone(folder: &Path) -> bool {
1136    cfg!(target_os = "android") && sioul_sync::remote::State::load(&memory_path()).pulls_well(folder)
1137}
1138
1139/// How an exchange here waits for the other devices' news first (`exchange_here`).
1140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1141enum News {
1142    /// Nothing waited for: a button pressed (Android waits eight seconds for
1143    /// its answer), or Sioul keeping the folder itself (the server looked through already).
1144    None,
1145    /// The background step: Sioul's own pull alone, waited for twenty-five
1146    /// seconds at most (as long as the sync app's twenty and the five after
1147    /// them, never later), and over as soon as the pull is.
1148    Pull,
1149    /// A dose's or a waking's alarm, the folder found on the server: a pull
1150    /// begun since the alarm, trusted alone once it went through (this one,
1151    /// or another's that ran meanwhile, waited for); else the sync app asked
1152    /// and waited for, as before; neither bringing anything, the doubt said
1153    /// (`remote::alarm_news`, `news_missed`).
1154    Alarm,
1155    /// The sync app asked and given twenty seconds, the pull meanwhile, then
1156    /// five more seconds at most for it.
1157    SyncApp,
1158    /// A computer: its sync app looks by itself; the pull waited for fifteen seconds at most.
1159    Computer,
1160}
1161
1162/// How an exchange waits for the others' news, as it is asked (`fetch_first`:
1163/// the background step, or an `alarm`, a dose's or a waking's) on this
1164/// device: its pull working (`pulls_well`), or the folder found on the server
1165/// at least (`found`: an alarm tries a pull all the same).
1166fn news(fetch_first: bool, phone: bool, mirror: bool, pulls_well: bool, found: bool, alarm: bool) -> News {
1167    match (fetch_first && !mirror, phone) {
1168        (false, _) => News::None,
1169        (true, false) => News::Computer,
1170        (true, true) if alarm && found => News::Alarm,
1171        (true, true) if pulls_well && !alarm => News::Pull,
1172        (true, true) => News::SyncApp,
1173    }
1174}
1175
1176thread_local! {
1177    /// An alarm on this thread whose pull did not go through since it began
1178    /// (`remote::AlarmNews::SyncApp`): when it began (Unix seconds), and the
1179    /// devices whose entry or health claim says more since.
1180    static NEWS_MISSED: std::cell::Cell<Option<(i64, std::collections::BTreeSet<String>)>> = const { std::cell::Cell::new(None) };
1181}
1182
1183/// Whether the last alarm's exchange on this thread (`exchange_here(true)`)
1184/// had no pull go through since it began: then since when, and the devices of
1185/// which news came all the same (their entry or health claim, later by its own
1186/// times, brought by the sync app). Of the others, what was read before is not taken as knowledge
1187/// (`sioul_core::health::doubts_unread`).
1188pub(crate) fn news_missed() -> Option<(i64, std::collections::BTreeSet<String>)> {
1189    NEWS_MISSED.with(std::cell::Cell::take)
1190}
1191
1192/// The sync app asked to bring the others' news, as before Sioul's own pull,
1193/// and how long its twenty seconds still run: asked a moment ago already (two
1194/// doses due at once), only what is left of them. Only where it is really
1195/// asked is it said asked (`NUDGED`).
1196fn ask_sync_app() -> std::time::Duration {
1197    use std::sync::atomic::Ordering;
1198    let now = jiff::Timestamp::now().as_second();
1199    let last = NUDGED.load(Ordering::Relaxed);
1200    let waited = if now - last < 20 {
1201        now - last
1202    } else {
1203        NUDGED.store(now, Ordering::Relaxed);
1204        ask_carriers_with(true);
1205        0
1206    };
1207    std::time::Duration::from_secs((20 - waited).max(0) as u64)
1208}
1209
1210/// The sync apps asked to look now, and nothing else.
1211fn ask_carriers_only() {
1212    #[cfg(target_os = "android")]
1213    for (package, receiver, action) in CARRIERS {
1214        let text = |s: &str| std::ffi::CString::new(s).unwrap_or_default();
1215        let (package, receiver, action) = (text(package), text(receiver), text(action));
1216        // SAFETY: android/main.cpp's; the three C strings live until the call returns, and it copies them.
1217        unsafe { crate::backend::sioul_android_broadcast(package.as_ptr(), receiver.as_ptr(), action.as_ptr()) };
1218    }
1219}
1220
1221/// When the sync app was last asked to look (Unix seconds), and whether Sioul is on the screen.
1222static NUDGED: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0);
1223/// When `nudge` last did its work while Sioul's own pull brings the news, the sync app not asked (Unix seconds).
1224static ALONE: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0);
1225static SHOWN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
1226
1227/// Sioul on the phone's screen, or put away: the sync app is asked to look only while it is shown.
1228pub(crate) fn set_shown(shown: bool) {
1229    SHOWN.store(shown, std::sync::atomic::Ordering::Relaxed);
1230}
1231
1232/// On a phone: the sync app asked to look now rather than at its own pace
1233/// (`ask_carriers`), at most once in `every` seconds (once a minute at least);
1234/// with `then_read`, an exchange twenty seconds later reads what came down.
1235/// Elsewhere, nothing: the desktop's sync apps look by themselves within seconds.
1236pub(crate) fn nudge(qt: &QtThread, shared: &Arc<Shared>, every: i64, then_read: bool) {
1237    use std::sync::atomic::Ordering;
1238    let now = jiff::Timestamp::now().as_second();
1239    let last = NUDGED.load(Ordering::Relaxed);
1240    if !cfg!(target_os = "android") || now - last < every.max(60) {
1241        return;
1242    }
1243    let here = share::Here::load(&state_dir());
1244    let Some(folder) = here.folder_path() else { return };
1245    // Sioul's own pull bringing the others' news (`pulls_alone`): the sync
1246    // app is left to its own pace. Back on the screen, an exchange at once
1247    // pulls and reads them, each minute's after it, once a minute at most
1248    // (`ALONE`); after a write, Sioul's own send carried this device's files,
1249    // where it sends them there too.
1250    if pulls_alone(&folder) {
1251        let alone = ALONE.load(Ordering::Relaxed);
1252        if now - alone < every.max(60) || ALONE.compare_exchange(alone, now, Ordering::Relaxed, Ordering::Relaxed).is_err() {
1253            return;
1254        }
1255        if then_read && every == 0 {
1256            exchange(qt, shared);
1257        }
1258        if then_read || sending_on(&here) {
1259            return;
1260        }
1261    }
1262    // Said asked only where it is (an alarm reads it: asked a moment ago, it waits what is left).
1263    if NUDGED.compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed).is_err() {
1264        return;
1265    }
1266    ask_carriers();
1267    if then_read {
1268        let (qt, shared) = (qt.clone(), Arc::clone(shared));
1269        std::thread::spawn(move || {
1270            std::thread::sleep(std::time::Duration::from_secs(20));
1271            exchange(&qt, &shared);
1272        });
1273    }
1274}
1275
1276/// Each minute: while Sioul is shown on a phone, the sync app asked to look every five minutes.
1277pub(crate) fn nudge_tick(qt: &QtThread, shared: &Arc<Shared>) {
1278    if SHOWN.load(std::sync::atomic::Ordering::Relaxed) {
1279        nudge(qt, shared, 5 * 60, true);
1280    }
1281}
1282
1283/// An exchange, when sharing is on: off the window's thread; the pages read
1284/// again when changes came in.
1285/// One exchange here and now, on the calling thread, without the window (an
1286/// Android alarm wakes Sioul with nothing on the screen): with `fetch_first`,
1287/// what the other devices wrote is waited for first (`news`): Sioul's own
1288/// pull alone while it works, else the sync app asked and given twenty
1289/// seconds. None when sharing is off. Waits for an exchange running.
1290pub(crate) fn exchange_here(fetch_first: bool) -> Option<Result<share::Outcome, String>> {
1291    let here = here();
1292    let Some((folder, key)) = sealed(here.folder_path()) else { return None };
1293    // A phone's calls copied into its own log first (the background step's, a dose's alarm's).
1294    crate::calls::before_exchange();
1295    crate::phonemsgs::before_exchange();
1296    // texts: the phone's step (read, decide, send), the clock, the trims.
1297    crate::texts::before_exchange();
1298    let mirror = mirrored().is_some();
1299    // Sioul keeping the folder itself: the server looked through now, no sync app to wait for.
1300    if mirror && fetch_first {
1301        mirror_step(&here, true, true);
1302    }
1303    // Its own pull working (`pulls_alone`): the others' news come from the
1304    // server alone, for the background step and a dose's or a waking's alarm
1305    // alike; the sync app neither asked to look nor waited for, before or
1306    // after a send. Else, as before: asked, and given twenty seconds.
1307    let pull_alone = !mirror && pulls_alone(&folder);
1308    // A dose's or a waking's alarm, in Sioul's own process (the background step runs in its own).
1309    let alarm = fetch_first && !crate::steps::in_service();
1310    let found = {
1311        let state = sioul_sync::remote::State::load(&memory_path());
1312        state.fetching() && state.confirmed_for(&folder)
1313    };
1314    let waits = news(fetch_first, cfg!(target_os = "android"), mirror, pull_alone, found, alarm);
1315    NEWS_MISSED.with(|missed| missed.set(None));
1316    // The server asked too, meanwhile, when the folder is found there (`fetch_from_server`);
1317    // an alarm's pull is its own (`alarm_news`, below).
1318    let fetching = (fetch_first && !mirror && waits != News::Alarm).then(|| {
1319        let here = here.clone();
1320        std::thread::spawn(move || fetch_from_server(&here, true))
1321    });
1322    // A background step that asks the sync app nothing (you asleep and nobody
1323    // at another device, a file the sync app just wrote) still reads the
1324    // server at its pace: whether another device is in use is known from there.
1325    if !fetch_first && crate::steps::in_service() {
1326        if mirror { mirror_step(&here, true, false) } else { fetch_from_server(&here, false) }
1327    }
1328    // The sync app asked to bring the others' news, and given twenty seconds;
1329    // asked a moment ago already (two doses due at once), only what is left of them.
1330    if waits == News::SyncApp {
1331        std::thread::sleep(ask_sync_app());
1332    }
1333    // A dose's or a waking's alarm, its pull working: a pull begun since it
1334    // started, gone through, or the sync app as before (`remote::alarm_news`).
1335    if waits == News::Alarm {
1336        let memory = memory_path();
1337        let since = jiff::Timestamp::now().as_second();
1338        match attached(&here) {
1339            Some(at) => {
1340                let state = sioul_sync::remote::State::load(&memory);
1341                let login = load_config().accounts.iter().find(|a| a.id == state.account).and_then(login_of);
1342                let came = sioul_sync::remote::alarm_news(&memory, &at, &here.id, key, login, &FETCHING, since, std::sync::Arc::new(|| jiff::Timestamp::now().as_second()), &mut ask_sync_app);
1343                match came {
1344                    sioul_sync::remote::AlarmNews::Pulled => log_backup("alarm: the others' news pulled since it began".into()),
1345                    sioul_sync::remote::AlarmNews::SyncApp { heard } => {
1346                        log_backup(format!("alarm: no pull went through in time; the sync app asked and waited for, news of {} device(s) came: the others said in doubt where known closed", heard.len()));
1347                        NEWS_MISSED.with(|missed| missed.set(Some((since, heard))));
1348                    }
1349                }
1350            }
1351            None => std::thread::sleep(ask_sync_app()),
1352        }
1353    }
1354    // What the server brings, waited for a few seconds more at most: never
1355    // the whole of a slow network's (a dose's alarm has a minute in all). The
1356    // pull alone, up to the twenty-five seconds it had beside the sync app's
1357    // wait, never later than before, the step going on as soon as it is done.
1358    if let Some(fetching) = fetching {
1359        let until = std::time::Instant::now() + std::time::Duration::from_secs(match waits {
1360            News::Pull => 25,
1361            News::SyncApp => 5,
1362            News::Computer | News::None | News::Alarm => 15,
1363        });
1364        while !fetching.is_finished() && std::time::Instant::now() < until {
1365            std::thread::sleep(std::time::Duration::from_millis(100));
1366        }
1367    }
1368    // The one running told to hurry, then waited for: notes and papers wait,
1369    // what is marked never does (a dose, a button pressed).
1370    HURRY.store(true, std::sync::atomic::Ordering::Relaxed);
1371    let _busy = BUSY.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1372    HURRY.store(false, std::sync::atomic::Ordering::Relaxed);
1373    let stores = stores_here(&here);
1374    let memory = memory_path();
1375    // A bulk import of texts going on: no full round meanwhile (`share::set_importing`).
1376    share::set_importing(&memory, crate::texts::importing_in_bulk());
1377    let sharing = share::Sharing { folder: &folder, computer: &here.id, key: &key, memory: &memory, files: false, hurry: None };
1378    let outcome = share::exchange(&sharing, &stores, jiff::Timestamp::now().as_millisecond());
1379    if let Ok(outcome) = &outcome {
1380        crate::devices::exported(outcome);
1381        // Sioul keeping the folder itself: what it wrote goes up now; off this
1382        // thread for a quick exchange ("Taken" pressed: Android waits eight
1383        // seconds for its answer), the background step waiting for it.
1384        if mirror {
1385            if fetch_first || crate::steps::in_service() {
1386                mirror_step(&here, false, false);
1387            } else {
1388                let here = here.clone();
1389                std::thread::spawn(move || mirror_step(&here, false, false));
1390            }
1391        }
1392        remember(&outcome.problems, jiff::Timestamp::now().as_second());
1393        // On a phone, each quick exchange said in its log (adb logcat -s sioul), counts and codes
1394        // only: how often a background step or an alarm found nothing new (docs/android.md, "Its cost").
1395        if cfg!(target_os = "android") {
1396            let codes: Vec<&str> = outcome.problems.iter().map(|p| p.split(':').next().unwrap_or_default()).collect();
1397            match outcome.quiet {
1398                true => eprintln!("sioul: sharing: nothing new, nothing read"),
1399                false => eprintln!("sioul: sharing: {} sent, {} received, {} waiting{}{}{}", outcome.sent, outcome.received, outcome.pending, waits_said(&outcome.waits), if codes.is_empty() { "" } else { "; " }, codes.join("; ")),
1400            }
1401        }
1402        // Inside a reminder's own session, the sync app is asked once it is down (`devices::receiver`).
1403        let ask = outcome.sent > 0 && !crate::devices::in_receiver();
1404        // Beside a sync app, this device's files sent to the server too, then
1405        // the sync app asked to look (it finds them there as here): the
1406        // background step and an alarm wait for it; a button pressed does not
1407        // (Android waits eight seconds for its answer).
1408        if sending_on(&here) {
1409            let here = here.clone();
1410            let go = move || {
1411                send_to_server(&here, false);
1412                if ask && !pull_alone {
1413                    ask_carriers_only();
1414                }
1415            };
1416            if fetch_first || crate::steps::in_service() {
1417                go();
1418            } else {
1419                std::thread::spawn(go);
1420            }
1421        } else if ask {
1422            ask_carriers();
1423        }
1424    }
1425    Some(outcome)
1426}
1427
1428/// Files two devices changed, both kept: said for a day, not only until the next exchange.
1429fn remember(problems: &[String], now: i64) {
1430    if let Ok(mut kept) = SAID.lock() {
1431        for problem in problems.iter().filter(|p| kept_a_day(p)) {
1432            kept.retain(|(_, p)| p != problem);
1433            kept.push((now, problem.clone()));
1434        }
1435    }
1436}
1437
1438/// An exchange wanted now (a button, a dose answered, a switch, the
1439/// minute's tick), run by the one worker off the window's thread
1440/// (`worker`); asked while one runs, another follows it. A shared file
1441/// saved, seen by the watcher (`watcher`), wants one too, after a pause.
1442pub(crate) fn exchange(qt: &QtThread, shared: &Arc<Shared>) {
1443    start_worker(qt, shared);
1444    want(|soon, now| soon.ask(now));
1445}
1446
1447/// The minute's scheduled exchange: left out when one started less than 50
1448/// seconds ago (a save's), which did its work (`trigger::Soon::tick`).
1449pub(crate) fn exchange_tick(qt: &QtThread, shared: &Arc<Shared>) {
1450    start_worker(qt, shared);
1451    want(|soon, now| soon.tick(now));
1452}
1453
1454/// One exchange, on the worker's thread: the server's news first (Sioul
1455/// keeping the folder, or the backup), then the exchange, then this
1456/// device's files sent to the server (Sioul keeping the folder, or beside
1457/// the sync app), the pages read again when changes came in.
1458fn exchange_now(qt: &QtThread, shared: &Arc<Shared>) {
1459    let (qt, shared) = (qt.clone(), Arc::clone(shared));
1460    {
1461        // Sioul keeping the folder itself: the server looked through first; else
1462        // the other devices' files from the server too, when due, before they are read.
1463        let mirror = mirrored().is_some();
1464        if mirror {
1465            mirror_step(&here(), true, false);
1466        } else {
1467            fetch_from_server(&here(), false);
1468        }
1469        // A metered or slow connection: full rounds wait (`share::set_frugal`);
1470        // a bulk import of texts going on (in the background service), too (`share::set_importing`).
1471        share::set_frugal(&memory_path(), frugal_now());
1472        share::set_importing(&memory_path(), crate::texts::importing_in_bulk());
1473        // Waits for one running (a dose's alarm, Sioul closing): what was asked is never dropped.
1474        let busy = BUSY.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1475        let here = here();
1476        let Some((folder, key)) = sealed(here.folder_path()) else { return };
1477        // A phone's calls copied into its own log first, so that they go now (`calls`).
1478        crate::calls::before_exchange();
1479        crate::phonemsgs::before_exchange();
1480        // texts: the phone's step (read, decide, send), the clock, the trims.
1481        crate::texts::before_exchange();
1482        let stores = stores_here(&here);
1483        let now = jiff::Timestamp::now();
1484        let memory = memory_path();
1485        // Notes and papers only when they can be read whole (Android: "All files access").
1486        let readable = files_readable();
1487        let sharing = share::Sharing { folder: &folder, computer: &here.id, key: &key, memory: &memory, files: readable, hurry: Some(&HURRY) };
1488        let (mut received, mut pending, mut waits, mut no_blob) = (0, 0, String::new(), false);
1489        let (written, accounts, problems, sent) = match share::exchange(&sharing, &stores, now.as_millisecond()) {
1490            Ok(outcome) => {
1491                // What it read and wrote, said in this device's entry (docs/database.md, "Devices").
1492                crate::devices::exported(&outcome);
1493                (received, pending, waits) = (outcome.received, outcome.pending, waits_said(&outcome.waits));
1494                no_blob = outcome.waits.contains_key("no-blob");
1495                if !outcome.written.is_empty() {
1496                    // What the pages were read from (`work::loaded`: the notes, the
1497                    // recently changed, projects and tasks) came before these:
1498                    // read again, or a note come from another device stays unseen.
1499                    if let Ok(mut cache) = shared.loaded.lock() {
1500                        *cache = None;
1501                    }
1502                    crate::work::show_work(&qt, &shared);
1503                    let names = outcome.written.iter().cloned().collect::<Vec<_>>().join("\n");
1504                    let _ = qt.queue(move |mut sioul| sioul.as_mut().shared_in(QString::from(&names)));
1505                }
1506                // The night, its mornings or a day's change come from another device: a phone's alarm at waking follows now (`wake`).
1507                if outcome.written.contains("data/health.toml") || outcome.written.contains("data/health-days.toml") {
1508                    crate::wake::schedule();
1509                }
1510                // Do-not-disturb pressed, a pause, a focus session or its settings from another device: applied here now.
1511                if ["state/do-not-disturb.toml", "state/quiet.toml", "data/time/running.toml", "config/config.toml"].iter().any(|f| outcome.written.contains(*f)) {
1512                    std::thread::spawn(crate::everywhere::apply);
1513                }
1514                (!outcome.written.is_empty(), outcome.written.contains("config/config.toml"), outcome.problems, outcome.sent)
1515            }
1516            Err(e) => (false, false, vec![e], 0),
1517        };
1518        // Whatever was written while the exchange ran (the exchange itself,
1519        // what runs before it, the minute's other work) is never taken for a
1520        // save (`watcher`): a change it did not carry goes at the next one.
1521        if let Ok(mut watched) = WATCHED.lock() {
1522            *watched = Some(sioul_sync::trigger::Stamps::of(&stores));
1523        }
1524        drop(busy);
1525        // What was marked here goes up now, not at the sync app's next look:
1526        // Sioul keeping the folder itself sends it, its entry with it; beside
1527        // a sync app, Sioul sends this device's files too, then the sync app
1528        // is asked to look: it finds them there as they are here.
1529        if mirror {
1530            send_texts(&here);
1531            mirror_step(&here, false, false);
1532        } else {
1533            send_to_server(&here, false);
1534            if sent > 0 {
1535                nudge(&qt, &shared, 60, false);
1536            }
1537        }
1538        // On a phone nobody reads the status line: what each exchange did goes to its
1539        // log (adb logcat), counts and problems only, never what was exchanged.
1540        if cfg!(target_os = "android") {
1541            // Codes alone: a note's name is what was exchanged.
1542            let codes: Vec<&str> = problems.iter().map(|p| p.split(':').next().unwrap_or_default()).collect();
1543            eprintln!("sioul: sharing: {sent} sent, {received} received, {pending} waiting{waits}{}{}", if codes.is_empty() { "" } else { "; " }, codes.join("; "));
1544        }
1545        // A part met in a newer form than this Sioul reads: said once in the
1546        // status line, calmly, the first time this Sioul meets it (Settings
1547        // says it as long as it lasts).
1548        for part in problems.iter().filter_map(|p| p.strip_prefix("share-newer:")) {
1549            crate::backend::set_status(&qt, say("share-newer-status", &[("part", part_words(part).0)]));
1550        }
1551        let mut said = problems;
1552        // Notes and papers wait while Android does not let Sioul read them all.
1553        if !readable && stores.iter().any(|s| matches!(s.shape, share::Shape::Files)) {
1554            said.push("share-files-unreadable".into());
1555        }
1556        said.dedup();
1557        remember(&said, now.as_second());
1558        if let Ok(mut last) = LAST.lock() {
1559            *last = Some((now.as_second(), said));
1560        }
1561        // Accounts come with another device's settings: their syncs start (asking
1562        // their passwords, which never travel).
1563        if accounts {
1564            crate::backend::start_new_watchers(&qt, &shared);
1565        }
1566        if written {
1567            crate::backend::show(&qt, &shared);
1568            crate::pim::show_pim(&qt, &shared);
1569            crate::work::show_work(&qt, &shared);
1570        }
1571        // Notes or papers come whose sealed file is not here yet (neither
1572        // brought by the sync app nor fetched): fetched from the server by
1573        // their names now, then another exchange at once writes them. On a
1574        // thread of its own: a large paper on a slow line never holds back
1575        // the next exchange (a dose marked meanwhile goes at once).
1576        if !mirror && no_blob {
1577            let folder = folder.clone();
1578            std::thread::spawn(move || {
1579                if fetch_wanted(&folder, &key) > 0 {
1580                    want(|soon, now| soon.ask(now));
1581                }
1582            });
1583        }
1584    }
1585}
1586
1587// ---------------------------------------------------------------- shared as soon as it is saved
1588
1589/// Why notes and papers received wait, for the log: " (unknown 2, backoff 1)", "" when none.
1590fn waits_said(waits: &std::collections::BTreeMap<String, usize>) -> String {
1591    if waits.is_empty() {
1592        return String::new();
1593    }
1594    format!(" ({})", waits.iter().map(|(why, n)| format!("{why} {n}")).collect::<Vec<_>>().join(", "))
1595}
1596
1597/// What wants an exchange (`sioul_sync::trigger::Soon`), and the worker waiting on it.
1598static SOON: Mutex<sioul_sync::trigger::Soon> = Mutex::new(sioul_sync::trigger::Soon::new());
1599static SOON_WAKE: std::sync::Condvar = std::sync::Condvar::new();
1600/// The window's handles, once an exchange was first asked: the worker and the watcher run from then on.
1601static WORKER: std::sync::OnceLock<(QtThread, Arc<Shared>)> = std::sync::OnceLock::new();
1602/// The shared files as the watcher last saw them (`sioul_sync::trigger::Stamps`).
1603static WATCHED: Mutex<Option<sioul_sync::trigger::Stamps>> = Mutex::new(None);
1604
1605fn now_ms() -> i64 {
1606    jiff::Timestamp::now().as_millisecond()
1607}
1608
1609/// An exchange wanted, as `change` says (asked now, or a save seen): the worker woken.
1610fn want(change: impl FnOnce(&mut sioul_sync::trigger::Soon, i64)) {
1611    let mut soon = SOON.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1612    change(&mut soon, now_ms());
1613    SOON_WAKE.notify_all();
1614}
1615
1616/// The worker and the watcher, started once, with the window's handles.
1617fn start_worker(qt: &QtThread, shared: &Arc<Shared>) {
1618    if WORKER.set((qt.clone(), Arc::clone(shared))).is_ok() {
1619        std::thread::spawn(worker);
1620        std::thread::spawn(watcher);
1621    }
1622}
1623
1624/// The one thread that runs the window's exchanges, each when it is due
1625/// (docs/database.md, "Sent as soon as it is saved"): asked, at once; saves
1626/// seen, two seconds after the last, five after the first at most. What is
1627/// wanted while one runs makes the next. One that fails (a panic) never stops
1628/// the next ones.
1629fn worker() {
1630    let Some((qt, shared)) = WORKER.get() else { return };
1631    loop {
1632        {
1633            let mut soon = SOON.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1634            loop {
1635                let now = now_ms();
1636                match soon.due() {
1637                    Some(due) if due <= now => {
1638                        soon.take(now);
1639                        break;
1640                    }
1641                    Some(due) => soon = SOON_WAKE.wait_timeout(soon, std::time::Duration::from_millis((due - now) as u64)).unwrap_or_else(std::sync::PoisonError::into_inner).0,
1642                    None => soon = SOON_WAKE.wait(soon).unwrap_or_else(std::sync::PoisonError::into_inner),
1643                }
1644            }
1645        }
1646        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| exchange_now(qt, shared)));
1647        give_back_memory();
1648    }
1649}
1650
1651/// What an exchange held a moment (the memory of every line it knows, read
1652/// and parsed: tens of megabytes with thousands of texts) given back to the
1653/// system after it, where the C library keeps freed memory for itself
1654/// (glibc): the window's footprint stays its own, not its largest exchange's.
1655fn give_back_memory() {
1656    #[cfg(all(target_os = "linux", target_env = "gnu"))]
1657    {
1658        // SAFETY: glibc's malloc_trim, as malloc.h declares it.
1659        unsafe extern "C" {
1660            fn malloc_trim(pad: usize) -> std::ffi::c_int;
1661        }
1662        // SAFETY: glibc's own, asked to return the free pages of its heaps; nothing of Rust's is touched.
1663        unsafe {
1664            malloc_trim(0);
1665        }
1666    }
1667}
1668
1669/// Every two seconds, the shared files looked at (their size and time, the
1670/// notes and papers folders left out): one changed since, by whatever wrote
1671/// it, wants an exchange after a pause. Not while an exchange runs (what it
1672/// writes is taken in after it, `exchange_now`); on a phone, only while Sioul
1673/// is on its screen: in the background, what writes exchanges itself
1674/// (`exchange_here`).
1675fn watcher() {
1676    loop {
1677        std::thread::sleep(std::time::Duration::from_secs(2));
1678        let shown = SHOWN.load(std::sync::atomic::Ordering::Relaxed);
1679        let here = share::Here::load(&state_dir());
1680        if !shown || here.folder_path().is_none() || key().is_none() {
1681            if let Ok(mut watched) = WATCHED.lock() {
1682                *watched = None;
1683            }
1684            continue;
1685        }
1686        let _quiet = match BUSY.try_lock() {
1687            Ok(held) => held,
1688            Err(std::sync::TryLockError::Poisoned(held)) => held.into_inner(),
1689            Err(std::sync::TryLockError::WouldBlock) => continue,
1690        };
1691        let now = sioul_sync::trigger::Stamps::of(&stores_here(&here));
1692        let Ok(mut watched) = WATCHED.lock() else { continue };
1693        if watched.as_ref().is_some_and(|before| !now.changed(before).is_empty()) {
1694            want(|soon, at| soon.saved(at));
1695        }
1696        *watched = Some(now);
1697    }
1698}
1699
1700// ---------------------------------------------------------------- sent beside the sync app
1701
1702/// Sending this device's own files beside the sync app is on here: the
1703/// backup found the folder on a server, and neither switch is off.
1704fn sending_on(here: &share::Here) -> bool {
1705    let Some(folder) = here.folder_path() else { return false };
1706    let state = sioul_sync::remote::State::load(&memory_path());
1707    state.mode != sioul_sync::remote::MIRROR && state.confirmed_for(&folder) && state.sending()
1708}
1709
1710/// This device's own files sent to the server beside the sync app
1711/// (`sioul_sync::remote::send`, docs/database.md, "Sent to the server too"):
1712/// those changed since they went, each over what is there; with `again`,
1713/// each compared with the server's copy, whatever was sent before. Never on
1714/// the window's thread. Said in a phone's log, counts and a code only.
1715fn send_to_server(here: &share::Here, again: bool) -> Option<sioul_sync::remote::Pulled> {
1716    let folder = here.folder_path()?;
1717    if mirrored().is_some() || here.id.is_empty() {
1718        return None;
1719    }
1720    let memory = memory_path();
1721    let now = jiff::Timestamp::now().as_second();
1722    let state = sioul_sync::remote::State::load(&memory);
1723    if !state.confirmed_for(&folder) {
1724        if again {
1725            sioul_sync::remote::Sending::note_again(&memory, now, "not-confirmed");
1726        }
1727        return None;
1728    }
1729    let config = load_config();
1730    let Some(login) = config.accounts.iter().find(|a| a.id == state.account).and_then(login_of) else {
1731        if again {
1732            sioul_sync::remote::Sending::note_again(&memory, now, &format!("no-account-for:{}", state.host()));
1733        }
1734        return None;
1735    };
1736    let sealed = share::own_sealed(&memory, &here.id);
1737    // The texts to send first, on their own lane: never behind a large file.
1738    send_texts(here);
1739    let sent = sioul_sync::remote::send(&memory, &folder, &here.id, &login, &sealed, now, again, metered());
1740    if sent.sent > 0 || sent.held > 0 || again || sent.problem.as_deref().is_some_and(|p| p != "off" && !p.starts_with("waiting:")) {
1741        let at = jiff::Timestamp::now().to_zoned(jiff::tz::TimeZone::system()).strftime("%H:%M:%S").to_string();
1742        let detail = sent.failure.as_ref().map(|f| format!("; {}", failure_words(f))).unwrap_or_default();
1743        log_send(match &sent.problem {
1744            None => format!("{at} sent to {}: {} sent, {} there already, {} records longer there left as they are, {} paced, {} bytes{}", state.host(), sent.sent, sent.same, sent.longer, sent.paced, sent.bytes, if sent.differ.is_empty() { String::new() } else { format!("; otherwise there: {}", sent.differ.join("; ")) }),
1745            Some(why) => format!("{at} send to {} stopped: {}{detail}", state.host(), backup_words(why)),
1746        });
1747    }
1748    Some(sent)
1749}
1750
1751/// A send's failure in words for the log (English, as the log is): "records:
1752/// timeout after 216 s, 1081344 of 2530934 bytes sent (timeout: global)".
1753fn failure_words(f: &sioul_sync::remote::Failure) -> String {
1754    let file = if f.file.is_empty() { String::new() } else { format!("{}: ", f.file) };
1755    let bytes = if f.total > 0 { format!(", {} of {} bytes handed to the network", f.sent, f.total) } else { String::new() };
1756    format!("{file}{} after {} s{bytes} ({})", f.kind, f.seconds, f.words)
1757}
1758
1759/// A send's words in the log, on every system (a computer's too: its
1760/// journal): what went, or why not, the same line never twice in a row.
1761fn log_send(line: String) {
1762    static SAID: Mutex<String> = Mutex::new(String::new());
1763    if let Ok(mut said) = SAID.lock() {
1764        if *said == line {
1765            return;
1766        }
1767        said.clone_from(&line);
1768    }
1769    eprintln!("sioul: backup: {line}");
1770}
1771
1772/// The connection is metered (NetworkManager: yes, or guessed yes), asked at
1773/// most every five minutes; on a phone, not known here (measured instead:
1774/// `Sending::slow`).
1775fn metered() -> bool {
1776    static ASKED: Mutex<(i64, bool)> = Mutex::new((0, false));
1777    if cfg!(target_os = "android") {
1778        return false;
1779    }
1780    let now = jiff::Timestamp::now().as_second();
1781    let Ok(mut asked) = ASKED.lock() else { return false };
1782    if now - asked.0 >= 300 {
1783        *asked = (now, sioul_sync::power::read().metered == Some(true));
1784    }
1785    asked.1
1786}
1787
1788/// The connection metered or measured slow: the sharing's full rounds wait
1789/// (`share::set_frugal`), large files go at most every ten minutes.
1790fn frugal_now() -> bool {
1791    metered() || sioul_sync::remote::Sending::load(&memory_path()).slow()
1792}
1793
1794/// This device's texts to send copied into the sharing folder as a small
1795/// file of their own, when the part "texts" is shared here, then sent to
1796/// the server on their own lane (Sioul keeping the folder itself, or beside
1797/// the sync app): a request reaches the phone within its quarter of an hour
1798/// whatever the records' size (docs/database.md, "Sent to the server too").
1799fn send_texts(here: &share::Here) {
1800    let Some(folder) = here.folder_path() else { return };
1801    if here.id.is_empty() || !here.shares(sioul_core::texts::PART, &load_config()) {
1802        return;
1803    }
1804    let source = sioul_core::texts::own_file(&sioul_core::texts::folder(), sioul_core::texts::SEND, &here.id);
1805    if let Err(e) = sioul_sync::remote::place_texts(&folder, &here.id, &source) {
1806        eprintln!("sioul: texts: {e}");
1807    }
1808    let memory = memory_path();
1809    let state = sioul_sync::remote::State::load(&memory);
1810    if !state.confirmed_for(&folder) || !(state.mode == sioul_sync::remote::MIRROR || state.sending()) {
1811        return;
1812    }
1813    let config = load_config();
1814    let Some(login) = config.accounts.iter().find(|a| a.id == state.account).and_then(login_of) else { return };
1815    let sent = sioul_sync::remote::send_urgent(&memory, &folder, &here.id, &login, jiff::Timestamp::now().as_second());
1816    if sent.sent > 0 || sent.failure.is_some() {
1817        let at = jiff::Timestamp::now().to_zoned(jiff::tz::TimeZone::system()).strftime("%H:%M:%S").to_string();
1818        log_send(match &sent.failure {
1819            None => format!("{at} texts to send sent to {}", state.host()),
1820            Some(f) => format!("{at} texts to send not sent to {}: {}", state.host(), failure_words(f)),
1821        });
1822    }
1823}
1824
1825/// A text was just written to send (`texts`): its small file placed and sent
1826/// now, off the caller's thread, not at the next exchange.
1827pub(crate) fn texts_now() {
1828    std::thread::spawn(|| send_texts(&here()));
1829}
1830
1831/// Where the other devices' texts to send are found beside the records'
1832/// copy: the sharing folder's small files (`texts/send/<id>.jsonl`, carried
1833/// by the sync app, or kept in step by Sioul itself), and those fetched from
1834/// its server (`remote::overlay`). Each a root holding `send/`.
1835pub(crate) fn texts_copies() -> Vec<PathBuf> {
1836    let here = share::Here::load(&state_dir());
1837    let Some(folder) = attached(&here) else { return Vec::new() };
1838    let mut out = vec![folder.join(sioul_core::texts::FOLDER)];
1839    out.extend(sioul_sync::remote::overlay(&folder).map(|cache| cache.join(sioul_core::texts::FOLDER)));
1840    out
1841}
1842
1843/// "Send everything again" (Settings ▸ Your folder and sharing): this
1844/// device's files sent to the server again, each one the server lacks or
1845/// holds otherwise, whatever was sent before; then the others' news fetched,
1846/// and an exchange. For when a sync app was late, or the server lost files.
1847/// Off the window's thread; what it did is said in the panel (`Status::again`).
1848pub(crate) fn send_again(qt: &QtThread, shared: &Arc<Shared>) {
1849    let (qt, shared) = (qt.clone(), Arc::clone(shared));
1850    std::thread::spawn(move || {
1851        let here = here();
1852        let memory = memory_path();
1853        let now = jiff::Timestamp::now().as_second();
1854        match (mirrored(), here.folder_path()) {
1855            (Some(state), Some(folder)) => match load_config().accounts.iter().find(|a| a.id == state.account).and_then(login_of) {
1856                Some(login) => {
1857                    sioul_sync::remote::again_mirror(&memory, &folder, &here.id, &login, now);
1858                }
1859                None => sioul_sync::remote::Sending::note_again(&memory, now, &format!("no-account-for:{}", state.host())),
1860            },
1861            (None, Some(_)) => {
1862                send_to_server(&here, true);
1863                fetch_from_server(&here, true);
1864            }
1865            _ => {}
1866        }
1867        exchange(&qt, &shared);
1868    });
1869}
1870
1871/// This device's own files sent to the server beside the sync app, or not:
1872/// this device's own choice, never shared. "" when kept, else why not.
1873pub(crate) fn set_send(on: bool) -> String {
1874    sioul_sync::remote::State::choose(&memory_path(), |state| state.send = (!on).then_some(false)).err().unwrap_or_default()
1875}
1876
1877// ---------------------------------------------------------------- kept in step by Sioul itself
1878
1879/// Sioul keeping the folder in step with the server itself here (`remote::MIRROR`): its state.
1880fn mirrored() -> Option<sioul_sync::remote::State> {
1881    let state = sioul_sync::remote::State::load(&memory_path());
1882    (state.mode == sioul_sync::remote::MIRROR).then_some(state)
1883}
1884
1885/// A step of Sioul keeping the folder itself (`sioul_sync::remote::step`):
1886/// `whole`, the server looked through first and what is new there brought
1887/// (at most every twenty seconds, unless `urgent`); then what changed here
1888/// sent. Never on the window's thread.
1889fn mirror_step(here: &share::Here, whole: bool, urgent: bool) {
1890    if let Some(folder) = here.folder_path() {
1891        mirror_step_for(&folder, &here.id, whole, urgent);
1892    }
1893}
1894
1895fn mirror_step_for(folder: &Path, own: &str, whole: bool, urgent: bool) {
1896    let Some(state) = mirrored().filter(|s| s.confirmed_for(folder)) else { return };
1897    let now = jiff::Timestamp::now().as_second();
1898    let whole = whole && (urgent || now - state.tried >= 20);
1899    let config = load_config();
1900    let Some(login) = config.accounts.iter().find(|a| a.id == state.account).and_then(login_of) else { return };
1901    let stepped = sioul_sync::remote::step(&memory_path(), folder, own, &login, now, whole);
1902    // On a phone, its log (adb logcat): counts and a code, never a name nor an address.
1903    if cfg!(target_os = "android") && (stepped.fetched + stepped.sent > 0 || stepped.problem.is_some()) {
1904        let code = stepped.problem.as_deref().map(|p| format!("; {}", p.split(':').next().unwrap_or_default())).unwrap_or_default();
1905        eprintln!("sioul: sharing: {} fetched, {} sent by Sioul itself{code}", stepped.fetched, stepped.sent);
1906    }
1907}
1908
1909/// An account Sioul can keep the folder with, for the panel: its id, and how to say it.
1910#[derive(Serialize)]
1911struct ServerAccount {
1912    id: String,
1913    label: String,
1914}
1915
1916/// The contacts-and-calendars accounts whose server keeps files Nextcloud's
1917/// way, without their passwords (read on the window's thread: no keyring).
1918fn server_accounts(config: &Config) -> Vec<ServerAccount> {
1919    config
1920        .accounts
1921        .iter()
1922        .filter(|a| a.is_dav() && a.auth.as_deref() != Some("google") && a.url.as_deref().is_some_and(|u| u.contains("/remote.php/")))
1923        .map(|a| {
1924            let host = sioul_sync::remote::host_of(a.url.as_deref().unwrap_or_default());
1925            let who = a.address.clone().unwrap_or_else(|| a.id.clone());
1926            ServerAccount { id: a.id.clone(), label: if who.ends_with(&host) { who } else { format!("{who} ({host})") } }
1927        })
1928        .collect()
1929}
1930
1931/// Where Sioul keeping the folder itself stands, in words: when last in step, or why not.
1932fn mirror_line(state: &sioul_sync::remote::State) -> String {
1933    let host = state.host();
1934    let (code, _) = state.said.split_once(':').unwrap_or((state.said.as_str(), ""));
1935    if state.confirmed == 0 {
1936        return say("share-server-unconfirmed", &[("host", host)]);
1937    }
1938    let why = || {
1939        tr().text(
1940            match code {
1941                "login" => "share-backup-why-login",
1942                "tls" => "share-backup-why-tls",
1943                "network" => "share-backup-why-network",
1944                "disk" => "share-backup-why-disk",
1945                "quota" => "share-backup-why-quota",
1946                _ => "share-backup-why-server",
1947            },
1948            None,
1949        )
1950    };
1951    let last = state.last.max(state.pushed);
1952    match (code, last) {
1953        ("", 0) => String::new(),
1954        ("", last) => say("share-server-last", &[("when", when(last))]),
1955        (_, 0) => say("share-server-failing-never", &[("why", why())]),
1956        (_, last) => say("share-server-failing", &[("when", when(last)), ("why", why())]),
1957    }
1958}
1959
1960/// A start or a join that did not go, in words.
1961fn server_problem(code: &str) -> String {
1962    let (kind, about) = code.split_once(':').unwrap_or((code, ""));
1963    let about = || about.to_string();
1964    match kind {
1965        "no-place" => tr().text("share-server-no-place", None),
1966        "no-files" => say("share-server-no-files", &[("host", about())]),
1967        "no-password" => tr().text("share-server-no-password", None),
1968        "login" => say("share-server-refused", &[("host", about())]),
1969        "tls" => say("share-server-tls", &[("host", about())]),
1970        "network" => say("share-server-unreachable", &[("host", about())]),
1971        "sealed-meanwhile" => tr().text("share-server-sealed-meanwhile", None),
1972        _ => say("share-server-error", &[("host", about())]),
1973    }
1974}
1975
1976/// Starts sharing through a folder of an account's files that Sioul keeps in
1977/// step itself (`remote::MIRROR`): joined when another device shares through
1978/// it already (its seal, the passphrase chosen there), else made there and
1979/// sealed (the passphrase typed twice). Off the window's thread: the result
1980/// comes through `share_started` ("" when it did, else why not).
1981pub(crate) fn start_on_server(qt: &QtThread, shared: &Arc<Shared>, account: String, place: String, passphrase: String, again: String) {
1982    let (qt, shared) = (qt.clone(), Arc::clone(shared));
1983    std::thread::spawn(move || {
1984        let started = (|| -> Result<(), String> {
1985            if passphrase.chars().count() < 12 {
1986                return Err(tr().text("share-short", None));
1987            }
1988            let config = load_config();
1989            let login = config.accounts.iter().find(|a| a.id == account).and_then(login_of).ok_or_else(|| tr().text("share-server-none", None))?;
1990            if login.password.is_none() {
1991                return Err(tr().text("share-server-no-password", None));
1992            }
1993            let opened = sioul_sync::remote::open(&login, &place).map_err(|code| server_problem(&code))?;
1994            let memory = memory_path();
1995            let mirror = sioul_sync::remote::mirror_of(&memory);
1996            // A copy of its own, new: what an earlier sharing left there goes.
1997            let _ = std::fs::remove_dir_all(&mirror);
1998            share::private_dirs(&mirror).map_err(|e| format!("{}: {e}", mirror.display()))?;
1999            let key = match &opened.seal {
2000                // Joining: the folder's seal, the passphrase chosen there.
2001                Some(seal) => {
2002                    share::write_atomically(&mirror.join("seal.toml"), seal)?;
2003                    share::key_for(&mirror, &passphrase)
2004                }
2005                None if passphrase != again => return Err(tr().text("share-differ", None)),
2006                None => share::key_for(&mirror, &passphrase),
2007            };
2008            let key = match key {
2009                Ok(key) => key,
2010                Err(share::Refused::WrongPassphrase) => {
2011                    let _ = std::fs::remove_dir_all(&mirror);
2012                    return Err(tr().text("share-wrong", None));
2013                }
2014                Err(share::Refused::Other(e)) => return Err(e),
2015            };
2016            if opened.seal.is_none() {
2017                sioul_sync::remote::create(&login, &opened, &mirror).map_err(|code| {
2018                    let _ = std::fs::remove_dir_all(&mirror);
2019                    server_problem(&code)
2020                })?;
2021            }
2022            sioul_sync::secret::save_named(share::KEY_NAME, &hex(&key)).map_err(|e| e.sentence(tr(), "Sioul"))?;
2023            if let Ok(mut held) = KEY.lock() {
2024                *held = Some(key);
2025            }
2026            sioul_sync::remote::begin(&memory, &mirror, &login, &opened, jiff::Timestamp::now().as_second())?;
2027            let state = state_dir();
2028            let _writing = HERE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
2029            let mut here = share::Here::load(&state);
2030            here.folder = Some(mirror.display().to_string());
2031            here.save(&state)
2032        })();
2033        let problem = started.err().unwrap_or_default();
2034        // The first exchange brings what the others shared there.
2035        if problem.is_empty() {
2036            exchange(&qt, &shared);
2037        }
2038        let _ = qt.queue(move |mut sioul| sioul.as_mut().share_started(QString::from(&problem)));
2039    });
2040}
2041
2042// ---------------------------------------------------------------- fetched from the server too
2043
2044/// One look or fetch at a time in this process: two exchanges' threads never fetch at once.
2045static FETCHING: Mutex<()> = Mutex::new(());
2046
2047/// The sharing folder, and what was fetched from its server read beside it
2048/// from now on in this process (`sioul_sync::remote::attach`): cheap, no network.
2049fn attached(here: &share::Here) -> Option<PathBuf> {
2050    let folder = here.folder_path()?;
2051    sioul_sync::remote::attach(&folder, &memory_path());
2052    Some(folder)
2053}
2054
2055/// The contacts-and-calendars accounts whose server may keep files
2056/// Nextcloud's way (found under `/remote.php/`: Nextcloud, Murena, ownCloud),
2057/// each with its password from the keyring as their sync has it. Google's never.
2058fn logins(config: &Config) -> Vec<sioul_sync::remote::Login> {
2059    config.accounts.iter().filter(|a| a.auth.as_deref() != Some("google")).filter_map(login_of).collect()
2060}
2061
2062fn login_of(account: &sioul_core::config::Account) -> Option<sioul_sync::remote::Login> {
2063    let url = account.url.clone().filter(|u| account.is_dav() && u.contains("/remote.php/"))?;
2064    Some(sioul_sync::remote::Login { account: account.id.clone(), url, user: account.login()?.to_string(), password: sioul_sync::secret::password(account).ok() })
2065}
2066
2067/// What the Nextcloud client says it carries, on a computer: its `nextcloud.cfg`.
2068fn nextcloud_configs() -> Vec<String> {
2069    if cfg!(target_os = "android") {
2070        return Vec::new();
2071    }
2072    let home = expand_home("~");
2073    let windows = std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("Nextcloud/nextcloud.cfg"));
2074    [Some(home.join(".config/Nextcloud/nextcloud.cfg")), Some(home.join("Library/Preferences/Nextcloud/nextcloud.cfg")), windows].into_iter().flatten().filter_map(|p| std::fs::read_to_string(p).ok()).collect()
2075}
2076
2077/// The backup's words for a phone's log (`adb logcat -s sioul`): what it did
2078/// or why not, and when; never a password, a name of yours, an address or a
2079/// content. A line the same as the last one is not said again.
2080fn log_backup(line: String) {
2081    static SAID: Mutex<String> = Mutex::new(String::new());
2082    if !cfg!(target_os = "android") {
2083        return;
2084    }
2085    if let Ok(mut said) = SAID.lock() {
2086        if *said == line {
2087            return;
2088        }
2089        said.clone_from(&line);
2090    }
2091    eprintln!("sioul: backup: {line}");
2092}
2093
2094/// A backup's code (`State::said`) in plain words, for the log: "network:murena.io" → "murena.io: could not be reached".
2095fn backup_words(said: &str) -> String {
2096    let (code, about) = said.split_once(':').unwrap_or((said, ""));
2097    let why = match code {
2098        "network" => "could not be reached",
2099        "login" => "refused the password kept for the account",
2100        "tls" => "its certificate could not be checked",
2101        "server" => "answered with an error",
2102        "quota" => "its space is full",
2103        "disk" => "could not be written here",
2104        "no-password" => "no password kept on this device for the account",
2105        "not-found" => "the folder is not there",
2106        "seal-differs" => "a folder sealed otherwise, never read",
2107        "seal-gone" => "the folder holds no seal any more",
2108        "no-account" => "no account with a Nextcloud server",
2109        "no-account-for" => "no account on that server",
2110        "no-seal" => "no seal in the folder here",
2111        "off" => "switched off here",
2112        other => other,
2113    };
2114    if about.is_empty() { why.to_string() } else { format!("{about}: {why}") }
2115}
2116
2117/// The other devices' files fetched from the server too (docs/database.md,
2118/// "Fetched from the server too"), when the sharing folder is found on one of
2119/// your accounts' servers under the same seal, and a pull is due
2120/// (`sioul_sync::remote::due`: the first at once, then at the pace of how
2121/// this device stands). Looked for at once when asked, else every six hours.
2122/// Never on the window's thread; the switch off, not a request. Each look and
2123/// each pull said in a phone's log.
2124fn fetch_from_server(here: &share::Here, urgent: bool) {
2125    let Some(folder) = attached(here) else { return };
2126    if mirrored().is_some() {
2127        return;
2128    }
2129    // One at a time; one that broke (a panic) never stops the next ones.
2130    let _fetching = match FETCHING.try_lock() {
2131        Ok(held) => held,
2132        Err(std::sync::TryLockError::Poisoned(held)) => held.into_inner(),
2133        Err(std::sync::TryLockError::WouldBlock) => return,
2134    };
2135    let memory = memory_path();
2136    let now = jiff::Timestamp::now().as_second();
2137    let mut state = sioul_sync::remote::State::load(&memory);
2138    if !state.fetching() || here.id.is_empty() {
2139        sioul_sync::remote::tidy(&memory, &folder, &here.id);
2140        log_backup("switched off here".into());
2141        return;
2142    }
2143    let config = load_config();
2144    let login = |account: &str| config.accounts.iter().find(|a| a.id == account).and_then(login_of);
2145    // Its account gone, or its password: looked for again.
2146    if state.look_due(&folder, now) || (state.confirmed_for(&folder) && login(&state.account).is_none_or(|l| l.password.is_none())) {
2147        let places = sioul_sync::remote::places(&folder, &nextcloud_configs());
2148        let logins = logins(&config);
2149        state = sioul_sync::remote::find(&memory, &folder, &logins, &places, now);
2150        log_backup(if state.confirmed_for(&folder) { format!("the folder found on {}", state.host()) } else { format!("the folder not found on {} account(s): {}", logins.len(), backup_words(&state.said)) });
2151    }
2152    if !state.confirmed_for(&folder) {
2153        return;
2154    }
2155    let phone = cfg!(target_os = "android");
2156    let asleep = phone && crate::everywhere::rest_now() == (true, false);
2157    let pace = sioul_sync::remote::Pace {
2158        phone,
2159        // On the phone's screen: the window's process only, the background service's never.
2160        shown: !crate::steps::in_service() && SHOWN.load(std::sync::atomic::Ordering::Relaxed),
2161        in_use: phone && crate::devices::others_in_use(),
2162        urgent,
2163        step: if asleep { crate::steps::STEP_ASLEEP } else { crate::steps::STEP_AWAKE },
2164    };
2165    if !sioul_sync::remote::due(&state, now, pace) {
2166        let pace_said = match (pace.shown, pace.in_use, asleep) {
2167            (true, _, _) => "Sioul on the screen: once a minute",
2168            (_, true, _) => "another device in use: once a minute",
2169            (_, false, true) => "in the background, nobody at another device, at night: every fifteen minutes",
2170            _ => "in the background, nobody at another device: every five minutes",
2171        };
2172        log_backup(format!("next pull at its pace ({pace_said})"));
2173        return;
2174    }
2175    let Some(login) = login(&state.account) else { return };
2176    let pulled = sioul_sync::remote::pull(&memory, &folder, &here.id, &login, now);
2177    let at = jiff::Timestamp::now().to_zoned(jiff::tz::TimeZone::system()).strftime("%H:%M:%S").to_string();
2178    log_backup(match &pulled.problem {
2179        None => format!("{at} pulled from {}: {} listed, {} fetched, {} bytes{}", state.host(), pulled.listed, pulled.fetched, pulled.bytes, if pulled.left > 0 { format!("; {} records too large left to the sync app", pulled.left) } else { String::new() }),
2180        Some(why) => format!("{at} pull from {} stopped: {}", state.host(), backup_words(why)),
2181    });
2182}
2183
2184/// The background step's (`steps::step`, after its exchange): the sealed
2185/// files of notes and papers waited for, fetched (`fetch_wanted`); the
2186/// window's exchange, or the next step, writes them. Nothing asked, not even
2187/// the key, while nothing waits.
2188pub(crate) fn fetch_wanted_here() {
2189    let here = here();
2190    let Some(folder) = attached(&here) else { return };
2191    if !sioul_sync::remote::wanted_due(&memory_path(), &folder, jiff::Timestamp::now().as_second()) {
2192        return;
2193    }
2194    if let Some((folder, key)) = sealed(Some(folder)) {
2195        fetch_wanted(&folder, &key);
2196    }
2197}
2198
2199/// The others' sealed notes and papers an exchange found not here yet,
2200/// neither brought by the sync app nor fetched (`sioul_sync::blobs::Wanted`),
2201/// fetched by their names from the folder's server when the folder is found
2202/// there (`sioul_sync::remote::fetch_wanted`): the next exchange writes them
2203/// (docs/database.md, "Sealed files fetched when waited for"). Never on the
2204/// window's thread, nor while an exchange runs: a dose's alarm never waits
2205/// for it. Said in a phone's log. How many came.
2206fn fetch_wanted(folder: &Path, key: &[u8; 32]) -> usize {
2207    if mirrored().is_some() {
2208        return 0;
2209    }
2210    // One at a time in this process: one fetching already brings them.
2211    static WANTING: Mutex<()> = Mutex::new(());
2212    let _one = match WANTING.try_lock() {
2213        Ok(held) => held,
2214        Err(std::sync::TryLockError::Poisoned(held)) => held.into_inner(),
2215        Err(std::sync::TryLockError::WouldBlock) => return 0,
2216    };
2217    let memory = memory_path();
2218    let now = jiff::Timestamp::now().as_second();
2219    let state = sioul_sync::remote::State::load(&memory);
2220    if !state.fetching() || !state.confirmed_for(folder) || !sioul_sync::remote::wanted_due(&memory, folder, now) {
2221        return 0;
2222    }
2223    let Some(login) = load_config().accounts.iter().find(|a| a.id == state.account).and_then(login_of) else { return 0 };
2224    let pulled = sioul_sync::remote::fetch_wanted(&memory, folder, &login, key, now, frugal_now());
2225    let at = jiff::Timestamp::now().to_zoned(jiff::tz::TimeZone::system()).strftime("%H:%M:%S").to_string();
2226    log_backup(match &pulled.problem {
2227        None => format!(
2228            "{at} sealed files waited for: {} fetched from {}, {} bytes{}{}",
2229            pulled.fetched,
2230            state.host(),
2231            pulled.bytes,
2232            if pulled.paced > 0 { format!("; {} large left for later on this connection", pulled.paced) } else { String::new() },
2233            if pulled.held > 0 { format!("; {} did not come whole, asked again later", pulled.held) } else { String::new() }
2234        ),
2235        Some(why) => format!("{at} sealed files waited for: fetching from {} stopped: {}", state.host(), backup_words(why)),
2236    });
2237    pulled.fetched
2238}
2239
2240/// Fetched from the server too, as the panel shows it.
2241#[derive(Serialize)]
2242struct Backup {
2243    /// Shown: sharing is on.
2244    shown: bool,
2245    /// Where things stand, in words.
2246    line: String,
2247    /// The switch: fetching from the server, on this device.
2248    on: bool,
2249    /// The place given by hand; "" when looked for by itself.
2250    given: String,
2251    /// Found under the same seal.
2252    found: bool,
2253    /// The second switch: this device's own files sent there too, beside the sync app.
2254    send: bool,
2255    /// Its words, with the server's name when it is known.
2256    send_switch: String,
2257    /// Where the sending stands, in words: when last, or why not.
2258    send_line: String,
2259}
2260
2261fn backup(on: bool, folder: &Path) -> Backup {
2262    let state = sioul_sync::remote::State::load(&memory_path());
2263    let line = if on { backup_line(&state, folder) } else { String::new() };
2264    let host = state.host();
2265    let send_switch = if host.is_empty() { tr().text("share-send-switch-none", None) } else { say("share-send-switch", &[("host", host)]) };
2266    let send_line = if on && state.fetching() { send_line(&state, &sioul_sync::remote::Sending::load(&memory_path()), folder) } else { String::new() };
2267    Backup { shown: on, line, on: state.fetching(), given: state.given.clone(), found: state.confirmed_for(folder), send: state.sending(), send_switch, send_line }
2268}
2269
2270/// A send's code in words, for the panel: "network" → "the server could not be reached".
2271fn why_words(code: &str) -> String {
2272    tr().text(
2273        match code {
2274            "login" => "share-backup-why-login",
2275            "tls" => "share-backup-why-tls",
2276            "network" => "share-backup-why-network",
2277            "disk" => "share-backup-why-disk",
2278            "quota" => "share-backup-why-quota",
2279            _ => "share-backup-why-server",
2280        },
2281        None,
2282    )
2283}
2284
2285/// Where sending this device's files beside the sync app stands, in words.
2286fn send_line(state: &sioul_sync::remote::State, sending: &sioul_sync::remote::Sending, folder: &Path) -> String {
2287    let host = state.host();
2288    if !state.sending() {
2289        return if host.is_empty() { String::new() } else { say("share-send-off", &[("host", host)]) };
2290    }
2291    if !state.confirmed_for(folder) {
2292        return tr().text("share-send-waiting", None);
2293    }
2294    let (code, _) = sending.said.split_once(':').unwrap_or((sending.said.as_str(), ""));
2295    let line = match (code, sending.last) {
2296        ("", 0) => say("share-send-soon", &[("host", host)]),
2297        ("", last) => say("share-send-on", &[("host", host), ("when", when(last))]),
2298        (code, 0) => say("share-send-failing-never", &[("host", host), ("why", why_words(code))]),
2299        (code, last) => say("share-send-failing", &[("host", host), ("when", when(last)), ("why", why_words(code))]),
2300    };
2301    // What failed, in detail: which file, how far it went, when it is tried again.
2302    match sending.failure.as_ref().filter(|_| !sending.said.is_empty()) {
2303        Some(failure) => [line, failure_line(failure, sending.next_try())].join(" "),
2304        None => line,
2305    }
2306}
2307
2308/// A failed send in detail, in words: "Your records did not go at 19:24:
2309/// 1.1 MB of 2.5 MB went in 216 s, slower than Sioul waits for. Tried again at 19:39."
2310fn failure_line(failure: &sioul_sync::remote::Failure, next: i64) -> String {
2311    let size = |bytes: u64| sioul_core::view::size(tr(), usize::try_from(bytes).unwrap_or(usize::MAX));
2312    let detail = match failure.kind.as_str() {
2313        "timeout" => say("share-send-detail-timeout", &[("total", size(failure.total)), ("seconds", failure.seconds.to_string())]),
2314        "stalled" => say("share-send-detail-stalled", &[("total", size(failure.total))]),
2315        "network" if failure.sent > 0 => say("share-send-detail-cut", &[("total", size(failure.total))]),
2316        "out-of-time" => tr().text("share-send-detail-out-of-time", None),
2317        "unreachable" | "network" => tr().text("share-send-detail-unreachable", None),
2318        kind => format!("{}.", why_words(kind)),
2319    };
2320    let file = match failure.file.as_str() {
2321        "records" => "share-send-file-records",
2322        "entry" => "share-send-file-entry",
2323        "notes" => "share-send-file-notes",
2324        "claim" => "share-send-file-claim",
2325        "texts" => "share-send-file-texts",
2326        "sealed" => "share-send-file-sealed",
2327        _ => "",
2328    };
2329    let next = if next > 0 { say("share-send-next", &[("when", when(next))]) } else { String::new() };
2330    let said = if file.is_empty() { say("share-send-failure", &[("when", when(failure.at)), ("detail", detail)]) } else { say("share-send-failure-file", &[("file", tr().text(file, None)), ("when", when(failure.at)), ("detail", detail)]) };
2331    [said, next].join(" ").trim().to_string()
2332}
2333
2334/// What "Send everything again" did, in words: "Sent 14 files to cloud.example.org at 10:42.", or why not.
2335fn again_line(again: &sioul_sync::remote::Again, host: &str) -> String {
2336    if again.at == 0 {
2337        return String::new();
2338    }
2339    let (code, about) = again.said.split_once(':').unwrap_or((again.said.as_str(), ""));
2340    let host = if about.is_empty() { host.to_string() } else { about.to_string() };
2341    let at = when(again.at);
2342    let counted = |id: &str, count: usize| {
2343        let mut args = sioul_core::i18n::args();
2344        args.set("count", count as i64);
2345        args.set("host", host.clone());
2346        args.set("when", at.clone());
2347        tr().text(id, Some(&args))
2348    };
2349    match code {
2350        "" if again.sent > 0 => counted("share-send-again-sent", again.sent),
2351        "" => counted("share-send-again-same", again.same),
2352        "off" => tr().text("share-send-again-off", None),
2353        "not-confirmed" | "mirror" | "no-seal" | "seal-gone" | "seal-differs" => tr().text("share-send-again-not-found", None),
2354        "no-account-for" => say("share-send-again-no-account", &[("host", host)]),
2355        code => say("share-send-again-failed", &[("host", host), ("when", at), ("why", why_words(code))]),
2356    }
2357}
2358
2359/// Where the backup stands, in words: from where, and since when, or why not.
2360fn backup_line(state: &sioul_sync::remote::State, folder: &Path) -> String {
2361    let host = state.host();
2362    let (code, about) = state.said.split_once(':').unwrap_or((state.said.as_str(), ""));
2363    let about = || about.to_string();
2364    if !state.fetching() {
2365        return if host.is_empty() { tr().text("share-backup-off-none", None) } else { say("share-backup-off", &[("host", host)]) };
2366    }
2367    if state.confirmed_for(folder) {
2368        let why = |code: &str| {
2369            tr().text(
2370                match code {
2371                    "login" => "share-backup-why-login",
2372                    "tls" => "share-backup-why-tls",
2373                    "network" => "share-backup-why-network",
2374                    "disk" => "share-backup-why-disk",
2375                    _ => "share-backup-why-server",
2376                },
2377                None,
2378            )
2379        };
2380        return match (code, state.last) {
2381            ("", 0) => say("share-backup-soon", &[("host", host)]),
2382            // Records too large to fetch: the sync app brings them, and is asked again (`State::left`).
2383            ("", last) if !state.left.is_empty() => format!("{} {}", say("share-backup-on", &[("host", host), ("when", when(last))]), tr().text("share-backup-left", None)),
2384            ("", last) => say("share-backup-on", &[("host", host), ("when", when(last))]),
2385            (code, 0) => say("share-backup-failing-never", &[("host", host), ("why", why(code))]),
2386            (code, last) => say("share-backup-failing", &[("host", host), ("when", when(last)), ("why", why(code))]),
2387        };
2388    }
2389    if state.looked == 0 || state.folder != folder.display().to_string() || state.asked > state.looked {
2390        return tr().text("share-backup-looking", None);
2391    }
2392    match code {
2393        "no-account" => tr().text("share-backup-no-account", None),
2394        "no-account-for" => say("share-backup-no-account-for", &[("host", about())]),
2395        "not-found" => say("share-backup-not-found", &[("hosts", about())]),
2396        "seal-differs" => say("share-backup-seal-differs", &[("host", about())]),
2397        "seal-gone" => say("share-backup-seal-gone", &[("host", about())]),
2398        "login" => say("share-backup-login", &[("host", about())]),
2399        "no-password" => say("share-backup-no-password", &[("account", about())]),
2400        "no-seal" => String::new(),
2401        _ => say("share-backup-unreachable", &[("host", about())]),
2402    }
2403}
2404
2405/// The other devices' files fetched from the server too, or not: this
2406/// device's own choice, never shared. "" when kept, else why not.
2407pub(crate) fn set_backup(on: bool) -> String {
2408    let now = jiff::Timestamp::now().as_second();
2409    let chosen = sioul_sync::remote::State::choose(&memory_path(), |state| {
2410        state.on = (!on).then_some(false);
2411        // On again: looked for at once.
2412        if on {
2413            state.asked = now;
2414        }
2415    });
2416    chosen.err().unwrap_or_default()
2417}
2418
2419/// The sharing folder's place on the server, given by hand ("Documents/Sioul",
2420/// or its address; "" to look for it by itself): looked at with the next
2421/// exchange, that place alone. "" when kept, else why not.
2422pub(crate) fn set_backup_place(place: &str) -> String {
2423    let now = jiff::Timestamp::now().as_second();
2424    let chosen = sioul_sync::remote::State::choose(&memory_path(), |state| {
2425        state.given = place.trim().to_string();
2426        state.asked = now;
2427    });
2428    chosen.err().unwrap_or_default()
2429}
2430
2431#[cfg(test)]
2432mod tests {
2433    use super::*;
2434
2435    /// A phone's log says why notes and papers wait, counted, never named:
2436    /// a sealed file not here yet apart from a backoff (`Outcome::waits`).
2437    #[test]
2438    fn the_log_says_why_notes_wait() {
2439        assert_eq!(waits_said(&std::collections::BTreeMap::new()), "");
2440        let waits: std::collections::BTreeMap<String, usize> = [("no-blob".to_string(), 2), ("backoff".to_string(), 1)].into();
2441        assert_eq!(waits_said(&waits), " (backoff 1, no-blob 2)");
2442    }
2443
2444    /// A dose's alarm (`health::alarm_decide`) asks for the others' news
2445    /// before it decides (`exchange_here(true)`): on a phone whose own pull
2446    /// works (`pulls_alone`), a pull begun since the alarm, gone through, else
2447    /// the sync app asked and waited for as before (`remote::alarm_news`); the
2448    /// pull failing, or the backup off, the sync app's twenty seconds as
2449    /// before; the background step, its pull alone. What the doses then know,
2450    /// in sioul-sync's `remote` tests: two alarms at once, an alarm while the
2451    /// window pulls, a pull that fails, one too slow.
2452    #[test]
2453    fn a_doses_alarm_waits_for_the_pull_alone_while_it_works() {
2454        assert_eq!(news(true, true, false, true, true, true), News::Alarm, "an alarm: a pull since it began, else the sync app (`remote::alarm_news`)");
2455        assert_eq!(news(true, true, false, false, true, true), News::Alarm, "the last pull failed: an alarm tries one all the same, the sync app asked when it fails");
2456        assert_eq!(news(true, true, false, true, true, false), News::Pull, "the background step: its pull alone");
2457        assert_eq!(news(true, true, false, false, true, false), News::SyncApp, "the background step, its pull failing: as before");
2458        assert_eq!(news(true, true, false, false, false, true), News::SyncApp, "no folder found on a server: the sync app, as before");
2459        assert_eq!(news(true, true, true, false, false, true), News::None, "Sioul keeping the folder: the server looked through already");
2460        assert_eq!(news(true, false, false, false, false, true), News::Computer);
2461        assert_eq!(news(false, true, false, true, true, false), News::None, "a button pressed: nothing waited for");
2462    }
2463
2464    /// F9: on a phone put away, its claims said closed, the minute's tick
2465    /// renews none (it would say it open in a pocket); back, or on a computer, each minute.
2466    #[test]
2467    fn a_phone_put_away_renews_no_claim() {
2468        assert!(!renews(true, true));
2469        assert!(renews(true, false) && renews(false, true) && renews(false, false));
2470    }
2471
2472    #[test]
2473    fn keeping_the_folder_says_in_words_where_it_stands() {
2474        use sioul_sync::remote::{MIRROR, State};
2475        let kept = State { mode: MIRROR.into(), place: "Documents/Sioul".into(), url: "https://murena.io/remote.php/dav/files/jane/Documents/Sioul/".into(), confirmed: 1, ..State::default() };
2476        assert_eq!(mirror_line(&kept), "", "nothing to say before its first step");
2477        let stepped = State { last: jiff::Timestamp::now().as_second(), ..kept.clone() };
2478        assert!(!mirror_line(&stepped).is_empty());
2479        for said in ["network:murena.io", "login:murena.io", "tls:murena.io", "server:murena.io", "quota:murena.io", "disk:murena.io"] {
2480            let line = mirror_line(&State { said: said.into(), ..stepped.clone() });
2481            assert!(!line.is_empty() && !line.contains("share-"), "{said}: {line}");
2482        }
2483        assert!(mirror_line(&State { confirmed: 0, said: "seal-differs:murena.io".into(), ..kept }).contains("murena.io"));
2484        for code in ["no-place", "no-files:murena.io", "no-password:murena", "login:murena.io", "tls:murena.io", "network:murena.io", "server:murena.io", "sealed-meanwhile"] {
2485            let words = server_problem(code);
2486            assert!(!words.is_empty() && !words.contains("share-"), "{code}: {words}");
2487        }
2488    }
2489
2490    /// A send that failed, said in words, whichever way it failed: the file,
2491    /// how long it tried, when it is tried again; never a message's key.
2492    #[test]
2493    fn a_failed_send_is_said_in_words() {
2494        use sioul_sync::remote::Failure;
2495        let at = jiff::Timestamp::now().as_second();
2496        let timeout = Failure { kind: "timeout".into(), file: "records".into(), seconds: 216, sent: 1_081_344, total: 2_530_934, words: "timeout: global".into(), at };
2497        let line = failure_line(&timeout, at + 60);
2498        assert!(line.contains("216") && !line.contains("share-"), "{line}");
2499        for kind in ["stalled", "network", "unreachable", "out-of-time", "tls", "login", "quota", "server"] {
2500            for file in ["records", "entry", "notes", "claim", "texts", "sealed", ""] {
2501                let line = failure_line(&Failure { kind: kind.into(), file: file.into(), ..timeout.clone() }, 0);
2502                assert!(!line.is_empty() && !line.contains("share-"), "{kind} {file}: {line}");
2503            }
2504        }
2505        assert_eq!(failure_words(&timeout), "records: timeout after 216 s, 1081344 of 2530934 bytes handed to the network (timeout: global)");
2506    }
2507
2508    #[test]
2509    fn the_backup_says_in_words_what_it_did() {
2510        assert_eq!(backup_words("network:murena.io"), "murena.io: could not be reached");
2511        assert_eq!(backup_words("no-account"), "no account with a Nextcloud server");
2512        assert_eq!(backup_words("seal-differs:murena.io"), "murena.io: a folder sealed otherwise, never read");
2513    }
2514
2515    #[test]
2516    fn where_the_backup_stands_in_words() {
2517        use sioul_sync::remote::State;
2518        let folder = Path::new("/storage/emulated/0/Documents/Sioul");
2519        let here = folder.display().to_string();
2520        let found = State { folder: here.clone(), url: "https://murena.io/remote.php/dav/files/jane/Documents/Sioul/".into(), account: "murena".into(), confirmed: 1, looked: 1, ..State::default() };
2521        assert!(backup_line(&found, folder).contains("murena.io"));
2522        let fetched = State { last: jiff::Timestamp::now().as_second(), ..found.clone() };
2523        assert!(backup_line(&fetched, folder).contains("murena.io"));
2524        let failing = State { said: "network:murena.io".into(), ..fetched.clone() };
2525        assert_ne!(backup_line(&failing, folder), backup_line(&fetched, folder));
2526        let left = State { left: [("desk-9.jsonl".to_string(), 70 << 20)].into(), ..fetched.clone() };
2527        assert!(backup_line(&left, folder).ends_with(&tr().text("share-backup-left", None)), "a round left to the sync app, said");
2528        let off = State { on: Some(false), ..fetched.clone() };
2529        assert!(backup_line(&off, folder).contains("murena.io"));
2530        let looking = State { folder: here.clone(), ..State::default() };
2531        assert_eq!(backup_line(&looking, folder), tr().text("share-backup-looking", None));
2532        for said in ["no-account", "not-found:murena.io", "seal-differs:murena.io", "seal-gone:murena.io", "login:murena.io", "no-password:murena", "network:murena.io", "no-account-for:cloud.example.org"] {
2533            let state = State { folder: here.clone(), looked: 1, said: said.into(), ..State::default() };
2534            let line = backup_line(&state, folder);
2535            assert!(!line.is_empty() && !line.contains("share-backup"), "{said}: {line}");
2536        }
2537    }
2538
2539    #[test]
2540    fn documents_folders() {
2541        let base = std::env::temp_dir().join(format!("sioul-share-documents-{}", std::process::id()));
2542        let root = base.join("Nextcloud");
2543        std::fs::create_dir_all(root.join("Documents")).unwrap();
2544        let roots = vec![root.clone()];
2545        assert!(in_documents(&root.join("Documents").join("Sioul")));
2546        assert!(in_documents(&root.join("documents").join("Sioul")));
2547        assert!(!in_documents(&root.join("Sioul")));
2548        assert!(beside_documents(&root.join("Sioul"), &roots));
2549        assert!(!beside_documents(&root.join("Documents").join("Sioul"), &roots));
2550        // A sync without a Documents folder (Syncthing, Dropbox): nothing to say.
2551        let other = base.join("Sync");
2552        std::fs::create_dir_all(&other).unwrap();
2553        assert!(!beside_documents(&other.join("Sioul"), &[other.clone()]));
2554        let _ = std::fs::remove_dir_all(&base);
2555    }
2556
2557    /// An event or a contact changed here and on its server, yours kept among
2558    /// the earlier versions (`dav::set_aside`): listed by its title, put back
2559    /// only inside Sioul's calendars and address books.
2560    #[test]
2561    fn your_calendars_and_contacts_set_aside_are_named_and_kept_inside() {
2562        let root = std::env::temp_dir().join(format!("sioul-kept-items-{}", std::process::id()));
2563        let _ = std::fs::remove_dir_all(&root);
2564        let event = "data/calendars/work/personal/dentist.ics";
2565        sioul_sync::history::keep_bytes(&root, sioul_sync::history::ACCOUNTS, event, b"BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Dentist\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n", 1_790_000_000_000).unwrap();
2566        let card = "data/contacts/work/friends/jane.vcf";
2567        sioul_sync::history::keep_bytes(&root, sioul_sync::history::ACCOUNTS, card, b"BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Jane Doe\r\nEND:VCARD\r\n", 1_790_000_000_000).unwrap();
2568        let named = |file: &str| shown_kept(&root, sioul_sync::history::ACCOUNTS, file, &sioul_sync::history::versions(&root, sioul_sync::history::ACCOUNTS, file));
2569        assert_eq!((named(event).as_str(), named(card).as_str()), ("Dentist", "Jane Doe"));
2570        assert_eq!(shown_kept(&root, "settings", "config/config.toml", &[]), "config.toml");
2571        assert!(account_item(event).is_some_and(|p| p.ends_with("calendars/work/personal/dentist.ics")));
2572        assert!(account_item("data/../config/config.toml").is_none() && account_item("data/drafts/reply.eml").is_none() && account_item("config/config.toml").is_none());
2573        let _ = std::fs::remove_dir_all(&root);
2574    }
2575
2576    /// On a phone, what you said of the notes folder wins over the guess,
2577    /// either way; until you said, the guess (the same top folder).
2578    #[test]
2579    fn on_a_phone_your_word_on_the_notes_folder_wins_over_the_guess() {
2580        assert!(carried_on_phone(None, true) && !carried_on_phone(None, false));
2581        assert!(!carried_on_phone(Some(false), true), "not carried, as you said, though it looked so");
2582        assert!(carried_on_phone(Some(true), false), "carried, as you said, though it did not look so");
2583    }
2584
2585    /// R12 of the review of 10 October 2026: your answer on a phone's notes
2586    /// folder is for that folder: the notes moved, it is asked again, the
2587    /// guess holding meanwhile; an answer from before, untied, counts for
2588    /// the notes folder of then.
2589    #[test]
2590    fn the_answer_on_the_notes_folder_is_for_that_folder() {
2591        let notes = Path::new(STORAGE).join("Notes");
2592        let moved = Path::new(STORAGE).join("Documents/Notes");
2593        let mut here = share::Here { notes_carried: Some(false), ..share::Here::default() };
2594        assert_eq!(here.notes_said(&notes), Some(false), "an answer from before: for the notes of then");
2595        here.notes_carried_for = Some(notes.display().to_string());
2596        assert_eq!(here.notes_said(&notes), Some(false));
2597        assert_eq!(here.notes_said(&moved), None, "moved: asked again");
2598        assert!(carried_on_phone(here.notes_said(&moved), true), "the guess meanwhile: in the sharing folder's top folder, carried");
2599        assert!(!carried_on_phone(here.notes_said(&notes), true), "where it was said: as said");
2600    }
2601
2602    #[test]
2603    fn notes_carried_twice_are_refused() {
2604        let home = std::env::temp_dir().join(format!("sioul-share-carried-{}", std::process::id()));
2605        let roots = vec![home.join("Nextcloud")];
2606        assert!(carried(&home.join("Nextcloud").join("Notes"), &roots));
2607        assert!(!carried(&home.join("Notes"), &roots));
2608        // On a phone: notes in the top folder of its storage the sharing folder is in, whatever name the storage goes by.
2609        let storage = Path::new(STORAGE);
2610        assert!(same_top(&storage.join("Documents/Notes"), &storage.join("Documents/Sioul"), storage));
2611        assert!(!same_top(&storage.join("Notes"), &storage.join("Documents/Sioul"), storage));
2612        assert!(!same_top(Path::new("/data/user/0/sioul/files/Notes"), &storage.join("Documents/Sioul"), storage));
2613        assert!(same_top(&on_storage(Path::new("/sdcard/Documents/Notes")), &on_storage(&storage.join("Documents/Sioul")), storage));
2614        assert!(same_top(&on_storage(Path::new("/storage/self/primary/Documents/Notes")), &on_storage(Path::new("/sdcard/Documents/Sioul")), storage));
2615        // Syncthing's folders and Dropbox's, wherever they are.
2616        let xml = "<configuration>\n<folder id=\"a\" label=\"Notes\" path=\"~/Writing &amp; Notes\" type=\"sendreceive\">\n<device id=\"X\"></device>\n</folder>\n<folder path=\"/data/Sync\" id=\"b\"></folder>\n<folderish/>\n</configuration>";
2617        assert_eq!(syncthing_folders(xml), ["~/Writing & Notes", "/data/Sync"]);
2618        let mut boxes = dropbox_folders(r#"{"personal": {"path": "/home/me/Boxes/Dropbox", "host": 1}, "business": {"path": "/home/me/Work"}}"#);
2619        boxes.sort();
2620        assert_eq!(boxes, ["/home/me/Boxes/Dropbox", "/home/me/Work"]);
2621        assert!(dropbox_folders("not json").is_empty());
2622    }
2623
2624    #[test]
2625    fn folders_listed() {
2626        let base = std::env::temp_dir().join(format!("sioul-folders-in-{}", std::process::id()));
2627        for dir in ["Documents/Sioul", "Documents/.hidden", "Documents/archive", "Documents/Bills"] {
2628            std::fs::create_dir_all(base.join(dir)).unwrap();
2629        }
2630        std::fs::write(base.join("Documents/Sioul/seal.toml"), "").unwrap();
2631        std::fs::write(base.join("Documents/notes.txt"), "").unwrap();
2632        let listed: serde_json::Value = serde_json::from_str(&folders_in(&base.join("Documents").display().to_string())).unwrap();
2633        let names: Vec<&str> = listed["folders"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect();
2634        assert_eq!(names, ["archive", "Bills", "Sioul"]);
2635        assert_eq!(listed["folders"][2]["sealed"], true);
2636        assert_eq!(listed["folders"][0]["sealed"], false);
2637        assert_eq!(listed["parent"], base.display().to_string());
2638        assert_eq!(listed["readable"], true);
2639        let missing: serde_json::Value = serde_json::from_str(&folders_in(&base.join("nowhere").display().to_string())).unwrap();
2640        assert_eq!(missing["readable"], false);
2641        let _ = std::fs::remove_dir_all(&base);
2642    }
2643}