Skip to main content

sioul_app/
join.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! A device joining your other devices through your Nextcloud's own sign-in
5//! (docs/database.md, "Joining through your Nextcloud's sign-in"): nothing to
6//! type but the passphrase, no sync app needed. Offered by the Porch's card
7//! on a device that does not share yet ("Join your other devices") and in
8//! Settings ▸ Your folder and sharing (`qml/JoinPanel.qml`), on a phone and
9//! on a computer alike.
10//!
11//! 1. **Signed in** (`sign_in`): the server's sign-in page opens in the
12//!    browser (`sioul_sync::loginflow`; Murena's, or another Nextcloud by its
13//!    address); the app password it gives goes to the keyring at once
14//!    (`loginflow::keep`), never to a file nor a log.
15//! 2. **The folder found** there (`remote::seek`): Documents/Sioul, Sioul, or
16//!    a folder in Documents; proposed, or a place you type; none found, every
17//!    place looked at: you may start sharing there, as before; the look
18//!    incomplete (a slow or failing server): look again, or type its place,
19//!    never a new sharing offered.
20//! 3. **Joined** (`join`): the passphrase typed once, checked against the
21//!    seal (twice for a new sharing); Sioul keeps its own copy of the folder
22//!    in step with that login (`remote::begin_signed_in`); a first exchange
23//!    runs at once and brings the shared settings, accounts and hours. A
24//!    sharing started there instead is said as such, never as joined.
25//! 4. **The contacts and calendars** (`bind_agenda`), once the first
26//!    exchange brought everything (`complete_since`), else at a later one
27//!    (`agenda_later`): an account of yours on that server (its address's
28//!    origin) with that login, come with the settings or here already, is
29//!    used, given the homes found there and the app password when it has none
30//!    and it opens it there; else one is made, its homes found on that server alone.
31//! 5. **Said** (`Stand`): who this device shares with now, what came, and
32//!    what still needs a sign-in here; on a phone, whether it replaces
33//!    another phone (`replacing`).
34//!
35//! Cancel stops it at any step before the join: an app password already given
36//! is given back to the server (`loginflow::revoke`) and forgotten here.
37
38use crate::backend::{QtThread, Shared, json, load_config, say, tr};
39use cxx_qt_lib::QString;
40use serde::{Deserialize, Serialize};
41use sioul_core::config::{self, Account, AccountKind, Config, state_dir};
42use sioul_sync::loginflow::{self, Grant, Refusal};
43use sioul_sync::{remote, secret};
44use std::sync::atomic::{AtomicBool, Ordering};
45use std::sync::{Arc, Mutex};
46
47/// Where a sign-in and its joining stand, for the window (`status`).
48#[derive(Serialize, Clone, Debug, Default, PartialEq, Eq)]
49struct Stand {
50    /// "" nothing under way; "signing": the browser open, waited for;
51    /// "looking": signed in, the folder looked for; "found": places found;
52    /// "none": none found, every place looked at (a new sharing may start);
53    /// "unchecked": the look could not go through (look again, or a place
54    /// typed, never a new sharing); "joining": the first exchange running;
55    /// "joined": done, said below; "failed": the sign-in did not go (`problem`).
56    step: String,
57    /// The server, by its name: "murena.io".
58    host: String,
59    /// "Signed in to murena.io as jane."
60    signed: String,
61    /// Where the folder was found there: "Documents/Sioul".
62    places: Vec<String>,
63    /// Why not, in words.
64    problem: String,
65    /// What the join did and brought, in sentences.
66    lines: Vec<String>,
67    /// What still needs a sign-in on this device, one line an account.
68    needs: Vec<String>,
69}
70
71static STAND: Mutex<Stand> = Mutex::new(Stand { step: String::new(), host: String::new(), signed: String::new(), places: Vec::new(), problem: String::new(), lines: Vec::new(), needs: Vec::new() });
72
73/// The sign-in's grant, between the sign-in and the join: in memory only
74/// (zeroed when dropped), its app password in the keyring besides.
75static GRANT: Mutex<Option<Grant>> = Mutex::new(None);
76
77/// The page of the sign-in under way, to open again: its address holds the
78/// sign-in's own key, never logged.
79static PAGE: Mutex<Option<loginflow::Secret>> = Mutex::new(None);
80
81/// Cancel pressed.
82static STOP: AtomicBool = AtomicBool::new(false);
83
84/// A sign-in, or a join, running: one at a time.
85static RUNNING: AtomicBool = AtomicBool::new(false);
86
87/// `RUNNING` let go when its thread ends, however it ends (a panic too):
88/// the next sign-in is never refused for one that is over.
89struct Running;
90
91impl Drop for Running {
92    fn drop(&mut self) {
93        RUNNING.store(false, Ordering::SeqCst);
94    }
95}
96
97fn stand() -> std::sync::MutexGuard<'static, Stand> {
98    STAND.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
99}
100
101fn set(change: impl FnOnce(&mut Stand)) {
102    change(&mut stand());
103}
104
105fn grant() -> std::sync::MutexGuard<'static, Option<Grant>> {
106    GRANT.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
107}
108
109/// The window told: it reads `status` again.
110fn told(qt: &QtThread) {
111    let _ = qt.queue(|mut sioul| sioul.as_mut().join_changed());
112}
113
114/// Where things stand, as JSON (`Stand`).
115pub(crate) fn status() -> String {
116    json(&*stand())
117}
118
119/// What Sioul says it is on the server's sign-in page and in its list of
120/// devices and sessions: "Sioul (Android)".
121fn client() -> String {
122    let system = if cfg!(target_os = "android") {
123        "Android"
124    } else if cfg!(windows) {
125        "Windows"
126    } else if cfg!(target_os = "macos") {
127        "macOS"
128    } else {
129        "Linux"
130    };
131    format!("Sioul ({system})")
132}
133
134/// Why a sign-in did not go, in words.
135fn refusal_words(refusal: &Refusal) -> String {
136    match refusal {
137        Refusal::Address => tr().text("join-why-address", None),
138        Refusal::NotHttps(host) => say("join-why-not-https", &[("host", host.clone())]),
139        Refusal::Unreachable(host) => say("join-why-unreachable", &[("host", host.clone())]),
140        Refusal::Tls(host) => say("join-why-tls", &[("host", host.clone())]),
141        Refusal::NotNextcloud(host) => say("join-why-not-nextcloud", &[("host", host.clone())]),
142        Refusal::Elsewhere(host, other) => say("join-why-elsewhere", &[("host", host.clone()), ("other", other.clone())]),
143        Refusal::Server(host) => say("join-why-server", &[("host", host.clone())]),
144        Refusal::TimedOut => tr().text("join-why-timed-out", None),
145        Refusal::Cancelled => String::new(),
146    }
147}
148
149/// An app password not used after all: given back to its server, forgotten here.
150fn give_back(grant: &Grant) {
151    let _ = loginflow::revoke(grant);
152    let _ = loginflow::forget(&grant.login, &grant.host());
153}
154
155/// Signs in to `typed` ("murena", or another Nextcloud's address), off the
156/// window's thread: its page opened in the browser (`open_url`), the poll
157/// asked until you signed in there, Cancel or twenty minutes; then the
158/// sharing folder looked for there. `join_changed` says each step.
159pub(crate) fn sign_in(qt: &QtThread, typed: String) {
160    if RUNNING.swap(true, Ordering::SeqCst) {
161        return;
162    }
163    STOP.store(false, Ordering::SeqCst);
164    // An earlier sign-in not joined: given back first.
165    if let Some(earlier) = grant().take() {
166        std::thread::spawn(move || give_back(&earlier));
167    }
168    let typed = if typed.trim().is_empty() || typed.trim().eq_ignore_ascii_case("murena") { loginflow::MURENA.to_string() } else { typed.trim().to_string() };
169    set(|s| *s = Stand { step: "signing".into(), host: remote::host_of(&typed), ..Stand::default() });
170    told(qt);
171    let qt = qt.clone();
172    std::thread::spawn(move || {
173        let running = Running;
174        let outcome = (|| -> Result<(), String> {
175            let base = loginflow::address_of(&typed).map_err(|r| refusal_words(&r))?;
176            let flow = loginflow::begin(&base, &client()).map_err(|r| refusal_words(&r))?;
177            set(|s| s.host = flow.host());
178            if let Ok(mut page) = PAGE.lock() {
179                *page = Some(loginflow::secret(flow.page()));
180            }
181            let page = flow.page().to_string();
182            let _ = qt.queue(move |mut sioul| sioul.as_mut().open_url(QString::from(&page)));
183            told(&qt);
184            let signed = loginflow::wait(&flow, &STOP, loginflow::LIMIT, loginflow::EVERY);
185            if let Ok(mut page) = PAGE.lock() {
186                *page = None;
187            }
188            let signed = signed.map_err(|r| refusal_words(&r))?;
189            if STOP.load(Ordering::SeqCst) {
190                give_back(&signed);
191                return Err(String::new());
192            }
193            // In the keyring at once, under that login on that server: the sharing's own.
194            loginflow::keep(&signed).map_err(|e| e.sentence(tr(), &signed.host()))?;
195            set(|s| {
196                s.step = "looking".into();
197                s.signed = say("join-signed", &[("host", signed.host()), ("login", signed.login.clone())]);
198            });
199            told(&qt);
200            let sought = remote::seek(&signed.login_for(""));
201            if STOP.load(Ordering::SeqCst) {
202                give_back(&signed);
203                return Err(String::new());
204            }
205            set(|s| looked(s, &signed.host(), sought));
206            *grant() = Some(signed);
207            Ok(())
208        })();
209        if let Err(problem) = outcome {
210            set(|s| {
211                s.step = if problem.is_empty() { String::new() } else { "failed".into() };
212                s.problem = problem;
213            });
214        }
215        drop(running);
216        // Cancel pressed as it ended: what came is given back.
217        if STOP.load(Ordering::SeqCst)
218            && let Some(late) = grant().take()
219        {
220            give_back(&late);
221            set(|s| *s = Stand::default());
222        }
223        told(&qt);
224    });
225}
226
227/// What the look found, said (`Stand`): places found, proposed; none found,
228/// every place looked at, a new sharing may start there; the look
229/// incomplete, or not possible at all (a slow server, a failing listing):
230/// "could not look", look again or type a place, never a new sharing
231/// offered where the folder may be (the review of the join, M3).
232fn looked(stand: &mut Stand, host: &str, sought: Result<remote::Sought, String>) {
233    stand.places.clear();
234    stand.problem.clear();
235    match sought {
236        Ok(sought) if !sought.found.is_empty() => {
237            stand.step = "found".into();
238            stand.places = sought.found;
239        }
240        Ok(sought) if sought.complete => stand.step = "none".into(),
241        Ok(_) => {
242            stand.step = "unchecked".into();
243            stand.problem = say("join-unchecked", &[("host", host.to_string())]);
244        }
245        Err(code) => {
246            stand.step = "unchecked".into();
247            stand.problem = [say("join-unchecked", &[("host", host.to_string())]), crate::share::server_problem(&code)].join(" ");
248        }
249    }
250}
251
252/// "Look again", the look having found nothing it could trust (`unchecked`)
253/// or not the place you expected: the server looked through again with the
254/// sign-in held, off the window's thread.
255pub(crate) fn look_again(qt: &QtThread) {
256    let Some(held) = grant().as_ref().map(|g| Grant::of(&g.server, &g.login, g.password())) else { return };
257    if RUNNING.swap(true, Ordering::SeqCst) {
258        return;
259    }
260    set(|s| {
261        s.step = "looking".into();
262        s.problem.clear();
263    });
264    told(qt);
265    let qt = qt.clone();
266    std::thread::spawn(move || {
267        let running = Running;
268        let sought = remote::seek(&held.login_for(""));
269        set(|s| looked(s, &held.host(), sought));
270        drop(running);
271        told(&qt);
272    });
273}
274
275/// The sign-in's page opened again in the browser (closed by mistake).
276pub(crate) fn open_again(qt: &QtThread) {
277    let page = PAGE.lock().ok().and_then(|p| p.as_ref().map(|p| p.to_string()));
278    if let Some(page) = page {
279        let _ = qt.queue(move |mut sioul| sioul.as_mut().open_url(QString::from(&page)));
280    }
281}
282
283/// Cancel: the sign-in stops where it is; an app password given already is
284/// given back to its server and forgotten here. Nothing joined is undone.
285pub(crate) fn cancel(qt: &QtThread) {
286    STOP.store(true, Ordering::SeqCst);
287    if !RUNNING.load(Ordering::SeqCst) {
288        if let Some(held) = grant().take() {
289            std::thread::spawn(move || give_back(&held));
290        }
291        set(|s| *s = Stand::default());
292    }
293    told(qt);
294}
295
296/// The result read, or a failed sign-in left: nothing said any more.
297pub(crate) fn done(qt: &QtThread) {
298    if RUNNING.load(Ordering::SeqCst) {
299        return;
300    }
301    let step = stand().step.clone();
302    if matches!(step.as_str(), "joined" | "failed") {
303        set(|s| *s = Stand::default());
304    }
305    told(qt);
306}
307
308/// Joins the sharing through `place` on the server signed in to, with the
309/// passphrase (typed once, checked against the folder's seal; twice, `again`,
310/// for a new sharing there), off the window's thread; then a first exchange
311/// at once, the contacts and calendars of that server, and what came, said.
312/// The grant is kept while the passphrase is wrong, for another try.
313pub(crate) fn join(qt: &QtThread, shared: &Arc<Shared>, place: String, passphrase: String, again: String) {
314    if RUNNING.swap(true, Ordering::SeqCst) {
315        return;
316    }
317    let (qt, shared) = (qt.clone(), Arc::clone(shared));
318    let back = stand().step.clone();
319    // A new sharing only where every place was looked at and none found.
320    let may_start = back == "none";
321    set(|s| {
322        s.step = "joining".into();
323        s.problem.clear();
324    });
325    told(&qt);
326    std::thread::spawn(move || {
327        let running = Running;
328        let held = grant().as_ref().map(|g| Grant::of(&g.server, &g.login, g.password()));
329        let outcome = match held {
330            None => Err(tr().text("join-why-signed-out", None)),
331            Some(signed) => joined(&qt, &shared, &signed, &place, &passphrase, &again, may_start),
332        };
333        match outcome {
334            Ok((lines, needs)) => {
335                // Joined: the grant leaves memory (its app password stays in the keyring).
336                *grant() = None;
337                set(|s| {
338                    s.step = "joined".into();
339                    s.lines = lines;
340                    s.needs = needs;
341                    s.places.clear();
342                });
343            }
344            Err(problem) => set(|s| {
345                s.step = back.clone();
346                s.problem = problem;
347            }),
348        }
349        drop(running);
350        told(&qt);
351        crate::backend::show(&qt, &shared);
352    });
353}
354
355/// The join itself, on its thread: what it said, and what still needs a sign-in here.
356#[allow(clippy::too_many_arguments)]
357fn joined(qt: &QtThread, shared: &Arc<Shared>, signed: &Grant, place: &str, passphrase: &str, again: &str, may_start: bool) -> Result<(Vec<String>, Vec<String>), String> {
358    let before = load_config();
359    let before_text = std::fs::read_to_string(crate::backend::config_path()).unwrap_or_default();
360    let login = signed.login_for(&agenda_id(&before, signed));
361    let since = jiff::Timestamp::now().as_second();
362    let begun = crate::share::begin_on_server(&login, place, passphrase, again, true, may_start)?;
363    // The first exchange: the server looked through, the others' files read, this device's sent.
364    crate::share::exchange_at_once(qt, shared);
365    let host = signed.host();
366    let mut lines = match begun {
367        // A new sharing: nothing to wait for from other devices, never "joined".
368        crate::share::Begun::Started => vec![say("join-started", &[("host", host.clone()), ("place", place.trim().trim_matches('/').to_string())])],
369        crate::share::Begun::Joined => {
370            let after_text = std::fs::read_to_string(crate::backend::config_path()).unwrap_or_default();
371            said_joined(&load_config(), before_text != after_text)
372        }
373    };
374    // The contacts and calendars once everything the others shared came, never on half of it (M2).
375    let agenda = if begun == crate::share::Begun::Started || complete_since(since) {
376        bind_agenda(qt, shared, signed).and_then(|bound| line_of(bound, &host))
377    } else {
378        defer_agenda(signed, since);
379        Some(say("join-agenda-later", &[("host", host.clone())]))
380    };
381    lines.extend(agenda);
382    let needs = still_to_sign_in(&load_config());
383    Ok((lines, needs))
384}
385
386/// Whether a whole look through the server went through since `since`
387/// (Unix seconds), every other device's records, entries and claims brought
388/// into this device's copy, nothing left there to bring, and an exchange read
389/// them since: then the settings and the accounts your other devices share
390/// are all here, and the contacts and calendars may be decided (M2).
391fn complete_since(since: i64) -> bool {
392    let state = remote::State::load(&crate::share::memory_path());
393    state_complete(&state, since) && crate::share::last_exchange().is_some_and(|(at, _)| at >= since)
394}
395
396/// `complete_since`'s reading of the state: Sioul keeping the folder itself,
397/// a whole step gone through at or after `since`, no error said since, nothing
398/// too large left on the server.
399fn state_complete(state: &remote::State, since: i64) -> bool {
400    state.mode == remote::MIRROR && state.last >= since && state.said.is_empty() && state.left.is_empty()
401}
402
403/// The contacts and calendars left for a later exchange, the first one not
404/// having brought everything: what to decide them with, this device's own
405/// (`share/agenda-pending.toml`), never shared, never a secret (the app
406/// password stays in the keyring, `loginflow::kept`).
407#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)]
408struct Pending {
409    server: String,
410    login: String,
411    since: i64,
412}
413
414fn pending_path() -> std::path::PathBuf {
415    state_dir().join("share").join("agenda-pending.toml")
416}
417
418fn defer_agenda(signed: &Grant, since: i64) {
419    let pending = Pending { server: signed.server.clone(), login: signed.login.clone(), since };
420    if let Ok(text) = toml::to_string(&pending) {
421        let _ = sioul_sync::share::write_atomically(&pending_path(), format!("# Sioul: the contacts and calendars of the server signed in to, decided once your other devices' settings came (docs/database.md). Never shared.\n{text}").as_bytes());
422    }
423}
424
425/// After an exchange of the window's: the contacts and calendars left for
426/// later (`defer_agenda`) decided once a whole look and an exchange went
427/// through, with the app password the keyring keeps; said in the status
428/// line. Nothing while none is left. Off the window's thread.
429pub(crate) fn agenda_later(qt: &QtThread, shared: &Arc<Shared>) {
430    let path = pending_path();
431    let Some(pending) = std::fs::read_to_string(&path).ok().and_then(|t| toml::from_str::<Pending>(&t).ok()) else { return };
432    if !complete_since(pending.since) {
433        return;
434    }
435    let host = remote::host_of(&pending.server);
436    let Some(password) = loginflow::kept(&pending.login, &host) else {
437        let _ = std::fs::remove_file(&path);
438        return;
439    };
440    let signed = Grant::of(&pending.server, &pending.login, &password);
441    let line = bind_agenda(qt, shared, &signed).and_then(|bound| line_of(bound, &host));
442    let _ = std::fs::remove_file(&path);
443    if let Some(line) = line {
444        crate::backend::set_status(qt, line);
445    }
446}
447
448// ---------------------------------------------------------------- the contacts and calendars
449
450/// The user of a Nextcloud's address, as its paths name it
451/// (`…/remote.php/dav/calendars/<user>/`, `…/addressbooks/users/<user>/`,
452/// `…/principals/users/<user>/`, `…/files/<user>/`), decoded.
453fn user_of(url: &str) -> Option<String> {
454    let (_, rest) = url.split_once("/remote.php/dav/")?;
455    let mut parts = rest.split('/').filter(|p| !p.is_empty());
456    let user = match parts.next()? {
457        "calendars" | "files" => parts.next()?,
458        "addressbooks" | "principals" => match parts.next()? {
459            "users" => parts.next()?,
460            _ => return None,
461        },
462        _ => return None,
463    };
464    Some(decoded(user)).filter(|u| !u.is_empty())
465}
466
467/// A path's part percent-decoded ("jane%40example.org" → "jane@example.org").
468fn decoded(part: &str) -> String {
469    let bytes = part.as_bytes();
470    let mut out = Vec::with_capacity(bytes.len());
471    let mut at = 0;
472    while at < bytes.len() {
473        if bytes[at] == b'%'
474            && let Some(byte) = part.get(at + 1..at + 3).and_then(|hex| u8::from_str_radix(hex, 16).ok())
475        {
476            out.push(byte);
477            at += 3;
478            continue;
479        }
480        out.push(bytes[at]);
481        at += 1;
482    }
483    String::from_utf8_lossy(&out).into_owned()
484}
485
486/// A server's name as an account says it: its `host`, else its address's.
487fn account_host(account: &Account) -> String {
488    account.host.clone().filter(|h| !h.is_empty()).unwrap_or_else(|| account.url.as_deref().map(remote::host_of).unwrap_or_default())
489}
490
491/// Whether an account's address is on `server`'s origin (scheme, host and
492/// port), so that the sign-in's app password may go to it: an account
493/// without an address, or one whose host is the server's but whose address
494/// points elsewhere, never (the review of the join, L2).
495fn on_server(account: &Account, server: &str) -> bool {
496    let origin = sioul_sync::dav::origin(server);
497    origin.is_some() && account.url.as_deref().and_then(sioul_sync::dav::origin) == origin
498}
499
500/// Your contacts-and-calendars account for that server and login, if there
501/// is one (switched off ones too): its address on the server's origin
502/// (`on_server`: scheme, host and port, never the host's name alone, which
503/// Accounts keeps without its port), with the same login (case aside) or the
504/// same user in its address (`user_of`). Google's are never.
505fn matching<'a>(config: &'a Config, server: &str, login: &str, user: Option<&str>) -> Option<&'a Account> {
506    config.every_account().filter(|a| a.kind == AccountKind::Dav && a.auth.is_none()).find(|a| {
507        on_server(a, server) && (a.login().is_some_and(|l| l.trim().eq_ignore_ascii_case(login.trim())) || user.is_some_and(|u| a.url.as_deref().and_then(user_of).as_deref() == Some(u)))
508    })
509}
510
511/// The id a new account for that server would take: "murena-agenda", as
512/// Accounts names one (`<server's first name>-agenda`), free among yours.
513fn new_id(config: &Config, host: &str) -> String {
514    let label = host.split(['.', ':']).next().filter(|l| !l.is_empty()).unwrap_or("nextcloud");
515    let base = config::slug(&format!("{label}-agenda"));
516    std::iter::once(base.clone()).chain((2..).map(|n| format!("{base}-{n}"))).find(|id| config.every_account().all(|a| &a.id != id)).unwrap_or(base)
517}
518
519/// The id the sharing's state names for the account of that server and
520/// login: yours when there is one, else the one it would be made under.
521/// A name only: the folder is kept with the sign-in's own login (`State::user`).
522fn agenda_id(config: &Config, signed: &Grant) -> String {
523    matching(config, &signed.server, &signed.login, None).map_or_else(|| new_id(config, &signed.host()), |a| a.id.clone())
524}
525
526/// What becomes of the contacts and calendars of the server signed in to.
527#[derive(Debug, Clone)]
528enum Agenda {
529    /// An account of yours, by its id: its own login is the sign-in's, the
530    /// very same, so the app password opens it; else `opens` decides.
531    Use { id: String, same: bool },
532    /// None: one made.
533    Make(Account),
534}
535
536/// The decision, from your accounts as they are after the first exchange
537/// (those another device brought among them): the server as the sign-in
538/// named it, the sign-in's login, its user as the homes name it, and where
539/// its homes are.
540fn agenda_for(config: &Config, server: &str, login: &str, homes: &sioul_sync::dav::Homes) -> Agenda {
541    let host = remote::host_of(server);
542    let user = homes.calendars.as_deref().or(homes.contacts.as_deref()).and_then(user_of);
543    match matching(config, server, login, user.as_deref()) {
544        Some(account) => Agenda::Use { id: account.id.clone(), same: account.login().is_some_and(|l| l.trim() == login.trim()) },
545        None => {
546            let found = homes.calendars.clone().or_else(|| homes.contacts.clone()).unwrap_or_default();
547            Agenda::Make(Account::dav(&new_id(config, &host), login, &host, Some(&found), Some(login)))
548        }
549    }
550}
551
552/// What became of the contacts and calendars of the server signed in to (`bind_agenda`).
553#[derive(Debug, Clone, Copy, PartialEq, Eq)]
554enum Bound {
555    /// An account made from the sign-in, synced now.
556    Made,
557    /// Yours, given the app password, synced now.
558    Kept,
559    /// Yours, which keeps the password it has here.
560    Own,
561    /// Yours, which the app password does not open: it asks for its own (`still_to_sign_in`).
562    Needs,
563}
564
565/// A binding's line, said with the rest: "use this sign-in" only where they
566/// do (the review of the join, L4); none for an account that keeps its own
567/// password, and none for one that needs one (its line under "Still to sign in").
568fn line_of(bound: Bound, host: &str) -> Option<String> {
569    match bound {
570        Bound::Made => Some(say("join-agenda-made", &[("host", host.to_string())])),
571        Bound::Kept => Some(say("join-agenda-kept", &[("host", host.to_string())])),
572        Bound::Own | Bound::Needs => None,
573    }
574}
575
576/// The contacts and calendars of the server signed in to, once the first
577/// exchange brought your accounts: yours used, given the homes found there
578/// when it has none here (its first sync then never looks for them
579/// elsewhere with the app password: L1) and the app password when it has
580/// none here and opens it there; else one made and synced. None when nothing
581/// could be found there.
582fn bind_agenda(qt: &QtThread, shared: &Arc<Shared>, signed: &Grant) -> Option<Bound> {
583    let homes = loginflow::homes(signed).ok()?;
584    let config = load_config();
585    match agenda_for(&config, &signed.server, &signed.login, &homes) {
586        Agenda::Use { id, same } => {
587            let account = config.every_account().find(|a| a.id == id).cloned()?;
588            if sioul_sync::dav::Homes::load(&account.id).is_empty() {
589                let _ = homes.save(&account.id);
590            }
591            if secret::password(&account).is_ok() {
592                return Some(Bound::Own);
593            }
594            let login = account.login().unwrap_or_default().to_string();
595            if (same || loginflow::opens(signed, &login).unwrap_or(false)) && secret::save(&account, signed.password()).is_ok() {
596                crate::pim::start_dav_watcher(qt, shared, account);
597                return Some(Bound::Kept);
598            }
599            Some(Bound::Needs)
600        }
601        Agenda::Make(account) => {
602            secret::save(&account, signed.password()).ok()?;
603            config::add_dav_account(&crate::backend::config_path(), &account).ok()?;
604            let _ = homes.save(&account.id);
605            crate::pim::start_dav_watcher(qt, shared, account);
606            crate::pim::show_pim(qt, shared);
607            Some(Bound::Made)
608        }
609    }
610}
611
612// ---------------------------------------------------------------- what came
613
614/// An account, as the join's lines name it: its address, or its contacts and calendars' server.
615fn account_label(account: &Account) -> String {
616    if account.kind == AccountKind::Dav {
617        return say("join-agenda", &[("host", account_host(account))]);
618    }
619    account.address.clone().unwrap_or_else(|| account.id.clone())
620}
621
622/// A device as the join's lines name it: a computer by its name, a phone by its model.
623fn device_label(entry: &sioul_sync::devices::Entry) -> String {
624    match (entry.kind == sioul_sync::devices::PHONE, entry.name.is_empty() || entry.name == sioul_sync::devices::PHONE) {
625        (true, true) => tr().text("device-the-phone", None),
626        _ if entry.name.is_empty() => tr().text("share-other-device", None),
627        _ => entry.name.clone(),
628    }
629}
630
631/// Words joined as a list: "a, b and c".
632fn listed(items: Vec<String>) -> String {
633    match items.len() {
634        0 => String::new(),
635        1 => items[0].clone(),
636        n => say("join-and", &[("first", items[..n - 1].join(", ")), ("last", items[n - 1].clone())]),
637    }
638}
639
640/// What the join brought, in sentences: who this device shares with now;
641/// the settings, the accounts and the hours that came (`changed`: the
642/// settings' file changed with the first exchange).
643fn said_joined(config: &Config, changed: bool) -> Vec<String> {
644    let mut lines = Vec::new();
645    let others: Vec<String> = crate::devices::entries().map(|(own, entries)| entries.iter().filter(|e| e.id != own && !e.left).map(device_label).collect()).unwrap_or_default();
646    if others.is_empty() {
647        lines.push(tr().text("join-alone", None));
648        return lines;
649    }
650    lines.push(say("join-with", &[("names", listed(others))]));
651    if changed {
652        lines.push(tr().text("join-settings", None));
653    }
654    let accounts: Vec<String> = config.accounts.iter().filter(|a| a.syncs() || a.is_dav()).map(account_label).collect();
655    if !accounts.is_empty() {
656        lines.push(say("join-accounts", &[("accounts", listed(accounts))]));
657    }
658    let set = |kind: &str| config.windows.iter().any(|w| w.kind() == kind);
659    let mut hours: Vec<String> = Vec::new();
660    if set("work") {
661        hours.push(tr().text("join-hours-work", None));
662    }
663    if set("admin") {
664        hours.push(tr().text("join-hours-admin", None));
665    }
666    lines.push(if hours.is_empty() { tr().text("join-hours-none", None) } else { say("join-hours", &[("which", listed(hours))]) });
667    lines
668}
669
670/// The accounts that came, or were here, and need a sign-in on this device:
671/// their passwords never travel. One line each, saying where.
672fn still_to_sign_in(config: &Config) -> Vec<String> {
673    config
674        .accounts
675        .iter()
676        .filter(|a| a.enabled && (a.syncs() || a.is_dav()))
677        .filter(|a| secret::password(a).is_err())
678        .map(|a| {
679            let words = if a.auth.is_some() { "join-needs-sign-in" } else { "join-needs-password" };
680            say(words, &[("account", account_label(a))])
681        })
682        .collect()
683}
684
685// ---------------------------------------------------------------- adding a device
686
687/// A folder's place among a server's files, decoded part by part ("Documents/Mes%20dossiers" → "Documents/Mes dossiers").
688pub(crate) fn decoded_path(place: &str) -> String {
689    place.split('/').map(decoded).collect::<Vec<_>>().join("/")
690}
691
692/// What a new device needs, in a few lines, for Settings ▸ Your folder and
693/// sharing ▸ "Add a device" on a computer that shares: where to find the
694/// join on it; the server to sign in to and the folder's place there, when
695/// the folder is known on a Nextcloud (`found`: its name and the place); else
696/// the same folder, carried by a sync app; and the passphrase, the one you
697/// chose, never shown.
698pub(crate) fn add_device(found: Option<(String, String)>) -> Vec<String> {
699    let mut lines = vec![tr().text("share-add-intro", None)];
700    match found {
701        Some((host, place)) => {
702            lines.push(if host.eq_ignore_ascii_case(&remote::host_of(loginflow::MURENA)) { tr().text("share-add-murena", None) } else { say("share-add-nextcloud", &[("host", host)]) });
703            // "Found by itself" only of the places the look goes through (L5); another, to type.
704            lines.push(say(if remote::sought(&place) { "share-add-place" } else { "share-add-place-typed" }, &[("place", place)]));
705        }
706        None => lines.push(tr().text("share-add-folder", None)),
707    }
708    lines.push(tr().text("share-add-passphrase", None));
709    lines
710}
711
712// ---------------------------------------------------------------- stopping
713
714/// Sharing stopped where it was joined through the server's sign-in
715/// (`State::user`): its app password forgotten here, and given back to the
716/// server unless one of your accounts here uses the same (its contacts and
717/// calendars, `bind_agenda`). Off the window's thread.
718pub(crate) fn let_go(state: &remote::State) {
719    if state.user.is_empty() {
720        return;
721    }
722    let host = state.host();
723    let Some(password) = loginflow::kept(&state.user, &host) else { return };
724    let used = load_config().every_account().any(|a| secret::password(a).is_ok_and(|p| p == password));
725    if !used {
726        let server = state.url.split_once("/remote.php/").map_or(state.url.as_str(), |(server, _)| server);
727        let _ = loginflow::revoke(&Grant::of(server, &state.user, &password));
728    }
729    let _ = loginflow::forget(&state.user, &host);
730}
731
732// ---------------------------------------------------------------- a phone in another's place
733
734/// A phone this one may replace, asked once (`replacing`): its id, and the
735/// question in words.
736#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
737pub(crate) struct Replacing {
738    id: String,
739    ask: String,
740}
741
742/// The phones asked about already, by id, whatever the answer: never asked
743/// again. This device's own (`share/replacing.toml`), never shared.
744#[derive(Serialize, Deserialize, Default, Debug)]
745struct Asked {
746    #[serde(default)]
747    answered: Vec<String>,
748}
749
750fn asked_path() -> std::path::PathBuf {
751    state_dir().join("share").join("replacing.toml")
752}
753
754fn asked() -> Asked {
755    std::fs::read_to_string(asked_path()).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
756}
757
758/// A moment, said shortly: "14:05" today, "Fri 2 Oct" before.
759fn moment(seconds: i64) -> String {
760    let Ok(at) = jiff::Timestamp::from_second(seconds) else { return String::new() };
761    let at = at.to_zoned(jiff::tz::TimeZone::system());
762    if at.date() == jiff::Zoned::now().date() { at.strftime("%H:%M").to_string() } else { tr().date(&at, true) }
763}
764
765/// How long another phone says nothing, as this device sees it, before this
766/// one asks whether it replaces it (`to_ask`): a day, as a computer offers
767/// "Forget it now" (`health::FORGET_NOW_AFTER`). A phone in use goes silent
768/// for hours at times (no network, switched off, Android deferring its
769/// steps), and a Yes about one would leave the doses without its word (the
770/// review of the join, M1); judged by this device's sight of its entry and
771/// its own last word, the later, never by its clock alone.
772const ASK_AFTER: i64 = 86_400;
773
774/// The latest time an entry says, by its device's clock.
775fn latest_of(entry: &sioul_sync::devices::Entry) -> i64 {
776    entry.started.max(entry.closed).max(entry.imported).max(entry.exported)
777}
778
779/// Of the other phones in the folder, the one to ask about, if any: on a
780/// phone sharing, one that has not stopped sharing, nor said anything for
781/// `ASK_AFTER` as this device sees it (`quiet_since`: the later of when this
782/// device saw its entry change and its own last word; none known: not
783/// asked), nor been asked about (`Asked`), nor been replaced or forgotten
784/// here; the one heard of last.
785#[allow(clippy::too_many_arguments)]
786fn to_ask(own: &str, own_entry: &sioul_sync::devices::Entry, entries: &[sioul_sync::devices::Entry], answered: &[String], forgotten: &dyn Fn(&str) -> bool, quiet_since: &dyn Fn(&str) -> Option<i64>, now: i64) -> Option<sioul_sync::devices::Entry> {
787    entries
788        .iter()
789        .filter(|e| e.id != own && e.kind == sioul_sync::devices::PHONE && !e.left && quiet_since(&e.id).is_some_and(|since| now - since >= ASK_AFTER))
790        .filter(|e| !answered.contains(&e.id) && own_entry.replaces.iter().all(|r| r.id != e.id) && !forgotten(&e.id))
791        .max_by_key(|e| sioul_sync::devices::written(e))
792        .cloned()
793}
794
795/// "Does this phone replace GS290, silent since …?", asked once on a phone that
796/// shares while another phone shares too (the owner's, 16 October 2026: the
797/// old phone dead, its battery gone, never to say Stop sharing). None
798/// elsewhere, and once answered.
799pub(crate) fn replacing() -> Option<Replacing> {
800    if crate::devices::kind() != sioul_sync::devices::PHONE {
801        return None;
802    }
803    let (own, entries) = crate::devices::entries()?;
804    let own_entry = sioul_sync::devices::own(&sioul_sync::devices::own_path(&state_dir()));
805    let other = to_ask(&own, &own_entry, &entries, &asked().answered, &crate::health::forgotten_here, &crate::health::quiet_since, jiff::Timestamp::now().as_second())?;
806    let when = moment(latest_of(&other));
807    Some(Replacing { ask: say("join-replace-ask", &[("name", device_label(&other)), ("when", when)]), id: other.id })
808}
809
810/// The answer: yes, this phone's entry says it replaces that one (sealed,
811/// for every device: `devices::Entry::replaces`), forgotten here at once,
812/// and an exchange tells the others; no, a second phone, nothing changes.
813/// Asked once either way. Nothing of the other phone is deleted.
814pub(crate) fn replace(qt: &QtThread, shared: &Arc<Shared>, id: &str, yes: bool) -> String {
815    let id = id.trim().to_string();
816    if id.is_empty() {
817        return String::new();
818    }
819    let mut kept = asked();
820    if !kept.answered.contains(&id) {
821        kept.answered.push(id.clone());
822    }
823    let written = toml::to_string(&kept).map_err(|e| e.to_string()).and_then(|text| sioul_sync::share::write_atomically(&asked_path(), format!("# Sioul: the phones this one was asked whether it replaces (docs/database.md). Never shared.\n{text}").as_bytes()));
824    if let Err(e) = written {
825        return e;
826    }
827    if !yes {
828        return String::new();
829    }
830    let replaced = crate::health::replaced_now(&id);
831    crate::devices::replace(replaced);
832    let problem = crate::health::device_forget(&id);
833    crate::share::exchange(qt, shared);
834    problem
835}
836
837#[cfg(test)]
838mod tests {
839    use super::*;
840    use sioul_sync::dav::Homes;
841
842    fn config(accounts: &str) -> Config {
843        toml::from_str(accounts).unwrap()
844    }
845
846    #[test]
847    fn a_nextcloud_s_user_is_read_from_its_paths() {
848        assert_eq!(user_of("https://murena.io/remote.php/dav/calendars/jane/").as_deref(), Some("jane"));
849        assert_eq!(user_of("https://cloud.example.org/nc/remote.php/dav/addressbooks/users/jane%40example.org/").as_deref(), Some("jane@example.org"));
850        assert_eq!(user_of("https://murena.io/remote.php/dav/principals/users/jane/").as_deref(), Some("jane"));
851        assert_eq!(user_of("https://murena.io/remote.php/dav/files/jane/Documents/Sioul/").as_deref(), Some("jane"));
852        assert_eq!(user_of("https://murena.io/remote.php/dav/"), None);
853        assert_eq!(user_of("https://caldav.example.org/cal/jane/"), None);
854    }
855
856    /// The account made from a sign-in (docs/database.md, "Joining through
857    /// your Nextcloud's sign-in"): none of yours for that server and login,
858    /// one is made at its homes, named as Accounts names one; yours for
859    /// them, come with the shared settings or here already, used instead,
860    /// never a second: by its login (case aside), by the user its address
861    /// names; matched by its address's origin, so that a server on its own
862    /// port is found again, and an account naming the server's host but
863    /// pointing elsewhere never receives the app password (M2, L2); another
864    /// login's, another server's, Google's, never.
865    #[test]
866    fn the_account_is_made_from_the_sign_in_or_yours_is_used() {
867        let homes = Homes { calendars: Some("https://murena.io/remote.php/dav/calendars/jane/".into()), contacts: Some("https://murena.io/remote.php/dav/addressbooks/users/jane/".into()) };
868        let server = "https://murena.io";
869        // None yet: made, at the calendars' home, its login the sign-in's.
870        let empty = config("");
871        let Agenda::Make(made) = agenda_for(&empty, server, "jane", &homes) else { panic!("made") };
872        assert_eq!((made.id.as_str(), made.kind, made.host.as_deref(), made.url.as_deref(), made.login()), ("murena-agenda", AccountKind::Dav, Some("murena.io"), Some("https://murena.io/remote.php/dav/calendars/jane/"), Some("jane")));
873        assert_eq!(agenda_id(&empty, &Grant::of(server, "jane", "x")), "murena-agenda");
874        // The desk's, come with the settings: used, never a second.
875        let desk = config("[[account]]\nid = \"murena\"\nkind = \"dav\"\naddress = \"Jane\"\nhost = \"murena.io\"\nurl = \"https://murena.io/remote.php/dav/calendars/jane/\"\n");
876        assert!(matches!(agenda_for(&desk, server, "jane", &homes), Agenda::Use { id, same: false } if id == "murena"), "its login Jane: the same account, the app password tried with it first");
877        let exact = config("[[account]]\nid = \"murena\"\nkind = \"dav\"\naddress = \"jane\"\nhost = \"murena.io\"\nurl = \"https://murena.io/remote.php/dav/calendars/jane/\"\n");
878        assert!(matches!(agenda_for(&exact, server, "jane", &homes), Agenda::Use { id, same: true } if id == "murena"));
879        assert_eq!(agenda_id(&desk, &Grant::of(server, "jane", "x")), "murena");
880        // Its login the address, the sign-in's the user: the same account, by the user its address names.
881        let by_address = config("[[account]]\nid = \"cloud\"\nkind = \"dav\"\naddress = \"jane@example.org\"\nhost = \"murena.io\"\nurl = \"https://murena.io/remote.php/dav/calendars/jane/\"\n");
882        assert!(matches!(agenda_for(&by_address, server, "jane", &homes), Agenda::Use { id, same: false } if id == "cloud"));
883        // A Nextcloud on its own port: Accounts keeps its host without the port; its address's origin finds it again.
884        let ported = config("[[account]]\nid = \"cloud\"\nkind = \"dav\"\naddress = \"jane\"\nhost = \"cloud.example.org\"\nurl = \"https://cloud.example.org:8443/nc/remote.php/dav/calendars/jane/\"\n");
885        let ported_homes = Homes { calendars: Some("https://cloud.example.org:8443/nc/remote.php/dav/calendars/jane/".into()), contacts: None };
886        assert!(matches!(agenda_for(&ported, "https://cloud.example.org:8443/nc", "jane", &ported_homes), Agenda::Use { id, .. } if id == "cloud"));
887        // The server's host in its name, its address elsewhere (a hand's edit): never given the app password.
888        let elsewhere = config("[[account]]\nid = \"odd\"\nkind = \"dav\"\naddress = \"jane\"\nhost = \"murena.io\"\nurl = \"https://dav.example.org/remote.php/dav/calendars/jane/\"\n");
889        assert!(matches!(agenda_for(&elsewhere, server, "jane", &homes), Agenda::Make(_)));
890        assert!(!on_server(&elsewhere.accounts[0], server) && on_server(&exact.accounts[0], server) && !on_server(&exact.accounts[0], "http://murena.io"));
891        // Another login, another server, Google's: none of them; made, under a free name.
892        let others = config(
893            "[[account]]\nid = \"murena-agenda\"\nkind = \"dav\"\naddress = \"paul@example.org\"\nhost = \"murena.io\"\nurl = \"https://murena.io/remote.php/dav/calendars/paul/\"\n\n[[account]]\nid = \"work\"\nkind = \"dav\"\naddress = \"jane\"\nhost = \"cloud.example.org\"\nurl = \"https://cloud.example.org/remote.php/dav/calendars/jane/\"\n\n[[account]]\nid = \"google\"\nkind = \"dav\"\naddress = \"jane@example.org\"\nhost = \"murena.io\"\nauth = \"google\"\n",
894        );
895        let Agenda::Make(made) = agenda_for(&others, server, "jane", &homes) else { panic!("made") };
896        assert_eq!(made.id, "murena-agenda-2");
897    }
898
899    /// The desk's Murena account arriving late (the review of the join, M2):
900    /// a first exchange that did not bring everything (the time out, an error
901    /// said, records left on the server, no exchange since) decides nothing,
902    /// so no account is made beside the desk's, which the later exchange
903    /// brings: used then, never doubled, on any device (an account made here
904    /// would travel to the others under another id).
905    #[test]
906    fn the_contacts_and_calendars_wait_for_a_complete_first_exchange() {
907        let since: i64 = 1_791_360_000;
908        let whole = remote::State { mode: remote::MIRROR.into(), last: since + 20, ..remote::State::default() };
909        assert!(state_complete(&whole, since));
910        assert!(!state_complete(&remote::State { last: since - 1, ..whole.clone() }, since), "no whole look since the join");
911        assert!(!state_complete(&remote::State { said: "network:murena.io".into(), ..whole.clone() }, since), "the look cut by the time out, or an error");
912        assert!(!state_complete(&remote::State { left: [("desk-1.jsonl".to_string(), 70 << 20)].into(), ..whole.clone() }, since), "records left on the server");
913        assert!(!state_complete(&remote::State { mode: String::new(), ..whole.clone() }, since), "not Sioul keeping the folder");
914        // Decided at the first exchange, the desk's account not here yet, one would be made beside it;
915        // decided once it came, the desk's is used.
916        let homes = Homes { calendars: Some("https://murena.io/remote.php/dav/calendars/jane/".into()), contacts: None };
917        assert!(matches!(agenda_for(&config(""), "https://murena.io", "jane", &homes), Agenda::Make(_)), "what deciding early would do");
918        let arrived = config("[[account]]\nid = \"murena\"\nkind = \"dav\"\naddress = \"jane\"\nhost = \"murena.io\"\nurl = \"https://murena.io/remote.php/dav/calendars/jane/\"\n");
919        assert!(matches!(agenda_for(&arrived, "https://murena.io", "jane", &homes), Agenda::Use { id, .. } if id == "murena"));
920        // What each binding says: "use this sign-in" only where they do (L4).
921        assert!(line_of(Bound::Kept, "murena.io").is_some_and(|l| l.contains("murena.io")) && line_of(Bound::Made, "murena.io").is_some());
922        assert_eq!((line_of(Bound::Own, "murena.io"), line_of(Bound::Needs, "murena.io")), (None, None));
923    }
924
925    /// What the look found, said (M3): places found are proposed; none found
926    /// with every place looked at, a new sharing may start; the look
927    /// incomplete or not possible, "could not look", never "none found".
928    #[test]
929    fn a_look_that_could_not_go_through_never_offers_a_new_sharing() {
930        let mut stand = Stand::default();
931        looked(&mut stand, "murena.io", Ok(remote::Sought { found: vec!["Documents/Sioul".into()], complete: false }));
932        assert_eq!((stand.step.as_str(), stand.places.as_slice()), ("found", ["Documents/Sioul".to_string()].as_slice()));
933        looked(&mut stand, "murena.io", Ok(remote::Sought { found: Vec::new(), complete: true }));
934        assert_eq!(stand.step, "none");
935        assert!(stand.places.is_empty() && stand.problem.is_empty());
936        looked(&mut stand, "murena.io", Ok(remote::Sought { found: Vec::new(), complete: false }));
937        assert_eq!(stand.step, "unchecked");
938        assert!(stand.problem.contains("murena.io"), "{}", stand.problem);
939        looked(&mut stand, "murena.io", Err("network:murena.io".into()));
940        assert_eq!(stand.step, "unchecked");
941        // "Add a device" says "found by itself" only of the places the look goes through.
942        let deep = add_device(Some(("murena.io".into(), "Documents/Partage/Sioul".into())));
943        let own = add_device(Some(("murena.io".into(), "Documents/Sioul".into())));
944        assert_ne!(deep[2], own[2]);
945        assert!(deep[2].contains("Documents/Partage/Sioul") && own[2].contains("Documents/Sioul"));
946    }
947
948    /// "Does this phone replace …?", asked once (the owner's, 16 October
949    /// 2026): of the other phones that have not stopped sharing, the one
950    /// heard of last, silent a day as this device sees it (M1: never by the
951    /// other phone's clock alone; a phone first seen now waits a day); never
952    /// one asked about, replaced or forgotten here, nor a computer.
953    #[test]
954    fn a_phone_is_asked_once_whether_it_replaces_another() {
955        use sioul_sync::devices::{COMPUTER, Entry, PHONE, Replaced};
956        let now: i64 = 1_791_360_000;
957        let phone = |id: &str, exported: i64| Entry { id: id.into(), name: format!("{id} model"), kind: PHONE.into(), started: exported - 60, exported, working: false, ..Entry::default() };
958        let desk = Entry { id: "desk".into(), kind: COMPUTER.into(), started: now - 900, exported: now - 10, working: true, ..Entry::default() };
959        let own = phone("new", now);
960        let entries = vec![own.clone(), desk, phone("old", now - 3 * 86_400), phone("older", now - 9 * 86_400), Entry { left: true, ..phone("gone", now - 86_400) }, phone("second", now - 600)];
961        let never = |_: &str| false;
962        // This device's view: the later of when it saw each entry change and its own last word.
963        let seen = |id: &str| match id {
964            "old" => Some(now - 3 * 86_400),
965            "older" => Some(now - 9 * 86_400),
966            "second" => Some(now - 600),
967            "fresh" => Some(now - 60),
968            _ => None,
969        };
970        assert_eq!(to_ask("new", &own, &entries, &[], &never, &seen, now).map(|e| e.id), Some("old".into()), "the one heard of last, silent a day or more");
971        assert_eq!(to_ask("new", &own, &entries, &["old".into()], &never, &seen, now).map(|e| e.id), Some("older".into()), "asked once");
972        let replacing = Entry { replaces: vec![Replaced { id: "old".into(), started: now - 3 * 86_400 - 60, heard: (now - 3 * 86_400) * 1000 }], ..own.clone() };
973        assert_eq!(to_ask("new", &replacing, &entries, &["older".into()], &never, &seen, now), None, "replaced already");
974        assert_eq!(to_ask("new", &own, &entries, &["older".into()], &|id| id == "old", &seen, now), None, "forgotten here");
975        assert_eq!(to_ask("new", &own, &entries[..2], &[], &never, &seen, now), None, "a computer is never asked about");
976        // A phone in use, heard ten minutes ago, or silent five hours (no network): a second phone, never asked about.
977        assert_eq!(to_ask("new", &own, &[own.clone(), phone("second", now - 600)], &[], &never, &seen, now), None);
978        assert_eq!(to_ask("new", &own, &[own.clone(), phone("second", now - 600)], &[], &never, &seen, now + 5 * 3_600), None, "five hours of silence are no proof");
979        assert_eq!(to_ask("new", &own, &[own.clone(), phone("second", now - 600)], &[], &never, &seen, now + ASK_AFTER).map(|e| e.id), Some("second".into()), "a day of silence: asked");
980        // Its own clock three days back, but first seen here a minute ago: this device's view decides, a day on.
981        assert_eq!(to_ask("new", &own, &[own.clone(), phone("fresh", now - 3 * 86_400)], &[], &never, &seen, now), None);
982        // Nothing known of it here yet: not asked.
983        assert_eq!(to_ask("new", &own, &[own.clone(), phone("unknown", now - 9 * 86_400)], &[], &never, &seen, now), None);
984    }
985}