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