Skip to main content

sioul_core/
replies.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Replies from your computers to the messages your phone's notifications
5//! brought, through each notification's own Reply, as the phone's shade sends
6//! a reply typed under it (docs/android.md, "Replies from your computers").
7//! The phone's side, here and in its listener (AppNotes.java, Replies.java;
8//! crates/sioul-app/src/replies.rs):
9//!
10//! - **Which notification**: each line for your computers names the
11//!   notification it came in by the phone's own name for it (`notice`: a
12//!   hash of its app and Android's key; Android's key, whose tag may hold a
13//!   number, never leaves the phone), and a reply names it back.
14//! - **What it offers** (`Live`): for each notification it relays, the phone
15//!   writes in its own shared file, `phone-messages/live/<device>.jsonl`,
16//!   whether it offers a Reply the phone can fill and a Mark as read, and
17//!   when it went; no words, no names. A computer offers Reply only while it
18//!   can work; the phone checks everything again when it sends.
19//! - **Handed over** (`Due`): the background step, which decides each
20//!   request once, hands the listener what is due in a private file sealed
21//!   with a key made from the sharing key (`due.jsonl`, in this phone's
22//!   `replies` folder of its state). The listener alone holds the
23//!   notifications: it takes each once (`take`: "taken" written and flushed
24//!   before anything is sent), finds the live notification, has it checked
25//!   (`check`: the same app, notification and conversation; their newer
26//!   messages), sends through its Reply or its Mark as read alone, and says
27//!   what happened (`Done`, never the words).
28//! - **Asked, once** (`Ask`, `verdict`): each computer writes its requests
29//!   in its own file, `phone-messages/ask/<computer>.jsonl`, each line padded
30//!   and sealed with a key made from the sharing key, its writer sealed in it
31//!   (and copied to the sharing folder, sent on the lane texts use); a line
32//!   found in another device's file than its writer's is passed over. The
33//!   ledger's mark is made at every phone step and when the listener relays
34//!   a line for your computers (`mark_ledger`). The phone decides each once
35//!   by the texts' rules (a ledger flushed before the listener is asked, a
36//!   mark, a request's life of a quarter of an hour, the doubt after a
37//!   crash), with two guards of its own; and says what became of each in its
38//!   own `phone-messages/outcome/<phone>.jsonl` (`Outcome`), never the words,
39//!   which a computer says in words (`state_of`), never a plain "Sent".
40//! - **On a computer's Porch** (`answer_of`): a line offers Reply while its
41//!   phone says the notification's Reply works, a text goes as one of
42//!   Sioul's own where this computer reads them, the replies written there
43//!   show under their messages with their states. A conversation answered
44//!   comes through at once on the phone for twenty minutes after
45//!   (`note_answered`, `answered_lately`), never during a pause.
46
47use crate::appnotes::{self, Kind, Posted};
48use crate::calls;
49use crate::phonemsgs::{self, Extra};
50use crate::phones::Region;
51use crate::texts::Sealer;
52use serde::{Deserialize, Serialize};
53use std::collections::{BTreeMap, BTreeSet};
54use std::path::{Path, PathBuf};
55
56/// The phone's private folder for replies, in its state folder: never shared.
57pub const FOLDER: &str = "replies";
58/// What is due, sealed lines (`Due`), written by the background step.
59pub const DUE: &str = "due.jsonl";
60/// What the listener did with each (`Done`), plain lines without words.
61pub const DONE: &str = "done.jsonl";
62/// The folder of the live lines, each phone's own file, under the phone's
63/// messages (`phonemsgs::folder`): shared with the part "Messages from your phone".
64pub const LIVE: &str = "live";
65/// A reply the listener did not take this long after the step handed it is
66/// not sent (`take`: "late"): its listener was not bound, or had no access.
67pub const TAKE_WITHIN_MS: i64 = 2 * 60_000;
68/// How long after a reply was handed its app's notification is looked at
69/// for your words shown as yours (`shows`).
70pub const SHOWN_WITHIN_MS: i64 = 2 * 60_000;
71/// A reply's words: at most this many characters.
72pub const WORDS_MAX: usize = 2000;
73/// A day, in milliseconds.
74const DAY_MS: i64 = 86_400_000;
75
76/// The phone's private folder for replies (`FOLDER`).
77pub fn private_folder() -> PathBuf {
78    crate::config::state_dir().join(FOLDER)
79}
80
81/// A notification's name off the phone: FNV-1a of its app and Android's key
82/// for it (`appnotes::talk_key`, as AppNotes.talkKey makes it), 16 hex
83/// digits. Android's key holds the app's tag, which may be a number.
84pub fn notice(package: &str, key: &str) -> String {
85    appnotes::talk_key(package, key)
86}
87
88fn is_false(value: &bool) -> bool {
89    !*value
90}
91
92fn is_zero(value: &i64) -> bool {
93    *value == 0
94}
95
96// ---------------------------------------------------------------- what the step hands the listener
97
98/// A reply or a Mark as read due now, as the background step claimed it
99/// (in `DUE`, sealed): what the listener needs to find the notification and
100/// check it, and the words.
101#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(default)]
103pub struct Due {
104    /// The request's key: 32 hex digits, made by the computer that wrote it.
105    pub key: String,
106    /// "reply", or "read" (the notification's Mark as read).
107    pub verb: String,
108    /// The notification, as the line answered named it (`notice`).
109    pub notice: String,
110    /// Its app's package.
111    pub app: String,
112    /// The conversation's key, as the line said it (`appnotes::talk_key`); "" none.
113    pub talk: String,
114    /// Who wrote the line answered: their number's key, and their name as the app gave it.
115    pub from: String,
116    pub name: String,
117    /// The line answered is a group's, and its title then (`phonemsgs::Line`):
118    /// the live conversation must have the same shape (`check`).
119    #[serde(skip_serializing_if = "is_false")]
120    pub group: bool,
121    #[serde(skip_serializing_if = "String::is_empty")]
122    pub title: String,
123    /// When the newest message you answered was sent (ms): a message of
124    /// theirs after it in the notification crossed the reply.
125    pub line_at: i64,
126    /// A reply's words; none for "read".
127    pub words: String,
128    /// When the step claimed it (ms, the phone's clock).
129    pub at: i64,
130}
131
132/// What the listener did with one (`DONE`), never its words.
133#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(default)]
135pub struct Done {
136    pub key: String,
137    /// "taken" (written before it is sent); "handed" (its app took it);
138    /// "shown" (its notification then showed your words as yours); or why it
139    /// was not sent: "gone" (no such notification now), "other" (another
140    /// conversation in it now, or nothing that tells: `check`), "no-reply" /
141    /// "no-read" (it offers none now),
142    /// "closed" (its Reply cannot be filled by another app), "locked" (its
143    /// app asks for an unlocked phone), "activity" (it would open the app on
144    /// the phone), "late" (the listener did not take it in time), "failed"
145    /// (Android refused the sending).
146    pub state: String,
147    /// When (ms, the phone's clock).
148    pub at: i64,
149    /// Their newer messages were in the notification answered.
150    #[serde(skip_serializing_if = "is_false")]
151    pub crossed: bool,
152    /// Answered among the notifications Sioul holds: a newer message waits on the phone.
153    #[serde(skip_serializing_if = "is_false")]
154    pub held: bool,
155}
156
157impl Done {
158    pub fn of(key: &str, state: &str, at: i64) -> Done {
159        Done { key: key.to_string(), state: state.to_string(), at, ..Done::default() }
160    }
161}
162
163/// What is due, as written; a line that does not open (another key, cut) is passed over.
164pub fn read_due(folder: &Path, seal: &dyn Sealer) -> Vec<Due> {
165    crate::texts::read_file(&folder.join(DUE), seal)
166}
167
168/// What the listener did, oldest first.
169pub fn read_done(folder: &Path) -> Vec<Done> {
170    calls::read_lines(&folder.join(DONE))
171}
172
173/// The private folder made, yours alone (0700 on Unix), before its lock is taken there.
174fn private_dir(folder: &Path) -> Result<(), String> {
175    let mut builder = std::fs::DirBuilder::new();
176    builder.recursive(true);
177    #[cfg(unix)]
178    std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
179    builder.create(folder).map_err(|e| format!("{}: {e}", folder.display()))
180}
181
182/// The replies' ledger (`texts::Ledger`, in the private folder), its mark
183/// made now when there is none; the folder made private first.
184pub fn open_ledger(folder: &Path, now_ms: i64) -> Result<crate::texts::Ledger, String> {
185    private_dir(folder)?;
186    crate::texts::Ledger::open(folder, now_ms)
187}
188
189/// The ledger's mark made once, when there is none (a request written
190/// before it is refused, as for texts: a ledger lost cannot make old
191/// requests new). Made at every phone step, before the requests are read,
192/// and when the listener relays a line for your computers
193/// (`phonemsgs::note`): a reply answers such a line, so the mark is older
194/// than the first reply. Never moved once made.
195pub fn mark_ledger(folder: &Path, now_ms: i64) -> Result<(), String> {
196    private_dir(folder)?;
197    if folder.join(crate::texts::MARK).is_file() {
198        return Ok(());
199    }
200    crate::filelock::with_lock(&folder.join(crate::texts::LEDGER), || crate::texts::Ledger::open(folder, now_ms).map(|_| ()))
201}
202
203/// What is due written whole (each line sealed), beside then put in place,
204/// under the folder's lock: the listener reads it whole or not at all.
205pub fn write_due(folder: &Path, due: &[Due], seal: &dyn Sealer) -> Result<(), String> {
206    private_dir(folder)?;
207    let path = folder.join(DUE);
208    crate::filelock::with_lock(&path, || {
209        let body: String = due.iter().filter_map(|d| serde_json::to_string(d).ok()).map(|plain| format!("{}\n", seal.seal(&plain))).collect();
210        let fresh = folder.join(format!(".{DUE}.{}.new", std::process::id()));
211        let _ = std::fs::remove_file(&fresh);
212        calls::append_private(&fresh, &body)?;
213        std::fs::rename(&fresh, &path).map_err(|e| {
214            let _ = std::fs::remove_file(&fresh);
215            format!("{}: {e}", path.display())
216        })
217    })
218}
219
220/// A line of what the listener did, appended and flushed to the disk, yours alone.
221fn append_flushed(path: &Path, done: &Done) -> Result<(), String> {
222    use std::io::Write;
223    let line = serde_json::to_string(done).map_err(|e| e.to_string())? + "\n";
224    let fail = |e: std::io::Error| format!("{}: {e}", path.display());
225    calls::append_private(path, "")?;
226    let mut file = std::fs::OpenOptions::new().append(true).open(path).map_err(fail)?;
227    file.write_all(line.as_bytes()).map_err(fail)?;
228    file.sync_all().map_err(fail)
229}
230
231/// Lines of what the listener did appended together and flushed once; nothing for none.
232fn append_all_flushed(path: &Path, done: &[Done]) -> Result<(), String> {
233    use std::io::Write;
234    if done.is_empty() {
235        return Ok(());
236    }
237    let mut text = String::new();
238    for d in done {
239        text.push_str(&serde_json::to_string(d).map_err(|e| e.to_string())?);
240        text.push('\n');
241    }
242    let fail = |e: std::io::Error| format!("{}: {e}", path.display());
243    calls::append_private(path, "")?;
244    let mut file = std::fs::OpenOptions::new().append(true).open(path).map_err(fail)?;
245    file.write_all(text.as_bytes()).map_err(fail)?;
246    file.sync_all().map_err(fail)
247}
248
249/// What the listener did with one, said: appended, flushed, under the folder's lock.
250pub fn say(folder: &Path, done: &Done) -> Result<(), String> {
251    private_dir(folder)?;
252    crate::filelock::with_lock(&folder.join(DUE), || append_flushed(&folder.join(DONE), done))
253}
254
255/// What is due that the listener has not taken yet: each due line with no word of it.
256pub fn waiting(folder: &Path, seal: &dyn Sealer) -> Vec<Due> {
257    let done = read_done(folder);
258    read_due(folder, seal).into_iter().filter(|d| !done.iter().any(|x| x.key == d.key)).collect()
259}
260
261/// Whether the listener may send one now (`take`).
262#[derive(Debug, Clone, PartialEq, Eq)]
263pub enum Taken {
264    /// "taken" written and flushed: send it, now.
265    Go(Box<Due>),
266    /// Not taken within `TAKE_WITHIN_MS` of the step's claim: said "late", never sent.
267    Late,
268    /// Not due, or done already (taken once, said otherwise): nothing.
269    No,
270}
271
272/// One taken by the listener, once, under the folder's lock: "taken"
273/// written and flushed before anything is sent, so that a crash after it
274/// leaves the doubt, never a second sending. Not due, or done already:
275/// nothing. Handed over more than `TAKE_WITHIN_MS` ago: "late", not sent.
276pub fn take(folder: &Path, key: &str, seal: &dyn Sealer, now_ms: i64) -> Result<Taken, String> {
277    private_dir(folder)?;
278    crate::filelock::with_lock(&folder.join(DUE), || {
279        if read_done(folder).iter().any(|d| d.key == key) {
280            return Ok(Taken::No);
281        }
282        let Some(due) = read_due(folder, seal).into_iter().find(|d| d.key == key) else { return Ok(Taken::No) };
283        if now_ms - due.at > TAKE_WITHIN_MS || due.at - now_ms > TAKE_WITHIN_MS {
284            append_flushed(&folder.join(DONE), &Done::of(key, "late", now_ms))?;
285            return Ok(Taken::Late);
286        }
287        append_flushed(&folder.join(DONE), &Done::of(key, "taken", now_ms))?;
288        // Its words are of no use there once taken: what is due written again
289        // without them (left as they were, sealed, until the day's trim when it fails).
290        let rest: Vec<Due> = read_due(folder, seal).into_iter().map(|d| if d.key == key { Due { words: String::new(), ..d } } else { d }).collect();
291        let _ = write_due(folder, &rest, seal);
292        Ok(Taken::Go(Box::new(due)))
293    })
294}
295
296// ---------------------------------------------------------------- the live notification, checked
297
298/// What the live notification found for a due one says (`check`).
299#[derive(Debug, Clone, Copy, PartialEq, Eq)]
300pub enum Checked {
301    /// The same app, notification and conversation: send it. `crossed`:
302    /// their messages newer than the one you answered are in it, by the
303    /// messages' own times (none given: not said).
304    Go { crossed: bool },
305    /// No longer that notification: "gone".
306    Gone,
307    /// That notification, but another conversation in it now (another key,
308    /// a group for a conversation between two or the other way round,
309    /// another group's title, another sender), or nothing that tells which
310    /// conversation the line was: "other", never a guess.
311    Other,
312}
313
314/// Whether the notification found now by its name is still the one a
315/// request answered: the same app and name (`notice`), and the same
316/// conversation as the line answered, read as when the line was made
317/// (`appnotes::classify`): the same shape (a group or not, a group's title),
318/// then its key; else, for a line without one, the same sender's number,
319/// else name; a line that names none of these never passes. What the shade's
320/// own Reply guarantees, and more: the Reply under a notification is that
321/// conversation's (apps make one per conversation, as Android tells them),
322/// and an app that reuses one notification for another conversation is caught.
323pub fn check(due: &Due, posted: &Posted, extra: &Extra, choices: &appnotes::Choices, words: &crate::words::Words, region: Option<&Region>) -> Checked {
324    if posted.package != due.app || posted.key.is_empty() || notice(&posted.package, &posted.key) != due.notice {
325        return Checked::Gone;
326    }
327    let conversation = match appnotes::classify(posted, choices, words) {
328        Kind::People(talk) => talk.conversation,
329        _ => None,
330    };
331    let (group, title) = conversation.as_ref().map_or((false, String::new()), |c| (c.group, phonemsgs::title_of(c)));
332    if group != due.group || (due.group && title != due.title) {
333        return Checked::Other;
334    }
335    // A group whose key came from its last writer names a person, not a group: two such groups where she wrote last look alike.
336    if due.group && conversation.as_ref().is_some_and(|c| c.by_writer) {
337        return Checked::Other;
338    }
339    let same = if !due.talk.is_empty() {
340        conversation.as_ref().is_some_and(|c| c.key == due.talk)
341    } else if !due.from.is_empty() {
342        phonemsgs::writers(posted, region).0.contains(&due.from)
343    } else if !due.name.is_empty() {
344        phonemsgs::writers(posted, region).1.contains(&due.name)
345    } else {
346        false
347    };
348    if !same {
349        return Checked::Other;
350    }
351    Checked::Go { crossed: phonemsgs::newest_timed_from_others(posted, extra) > due.line_at }
352}
353
354/// A reply's words as the phone compares them, never kept: a hash.
355pub fn words_hash(words: &str) -> String {
356    appnotes::talk_key("replies", words.trim())
357}
358
359/// Whether a notification shows a reply's words as the person's own (its
360/// hash, `words_hash`), as Signal and Element add your reply; the phone then
361/// says "shown". A message of yours in the conversation (no sender, or the
362/// conversation's own user) timed after the reply was taken (`since`, the
363/// phone's clock, a second's slack); where the notification gives no time,
364/// its words there, or in what went through its Reply lately
365/// (`Extra::history`), only when they were not there already before the
366/// reply (`before`, `shown_already`): an earlier "Oui" of yours never makes
367/// a later "Oui" shown.
368pub fn shows(posted: &Posted, extra: &Extra, hash: &str, since: i64, before: bool) -> bool {
369    let mut untimed = false;
370    for (i, m) in posted.messages.iter().enumerate() {
371        if m.sender.as_ref().is_some_and(|s| !phonemsgs::is_user(posted, s)) || words_hash(&m.text) != hash {
372            continue;
373        }
374        match extra.messages.get(i).map(|e| e.at).filter(|at| *at > 0) {
375            Some(at) if at >= since - 1_000 => return true,
376            Some(_) => {}
377            None => untimed = true,
378        }
379    }
380    !before && (untimed || extra.history.iter().any(|words| words_hash(words) == hash))
381}
382
383/// Whether a notification shows those words as yours already, whatever
384/// their time: looked at before the reply is sent (`shows`'s `before`).
385pub fn shown_already(posted: &Posted, extra: &Extra, hash: &str) -> bool {
386    let own = posted.messages.iter().filter(|m| m.sender.as_ref().is_none_or(|s| phonemsgs::is_user(posted, s))).map(|m| m.text.as_str());
387    own.chain(extra.history.iter().map(String::as_str)).any(|words| words_hash(words) == hash)
388}
389
390// ---------------------------------------------------------------- the live lines
391
392/// What a relayed notification offers now, a line in its phone's own file
393/// (`LIVE`), shared: whether a Reply the phone can fill and a Mark as read
394/// are there, and when it went. No words, no names.
395#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
396#[serde(default)]
397pub struct Live {
398    /// The notification (`notice`).
399    pub notice: String,
400    /// Its app's package.
401    pub app: String,
402    /// Its conversation's key, as its lines say it (`appnotes::talk_key`).
403    #[serde(skip_serializing_if = "String::is_empty")]
404    pub talk: String,
405    #[serde(skip_serializing_if = "is_false")]
406    pub reply: bool,
407    #[serde(skip_serializing_if = "is_false")]
408    pub read: bool,
409    /// When it went from the phone (ms; its app, or you, took it away); 0 while there.
410    #[serde(skip_serializing_if = "is_zero")]
411    pub gone: i64,
412    /// When this was said (ms, the phone's clock).
413    pub at: i64,
414    /// The phone that wrote it: its file's name. Never written.
415    #[serde(skip)]
416    pub device: String,
417}
418
419/// A phone's own live file under `root` (the phone's messages' folder).
420pub fn live_file(root: &Path, device: &str) -> PathBuf {
421    calls::own_file(root, LIVE, device)
422}
423
424/// What a file of live lines said last of each notification.
425fn last_of(path: &Path) -> BTreeMap<String, Live> {
426    calls::read_lines::<Live>(path).into_iter().filter(|l| !l.notice.is_empty() && l.at > 0).map(|l| (l.notice.clone(), l)).collect()
427}
428
429/// What a relayed notification offers now, said in its phone's own file
430/// (`own`) when it differs from what the file last said of it, under its
431/// lock: one line per change, not per message. Whether a line was written.
432pub fn offer(own: &Path, live: &Live) -> Result<bool, String> {
433    if live.notice.is_empty() || live.at <= 0 {
434        return Ok(false);
435    }
436    crate::filelock::with_lock(own, || {
437        let same = last_of(own).get(&live.notice).is_some_and(|last| last.gone == 0 && last.reply == live.reply && last.read == live.read && last.talk == live.talk && last.app == live.app);
438        if same {
439            return Ok(false);
440        }
441        let line = serde_json::to_string(&Live { gone: 0, device: String::new(), ..live.clone() }).map_err(|e| e.to_string())? + "\n";
442        calls::append_private(own, &line).map(|()| true)
443    })
444}
445
446/// A relayed notification gone from the phone, said once in its phone's own
447/// file (`own`), under its lock: only one the file says is there. Whether a
448/// line was written.
449pub fn went(own: &Path, notice: &str, now_ms: i64) -> Result<bool, String> {
450    crate::filelock::with_lock(own, || {
451        let Some(last) = last_of(own).remove(notice).filter(|l| l.gone == 0) else { return Ok(false) };
452        let line = serde_json::to_string(&Live { reply: false, read: false, gone: now_ms, at: now_ms, ..last }).map_err(|e| e.to_string())? + "\n";
453        calls::append_private(own, &line).map(|()| true)
454    })
455}
456
457/// What every phone under `root` said last of each notification, by phone
458/// and notice, within the week the lines are kept.
459pub fn read_live(root: &Path, now_ms: i64) -> BTreeMap<(String, String), Live> {
460    let mut out = BTreeMap::new();
461    for (device, path) in calls::device_files(&root.join(LIVE)) {
462        for (notice, live) in last_of(&path) {
463            if now_ms - live.at <= phonemsgs::KEPT_DAYS * DAY_MS {
464                out.insert((device.clone(), notice), Live { device: device.clone(), ..live });
465            }
466        }
467    }
468    out
469}
470
471// ---------------------------------------------------------------- the request, and never twice
472
473/// The folder of each computer's requests (`Ask`), under the phone's
474/// messages: each computer's own file, its lines padded and sealed; shared.
475pub const ASK: &str = "ask";
476/// The folder of each phone's word on each request (`Outcome`), its own
477/// file: shared, never the words.
478pub const OUTCOME: &str = "outcome";
479/// The same words to the same notification are refused this long after the
480/// first, unless the request sends that first one again on purpose; one its
481/// phone said was not sent never counts (`unsent`).
482pub const SAME_WORDS_MS: i64 = 10 * 60_000;
483/// What the listener says of a reply that did not go (`Done::state`): such a
484/// reply never blocks the same words again.
485pub const NOT_SENT: &[&str] = &["gone", "other", "no-reply", "no-read", "closed", "locked", "activity", "late", "failed"];
486/// At most this many requests decided in one step (`verdict`, the rest at
487/// the next), taken from each computer in turn (`in_turn`): a runaway
488/// computer's thousands never hold a step long, nor keep another computer's
489/// reply from its turn.
490pub const STEP_MAX: usize = 100;
491
492/// The requests for this phone (`here`), each computer's in its own order,
493/// one computer's after another's in turn.
494pub fn in_turn<'a>(asks: &'a [Ask], here: &str) -> Vec<&'a Ask> {
495    let mut by: BTreeMap<&str, Vec<&Ask>> = BTreeMap::new();
496    for ask in asks.iter().filter(|a| a.phone == here) {
497        by.entry(ask.device.as_str()).or_default().push(ask);
498    }
499    let longest = by.values().map(Vec::len).max().unwrap_or(0);
500    (0..longest).flat_map(|i| by.values().filter_map(move |list| list.get(i).copied())).collect()
501}
502/// At most this many replies an hour, from all your computers together.
503pub const PER_HOUR: usize = 60;
504/// A request's line is padded to a multiple of this before it is sealed.
505pub const PAD_STEP: usize = 256;
506/// An hour, in milliseconds.
507const HOUR_MS: i64 = 3_600_000;
508
509/// A request a computer writes for a phone (decision 2 of the design): a
510/// reply's words for a notification's Reply, a Mark as read, or a request
511/// taken back. Nothing else can be asked: there is no field for an action,
512/// an app's key or an address.
513#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
514#[serde(default)]
515pub struct Ask {
516    /// Random, 32 hex digits: the request's one name everywhere. A "cancel"
517    /// takes back the request of the same key, from the same computer.
518    pub key: String,
519    /// The phone that must send it: its name in the sharing.
520    pub phone: String,
521    /// "reply", "read" or "cancel".
522    pub verb: String,
523    /// The notification, by the name the phone gave it (`notice`).
524    pub notice: String,
525    /// Its app's package.
526    pub app: String,
527    /// The newest line you had in front of you, by its id in that phone's log.
528    pub line: String,
529    /// A reply's words, at most `WORDS_MAX` characters; none otherwise.
530    #[serde(skip_serializing_if = "String::is_empty")]
531    pub words: String,
532    /// When it was written (ms, the writer's clock), and how far the writer's
533    /// clock may run ahead of the phone's (an upper bound, as texts measure it).
534    pub written: i64,
535    #[serde(skip_serializing_if = "is_zero")]
536    pub ahead: i64,
537    /// The key of the request this one sends again (Send again), if any.
538    #[serde(skip_serializing_if = "String::is_empty")]
539    pub again: String,
540    /// The device that wrote it, its name in the sharing, sealed with it
541    /// (`write_ask`): a line read from another device's file than its
542    /// writer's is passed over (`read_asks`), so that nothing (the server, a
543    /// stray file) moves a request from one computer's name to another's. Any
544    /// device that holds the passphrase can still write in any name.
545    pub writer: String,
546    /// Spaces that make the line's length a multiple of `PAD_STEP` (`padded_ask`).
547    #[serde(skip_serializing_if = "String::is_empty")]
548    pub pad: String,
549    /// The computer that wrote it: its sealed writer, found in its own file. Never written.
550    #[serde(skip)]
551    pub device: String,
552}
553
554/// A request as written: its JSON padded with spaces to a multiple of
555/// `PAD_STEP` bytes, before it is sealed: no reply's length shows.
556pub fn padded_ask(ask: &Ask) -> String {
557    let bare = Ask { pad: String::new(), device: String::new(), ..ask.clone() };
558    let text = serde_json::to_string(&bare).unwrap_or_default();
559    if text.len() % PAD_STEP == 0 {
560        return text;
561    }
562    // `,"pad":""` and the spaces inside it.
563    let room = text.len() + 9;
564    let spaces = (PAD_STEP - room % PAD_STEP) % PAD_STEP;
565    serde_json::to_string(&Ask { pad: " ".repeat(spaces), ..bare }).unwrap_or(text)
566}
567
568/// A request added to its computer's own file under `root` (the phone's
569/// messages' folder), its writer (`device`) sealed in it, padded and
570/// sealed, under its lock: once by its key and verb.
571pub fn write_ask(root: &Path, device: &str, ask: &Ask, seal: &dyn Sealer) -> Result<bool, String> {
572    let own = calls::own_file(root, ASK, device);
573    let ask = Ask { writer: device.to_string(), ..ask.clone() };
574    crate::filelock::with_lock(&own, || {
575        if crate::texts::read_file::<Ask>(&own, seal).iter().any(|a| a.key == ask.key && a.verb == ask.verb) {
576            return Ok(false);
577        }
578        calls::append_private(&own, &format!("{}\n", seal.seal(&padded_ask(&ask)))).map(|()| true)
579    })
580}
581
582/// Every computer's requests under each root (the records' copy, the
583/// sharing folder's small files, the copy fetched from the server), each
584/// with its computer, its sealed writer: each key once, the first root's
585/// first; and the keys each computer took back ("cancel"), by computer and
586/// key. A line whose sealed writer is not its file's device (moved from
587/// another device's file, or written before the writer was sealed) is
588/// passed over, a cancel too.
589pub fn read_asks(roots: &[PathBuf], seal: &dyn Sealer) -> (Vec<Ask>, BTreeSet<(String, String)>) {
590    let mut asks: Vec<Ask> = Vec::new();
591    let mut known: BTreeSet<String> = BTreeSet::new();
592    let mut cancelled = BTreeSet::new();
593    for root in roots {
594        for (device, path) in calls::device_files(&root.join(ASK)) {
595            for ask in crate::texts::read_file::<Ask>(&path, seal) {
596                if ask.key.is_empty() || ask.writer.is_empty() || calls::file_safe(&ask.writer) != device {
597                    continue;
598                }
599                let ask = Ask { device: ask.writer.clone(), ..ask };
600                if ask.verb == "cancel" {
601                    cancelled.insert((ask.device.clone(), ask.key.clone()));
602                } else if known.insert(ask.key.clone()) {
603                    asks.push(ask);
604                }
605            }
606        }
607    }
608    (asks, cancelled)
609}
610
611// ---------------------------------------------------------------- how far the phone is done with each file
612
613/// How far the phone is done with each file of requests, in its private
614/// folder (`ReadMark`): past every line decided, another phone's, or that
615/// does not open (`moved_marks`). A step reads each file from there
616/// (`read_asks_after`): a computer's thousands decided already are never
617/// opened again.
618pub const READ_MARKS: &str = "asks-read.json";
619
620/// A file of requests done with up to `offset` bytes, its last line done
621/// with by its hash (`line`): read from there while the file still holds
622/// that line there, else whole again (rewritten, trimmed).
623#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
624pub struct ReadMark {
625    pub offset: u64,
626    pub line: String,
627}
628
629/// A file's lines read at a step (`read_asks_after`), from `start`: each
630/// with where it ends, its hash, the request's key and phone ("" for a line
631/// that does not open, or another writer's).
632#[derive(Debug, Clone, Default, PartialEq, Eq)]
633pub struct FileRead {
634    pub path: String,
635    pub start: u64,
636    pub lines: Vec<(u64, String, String, String)>,
637}
638
639/// The marks as kept; none when the file does not read.
640pub fn read_marks(folder: &Path) -> BTreeMap<String, ReadMark> {
641    std::fs::read_to_string(folder.join(READ_MARKS)).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default()
642}
643
644/// The marks written whole, beside then put in place.
645pub fn save_read_marks(folder: &Path, marks: &BTreeMap<String, ReadMark>) -> Result<(), String> {
646    private_dir(folder)?;
647    let text = serde_json::to_string(marks).map_err(|e| e.to_string())?;
648    let fresh = folder.join(format!(".{READ_MARKS}.{}.new", std::process::id()));
649    let _ = std::fs::remove_file(&fresh);
650    calls::append_private(&fresh, &text)?;
651    std::fs::rename(&fresh, folder.join(READ_MARKS)).map_err(|e| format!("{}: {e}", folder.display()))
652}
653
654/// A sealed line's name for the marks: a hash of it as written.
655fn line_hash(line: &str) -> String {
656    appnotes::talk_key("asks", line)
657}
658
659/// Whether a file still holds, just before a mark's offset, the line the mark names.
660fn holds(bytes: &[u8], mark: &ReadMark) -> bool {
661    let end = usize::try_from(mark.offset).unwrap_or(usize::MAX);
662    if end == 0 || end > bytes.len() || bytes[end - 1] != b'\n' {
663        return false;
664    }
665    let before = &bytes[..end - 1];
666    let start = before.iter().rposition(|b| *b == b'\n').map_or(0, |i| i + 1);
667    line_hash(String::from_utf8_lossy(&before[start..]).trim()) == mark.line
668}
669
670/// `read_asks` for a phone's step, each file from its mark (`marks`, by its
671/// path) when it still holds the line the mark names, else whole: the
672/// requests past the marks, the cancels, and each file's lines read, to move
673/// the marks once the step decided (`moved_marks`). A line not ended yet
674/// (being written) waits for the next step.
675pub fn read_asks_after(roots: &[PathBuf], seal: &dyn Sealer, marks: &BTreeMap<String, ReadMark>) -> ((Vec<Ask>, BTreeSet<(String, String)>), Vec<FileRead>) {
676    let mut asks: Vec<Ask> = Vec::new();
677    let mut known: BTreeSet<String> = BTreeSet::new();
678    let mut cancelled = BTreeSet::new();
679    let mut reads = Vec::new();
680    for root in roots {
681        for (device, path) in calls::device_files(&root.join(ASK)) {
682            let Ok(bytes) = std::fs::read(&path) else { continue };
683            let name = path.display().to_string();
684            let start = marks.get(&name).filter(|m| holds(&bytes, m)).map_or(0, |m| usize::try_from(m.offset).unwrap_or(0));
685            let mut read = FileRead { path: name, start: start as u64, lines: Vec::new() };
686            let mut at = start;
687            for piece in bytes[start..].split_inclusive(|b| *b == b'\n') {
688                if !piece.ends_with(b"\n") {
689                    break;
690                }
691                at += piece.len();
692                let text = String::from_utf8_lossy(piece);
693                let line = text.trim();
694                let opened = if line.is_empty() { None } else { seal.open(line).and_then(|plain| serde_json::from_str::<Ask>(&plain).ok()) };
695                let (key, phone) = match opened {
696                    Some(ask) if !ask.key.is_empty() && !ask.writer.is_empty() && calls::file_safe(&ask.writer) == device => {
697                        let ask = Ask { device: ask.writer.clone(), ..ask };
698                        let named = (ask.key.clone(), ask.phone.clone());
699                        if ask.verb == "cancel" {
700                            cancelled.insert((ask.device.clone(), ask.key.clone()));
701                        } else if known.insert(ask.key.clone()) {
702                            asks.push(ask);
703                        }
704                        named
705                    }
706                    _ => (String::new(), String::new()),
707                };
708                read.lines.push((at as u64, line_hash(line), key, phone));
709            }
710            reads.push(read);
711        }
712    }
713    ((asks, cancelled), reads)
714}
715
716/// The marks moved past every line done with, in each file's order: a line
717/// that does not open or is another writer's, or whose request is done
718/// with (`done`, by its key and phone: decided, or another phone's); the
719/// first line not done with stops its file's mark. Only the files read
720/// now keep one.
721pub fn moved_marks(reads: &[FileRead], done: &dyn Fn(&str, &str) -> bool, marks: &BTreeMap<String, ReadMark>) -> BTreeMap<String, ReadMark> {
722    let mut out = BTreeMap::new();
723    for read in reads {
724        let mut mark = marks.get(&read.path).filter(|m| m.offset == read.start && read.start > 0).cloned().unwrap_or_default();
725        for (end, hash, key, phone) in &read.lines {
726            if !key.is_empty() && !done(key, phone) {
727                break;
728            }
729            mark = ReadMark { offset: *end, line: hash.clone() };
730        }
731        if mark.offset > 0 {
732            out.insert(read.path.clone(), mark);
733        }
734    }
735    out
736}
737
738/// A phone's word on a request (decision 10): never the words.
739#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
740#[serde(default)]
741pub struct Outcome {
742    pub key: String,
743    /// "taken", "handed", "shown", the listener's reasons it was not sent
744    /// (`Done::state`), "doubt" (taken, and no word from its app after),
745    /// "taken-back", "expired", "clocks", "refused" (`reason` says why).
746    pub state: String,
747    /// When (ms, the phone's clock).
748    pub at: i64,
749    #[serde(skip_serializing_if = "String::is_empty")]
750    pub reason: String,
751    #[serde(skip_serializing_if = "is_false")]
752    pub crossed: bool,
753    #[serde(skip_serializing_if = "is_false")]
754    pub held: bool,
755    /// The phone that wrote it. Never written.
756    #[serde(skip)]
757    pub device: String,
758}
759
760/// Every phone's outcomes under `root`, each with its phone, oldest first.
761pub fn read_outcomes(root: &Path) -> Vec<Outcome> {
762    let mut all: Vec<Outcome> = calls::device_files(&root.join(OUTCOME)).into_iter().flat_map(|(device, path)| calls::read_lines::<Outcome>(&path).into_iter().map(move |o| Outcome { device: device.clone(), ..o })).collect();
763    all.sort_by_key(|o| o.at);
764    all
765}
766
767/// Outcomes added to a phone's own file (`own`), each (key, state) once,
768/// under its lock. How many were written.
769pub fn say_outcomes(own: &Path, outcomes: &[Outcome]) -> Result<usize, String> {
770    if outcomes.is_empty() {
771        return Ok(0);
772    }
773    crate::filelock::with_lock(own, || {
774        let mut known: BTreeSet<(String, String)> = calls::read_lines::<Outcome>(own).into_iter().map(|o| (o.key, o.state)).collect();
775        let mut text = String::new();
776        for o in outcomes {
777            if known.insert((o.key.clone(), o.state.clone())) {
778                text.push_str(&serde_json::to_string(&Outcome { device: String::new(), ..o.clone() }).map_err(|e| e.to_string())?);
779                text.push('\n');
780            }
781        }
782        let written = text.lines().count();
783        if written > 0 {
784            calls::append_private(own, &text)?;
785        }
786        Ok(written)
787    })
788}
789
790/// What the phone does with a request (decision 3), the texts' rules.
791#[derive(Debug, Clone, Copy, PartialEq, Eq)]
792pub enum Verdict {
793    /// Claim it, then hand it to the listener.
794    Send,
795    /// Another phone's, or a "cancel" (read with what it takes back): nothing done, nothing said.
796    NotMine,
797    /// Its key is in the ledger already, whatever was decided: nothing again.
798    Decided,
799    /// Its computer took it back before the phone claimed it.
800    TakenBack,
801    /// Older than `texts::EXPIRY_MS` on the phone's clock.
802    Expired,
803    /// Dated further ahead of the phone's clock than `texts::AHEAD_MS`.
804    Clocks,
805    /// Refused, and why: "not-counted", "verb", "no-words", "too-long",
806    /// "line", "code", "switched-off", "short-number", "before-mark",
807    /// "same-words" (the same words went), "same-words-pending" (they are on
808    /// their way), "too-many", "too-many-read" (Mark as read, sixty an hour too).
809    Refused(&'static str),
810    /// Asked by a device the sharing does not know yet (its records have not
811    /// come): nothing done, nothing said, judged again at the next step
812    /// while it is fresh (as a text's request: `texts::Verdict::Wait`).
813    Wait,
814}
815
816/// What the phone judges a request by.
817pub struct Judge<'a> {
818    /// This phone's name in the sharing.
819    pub here: &'a str,
820    pub now_ms: i64,
821    /// Its private ledger of replies (`texts::Ledger`, in its replies folder).
822    pub ledger: &'a crate::texts::Ledger,
823    /// Its own log's lines: a request answers one of them.
824    pub lines: &'a [phonemsgs::Line],
825    /// Where the device that asks stands in the sharing now
826    /// (`texts::Standing`): counted; gone (it left, you forgot it, another
827    /// device replaces it): refused; not known yet: its request waits while
828    /// it is fresh. Only your own devices ask.
829    pub standing: &'a dyn Fn(&str) -> crate::texts::Standing,
830    /// The keys each computer took back (`read_asks`).
831    pub cancelled: &'a BTreeSet<(String, String)>,
832    /// Whether an app's notifications may be answered from your computers (the phone's switch).
833    pub answers: &'a dyn Fn(&str) -> bool,
834    /// The requests its listener said were not sent (`unsent`).
835    pub unsent: &'a BTreeSet<String>,
836    /// The requests its listener said went (handed or shown: `went_keys`).
837    pub went: &'a BTreeSet<String>,
838}
839
840/// The requests the listener said went: handed to their app, or shown.
841pub fn went_keys(folder: &Path) -> BTreeSet<String> {
842    read_done(folder).into_iter().filter(|d| matches!(d.state.as_str(), "handed" | "shown")).map(|d| d.key).collect()
843}
844
845/// The requests the listener said were not sent (`NOT_SENT`), from its words
846/// in the private folder (a day of them: longer than the guards look back).
847pub fn unsent(folder: &Path) -> BTreeSet<String> {
848    read_done(folder).into_iter().filter(|d| NOT_SENT.contains(&d.state.as_str())).map(|d| d.key).collect()
849}
850
851/// The never-twice decision for a reply, step by step: another phone's (or
852/// a "cancel"); decided already; taken back by its computer; asked by a
853/// device the sharing no longer counts; refused (another verb, no words or
854/// too many, a line not in this phone's log or of another notification, a
855/// code's line, its app's answers switched off, a short number or a sender
856/// named in letters in the SMS app); written before the ledger's mark;
857/// dated ahead of the phone's clock; older than `texts::EXPIRY_MS`; the two
858/// guards (the same words to the same notification within `SAME_WORDS_MS`,
859/// from a reply that went or may still go, unless it sends the first again;
860/// `PER_HOUR` replies claimed in the last hour); else sent. The time written
861/// is corrected by how far the writer's clock may run ahead. `device` is
862/// the request's sealed writer (`read_asks`).
863pub fn verdict(ask: &Ask, j: &Judge) -> Verdict {
864    if ask.phone != j.here || ask.key.is_empty() || ask.verb == "cancel" {
865        return Verdict::NotMine;
866    }
867    if j.ledger.has(&ask.key) {
868        return Verdict::Decided;
869    }
870    if j.cancelled.contains(&(ask.device.clone(), ask.key.clone())) {
871        return Verdict::TakenBack;
872    }
873    match (j.standing)(&ask.device) {
874        crate::texts::Standing::Counted => {}
875        crate::texts::Standing::Gone => return Verdict::Refused("not-counted"),
876        crate::texts::Standing::Unknown => {
877            let written = ask.written - ask.ahead;
878            return if written > j.now_ms + crate::texts::AHEAD_MS {
879                Verdict::Clocks
880            } else if j.now_ms - written > crate::texts::EXPIRY_MS {
881                Verdict::Expired
882            } else {
883                Verdict::Wait
884            };
885        }
886    }
887    let reply = ask.verb == "reply";
888    if !reply && ask.verb != "read" {
889        return Verdict::Refused("verb");
890    }
891    if reply && ask.words.trim().is_empty() {
892        return Verdict::Refused("no-words");
893    }
894    if reply && ask.words.chars().count() > WORDS_MAX {
895        return Verdict::Refused("too-long");
896    }
897    let Some(line) = j.lines.iter().find(|l| l.id == ask.line && !l.notice.is_empty()) else { return Verdict::Refused("line") };
898    if line.notice != ask.notice || line.app != ask.app {
899        return Verdict::Refused("line");
900    }
901    if line.code {
902        return Verdict::Refused("code");
903    }
904    if !(j.answers)(&ask.app) {
905        return Verdict::Refused("switched-off");
906    }
907    if line.kind == "text" && !line.group && (line.who == "automaton" || !crate::phones::is_whole(&line.key)) {
908        return Verdict::Refused("short-number");
909    }
910    let written = ask.written - ask.ahead;
911    if written < j.ledger.mark.since {
912        return Verdict::Refused("before-mark");
913    }
914    if written > j.now_ms + crate::texts::AHEAD_MS {
915        return Verdict::Clocks;
916    }
917    if j.now_ms - written > crate::texts::EXPIRY_MS {
918        return Verdict::Expired;
919    }
920    // The claims of the last hour: replies by their words' hash, Marks as read without one.
921    let lately = j.ledger.claims.iter().filter(|c| c.state == "claimed" && j.now_ms - c.at <= HOUR_MS);
922    if reply {
923        let hash = words_hash(&ask.words);
924        let replies: Vec<&crate::texts::Claim> = lately.filter(|c| !c.hash.is_empty()).collect();
925        // A reply that went, or may still go (claimed, taken, handed, shown, in doubt); never one its phone said did not go.
926        // The same notification, or another of the same conversation (its newer one, which Send again names as the
927        // Porch does now): the same words never go twice (review of 18 October 2026, L-2).
928        let same = |c: &crate::texts::Claim| c.to == ask.notice || (!c.talk.is_empty() && c.talk == line.talk);
929        let twin = replies.iter().find(|c| !j.unsent.contains(&c.key) && same(c) && c.hash == hash && j.now_ms - c.at <= SAME_WORDS_MS && c.key != ask.again);
930        if let Some(twin) = twin {
931            // Said as it is known: went (its phone said so), or on its way.
932            return Verdict::Refused(if j.went.contains(&twin.key) { "same-words" } else { "same-words-pending" });
933        }
934        if replies.len() >= PER_HOUR {
935            return Verdict::Refused("too-many");
936        }
937    } else if lately.filter(|c| c.hash.is_empty()).count() >= PER_HOUR {
938        return Verdict::Refused("too-many-read");
939    }
940    Verdict::Send
941}
942
943/// A request claimed, as the ledger keeps it: the notification's name, the
944/// words' hash and the conversation of the line it answers (`talk`), for the
945/// guards; never the words.
946pub fn claim_of(ask: &Ask, talk: &str, now_ms: i64) -> crate::texts::Claim {
947    crate::texts::Claim { key: ask.key.clone(), at: now_ms, state: "claimed".into(), to: ask.notice.clone(), hash: if ask.verb == "reply" { words_hash(&ask.words) } else { String::new() }, talk: talk.to_string(), ..crate::texts::Claim::default() }
948}
949
950/// A request decided and ended at the step (taken back, expired, clocks,
951/// refused and why), as the ledger keeps it: `refused:same-words` for a
952/// refusal, its reason after the colon, so that its word can be said again
953/// (`ends_of`).
954pub fn end_of(key: &str, state: &str, reason: &str, now_ms: i64) -> crate::texts::Claim {
955    let state = if reason.is_empty() { state.to_string() } else { format!("{state}:{reason}") };
956    crate::texts::Claim { key: key.to_string(), at: now_ms, state, ..crate::texts::Claim::default() }
957}
958
959/// The step's decisions the ledger holds from the last day, as outcomes
960/// (`end_of`), to say again those a step could not write: `say_outcomes`
961/// writes each (key, state) once.
962pub fn ends_of(ledger: &crate::texts::Ledger, now_ms: i64) -> Vec<Outcome> {
963    ledger
964        .claims
965        .iter()
966        .filter(|c| now_ms - c.at <= DAY_MS)
967        .filter_map(|c| {
968            let (state, reason) = c.state.split_once(':').unwrap_or((c.state.as_str(), ""));
969            matches!(state, "taken-back" | "expired" | "clocks" | "refused").then(|| Outcome { key: c.key.clone(), state: state.to_string(), at: c.at, reason: reason.to_string(), ..Outcome::default() })
970        })
971        .collect()
972}
973
974/// What the listener needs of a request claimed: the line it answers, read
975/// from this phone's log (its conversation, who wrote, its time), the words.
976pub fn due_of(ask: &Ask, line: &phonemsgs::Line, now_ms: i64) -> Due {
977    Due { key: ask.key.clone(), verb: ask.verb.clone(), notice: ask.notice.clone(), app: ask.app.clone(), talk: line.talk.clone(), from: line.key.clone(), name: line.name.clone(), group: line.group, title: line.title.clone(), line_at: line.at, words: if ask.verb == "reply" { ask.words.clone() } else { String::new() }, at: now_ms }
978}
979
980/// What is due added to what the listener has not done yet (those done
981/// more than a day ago left out), written whole under the folder's lock.
982pub fn add_due(folder: &Path, fresh: &[Due], seal: &dyn Sealer, now_ms: i64) -> Result<(), String> {
983    private_dir(folder)?;
984    crate::filelock::with_lock(&folder.join(DUE), || {
985        let done = read_done(folder);
986        let mut due: Vec<Due> = read_due(folder, seal).into_iter().filter(|d| now_ms - d.at <= DAY_MS || !done.iter().any(|x| x.key == d.key)).filter(|d| !fresh.iter().any(|f| f.key == d.key)).collect();
987        due.extend(fresh.iter().cloned());
988        write_due(folder, &due, seal)
989    })
990}
991
992/// The listener's words on claimed requests, for its outcomes; and, under
993/// the folder's lock, what it never took within `TAKE_WITHIN_MS` of the
994/// claim said "late" (its listener was not bound), and what it took without
995/// a word after `texts::LOOK_AFTER_MS` said "doubt": its app may have it.
996/// `claimed`: the keys the ledger says claimed, with when.
997pub fn settle(folder: &Path, claimed: &[(String, i64)], now_ms: i64) -> Result<Vec<Done>, String> {
998    private_dir(folder)?;
999    crate::filelock::with_lock(&folder.join(DUE), || {
1000        let done = read_done(folder);
1001        let mut words: BTreeMap<&str, Vec<&Done>> = BTreeMap::new();
1002        for d in &done {
1003            words.entry(d.key.as_str()).or_default().push(d);
1004        }
1005        let mut ends = Vec::new();
1006        for (key, at) in claimed {
1007            let said = words.get(key.as_str()).map_or(&[][..], Vec::as_slice);
1008            if said.is_empty() && now_ms - at > TAKE_WITHIN_MS {
1009                ends.push(Done::of(key, "late", now_ms));
1010            } else if said.len() == 1 && said[0].state == "taken" && now_ms - said[0].at > crate::texts::LOOK_AFTER_MS {
1011                ends.push(Done::of(key, "doubt", now_ms));
1012            }
1013        }
1014        // The step's words together, one flush (a runaway computer's claims never cost one each).
1015        append_all_flushed(&folder.join(DONE), &ends)?;
1016        Ok(read_done(folder))
1017    })
1018}
1019
1020/// Whether a state ends a request: no other word of the phone's follows,
1021/// but "shown" after "handed".
1022pub fn is_final(state: &str) -> bool {
1023    !matches!(state, "taken" | "handed")
1024}
1025
1026/// What the phone did with each request, from the listener's words and the
1027/// step's verdicts: an outcome per state, never the words.
1028pub fn outcomes_of(done: &[Done]) -> Vec<Outcome> {
1029    done.iter().map(|d| Outcome { key: d.key.clone(), state: d.state.clone(), at: d.at, crossed: d.crossed, held: d.held, ..Outcome::default() }).collect()
1030}
1031
1032/// The private files old lines are taken out of, a day on: what is due and
1033/// done; the ledger keeps its keys `texts::LEDGER_KEPT_MS`.
1034pub fn trim_private(folder: &Path, seal: &dyn Sealer, now_ms: i64) -> Result<(), String> {
1035    crate::filelock::with_lock(&folder.join(DUE), || {
1036        let due: Vec<Due> = read_due(folder, seal).into_iter().filter(|d| now_ms - d.at <= DAY_MS).collect();
1037        if folder.join(DUE).is_file() {
1038            write_due(folder, &due, seal)?;
1039        }
1040        calls::rewrite_lines(&folder.join(DONE), |line| serde_json::from_str::<Done>(line).is_ok_and(|d| now_ms - d.at > DAY_MS)).map(|_| ())
1041    })?;
1042    crate::texts::trim_ledger(folder, now_ms).map(|_| ())
1043}
1044
1045/// The requests and outcomes kept past `phonemsgs::KEPT_DAYS` taken out of
1046/// this device's own files under `root`, those of other devices
1047/// `phonemsgs::LATE_DAYS` later (a computer gone for good never takes its
1048/// own out): the sharing then takes them out everywhere. The requests are
1049/// sealed: each opened to read its time, one that does not open kept.
1050pub fn trim_shared(root: &Path, here: &str, seal: &dyn Sealer, now_ms: i64) -> Result<usize, String> {
1051    let ask_old = |days: i64| move |line: &str| seal.open(line.trim()).and_then(|plain| serde_json::from_str::<Ask>(&plain).ok()).is_some_and(|a| now_ms - a.written > days * DAY_MS);
1052    let outcome_old = |days: i64| move |line: &str| serde_json::from_str::<Outcome>(line).is_ok_and(|o| now_ms - o.at > days * DAY_MS);
1053    let mut gone = 0;
1054    for (device, path) in calls::device_files(&root.join(ASK)) {
1055        let days = if device == calls::file_safe(here) { phonemsgs::KEPT_DAYS } else { phonemsgs::KEPT_DAYS + phonemsgs::LATE_DAYS };
1056        gone += calls::rewrite_lines(&path, ask_old(days))?;
1057    }
1058    for (device, path) in calls::device_files(&root.join(OUTCOME)) {
1059        let days = if device == calls::file_safe(here) { phonemsgs::KEPT_DAYS } else { phonemsgs::KEPT_DAYS + phonemsgs::LATE_DAYS };
1060        gone += calls::rewrite_lines(&path, outcome_old(days))?;
1061    }
1062    Ok(gone)
1063}
1064
1065// ---------------------------------------------------------------- the states, in words
1066
1067/// A request's state as a computer says it (decision 10): never red, never
1068/// "sent" nor "delivered", which the phone cannot know of another app.
1069#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
1070pub struct State {
1071    /// "Handed to Signal at 14:03."
1072    pub said: String,
1073    /// Still waiting for the phone: Take back offered.
1074    pub waiting: bool,
1075    /// Send again offered (a new key): after it expired, clocks apart, a doubt.
1076    pub again: bool,
1077    /// "Reply on your phone" fits: the notification gone, or its Reply closed to Sioul.
1078    pub on_phone: bool,
1079    /// The phone's last word on it: a state of `Outcome`, "" none.
1080    pub state: String,
1081}
1082
1083/// How far a state goes, to say the furthest: shown, then handed, then an end, then taken.
1084fn rank(state: &str) -> u8 {
1085    match state {
1086        "shown" => 4,
1087        "handed" => 3,
1088        "taken" => 1,
1089        _ => 2,
1090    }
1091}
1092
1093/// A request's state in words, from its phone's outcomes (`outcomes`, its
1094/// key's): the furthest it went, the latest of those. `app`: its app's name
1095/// as the phone shows it; `phone_shared`: when the phone last shared (ms),
1096/// for "Waiting for your phone"; `cancel`: this computer took it back
1097/// (Take back), said at once, and said too late when the phone had taken it
1098/// already; `time_of`: a moment (ms) as the computer says the time.
1099pub fn state_of(ask: &Ask, outcomes: &[&Outcome], app: &str, tr: &crate::i18n::Translator, phone_shared: Option<i64>, cancel: bool, time_of: &dyn Fn(i64) -> String) -> State {
1100    let mut args = crate::i18n::args();
1101    args.set("app", app.to_string());
1102    let Some(last) = outcomes.iter().copied().max_by_key(|o| (rank(&o.state), o.at)) else {
1103        if cancel {
1104            // Taken back here, the phone's word not come yet: no Take back again.
1105            return State { said: tr.text("replies-taking-back", None), ..State::default() };
1106        }
1107        return match phone_shared {
1108            Some(at) => {
1109                args.set("time", time_of(at));
1110                State { said: tr.text("replies-waiting", Some(&args)), waiting: true, ..State::default() }
1111            }
1112            None => State { said: tr.text("replies-waiting-never", None), waiting: true, ..State::default() },
1113        };
1114    };
1115    args.set("time", time_of(last.at));
1116    let read = ask.verb == "read";
1117    let (id, again, on_phone) = match (last.state.as_str(), read) {
1118        ("handed", true) => ("replies-read-handed", false, false),
1119        ("handed", false) if last.held && last.crossed => ("replies-handed-held", false, false),
1120        ("handed", false) if last.crossed => ("replies-handed-crossed", false, false),
1121        ("handed", false) => ("replies-handed", false, false),
1122        ("shown", _) => ("replies-shown", false, false),
1123        ("taken", _) => ("replies-taken", false, false),
1124        ("gone", _) => ("replies-gone", false, true),
1125        ("other", _) => ("replies-other", false, true),
1126        ("no-reply", _) => ("replies-no-reply", false, true),
1127        ("no-read", _) => ("replies-no-read", false, true),
1128        ("closed", _) => ("replies-closed", false, true),
1129        ("locked", _) => ("replies-locked", false, true),
1130        ("activity", _) => ("replies-activity", false, true),
1131        ("late", _) => ("replies-late", true, false),
1132        ("failed", _) => ("replies-failed", true, false),
1133        ("expired", _) => ("replies-expired", true, false),
1134        ("clocks", _) => ("replies-clocks", true, false),
1135        ("doubt", _) => ("replies-doubt", true, false),
1136        ("taken-back", _) => ("replies-taken-back", false, false),
1137        ("refused", _) => match last.reason.as_str() {
1138            "same-words" => ("replies-refused-same-words", false, false),
1139            // The first may yet fail: Send again goes only if it did.
1140            "same-words-pending" => ("replies-refused-same-words-pending", true, false),
1141            "too-many" => ("replies-refused-too-many", false, false),
1142            "too-many-read" => ("replies-refused-too-many-read", false, false),
1143            "too-long" => ("replies-refused-too-long", false, false),
1144            "no-words" => ("replies-refused-no-words", false, false),
1145            "line" => ("replies-refused-line", false, true),
1146            "code" => ("replies-refused-code", false, true),
1147            "switched-off" => ("replies-refused-switched-off", false, true),
1148            "short-number" => ("replies-refused-short-number", false, true),
1149            "not-counted" => ("replies-refused-not-counted", false, false),
1150            "before-mark" => ("replies-refused-before-mark", true, false),
1151            _ => ("replies-refused", false, true),
1152        },
1153        _ => ("replies-refused", false, true),
1154    };
1155    let mut said = tr.text(id, Some(&args));
1156    // Taken back, but the phone had taken it already: it went on, said so.
1157    if cancel && matches!(last.state.as_str(), "taken" | "handed" | "shown" | "doubt") {
1158        said = format!("{said} {}", tr.text("replies-take-back-late", None));
1159    }
1160    State { said, again, on_phone, state: last.state.clone(), ..State::default() }
1161}
1162
1163// ---------------------------------------------------------------- a conversation answered
1164
1165/// The conversations answered from a computer lately, in the phone's
1166/// private replies folder: each conversation's key (a hash) and until when
1167/// its messages come through at once (decision 18).
1168pub const ANSWERED: &str = "answered.json";
1169/// A conversation answered from a computer comes through at once this long
1170/// after the reply was handed, as a second call within a quarter of an hour
1171/// rings: their answer to your answer is not held. Never during a pause.
1172pub const ANSWERED_MS: i64 = 20 * 60_000;
1173
1174/// The conversations answered lately, as kept (those past their time left out).
1175fn answered(folder: &Path, now_ms: i64) -> BTreeMap<String, i64> {
1176    std::fs::read_to_string(folder.join(ANSWERED)).ok().and_then(|t| serde_json::from_str::<BTreeMap<String, i64>>(&t).ok()).unwrap_or_default().into_iter().filter(|(_, until)| *until > now_ms).collect()
1177}
1178
1179/// A conversation answered (its key, `appnotes::talk_key`): its messages
1180/// come through at once until `ANSWERED_MS` from now. Under the folder's lock.
1181pub fn note_answered(folder: &Path, talk: &str, now_ms: i64) -> Result<(), String> {
1182    if talk.is_empty() {
1183        return Ok(());
1184    }
1185    private_dir(folder)?;
1186    crate::filelock::with_lock(&folder.join(ANSWERED), || {
1187        let mut kept = answered(folder, now_ms);
1188        kept.insert(talk.to_string(), now_ms + ANSWERED_MS);
1189        let text = serde_json::to_string(&kept).map_err(|e| e.to_string())?;
1190        let fresh = folder.join(format!(".{ANSWERED}.{}.new", std::process::id()));
1191        let _ = std::fs::remove_file(&fresh);
1192        calls::append_private(&fresh, &text)?;
1193        std::fs::rename(&fresh, folder.join(ANSWERED)).map_err(|e| format!("{}: {e}", folder.display()))
1194    })
1195}
1196
1197/// Whether a conversation was answered from a computer within `ANSWERED_MS`.
1198pub fn answered_lately(folder: &Path, talk: &str, now_ms: i64) -> bool {
1199    !talk.is_empty() && answered(folder, now_ms).contains_key(talk)
1200}
1201
1202/// A number a computer texted (`phones::key`), as the conversations
1203/// answered keep it (`note_answered`): a hash, never the number. "" for none.
1204pub fn answered_number(number: &str) -> String {
1205    if number.is_empty() { String::new() } else { format!("tel:{}", appnotes::talk_key("tel", number)) }
1206}
1207
1208/// What this computer answered lately, for its Porch (decision 18): the
1209/// conversations its replies went to, handed or shown within `ANSWERED_MS`
1210/// (the phone, the conversation's key), and the numbers it texted then.
1211/// Their lines show at once, as their phone lets them through, but in a pause.
1212#[derive(Debug, Clone, Default, PartialEq, Eq)]
1213pub struct Answered {
1214    talks: BTreeSet<(String, String)>,
1215    numbers: BTreeSet<String>,
1216}
1217
1218impl Answered {
1219    /// From this computer's own requests (`own`), every phone's outcomes and
1220    /// the phones' lines: a reply whose phone said, within `ANSWERED_MS`, that
1221    /// it handed it, or that its app shows it (the phone's clock may run up
1222    /// to `texts::AHEAD_MS` ahead of this one's).
1223    pub fn of(own: &[Ask], outcomes: &[Outcome], all: &[phonemsgs::Line], now_ms: i64) -> Answered {
1224        let lately: BTreeSet<(&str, &str)> = outcomes.iter().filter(|o| matches!(o.state.as_str(), "handed" | "shown") && (-crate::texts::AHEAD_MS..=ANSWERED_MS).contains(&(now_ms - o.at))).map(|o| (o.device.as_str(), o.key.as_str())).collect();
1225        let talks = own
1226            .iter()
1227            .filter(|a| a.verb == "reply" && lately.contains(&(a.phone.as_str(), a.key.as_str())))
1228            .filter_map(|a| all.iter().find(|l| l.device == a.phone && l.id == a.line))
1229            .filter(|l| !l.talk.is_empty())
1230            .map(|l| (l.device.clone(), l.talk.clone()))
1231            .collect();
1232        Answered { talks, numbers: BTreeSet::new() }
1233    }
1234
1235    /// A number this computer texted lately (`phones::key`), its phone having taken it.
1236    pub fn texted(&mut self, number: String) {
1237        if !number.is_empty() {
1238            self.numbers.insert(number);
1239        }
1240    }
1241
1242    /// Whether a line is of a conversation answered lately: its conversation
1243    /// on its phone, or a text from a number texted.
1244    pub fn holds(&self, line: &phonemsgs::Line) -> bool {
1245        (!line.talk.is_empty() && self.talks.contains(&(line.device.clone(), line.talk.clone()))) || (line.kind == "text" && !line.key.is_empty() && self.numbers.contains(&line.key))
1246    }
1247}
1248
1249// ---------------------------------------------------------------- what a Porch's line offers
1250
1251/// The apps whose Reply marks the conversation read, as their sources say
1252/// (Signal, Telegram, /e/OS's Message and the QKSMS it comes from): the
1253/// first reply in each says so.
1254pub const MARKS_READ: &[&str] = &["org.thoughtcrime.securesms", "org.telegram.messenger", "foundation.e.message", "com.moez.QKSMS", "dev.octoshrimpy.quik"];
1255
1256/// What a Porch's line offers a reply from this computer (decision 14).
1257#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
1258pub struct Answer {
1259    /// The phone the line came from, the notification answered (`notice`;
1260    /// the conversation's current one, when its app posted it anew), its
1261    /// app, its name as the phone shows it, and the newest line answered.
1262    pub phone: String,
1263    pub notice: String,
1264    pub app: String,
1265    pub label: String,
1266    pub line: String,
1267    /// The phone says its Reply works now (`Live`): else "Reply on your phone".
1268    pub offered: bool,
1269    /// Its Mark as read too.
1270    pub read: bool,
1271    /// A text from a whole number, between two, where this computer reads
1272    /// the texts: the reply goes as a text of Sioul's own (decision 12),
1273    /// with its own states.
1274    pub texts: bool,
1275    /// "Reply to Alice on Signal…", "Reply in École on WhatsApp…".
1276    pub placeholder: String,
1277    /// The first reply in an app that marks the conversation read says so; "" otherwise.
1278    pub note: String,
1279    /// "Mark as read in Signal", and its warning: "Signal may tell them that you read it."
1280    pub read_item: String,
1281    pub read_warning: String,
1282    /// "Reply on your phone."
1283    pub on_phone: String,
1284    /// The most a reply may hold, in characters (a text's own limit for texts).
1285    pub limit: usize,
1286    /// This computer's replies to it, oldest first.
1287    pub mine: Vec<Mine>,
1288}
1289
1290/// A reply written on this computer, under the messages it answers.
1291#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
1292pub struct Mine {
1293    pub key: String,
1294    /// "14:03": when it was written.
1295    pub time: String,
1296    pub text: String,
1297    /// Its state, in words (`state_of`; a text's own words for texts).
1298    pub said: String,
1299    pub waiting: bool,
1300    pub again: bool,
1301    /// Went as a text of Sioul's own: Take back is not offered, Send again is the texts'.
1302    pub texts: bool,
1303}
1304
1305/// What a computer knows for a Porch's line's answer.
1306pub struct Offer<'a> {
1307    /// What each phone said last of each notification (`read_live`), by phone and notice.
1308    pub live: &'a BTreeMap<(String, String), Live>,
1309    /// This computer's own requests (`read_asks` of its file).
1310    pub asks: &'a [Ask],
1311    /// Every phone's outcomes.
1312    pub outcomes: &'a [Outcome],
1313    pub tr: &'a crate::i18n::Translator,
1314    /// The keys this computer took back (its "cancel" lines, `read_asks`).
1315    pub taken_back: &'a BTreeSet<String>,
1316    /// Every phone's lines (`phonemsgs::read_logs`): a reply through a
1317    /// conversation's newer notification names that one's newest line.
1318    pub lines: &'a [phonemsgs::Line],
1319    /// Whether a phone sends texts for this computer (its texts read here),
1320    /// by its name: a text that came to it is answered as one of Sioul's own
1321    /// texts, from that phone, never another's.
1322    pub texts_from: &'a dyn Fn(&str) -> bool,
1323    /// When a phone last shared (ms), by its name.
1324    pub shared: &'a dyn Fn(&str) -> Option<i64>,
1325    /// A moment (ms) as this computer says the time.
1326    pub time_of: &'a dyn Fn(i64) -> String,
1327    /// The texts this computer wrote from a phone (its name) to a number since a moment (ms), as `Mine`.
1328    pub sent_texts: &'a dyn Fn(&str, &str, i64) -> Vec<Mine>,
1329}
1330
1331/// A Porch's line's answer (`line`, of the messages `group`, oldest first):
1332/// none for a code's line, nor for lines from a phone that names no
1333/// notification (an older Sioul's: Text back as before).
1334pub fn answer_of(line: &phonemsgs::PorchLine, group: &[&phonemsgs::Line], o: &Offer) -> Option<Answer> {
1335    let newest = group.last()?;
1336    if line.code || newest.code || newest.notice.is_empty() {
1337        return None;
1338    }
1339    let tr = o.tr;
1340    let label = if newest.label.is_empty() { newest.app.clone() } else { newest.label.clone() };
1341    let mut args = crate::i18n::args();
1342    args.set("app", label.clone());
1343    args.set("who", line.who.clone());
1344    let placeholder = tr.text(if newest.group { "replies-placeholder-group" } else { "replies-placeholder" }, Some(&args));
1345    let base = Answer {
1346        phone: newest.device.clone(),
1347        app: newest.app.clone(),
1348        label: label.clone(),
1349        line: newest.id.clone(),
1350        placeholder,
1351        read_item: tr.text("replies-read-item", Some(&args)),
1352        read_warning: tr.text("replies-read-warning", Some(&args)),
1353        on_phone: tr.text("replies-on-phone", None),
1354        ..Answer::default()
1355    };
1356    // A text from a whole number, from a phone that sends texts for this computer: one of Sioul's own, from that phone.
1357    if line.texted && (o.texts_from)(&newest.device) {
1358        let since = group.first().map_or(newest.at, |l| l.at);
1359        return Some(Answer { notice: newest.notice.clone(), texts: true, offered: true, limit: crate::texts::WRITE_MAX, mine: (o.sent_texts)(&newest.device, &line.dial, since), ..base });
1360    }
1361    // The notification as the phone said it last; posted anew under another
1362    // name (its app's update), the conversation's newest that offers a Reply,
1363    // answered by its own newest line (the phone checks that the line is the
1364    // notification's: `verdict`); none of its lines here, not offered.
1365    let said = o.live.get(&(newest.device.clone(), newest.notice.clone()));
1366    let current = said.filter(|l| l.gone == 0).cloned().or_else(|| {
1367        (!newest.talk.is_empty()).then(|| o.live.values().filter(|l| l.device == newest.device && l.talk == newest.talk && l.gone == 0 && l.reply).max_by_key(|l| l.at).cloned()).flatten()
1368    });
1369    let mut answered = newest.id.clone();
1370    let (notice, offered, read) = match current {
1371        Some(l) if l.notice == newest.notice => (l.notice.clone(), l.reply, l.read),
1372        // Its newest line of this very conversation, never a code's: an app that reuses one notification for
1373        // another conversation never makes a reply to her go to him, even when the phone's live line was not written.
1374        // The same shape too (a group or not, its title): an untitled group keyed by its last writer is never answered
1375        // through her one-to-one's notification (review of 18 October 2026, L-1).
1376        Some(l) => match o.lines.iter().filter(|x| x.device == newest.device && x.notice == l.notice && x.talk == newest.talk && x.group == newest.group && x.title == newest.title && !x.code).max_by_key(|x| x.at) {
1377            Some(theirs) => {
1378                answered = theirs.id.clone();
1379                (l.notice.clone(), l.reply, l.read)
1380            }
1381            None => (newest.notice.clone(), false, false),
1382        },
1383        None => (newest.notice.clone(), false, false),
1384    };
1385    // A service's text (a short number, a sender named in letters): never from here, as the phone
1386    // refuses it (`verdict`: it may cost money); "Reply on your phone" in its place.
1387    let service = newest.kind == "text" && !newest.group && (newest.who == "automaton" || !crate::phones::is_whole(&newest.key));
1388    let (offered, read) = if service { (false, false) } else { (offered, read) };
1389    let note = if MARKS_READ.contains(&newest.app.as_str()) { tr.text("replies-note-marks-read", Some(&args)) } else { String::new() };
1390    let ids: BTreeSet<&str> = group.iter().map(|l| l.id.as_str()).collect();
1391    let mut mine: Vec<&Ask> = o.asks.iter().filter(|a| a.verb == "reply" && a.phone == newest.device && (ids.contains(a.line.as_str()) || a.notice == notice)).collect();
1392    mine.sort_by_key(|a| a.written);
1393    let mine = mine
1394        .into_iter()
1395        .map(|a| {
1396            let theirs: Vec<&Outcome> = o.outcomes.iter().filter(|x| x.key == a.key && x.device == a.phone).collect();
1397            let state = state_of(a, &theirs, &label, tr, (o.shared)(&a.phone), o.taken_back.contains(&a.key), o.time_of);
1398            Mine { key: a.key.clone(), time: (o.time_of)(a.written), text: a.words.clone(), said: state.said, waiting: state.waiting, again: state.again, texts: false }
1399        })
1400        .collect();
1401    Some(Answer { notice, offered, read, note, limit: WORDS_MAX, mine, line: answered, ..base })
1402}
1403
1404/// Whether a Mark as read this computer asked for a notification was
1405/// handed to its app: the line is then Seen too (decision 13).
1406pub fn read_handed(asks: &[Ask], outcomes: &[Outcome], phone: &str, notice: &str) -> bool {
1407    asks.iter().filter(|a| a.verb == "read" && a.phone == phone && a.notice == notice).any(|a| outcomes.iter().any(|o| o.key == a.key && o.device == phone && o.state == "handed"))
1408}
1409
1410#[cfg(test)]
1411mod tests {
1412    use super::*;
1413    use crate::appnotes::{Message, Person};
1414    use crate::phonemsgs::ExtraMessage;
1415
1416    /// Thursday 8 October 2026, 10:00 in Paris.
1417    const NOW: i64 = 1_791_446_400_000;
1418    const MINUTE: i64 = 60_000;
1419
1420    /// A stand-in for the sealer: reversible, and visibly not plain.
1421    struct Stand;
1422
1423    impl Sealer for Stand {
1424        fn seal(&self, plain: &str) -> String {
1425            format!("sealed:{}", plain.bytes().map(|b| format!("{:02x}", b ^ 0x5a)).collect::<String>())
1426        }
1427        fn open(&self, sealed: &str) -> Option<String> {
1428            let hex = sealed.strip_prefix("sealed:")?;
1429            let bytes: Option<Vec<u8>> = (0..hex.len()).step_by(2).map(|i| hex.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok()).map(|b| b ^ 0x5a)).collect();
1430            String::from_utf8(bytes?).ok()
1431        }
1432    }
1433
1434    fn scratch(name: &str) -> PathBuf {
1435        let dir = std::env::temp_dir().join(format!("sioul-replies-{name}-{}", std::process::id()));
1436        let _ = std::fs::remove_dir_all(&dir);
1437        dir
1438    }
1439
1440    fn words() -> std::sync::Arc<crate::words::Words> {
1441        let config: crate::config::Config = toml::from_str("[words]\nlanguages = [\"fr\", \"en\"]\ncountries = [\"FR\"]\n").unwrap();
1442        crate::words::Words::of(&config)
1443    }
1444
1445    fn fr() -> Option<&'static Region> {
1446        crate::phones::region_named("FR")
1447    }
1448
1449    /// Signal's notification of a conversation with Alice (fiction numbers):
1450    /// her two messages, the second at `last`; with `mine`, your reply after them.
1451    fn signal(last: i64, mine: Option<&str>) -> (Posted, Extra) {
1452        let alice = Person { name: "Alice".into(), key: "alice".into(), uri: "tel:+33199001234".into(), ..Person::default() };
1453        let mut messages = vec![Message { sender: Some(alice.clone()), text: "Tu passes ?".into() }, Message { sender: Some(alice.clone()), text: "Je suis au café.".into() }];
1454        let mut times = vec![ExtraMessage { at: last - MINUTE, picture: false }, ExtraMessage { at: last, picture: false }];
1455        if let Some(mine) = mine {
1456            messages.push(Message { sender: None, text: mine.into() });
1457            times.push(ExtraMessage { at: last + MINUTE, picture: false });
1458        }
1459        let posted = Posted {
1460            key: "0|org.thoughtcrime.securesms|42|null|10123".into(),
1461            package: "org.thoughtcrime.securesms".into(),
1462            app: "Signal".into(),
1463            category: "msg".into(),
1464            template: "android.app.Notification$MessagingStyle".into(),
1465            title: "Alice".into(),
1466            shortcut: "alice-thread".into(),
1467            is_conversation: true,
1468            messages,
1469            people: vec![alice],
1470            ..Posted::default()
1471        };
1472        (posted, Extra { when: last, posted: last, messages: times, ..Extra::default() })
1473    }
1474
1475    fn due_for(posted: &Posted, key: &str, line_at: i64, at: i64) -> Due {
1476        let talk = match appnotes::classify(posted, &appnotes::Choices::default(), &words()) {
1477            Kind::People(talk) => talk.conversation.map(|c| c.key).unwrap_or_default(),
1478            _ => String::new(),
1479        };
1480        Due { key: key.into(), verb: "reply".into(), notice: notice(&posted.package, &posted.key), app: posted.package.clone(), talk, from: "+33199001234".into(), name: "Alice".into(), line_at, words: "J'arrive.".into(), at, ..Due::default() }
1481    }
1482
1483    /// The notification's name off the phone is a hash of its app and
1484    /// Android's key, the conversation's key's kind (AppNotes.talkKey checks
1485    /// the same values on a JVM), never the key.
1486    #[test]
1487    fn a_notification_is_named_by_a_hash_made_on_the_phone() {
1488        let named = notice("org.thoughtcrime.securesms", "0|org.thoughtcrime.securesms|42|null|10123");
1489        assert_eq!(named, appnotes::talk_key("org.thoughtcrime.securesms", "0|org.thoughtcrime.securesms|42|null|10123"));
1490        assert_eq!(named, "a2b7df14a4081c04", "as AppNotes.talkKey makes it (android/jvm-checks/RepliesCheck.java)");
1491        assert!(!named.contains("10123"));
1492        assert_ne!(named, notice("org.thoughtcrime.securesms", "0|org.thoughtcrime.securesms|43|null|10123"), "another notification, another name");
1493    }
1494
1495    /// What is due goes to the listener sealed, is taken once ("taken"
1496    /// flushed first), never again after a word of it, and not at all when
1497    /// the listener comes late; what it did is said without the words.
1498    #[test]
1499    fn a_due_reply_is_taken_once_and_never_late() {
1500        let dir = scratch("take");
1501        let (posted, _) = signal(NOW - 5 * MINUTE, None);
1502        let first = due_for(&posted, "k1", NOW - 5 * MINUTE, NOW);
1503        let second = due_for(&posted, "k2", NOW - 5 * MINUTE, NOW - 3 * MINUTE);
1504        write_due(&dir, &[first.clone(), second.clone()], &Stand).unwrap();
1505        let on_disk = std::fs::read_to_string(dir.join(DUE)).unwrap();
1506        assert!(!on_disk.contains("J'arrive") && !on_disk.contains(&first.notice), "sealed at rest");
1507        assert_eq!(waiting(&dir, &Stand).len(), 2);
1508        assert_eq!(take(&dir, "k1", &Stand, NOW + 1_000).unwrap(), Taken::Go(Box::new(first)));
1509        assert_eq!(read_due(&dir, &Stand).iter().map(|d| (d.key.as_str(), d.words.as_str())).collect::<Vec<_>>(), [("k1", ""), ("k2", "J'arrive.")], "taken: its words no longer kept");
1510        assert_eq!(take(&dir, "k1", &Stand, NOW + 2_000).unwrap(), Taken::No, "once");
1511        assert_eq!(take(&dir, "k2", &Stand, NOW + 1_000).unwrap(), Taken::Late, "handed over three minutes before");
1512        assert_eq!(take(&dir, "k2", &Stand, NOW + 1_000).unwrap(), Taken::No);
1513        assert_eq!(take(&dir, "k3", &Stand, NOW).unwrap(), Taken::No, "not due");
1514        say(&dir, &Done { crossed: true, ..Done::of("k1", "handed", NOW + 3_000) }).unwrap();
1515        let done = read_done(&dir);
1516        assert_eq!(done.iter().map(|d| (d.key.as_str(), d.state.as_str())).collect::<Vec<_>>(), [("k1", "taken"), ("k2", "late"), ("k1", "handed")]);
1517        assert!(done[2].crossed);
1518        assert!(waiting(&dir, &Stand).is_empty());
1519        let said = std::fs::read_to_string(dir.join(DONE)).unwrap();
1520        assert!(!said.contains("J'arrive") && !said.contains("Alice"), "no words, no names: {said}");
1521        // Another key cannot open what is due: nothing is due then.
1522        struct Other;
1523        impl Sealer for Other {
1524            fn seal(&self, plain: &str) -> String {
1525                plain.to_string()
1526            }
1527            fn open(&self, _: &str) -> Option<String> {
1528                None
1529            }
1530        }
1531        assert!(read_due(&dir, &Other).is_empty());
1532        let _ = std::fs::remove_dir_all(&dir);
1533    }
1534
1535    /// The notification found by its name is checked against the line
1536    /// answered: the same conversation goes, with whether their newer
1537    /// messages crossed the reply; another app, another notification or
1538    /// another conversation in it (an app that reuses one) is "gone"; a line
1539    /// without a conversation's key is matched by its sender's number, else
1540    /// name.
1541    #[test]
1542    fn the_live_notification_must_be_the_conversation_answered() {
1543        let words = words();
1544        let choices = appnotes::Choices::default();
1545        let (posted, extra) = signal(NOW - 5 * MINUTE, None);
1546        let due = due_for(&posted, "k1", NOW - 5 * MINUTE, NOW);
1547        assert!(!due.talk.is_empty());
1548        assert_eq!(check(&due, &posted, &extra, &choices, &words, fr()), Checked::Go { crossed: false });
1549        // She wrote again since: it crossed.
1550        let (newer, newer_extra) = signal(NOW - MINUTE, None);
1551        assert_eq!(check(&due, &newer, &newer_extra, &choices, &words, fr()), Checked::Go { crossed: true });
1552        // Another app, another notification, another conversation under the same notification.
1553        assert_eq!(check(&due, &Posted { package: "org.telegram.messenger".into(), ..posted.clone() }, &extra, &choices, &words, fr()), Checked::Gone);
1554        assert_eq!(check(&due, &Posted { key: "0|org.thoughtcrime.securesms|43|null|10123".into(), ..posted.clone() }, &extra, &choices, &words, fr()), Checked::Gone);
1555        assert_eq!(check(&due, &Posted { shortcut: "bob-thread".into(), ..posted.clone() }, &extra, &choices, &words, fr()), Checked::Other, "another conversation under that notification");
1556        // A line without a conversation's key: her number, else her name; nothing that tells: never a guess.
1557        let bare = Due { talk: String::new(), ..due.clone() };
1558        assert_eq!(check(&bare, &posted, &extra, &choices, &words, fr()), Checked::Go { crossed: false });
1559        assert_eq!(check(&Due { from: "+33199009999".into(), ..bare.clone() }, &posted, &extra, &choices, &words, fr()), Checked::Other);
1560        assert_eq!(check(&Due { from: String::new(), ..bare.clone() }, &posted, &extra, &choices, &words, fr()), Checked::Go { crossed: false }, "her name");
1561        assert_eq!(check(&Due { from: String::new(), name: "Bob".into(), ..bare.clone() }, &posted, &extra, &choices, &words, fr()), Checked::Other);
1562        assert_eq!(check(&Due { from: String::new(), name: String::new(), ..bare.clone() }, &posted, &extra, &choices, &words, fr()), Checked::Other, "nothing names the conversation");
1563        // The crossing by the messages' own times only: a notification without them says none.
1564        let untimed = Extra { messages: Vec::new(), ..newer_extra.clone() };
1565        assert_eq!(check(&due, &newer, &untimed, &choices, &words, fr()), Checked::Go { crossed: false }, "its own time moves with a silent repost");
1566    }
1567
1568    /// An app that posts several conversations under one notification
1569    /// without shortcuts or titles: her conversation's key is her own, and
1570    /// so is an untitled group's where she wrote last. A reply to her never
1571    /// goes into the group, nor a group's reply to another group or to her.
1572    #[test]
1573    fn a_reply_never_goes_into_a_conversation_of_another_shape() {
1574        let words = words();
1575        let choices = appnotes::Choices::default();
1576        let alice = Person { name: "Alice".into(), key: "alice".into(), ..Person::default() };
1577        let bob = Person { name: "Bob".into(), key: "bob".into(), ..Person::default() };
1578        let base = Posted { key: "0|com.example.chat|7|null|10123".into(), package: "com.example.chat".into(), app: "Chat".into(), category: "msg".into(), template: "android.app.Notification$MessagingStyle".into(), is_conversation: true, ..Posted::default() };
1579        let one = Posted { title: "Alice".into(), messages: vec![Message { sender: Some(alice.clone()), text: "Tu passes ?".into() }], people: vec![alice.clone()], ..base.clone() };
1580        let group = Posted { messages: vec![Message { sender: Some(bob.clone()), text: "Qui vient ?".into() }, Message { sender: Some(alice.clone()), text: "Moi.".into() }], people: vec![bob.clone(), alice.clone()], ..base.clone() };
1581        let talk = |p: &Posted| match appnotes::classify(p, &choices, &words) {
1582            Kind::People(t) => t.conversation.unwrap(),
1583            other => panic!("{other:?}"),
1584        };
1585        let (mine, theirs) = (talk(&one), talk(&group));
1586        assert!(!mine.group && theirs.group);
1587        let due = Due { key: "k1".into(), verb: "reply".into(), notice: notice(&base.package, &base.key), app: base.package.clone(), talk: mine.key.clone(), name: "Alice".into(), words: "J'arrive.".into(), at: NOW, ..Due::default() };
1588        assert_eq!(check(&due, &one, &Extra::default(), &choices, &words, fr()), Checked::Go { crossed: false });
1589        assert_eq!(check(&due, &group, &Extra::default(), &choices, &words, fr()), Checked::Other, "her conversation, never the group");
1590        // An untitled group's key came from its last writer: it names her, and another untitled group where she
1591        // wrote last has it too: its reply never goes, through the notification.
1592        assert!(theirs.by_writer && mine.by_writer);
1593        let to_group = Due { talk: theirs.key.clone(), group: true, title: phonemsgs::title_of(&theirs), ..due.clone() };
1594        assert_eq!(check(&to_group, &group, &Extra::default(), &choices, &words, fr()), Checked::Other, "a group keyed by its last writer");
1595        assert_eq!(check(&to_group, &one, &Extra::default(), &choices, &words, fr()), Checked::Other, "the group's reply never goes to her alone");
1596        // A titled group: its key from its title; its reply goes, to it only.
1597        let titled = Posted { conversation: "École".into(), ..group.clone() };
1598        let school = talk(&titled);
1599        assert!(!school.by_writer);
1600        let to_school = Due { talk: school.key.clone(), group: true, title: phonemsgs::title_of(&school), ..due.clone() };
1601        assert_eq!(check(&to_school, &titled, &Extra::default(), &choices, &words, fr()), Checked::Go { crossed: false });
1602        assert_eq!(check(&Due { title: "Club".into(), ..to_school.clone() }, &titled, &Extra::default(), &choices, &words, fr()), Checked::Other, "another group's title");
1603        // From the line, as the phone wrote it.
1604        let line = phonemsgs::Line { group: true, title: "École".into(), talk: "aaaa".into(), ..alice_line() };
1605        let a = ask("k1", "J'arrive.", NOW);
1606        let d = due_of(&a, &line, NOW);
1607        assert_eq!((d.group, d.title.as_str()), (true, "École"));
1608    }
1609
1610    /// Your reply shown in its notification as yours: Signal's reposted
1611    /// conversation with your words last, or the Reply's own history; never
1612    /// someone else's words, never another reply's.
1613    #[test]
1614    fn a_reply_is_shown_when_its_notification_shows_your_words() {
1615        let hash = words_hash("J'arrive dans dix minutes.");
1616        let taken = NOW + 30_000;
1617        let (before, extra) = signal(NOW, None);
1618        assert!(!shows(&before, &extra, &hash, taken, false));
1619        // Your message, timed after the reply was taken (signal puts it a minute after hers).
1620        let (after, extra) = signal(NOW, Some("J'arrive dans dix minutes. "));
1621        assert!(shows(&after, &extra, &hash, taken, false), "your message, the trailing space aside");
1622        assert!(!shows(&after, &extra, &words_hash("Une autre réponse."), taken, false));
1623        // The same words of yours from before the reply: never this one shown.
1624        assert!(!shows(&after, &extra, &hash, NOW + 5 * MINUTE, true), "an earlier message of yours, timed before");
1625        assert!(shown_already(&after, &extra, &hash));
1626        // No times: the words, or the Reply's history, only when they were not there before.
1627        let (plain, extra) = signal(NOW, None);
1628        let history = Extra { history: vec!["J'arrive dans dix minutes.".into()], ..extra.clone() };
1629        assert!(shows(&plain, &history, &hash, taken, false), "the Reply's history");
1630        assert!(!shows(&plain, &history, &hash, taken, true), "there already before the reply");
1631        let (untimed, timed) = signal(NOW, Some("J'arrive dans dix minutes."));
1632        let untimed_extra = Extra { messages: Vec::new(), ..timed };
1633        assert!(shows(&untimed, &untimed_extra, &hash, taken, false));
1634        assert!(!shows(&untimed, &untimed_extra, &hash, taken, true));
1635        assert!(!shows(&plain, &extra, &words_hash("Je suis au café."), taken, false), "her words are not yours");
1636    }
1637
1638    /// The live lines: one per change of what a notification offers, its
1639    /// going said once, what each phone said last read by any device; no
1640    /// words, no names.
1641    #[test]
1642    fn what_a_notification_offers_is_said_once_per_change() {
1643        let root = scratch("live");
1644        let own = live_file(&root, "phone");
1645        let live = Live { notice: "0123456789abcdef".into(), app: "org.thoughtcrime.securesms".into(), talk: "fedcba9876543210".into(), reply: true, read: true, at: NOW, ..Live::default() };
1646        assert!(offer(&own, &live).unwrap());
1647        assert!(!offer(&own, &Live { at: NOW + MINUTE, ..live.clone() }).unwrap(), "the same offer: no line");
1648        assert!(offer(&own, &Live { read: false, at: NOW + 2 * MINUTE, ..live.clone() }).unwrap(), "Mark as read gone");
1649        assert!(went(&own, &live.notice, NOW + 3 * MINUTE).unwrap());
1650        assert!(!went(&own, &live.notice, NOW + 4 * MINUTE).unwrap(), "said once");
1651        assert!(!went(&own, "aaaaaaaaaaaaaaaa", NOW).unwrap(), "never relayed: nothing");
1652        assert!(offer(&own, &Live { at: NOW + 5 * MINUTE, ..live.clone() }).unwrap(), "posted again");
1653        let text = std::fs::read_to_string(&own).unwrap();
1654        assert_eq!(text.lines().count(), 4);
1655        assert!(!text.contains("Alice"));
1656        let read = read_live(&root, NOW + 6 * MINUTE);
1657        let last = read.get(&("phone".to_string(), live.notice.clone())).unwrap();
1658        assert_eq!((last.reply, last.read, last.gone, last.device.as_str()), (true, true, 0, "phone"));
1659        assert!(read_live(&root, NOW + 8 * DAY_MS).is_empty(), "a week");
1660        // Trimmed with the log, by its phone after a week.
1661        assert_eq!(phonemsgs::trim_own(&root, "phone", NOW + 8 * DAY_MS).unwrap(), 4);
1662        let _ = std::fs::remove_dir_all(&root);
1663    }
1664
1665    /// This phone's line for Alice's last message in Signal (fiction numbers).
1666    fn alice_line() -> phonemsgs::Line {
1667        phonemsgs::Line { at: NOW - 5 * MINUTE, id: format!("{}-aa11", NOW - 5 * MINUTE), app: "org.thoughtcrime.securesms".into(), label: "Signal".into(), kind: "chat".into(), talk: "fedcba9876543210".into(), notice: "0123456789abcdef".into(), name: "Alice".into(), key: "+33199001234".into(), who: "neutral".into(), text: "Je suis au café.".into(), shows: NOW - 5 * MINUTE, ..phonemsgs::Line::default() }
1668    }
1669
1670    fn ask(key: &str, words: &str, written: i64) -> Ask {
1671        Ask { key: key.into(), phone: "phone".into(), verb: "reply".into(), notice: "0123456789abcdef".into(), app: "org.thoughtcrime.securesms".into(), line: alice_line().id, words: words.into(), written, device: "desk".into(), ..Ask::default() }
1672    }
1673
1674    /// A request is written padded and sealed, once by its key and verb, and
1675    /// read back with its computer; a "cancel" of the same key is set apart;
1676    /// each key once across the places it is read from.
1677    #[test]
1678    fn a_request_travels_padded_and_sealed() {
1679        let root = scratch("asks");
1680        let first = ask("k1", "J'arrive.", NOW);
1681        assert!(write_ask(&root, "desk", &first, &Stand).unwrap());
1682        assert!(!write_ask(&root, "desk", &first, &Stand).unwrap(), "once");
1683        assert!(write_ask(&root, "desk", &Ask { verb: "cancel".into(), words: String::new(), ..first.clone() }, &Stand).unwrap(), "taken back: a line of its own");
1684        let text = std::fs::read_to_string(calls::own_file(&root, ASK, "desk")).unwrap();
1685        assert!(!text.contains("arrive") && !text.contains("securesms"), "sealed");
1686        assert!(text.lines().all(|l| Stand.open(l).is_some_and(|plain| plain.len() % PAD_STEP == 0)), "padded");
1687        let other = scratch("asks-copy");
1688        write_ask(&other, "desk", &first, &Stand).unwrap();
1689        write_ask(&other, "laptop", &ask("k2", "Bien reçu.", NOW), &Stand).unwrap();
1690        let (asks, cancelled) = read_asks(&[root.clone(), other.clone()], &Stand);
1691        assert_eq!(asks.iter().map(|a| (a.key.as_str(), a.device.as_str(), a.writer.as_str())).collect::<Vec<_>>(), [("k1", "desk", "desk"), ("k2", "laptop", "laptop")]);
1692        assert!(cancelled.contains(&("desk".to_string(), "k1".to_string())));
1693        assert_eq!(asks[0].words, "J'arrive.");
1694        let _ = std::fs::remove_dir_all(&root);
1695        let _ = std::fs::remove_dir_all(&other);
1696    }
1697
1698    /// Each request names its writer inside its seal: a line moved from one
1699    /// computer's file to another's (the server, a stray file), or written
1700    /// with no writer, is passed over, a "cancel" too; the request in its own
1701    /// file is the one read, with its own computer.
1702    #[test]
1703    fn a_request_moved_to_another_computer_s_file_is_passed_over() {
1704        let root = scratch("asks-moved");
1705        let first = ask("k1", "J'arrive.", NOW);
1706        write_ask(&root, "laptop", &first, &Stand).unwrap();
1707        write_ask(&root, "laptop", &Ask { verb: "cancel".into(), words: String::new(), ..first.clone() }, &Stand).unwrap();
1708        let moved = std::fs::read_to_string(calls::own_file(&root, ASK, "laptop")).unwrap();
1709        // The same sealed lines put in the desk's file, and a line with no writer.
1710        std::fs::write(calls::own_file(&root, ASK, "desk"), &moved).unwrap();
1711        let bare = format!("{}\n", Stand.seal(&serde_json::to_string(&Ask { key: "k2".into(), device: String::new(), ..ask("k2", "Oui.", NOW) }).unwrap()));
1712        std::fs::write(calls::own_file(&root, ASK, "phone"), bare).unwrap();
1713        let (asks, cancelled) = read_asks(std::slice::from_ref(&root), &Stand);
1714        assert_eq!(asks.iter().map(|a| (a.key.as_str(), a.device.as_str())).collect::<Vec<_>>(), [("k1", "laptop")], "the desk's moved copy and the bare line passed over");
1715        assert_eq!(cancelled.into_iter().collect::<Vec<_>>(), [("laptop".to_string(), "k1".to_string())]);
1716        let _ = std::fs::remove_dir_all(&root);
1717    }
1718
1719    /// Through a conversation's newer notification, the Porch names that
1720    /// notification's newest line of this very conversation, never a code's:
1721    /// an app that reuses one notification for another conversation, its new
1722    /// live line not written (or older than the log), never makes Alice's
1723    /// reply go to Bob.
1724    #[test]
1725    fn alice_s_reply_never_goes_to_bob() {
1726        let tr = crate::i18n::Translator::new("en");
1727        let line = phonemsgs::Line { device: "phone".into(), ..alice_line() };
1728        // The newer notification: its live line still says Alice's conversation; its newest line here is Bob's.
1729        let bob = phonemsgs::Line { at: NOW - MINUTE, id: format!("{}-bb33", NOW - MINUTE), notice: "1111222233334444".into(), talk: "0000111122223333".into(), name: "Bob".into(), key: "+33261910000".into(), text: "Salut !".into(), ..line.clone() };
1730        let code = phonemsgs::Line { at: NOW - MINUTE / 2, id: format!("{}-cc44", NOW - MINUTE / 2), notice: "1111222233334444".into(), code: true, text: String::new(), ..line.clone() };
1731        let porch = phonemsgs::PorchLine { ids: vec![line.id.clone()], head: String::new(), said: Vec::new(), note: String::new(), number: String::new(), dial: String::new(), known: true, texted: false, code: false, who: "Alice".into(), answer: None };
1732        let live = |notice: &str, reply: bool, gone: i64| (("phone".to_string(), notice.to_string()), Live { notice: notice.into(), app: line.app.clone(), talk: line.talk.clone(), reply, read: reply, gone, at: NOW, device: "phone".into() });
1733        let lives: BTreeMap<_, _> = [live(&line.notice, false, NOW), live("1111222233334444", true, 0)].into();
1734        let (shared, time, none, no_one, nobody) = (|_: &str| Some(NOW), |_: i64| "14:03".to_string(), |_: &str, _: &str, _: i64| Vec::new(), |_: &str| false, BTreeSet::new());
1735        for lines in [vec![line.clone(), bob.clone()], vec![line.clone(), code.clone()], vec![line.clone(), bob.clone(), code.clone()]] {
1736            let o = Offer { live: &lives, asks: &[], outcomes: &[], tr: &tr, taken_back: &nobody, lines: &lines, texts_from: &no_one, shared: &shared, time_of: &time, sent_texts: &none };
1737            let answer = answer_of(&porch, &[&line], &o).unwrap();
1738            assert!(!answer.offered, "no line of Alice's conversation in that notification: Reply on your phone");
1739            assert!(answer.line != bob.id && answer.line != code.id, "never Bob's line, never a code's: {}", answer.line);
1740        }
1741        // Alice's own newer line in it: that one.
1742        let hers = phonemsgs::Line { at: NOW - MINUTE / 4, id: format!("{}-dd55", NOW - MINUTE / 4), notice: "1111222233334444".into(), text: "Tu es où ?".into(), ..line.clone() };
1743        let lines = vec![line.clone(), bob.clone(), hers.clone(), code];
1744        let o = Offer { live: &lives, asks: &[], outcomes: &[], tr: &tr, taken_back: &nobody, lines: &lines, texts_from: &no_one, shared: &shared, time_of: &time, sent_texts: &none };
1745        let answer = answer_of(&porch, &[&line], &o).unwrap();
1746        assert_eq!((answer.offered, answer.line.as_str()), (true, hers.id.as_str()));
1747        // An untitled group keyed by its last writer (her key), its notification gone, and her one-to-one's live
1748        // with Reply: the group's words never go into her one-to-one (L-1).
1749        let group = phonemsgs::Line { group: true, title: "Alice".into(), ..line.clone() };
1750        let one = phonemsgs::Line { at: NOW - MINUTE / 4, id: format!("{}-ee66", NOW - MINUTE / 4), notice: "1111222233334444".into(), text: "Seule.".into(), ..line.clone() };
1751        let lines = vec![group.clone(), one];
1752        let gp = phonemsgs::PorchLine { who: "Alice".into(), ..porch.clone() };
1753        let o = Offer { live: &lives, asks: &[], outcomes: &[], tr: &tr, taken_back: &nobody, lines: &lines, texts_from: &no_one, shared: &shared, time_of: &time, sent_texts: &none };
1754        let answer = answer_of(&gp, &[&group], &o).unwrap();
1755        assert!(!answer.offered, "a group's reply never redirected to her one-to-one");
1756    }
1757
1758    /// A file of requests is read once: past every line done with (decided,
1759    /// another phone's, one that does not open) a step reads it from where it
1760    /// was, never opening those again; a request not decided yet, or its
1761    /// cancel, stops the mark; a file rewritten (trimmed) is read whole again.
1762    #[test]
1763    fn a_file_of_requests_is_read_from_where_the_phone_was_done_with_it() {
1764        let root = scratch("read-marks");
1765        let mine = |key: &str| ask(key, "Oui.", NOW);
1766        write_ask(&root, "desk", &mine("k1"), &Stand).unwrap();
1767        write_ask(&root, "desk", &Ask { phone: "other-phone".into(), ..mine("k2") }, &Stand).unwrap();
1768        write_ask(&root, "desk", &mine("k3"), &Stand).unwrap();
1769        let roots = vec![root.clone()];
1770        let keys = |asked: &(Vec<Ask>, BTreeSet<(String, String)>)| asked.0.iter().map(|a| a.key.clone()).collect::<Vec<_>>();
1771        let (asked, reads) = read_asks_after(&roots, &Stand, &BTreeMap::new());
1772        assert_eq!(keys(&asked), ["k1", "k2", "k3"]);
1773        // k1 decided, k2 another phone's, k3 not decided yet: the mark stops before k3.
1774        let decided = |set: &'static [&'static str]| move |key: &str, phone: &str| phone != "phone" || set.contains(&key);
1775        let marks = moved_marks(&reads, &decided(&["k1"]), &BTreeMap::new());
1776        let (asked, reads) = read_asks_after(&roots, &Stand, &marks);
1777        assert_eq!(keys(&asked), ["k3"], "k1 and k2 never opened again");
1778        // A cancel for k3 written since: it stops the mark too, until k3 is decided.
1779        write_ask(&root, "desk", &Ask { verb: "cancel".into(), words: String::new(), ..mine("k3") }, &Stand).unwrap();
1780        let (asked, reads) = read_asks_after(&roots, &Stand, &moved_marks(&reads, &decided(&["k1"]), &marks));
1781        assert_eq!((keys(&asked), asked.1.len()), (vec!["k3".to_string()], 1));
1782        let marks = moved_marks(&reads, &decided(&["k1", "k3"]), &marks);
1783        let (asked, reads) = read_asks_after(&roots, &Stand, &marks);
1784        assert!(asked.0.is_empty() && asked.1.is_empty() && reads[0].lines.is_empty(), "done with the whole file");
1785        // A line that does not open (another key) is passed, as before.
1786        let own = calls::own_file(&root, ASK, "desk");
1787        std::fs::write(&own, format!("{}not a sealed line\n", std::fs::read_to_string(&own).unwrap())).unwrap();
1788        let marks = moved_marks(&reads, &decided(&["k1", "k3"]), &marks);
1789        let (_, reads) = read_asks_after(&roots, &Stand, &marks);
1790        let marks = moved_marks(&reads, &decided(&["k1", "k3"]), &marks);
1791        assert_eq!(marks.values().next().unwrap().offset, std::fs::metadata(&own).unwrap().len());
1792        // Rewritten (its first line trimmed): read whole again.
1793        let text = std::fs::read_to_string(&own).unwrap();
1794        std::fs::write(&own, text.split_inclusive('\n').skip(1).collect::<String>()).unwrap();
1795        let (asked, _) = read_asks_after(&roots, &Stand, &marks);
1796        assert_eq!(keys(&asked), ["k2", "k3"], "the mark's line is no longer there: the whole file");
1797        let _ = std::fs::remove_dir_all(&root);
1798    }
1799
1800    /// Each computer's requests in turn: a runaway computer's many never keep another's from its turn.
1801    #[test]
1802    fn each_computer_s_requests_come_in_turn() {
1803        let of = |key: &str, device: &str| Ask { device: device.into(), ..ask(key, "Oui.", NOW) };
1804        let asks = vec![of("a1", "aaa"), of("a2", "aaa"), of("a3", "aaa"), of("d1", "desk"), Ask { phone: "other".into(), ..of("o1", "desk") }];
1805        assert_eq!(in_turn(&asks, "phone").iter().map(|a| a.key.as_str()).collect::<Vec<_>>(), ["a1", "d1", "a2", "a3"]);
1806        assert!(in_turn(&asks, "nobody").is_empty());
1807    }
1808
1809    /// The never-twice decision, step by step, as for texts: another
1810    /// phone's; decided already; taken back; a device the sharing no longer
1811    /// counts; a verb, words or a line it does not take; a code; an app
1812    /// switched off; a short number in the SMS app; before the mark, ahead,
1813    /// expired; the same words within ten minutes unless sent again on
1814    /// purpose; sixty in an hour.
1815    #[test]
1816    fn a_request_is_decided_once_as_texts_are() {
1817        let dir = scratch("verdict");
1818        let ledger = crate::texts::Ledger::open(&dir, NOW - 60 * MINUTE).unwrap();
1819        let lines = vec![alice_line(), phonemsgs::Line { id: "code".into(), code: true, ..alice_line() }];
1820        let none = BTreeSet::new();
1821        let nothing = BTreeSet::new();
1822        let yes = |_: &str| true;
1823        let counted = |_: &str| crate::texts::Standing::Counted;
1824        let judge = |ledger: &crate::texts::Ledger, cancelled: &BTreeSet<(String, String)>, standing: &dyn Fn(&str) -> crate::texts::Standing, answers: &dyn Fn(&str) -> bool, lines: &[phonemsgs::Line], a: &Ask| {
1825            verdict(a, &Judge { here: "phone", now_ms: NOW, ledger, lines, standing, cancelled, answers, unsent: &nothing, went: &nothing })
1826        };
1827        let v = |a: &Ask| judge(&ledger, &none, &counted, &yes, &lines, a);
1828        let good = ask("k1", "J'arrive.", NOW - MINUTE);
1829        assert_eq!(v(&good), Verdict::Send);
1830        assert_eq!(v(&Ask { phone: "other-phone".into(), ..good.clone() }), Verdict::NotMine);
1831        assert_eq!(v(&Ask { verb: "cancel".into(), ..good.clone() }), Verdict::NotMine, "read with what it takes back");
1832        let cancelled: BTreeSet<(String, String)> = [("desk".to_string(), "k1".to_string())].into();
1833        assert_eq!(judge(&ledger, &cancelled, &counted, &yes, &lines, &good), Verdict::TakenBack);
1834        assert_eq!(judge(&ledger, &cancelled, &counted, &yes, &lines, &Ask { device: "laptop".into(), ..good.clone() }), Verdict::Send, "another computer cannot take it back");
1835        let gone = |_: &str| crate::texts::Standing::Gone;
1836        assert_eq!(judge(&ledger, &none, &gone, &yes, &lines, &good), Verdict::Refused("not-counted"));
1837        // A device not known yet (its records not come): waits while fresh, then expires (the review of 18 October 2026, I-a).
1838        let unknown = |_: &str| crate::texts::Standing::Unknown;
1839        assert_eq!(judge(&ledger, &none, &unknown, &yes, &lines, &good), Verdict::Wait);
1840        assert_eq!(judge(&ledger, &none, &unknown, &yes, &lines, &ask("k9", "J'arrive.", NOW - 16 * MINUTE)), Verdict::Expired);
1841        assert_eq!(v(&Ask { verb: "delete".into(), ..good.clone() }), Verdict::Refused("verb"));
1842        assert_eq!(v(&Ask { words: "  ".into(), ..good.clone() }), Verdict::Refused("no-words"));
1843        assert_eq!(v(&Ask { words: "é".repeat(WORDS_MAX + 1), ..good.clone() }), Verdict::Refused("too-long"));
1844        assert_eq!(v(&Ask { verb: "read".into(), words: String::new(), ..good.clone() }), Verdict::Send, "Mark as read: no words");
1845        assert_eq!(v(&Ask { line: "nowhere".into(), ..good.clone() }), Verdict::Refused("line"));
1846        assert_eq!(v(&Ask { notice: "ffffffffffffffff".into(), ..good.clone() }), Verdict::Refused("line"), "another notification than the line's");
1847        assert_eq!(v(&Ask { line: "code".into(), ..good.clone() }), Verdict::Refused("code"));
1848        assert_eq!(judge(&ledger, &none, &counted, &|app: &str| app != "org.thoughtcrime.securesms", &lines, &good), Verdict::Refused("switched-off"));
1849        let service = vec![phonemsgs::Line { kind: "text".into(), app: "foundation.e.message".into(), who: "automaton".into(), key: String::new(), name: "AMELI".into(), ..alice_line() }];
1850        assert_eq!(judge(&ledger, &none, &counted, &yes, &service, &Ask { app: "foundation.e.message".into(), ..good.clone() }), Verdict::Refused("short-number"));
1851        let short = vec![phonemsgs::Line { kind: "text".into(), app: "foundation.e.message".into(), key: "36179".into(), ..alice_line() }];
1852        assert_eq!(judge(&ledger, &none, &counted, &yes, &short, &Ask { app: "foundation.e.message".into(), ..good.clone() }), Verdict::Refused("short-number"));
1853        assert_eq!(v(&Ask { written: NOW - 2 * 60 * MINUTE, ..good.clone() }), Verdict::Refused("before-mark"));
1854        assert_eq!(v(&Ask { written: NOW + 5 * MINUTE, ..good.clone() }), Verdict::Clocks);
1855        assert_eq!(v(&Ask { written: NOW + 5 * MINUTE, ahead: 4 * MINUTE, ..good.clone() }), Verdict::Send, "the writer's clock ahead, corrected");
1856        assert_eq!(v(&Ask { written: NOW - 16 * MINUTE, ..good.clone() }), Verdict::Expired);
1857        // Claimed: decided for good, whatever is read again.
1858        crate::texts::record(&dir, &claim_of(&good, "", NOW - MINUTE)).unwrap();
1859        let ledger = crate::texts::Ledger::open(&dir, NOW).unwrap();
1860        let v = |a: &Ask| judge(&ledger, &none, &counted, &yes, &lines, a);
1861        assert_eq!(v(&good), Verdict::Decided);
1862        assert!(!std::fs::read_to_string(dir.join(crate::texts::LEDGER)).unwrap().contains("arrive"), "the ledger keeps a hash, never the words");
1863        // The same words to the same notification within ten minutes: refused, unless sent again on purpose.
1864        assert_eq!(v(&ask("k2", "J'arrive. ", NOW)), Verdict::Refused("same-words-pending"), "on its way: no word of the phone yet");
1865        assert_eq!(v(&Ask { again: "k1".into(), ..ask("k2", "J'arrive.", NOW) }), Verdict::Send);
1866        assert_eq!(v(&ask("k2", "Dans cinq minutes.", NOW)), Verdict::Send);
1867        // Sixty replies in the last hour, from all computers.
1868        for i in 0..PER_HOUR {
1869            crate::texts::record(&dir, &claim_of(&ask(&format!("n{i}"), &format!("Réponse {i}"), NOW), "", NOW - 30 * MINUTE)).unwrap();
1870        }
1871        let ledger = crate::texts::Ledger::open(&dir, NOW).unwrap();
1872        assert_eq!(judge(&ledger, &none, &counted, &yes, &lines, &ask("k3", "Encore une.", NOW)), Verdict::Refused("too-many"));
1873        assert_eq!(judge(&ledger, &none, &counted, &yes, &lines, &Ask { verb: "read".into(), words: String::new(), ..ask("k4", "", NOW) }), Verdict::Send, "a Mark as read counts for none");
1874        // Sixty Marks as read in the last hour: the next one refused too, in its own words.
1875        let read = |key: &str| Ask { verb: "read".into(), words: String::new(), ..ask(key, "", NOW) };
1876        let claims: Vec<crate::texts::Claim> = (0..PER_HOUR).map(|i| claim_of(&read(&format!("r{i}")), "", NOW - 20 * MINUTE)).collect();
1877        crate::texts::record_all(&dir, &claims).unwrap();
1878        let ledger = crate::texts::Ledger::open(&dir, NOW).unwrap();
1879        assert_eq!(judge(&ledger, &none, &counted, &yes, &lines, &read("r99")), Verdict::Refused("too-many-read"));
1880        let _ = std::fs::remove_dir_all(&dir);
1881    }
1882
1883    /// The same words go again when the first never went: its phone said it
1884    /// was gone (the notification had gone, then hers came back under the
1885    /// same name), late, closed, failed…; one that went, may still go, or
1886    /// is in doubt holds them back ten minutes.
1887    #[test]
1888    fn the_same_words_go_again_when_the_first_never_went() {
1889        let dir = scratch("same-words");
1890        crate::texts::Ledger::open(&dir, NOW - 60 * MINUTE).unwrap();
1891        let lines = vec![alice_line()];
1892        let (none, yes) = (BTreeSet::new(), |_: &str| true);
1893        let first = ask("k1", "J'arrive.", NOW - 3 * MINUTE);
1894        crate::texts::record(&dir, &claim_of(&first, &alice_line().talk, NOW - 3 * MINUTE)).unwrap();
1895        let again = ask("k2", "J'arrive.", NOW);
1896        let v = |listener: &[(&str, &str)]| {
1897            let folder = scratch("same-words-done");
1898            for (key, state) in listener {
1899                say(&folder, &Done::of(key, state, NOW - 2 * MINUTE)).unwrap();
1900            }
1901            let (unsent, went) = (unsent(&folder), went_keys(&folder));
1902            let ledger = crate::texts::Ledger::open(&dir, NOW).unwrap();
1903            let said = verdict(&again, &Judge { here: "phone", now_ms: NOW, ledger: &ledger, lines: &lines, standing: &|_| crate::texts::Standing::Counted, cancelled: &none, answers: &yes, unsent: &unsent, went: &went });
1904            let _ = std::fs::remove_dir_all(&folder);
1905            said
1906        };
1907        // Said as it is known: on its way while the phone has not said it went, went once it did.
1908        assert_eq!(v(&[]), Verdict::Refused("same-words-pending"), "claimed, not said yet: it may go");
1909        for (word, said) in [("taken", "same-words-pending"), ("doubt", "same-words-pending"), ("handed", "same-words"), ("shown", "same-words")] {
1910            assert_eq!(v(&[("k1", "taken"), ("k1", word)]), Verdict::Refused(said), "{word}");
1911        }
1912        for not in NOT_SENT {
1913            assert_eq!(v(&[("k1", "taken"), ("k1", not)]), Verdict::Send, "{not}: it never went");
1914        }
1915        // Through the conversation's newer notification (its app posted it anew): the same words found there
1916        // too, a fresh reply or Send again of the refused one, while the first may still go (L-2).
1917        let newer = phonemsgs::Line { id: format!("{}-bb22", NOW - MINUTE), at: NOW - MINUTE, notice: "1111222233334444".into(), ..alice_line() };
1918        let lines = vec![alice_line(), newer.clone()];
1919        let ledger = crate::texts::Ledger::open(&dir, NOW).unwrap();
1920        let through_newer = |a: &Ask, unsent: &BTreeSet<String>| verdict(a, &Judge { here: "phone", now_ms: NOW, ledger: &ledger, lines: &lines, standing: &|_| crate::texts::Standing::Counted, cancelled: &none, answers: &yes, unsent, went: &none_keys() });
1921        let fresh = Ask { notice: newer.notice.clone(), line: newer.id.clone(), ..ask("k3", "J'arrive.", NOW) };
1922        assert_eq!(through_newer(&fresh, &BTreeSet::new()), Verdict::Refused("same-words-pending"), "a fresh reply through the newer notification");
1923        let again = Ask { again: "k2".into(), ..fresh.clone() };
1924        assert_eq!(through_newer(&again, &BTreeSet::new()), Verdict::Refused("same-words-pending"), "Send again of the refused one through the newer notification");
1925        let first_went: BTreeSet<String> = ["k1".to_string()].into();
1926        let through_newer_after = |a: &Ask| verdict(a, &Judge { here: "phone", now_ms: NOW, ledger: &ledger, lines: &lines, standing: &|_| crate::texts::Standing::Counted, cancelled: &none, answers: &yes, unsent: &BTreeSet::new(), went: &first_went });
1927        assert_eq!(through_newer_after(&again), Verdict::Refused("same-words"), "the first went: never twice");
1928        let gone: BTreeSet<String> = ["k1".to_string()].into();
1929        assert_eq!(through_newer(&again, &gone), Verdict::Send, "the first never went: it goes, once");
1930        let _ = std::fs::remove_dir_all(&dir);
1931    }
1932
1933    fn none_keys() -> BTreeSet<String> {
1934        BTreeSet::new()
1935    }
1936
1937    /// The ledger's mark is made once, at the first step or the first line
1938    /// relayed, in a folder made yours alone; never moved after: a request
1939    /// written after it goes, one written before it (a ledger lost and made
1940    /// again) is refused.
1941    #[test]
1942    fn the_mark_is_made_once_before_the_first_request() {
1943        let dir = scratch("mark").join("replies");
1944        mark_ledger(&dir, NOW - 10 * MINUTE).unwrap();
1945        mark_ledger(&dir, NOW).unwrap();
1946        let ledger = open_ledger(&dir, NOW).unwrap();
1947        assert_eq!(ledger.mark.since, NOW - 10 * MINUTE, "never moved");
1948        #[cfg(unix)]
1949        {
1950            use std::os::unix::fs::PermissionsExt;
1951            assert_eq!(std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777, 0o700, "yours alone");
1952        }
1953        let lines = vec![alice_line()];
1954        let (none, nothing, yes) = (BTreeSet::new(), BTreeSet::new(), |_: &str| true);
1955        let v = |a: &Ask| verdict(a, &Judge { here: "phone", now_ms: NOW, ledger: &ledger, lines: &lines, standing: &|_| crate::texts::Standing::Counted, cancelled: &none, answers: &yes, unsent: &nothing, went: &nothing });
1956        assert_eq!(v(&ask("k1", "J'arrive.", NOW - 90_000)), Verdict::Send, "written after the mark");
1957        assert_eq!(v(&ask("k2", "J'arrive.", NOW - 11 * MINUTE)), Verdict::Refused("before-mark"));
1958        let _ = std::fs::remove_dir_all(dir.parent().unwrap());
1959    }
1960
1961    /// From a claim to an outcome: what is due goes to the listener once;
1962    /// one never taken in time is "late", one taken and never said "doubt";
1963    /// each state an outcome once, without the words; old lines go.
1964    #[test]
1965    fn what_the_listener_did_becomes_the_phone_s_word_once() {
1966        let dir = scratch("settle");
1967        let line = alice_line();
1968        let (a, b, c) = (ask("k1", "J'arrive.", NOW), ask("k2", "Merci.", NOW), ask("k3", "Oui.", NOW));
1969        add_due(&dir, &[due_of(&a, &line, NOW), due_of(&b, &line, NOW), due_of(&c, &line, NOW)], &Stand, NOW).unwrap();
1970        assert_eq!(read_due(&dir, &Stand)[0].from, "+33199001234");
1971        assert_eq!(read_due(&dir, &Stand)[0].line_at, line.at);
1972        assert!(matches!(take(&dir, "k1", &Stand, NOW + 1_000).unwrap(), Taken::Go(_)));
1973        say(&dir, &Done::of("k1", "handed", NOW + 2_000)).unwrap();
1974        assert!(matches!(take(&dir, "k2", &Stand, NOW + 1_000).unwrap(), Taken::Go(_)));
1975        let claimed = vec![("k1".to_string(), NOW), ("k2".to_string(), NOW), ("k3".to_string(), NOW)];
1976        let done = settle(&dir, &claimed, NOW + 3 * MINUTE).unwrap();
1977        let states: Vec<(&str, &str)> = done.iter().map(|d| (d.key.as_str(), d.state.as_str())).collect();
1978        assert!(states.contains(&("k2", "doubt")) && states.contains(&("k3", "late")), "{states:?}");
1979        assert!(!states.contains(&("k1", "doubt")));
1980        assert_eq!(take(&dir, "k3", &Stand, NOW + 3 * MINUTE).unwrap(), Taken::No, "late is the end");
1981        // Each state once in the phone's own outcomes, without the words.
1982        let root = scratch("outcomes");
1983        let own = calls::own_file(&root, OUTCOME, "phone");
1984        assert_eq!(say_outcomes(&own, &outcomes_of(&done)).unwrap(), done.len());
1985        assert_eq!(say_outcomes(&own, &outcomes_of(&done)).unwrap(), 0, "once");
1986        let text = std::fs::read_to_string(&own).unwrap();
1987        assert!(!text.contains("arrive") && !text.contains("Alice"));
1988        let read = read_outcomes(&root);
1989        assert!(read.iter().all(|o| o.device == "phone"));
1990        // A day on, the private lines go; a week on, the phone's own outcomes, and another's ten days on.
1991        trim_private(&dir, &Stand, NOW + 2 * DAY_MS).unwrap();
1992        assert!(read_due(&dir, &Stand).is_empty() && read_done(&dir).is_empty());
1993        let other = calls::own_file(&root, OUTCOME, "old-phone");
1994        say_outcomes(&other, &[Outcome { key: "k9".into(), state: "handed".into(), at: NOW, ..Outcome::default() }]).unwrap();
1995        write_ask(&root, "phone", &a, &Stand).unwrap();
1996        assert_eq!(trim_shared(&root, "phone", &Stand, NOW + 8 * DAY_MS).unwrap(), done.len() + 1, "the phone's own, its outcomes and its request");
1997        assert_eq!(read_outcomes(&root).len(), 1, "another's waits three more days");
1998        assert_eq!(trim_shared(&root, "phone", &Stand, NOW + 11 * DAY_MS).unwrap(), 1);
1999        let _ = std::fs::remove_dir_all(&dir);
2000        let _ = std::fs::remove_dir_all(&root);
2001    }
2002
2003    /// Each state in words, in English and French, never "Sent": waiting
2004    /// (with Take back), taken, handed (crossed, held), shown, every reason
2005    /// it was not sent (with Send again or "Reply on your phone" where they
2006    /// fit); a Mark as read's own words.
2007    #[test]
2008    fn each_state_is_said_in_words() {
2009        let at = |o: &str, reason: &str| Outcome { key: "k1".into(), state: o.into(), at: NOW, reason: reason.into(), ..Outcome::default() };
2010        let time = |_: i64| "14:03".to_string();
2011        for language in ["en", "fr"] {
2012            let tr = crate::i18n::Translator::new(language);
2013            let a = ask("k1", "J'arrive.", NOW);
2014            let waiting = state_of(&a, &[], "Signal", &tr, Some(NOW), false, &time);
2015            assert!(waiting.waiting && waiting.said.contains("14:03"), "{language}: {waiting:?}");
2016            assert!(state_of(&a, &[], "Signal", &tr, None, false, &time).waiting);
2017            let states = ["taken", "handed", "shown", "gone", "other", "no-reply", "no-read", "closed", "locked", "activity", "late", "failed", "expired", "clocks", "doubt", "taken-back"];
2018            for state in states {
2019                let o = at(state, "");
2020                let said = state_of(&a, &[&o], "Signal", &tr, Some(NOW), false, &time);
2021                assert!(!said.waiting && !said.said.starts_with("replies-") && !said.said.is_empty(), "{language} {state}: {said:?}");
2022                assert!(!said.said.to_lowercase().contains("sent at") && !said.said.contains("Envoyé"), "never a plain Sent: {language} {state}");
2023            }
2024            for reason in ["same-words", "same-words-pending", "too-many", "too-many-read", "too-long", "no-words", "line", "code", "switched-off", "short-number", "not-counted", "before-mark"] {
2025                let o = at("refused", reason);
2026                let said = state_of(&a, &[&o], "Signal", &tr, Some(NOW), false, &time);
2027                assert!(!said.said.starts_with("replies-"), "{language} {reason}");
2028            }
2029            let (handed, shown) = (at("handed", ""), Outcome { at: NOW + 1, ..at("shown", "") });
2030            let said = |a: &Ask, o: &[&Outcome], cancel: bool| state_of(a, o, "Signal", &tr, None, cancel, &time);
2031            assert_eq!(said(&a, &[&handed, &shown], false).state, "shown", "the furthest it went");
2032            let gone = said(&a, &[&at("gone", "")], false);
2033            assert!(gone.on_phone && !gone.again);
2034            assert!(said(&a, &[&at("other", "")], false).on_phone);
2035            assert!(said(&a, &[&at("expired", "")], false).again);
2036            let crossed = Outcome { crossed: true, ..at("handed", "") };
2037            assert_ne!(said(&a, &[&crossed], false).said, said(&a, &[&handed], false).said, "crossed said");
2038            // Held, with nothing newer of theirs: plainly handed; "waits on your phone" only with a newer message.
2039            let held = Outcome { held: true, ..at("handed", "") };
2040            assert_eq!(said(&a, &[&held], false).said, said(&a, &[&handed], false).said, "{language}: held, nothing newer");
2041            let both = Outcome { held: true, crossed: true, ..at("handed", "") };
2042            assert_ne!(said(&a, &[&both], false).said, said(&a, &[&crossed], false).said);
2043            // A Mark as read handed: handed, never "marked".
2044            let read = Ask { verb: "read".into(), words: String::new(), ..a.clone() };
2045            let read_said = said(&read, &[&handed], false).said;
2046            assert_ne!(read_said, said(&a, &[&handed], false).said, "a Mark as read's own words");
2047            if language == "en" {
2048                assert_eq!(read_said, "Mark as read handed to Signal at 14:03.");
2049                assert_eq!(said(&a, &[&both], false).said, "Handed to Signal at 14:03. A newer message of theirs waits on your phone.");
2050            }
2051            // Take back: said at once, no Take back again; too late after the phone took it; taken back.
2052            let asked = said(&a, &[], true);
2053            assert!(!asked.waiting && !asked.said.is_empty() && !asked.said.starts_with("replies-"), "{language}: {asked:?}");
2054            for went in ["taken", "handed", "shown", "doubt"] {
2055                let late = said(&a, &[&at(went, "")], true);
2056                assert!(late.said.len() > said(&a, &[&at(went, "")], false).said.len(), "{language} {went}: too late said");
2057            }
2058            assert_eq!(said(&a, &[&at("taken-back", "")], true).said, said(&a, &[&at("taken-back", "")], false).said);
2059            if language == "en" {
2060                assert_eq!(asked.said, "Taking it back: your phone will not send it, unless it has taken it already.");
2061                assert_eq!(said(&a, &[&handed], true).said, "Handed to Signal at 14:03. Take back came too late: your phone had taken it already.");
2062            }
2063        }
2064    }
2065
2066    /// A conversation answered comes through at once for twenty minutes, then no more.
2067    #[test]
2068    fn a_conversation_answered_is_let_through_for_twenty_minutes() {
2069        let dir = scratch("answered");
2070        assert!(!answered_lately(&dir, "fedcba9876543210", NOW));
2071        note_answered(&dir, "fedcba9876543210", NOW).unwrap();
2072        note_answered(&dir, "", NOW).unwrap();
2073        assert!(answered_lately(&dir, "fedcba9876543210", NOW + 19 * MINUTE));
2074        assert!(!answered_lately(&dir, "fedcba9876543210", NOW + 21 * MINUTE));
2075        assert!(!answered_lately(&dir, "0000000000000000", NOW), "another conversation");
2076        assert!(!answered_lately(&dir, "", NOW));
2077        // A number texted from a computer: by a hash, never the number.
2078        note_answered(&dir, &answered_number("+33199001234"), NOW).unwrap();
2079        assert!(answered_lately(&dir, &answered_number("+33199001234"), NOW + MINUTE));
2080        assert!(!answered_lately(&dir, &answered_number("+33261910000"), NOW + MINUTE));
2081        assert!(!std::fs::read_to_string(dir.join(ANSWERED)).unwrap().contains("199001234"));
2082        assert_eq!(answered_number(""), "");
2083        // What would wait comes at once; what is held for good stays held; what comes stays as it is.
2084        let held = appnotes::Decision::held(NOW / 1000 + 3600, appnotes::Why::Waiting);
2085        assert_eq!(held.answered(), appnotes::Decision::through(appnotes::Why::Answered));
2086        assert_eq!(appnotes::Decision::held(NOW / 1000 + 3600, appnotes::Why::Gathered).answered().why, appnotes::Why::Answered);
2087        let never = appnotes::Decision::held(NOW / 1000 + 3600, appnotes::Why::Never);
2088        assert_eq!(never.answered(), never, "the blocked, a conversation set to never");
2089        let through = appnotes::Decision::through(appnotes::Why::Allowed);
2090        assert_eq!(through.answered(), through);
2091        let _ = std::fs::remove_dir_all(&dir);
2092    }
2093
2094    /// On a computer's Porch: a conversation this computer answered, its
2095    /// phone having handed or shown the reply within twenty minutes, and a
2096    /// number it texted then; never another computer's reply, one refused or
2097    /// still waiting, nor another phone's line of the same conversation.
2098    #[test]
2099    fn what_this_computer_answered_lately_shows_at_once() {
2100        let line = phonemsgs::Line { device: "phone".into(), ..alice_line() };
2101        let mine = ask("k1", "J'arrive.", NOW);
2102        let outcome = |key: &str, state: &str, at: i64| Outcome { key: key.into(), state: state.into(), at, device: "phone".into(), ..Outcome::default() };
2103        let all = [line.clone()];
2104        let answered = Answered::of(std::slice::from_ref(&mine), &[outcome("k1", "handed", NOW)], &all, NOW + 5 * MINUTE);
2105        assert!(answered.holds(&line));
2106        assert!(answered.holds(&phonemsgs::Line { id: "2-bb".into(), text: "Tu es où ?".into(), ..line.clone() }), "their answer, in the same conversation");
2107        assert!(!answered.holds(&phonemsgs::Line { device: "other".into(), ..line.clone() }), "another phone's");
2108        assert!(!answered.holds(&phonemsgs::Line { talk: "0000000000000000".into(), ..line.clone() }), "another conversation");
2109        assert!(Answered::of(std::slice::from_ref(&mine), &[outcome("k1", "shown", NOW)], &all, NOW + 19 * MINUTE).holds(&line));
2110        assert!(Answered::of(std::slice::from_ref(&mine), &[outcome("k1", "handed", NOW + MINUTE)], &all, NOW).holds(&line), "the phone's clock a minute ahead");
2111        assert!(!Answered::of(std::slice::from_ref(&mine), &[outcome("k1", "handed", NOW + 3 * MINUTE)], &all, NOW).holds(&line), "three minutes ahead: not that reply");
2112        assert!(!Answered::of(std::slice::from_ref(&mine), &[outcome("k1", "handed", NOW)], &all, NOW + 21 * MINUTE).holds(&line), "past twenty minutes");
2113        assert!(!Answered::of(std::slice::from_ref(&mine), &[outcome("k1", "refused", NOW)], &all, NOW).holds(&line));
2114        assert!(!Answered::of(std::slice::from_ref(&mine), &[], &all, NOW).holds(&line), "still waiting");
2115        assert!(!Answered::of(&[], &[outcome("k1", "handed", NOW)], &all, NOW).holds(&line), "not this computer's");
2116        let forged = Outcome { device: "other".into(), ..outcome("k1", "handed", NOW) };
2117        assert!(!Answered::of(std::slice::from_ref(&mine), &[forged], &all, NOW).holds(&line), "said by another phone");
2118        let read = Ask { verb: "read".into(), words: String::new(), ..mine.clone() };
2119        assert!(!Answered::of(&[read], &[outcome("k1", "handed", NOW)], &all, NOW).holds(&line), "a Mark as read is no answer");
2120        // A number texted: its texts, not a chat app's line from the same number.
2121        let mut texted = Answered::default();
2122        texted.texted("+33199001234".into());
2123        texted.texted(String::new());
2124        let text = phonemsgs::Line { kind: "text".into(), talk: String::new(), app: "foundation.e.message".into(), ..line.clone() };
2125        assert!(texted.holds(&text));
2126        assert!(!texted.holds(&line), "Signal's line from the same number");
2127        assert!(!texted.holds(&phonemsgs::Line { key: "+33261910000".into(), ..text.clone() }));
2128    }
2129
2130    /// A Porch's line's answer: through the notification's Reply while the
2131    /// phone says it works (its conversation's newest notification, posted
2132    /// anew under another name, too, answered by that one's own line),
2133    /// "Reply on your phone" once it went; a text the texts' way from the
2134    /// phone it came to, where that phone sends texts for this computer;
2135    /// this computer's replies under it with their states, Take back said at
2136    /// once; none for an older phone's lines or a code's; a Mark as read
2137    /// handed makes the line seen.
2138    #[test]
2139    fn a_porch_line_offers_a_reply_while_the_phone_can_send_it() {
2140        let tr = crate::i18n::Translator::new("en");
2141        let line = phonemsgs::Line { device: "phone".into(), ..alice_line() };
2142        let porch = phonemsgs::PorchLine { ids: vec![line.id.clone()], head: "Alice wrote on Signal:".into(), said: Vec::new(), note: String::new(), number: String::new(), dial: String::new(), known: true, texted: false, code: false, who: "Alice".into(), answer: None };
2143        let live = |notice: &str, reply: bool, gone: i64, at: i64| (("phone".to_string(), notice.to_string()), Live { notice: notice.into(), app: line.app.clone(), talk: line.talk.clone(), reply, read: reply, gone, at, device: "phone".into() });
2144        let shared = |_: &str| Some(NOW);
2145        let time = |_: i64| "14:03".to_string();
2146        let none = |_: &str, _: &str, _: i64| Vec::new();
2147        let (no_one, nobody) = (|_: &str| false, BTreeSet::new());
2148        let all = vec![line.clone()];
2149        let offer = |live: &BTreeMap<(String, String), Live>, asks: &[Ask], outcomes: &[Outcome], taken_back: &BTreeSet<String>| {
2150            let o = Offer { live, asks, outcomes, tr: &tr, taken_back, lines: &all, texts_from: &no_one, shared: &shared, time_of: &time, sent_texts: &none };
2151            answer_of(&porch, &[&line], &o)
2152        };
2153        let open: BTreeMap<_, _> = [live(&line.notice, true, 0, NOW)].into();
2154        let answer = offer(&open, &[], &[], &nobody).unwrap();
2155        assert!(answer.offered && answer.read && !answer.texts);
2156        assert_eq!((answer.placeholder.as_str(), answer.label.as_str(), answer.phone.as_str(), answer.line.as_str()), ("Reply to Alice on Signal…", "Signal", "phone", line.id.as_str()));
2157        assert!(!answer.note.is_empty(), "Signal marks the conversation read when it replies");
2158        assert_eq!(answer.limit, WORDS_MAX);
2159        // Gone from the phone: "Reply on your phone".
2160        let gone: BTreeMap<_, _> = [live(&line.notice, false, NOW, NOW)].into();
2161        assert!(!offer(&gone, &[], &[], &nobody).unwrap().offered);
2162        // Posted anew under another name: that notification's Reply, by its own newest line; none of its lines here: not offered.
2163        let anew: BTreeMap<_, _> = [live(&line.notice, false, NOW, NOW), live("1111222233334444", true, 0, NOW + MINUTE)].into();
2164        assert!(!offer(&anew, &[], &[], &nobody).unwrap().offered, "no line of the newer notification here");
2165        let newer = phonemsgs::Line { at: NOW - MINUTE, id: format!("{}-bb22", NOW - MINUTE), notice: "1111222233334444".into(), text: "Tu es où ?".into(), ..line.clone() };
2166        let with_newer = vec![line.clone(), newer.clone()];
2167        let o = Offer { live: &anew, asks: &[], outcomes: &[], tr: &tr, taken_back: &nobody, lines: &with_newer, texts_from: &no_one, shared: &shared, time_of: &time, sent_texts: &none };
2168        let answer = answer_of(&porch, &[&line], &o).unwrap();
2169        assert_eq!((answer.offered, answer.notice.as_str(), answer.line.as_str()), (true, "1111222233334444", newer.id.as_str()));
2170        // Nothing said yet of it (an older phone's live lines): not offered.
2171        assert!(!offer(&BTreeMap::new(), &[], &[], &nobody).unwrap().offered);
2172        // A service's text (a sender named in letters, a short number): never from here.
2173        let service = phonemsgs::Line { kind: "text".into(), app: "foundation.e.message".into(), who: "automaton".into(), key: String::new(), name: "AMELI".into(), ..line.clone() };
2174        let o = Offer { live: &open, asks: &[], outcomes: &[], tr: &tr, taken_back: &nobody, lines: &all, texts_from: &no_one, shared: &shared, time_of: &time, sent_texts: &none };
2175        let said = answer_of(&porch, &[&service], &o).unwrap();
2176        assert!(!said.offered && !said.read, "the phone would refuse it");
2177        let short = phonemsgs::Line { who: "neutral".into(), key: "36179".into(), ..service.clone() };
2178        assert!(!answer_of(&porch, &[&short], &o).unwrap().offered);
2179        // This computer's reply under it, with its state; Take back said at once.
2180        let mine = Ask { phone: "phone".into(), ..ask("k1", "J'arrive.", NOW) };
2181        let handed = Outcome { key: "k1".into(), state: "handed".into(), at: NOW, device: "phone".into(), ..Outcome::default() };
2182        let answer = offer(&open, std::slice::from_ref(&mine), std::slice::from_ref(&handed), &nobody).unwrap();
2183        assert_eq!(answer.mine.iter().map(|m| (m.text.as_str(), m.said.as_str(), m.waiting)).collect::<Vec<_>>(), [("J'arrive.", "Handed to Signal at 14:03.", false)]);
2184        let waiting = offer(&open, std::slice::from_ref(&mine), &[], &nobody).unwrap();
2185        assert!(waiting.mine[0].waiting, "Take back while it waits");
2186        let back: BTreeSet<String> = ["k1".to_string()].into();
2187        let taking = offer(&open, std::slice::from_ref(&mine), &[], &back).unwrap();
2188        assert_eq!((taking.mine[0].waiting, taking.mine[0].said.as_str()), (false, "Taking it back: your phone will not send it, unless it has taken it already."));
2189        let late = offer(&open, std::slice::from_ref(&mine), std::slice::from_ref(&handed), &back).unwrap();
2190        assert!(late.mine[0].said.ends_with("Take back came too late: your phone had taken it already."));
2191        // A text from a whole number: the texts' way from the phone it came to, where it sends texts for this computer.
2192        let texted = phonemsgs::PorchLine { texted: true, dial: "+33199001234".into(), ..porch.clone() };
2193        let sent = |phone: &str, to: &str, _: i64| vec![Mine { key: "t1".into(), time: "14:00".into(), text: "Oui.".into(), said: format!("Sent from {phone} to {to}."), texts: true, ..Mine::default() }];
2194        let this_phone = |phone: &str| phone == "phone";
2195        let o = Offer { live: &BTreeMap::new(), asks: &[], outcomes: &[], tr: &tr, taken_back: &nobody, lines: &all, texts_from: &this_phone, shared: &shared, time_of: &time, sent_texts: &sent };
2196        let answer = answer_of(&texted, &[&line], &o).unwrap();
2197        assert!(answer.texts && answer.offered && answer.limit == crate::texts::WRITE_MAX && answer.phone == "phone");
2198        assert_eq!(answer.mine[0].said, "Sent from phone to +33199001234.");
2199        let other_phone = |phone: &str| phone == "other-phone";
2200        let o = Offer { live: &open, asks: &[], outcomes: &[], tr: &tr, taken_back: &nobody, lines: &all, texts_from: &other_phone, shared: &shared, time_of: &time, sent_texts: &sent };
2201        let answer = answer_of(&texted, &[&line], &o).unwrap();
2202        assert!(!answer.texts && answer.offered, "another phone sends texts: its own Reply, never the other's SIM");
2203        // None for a code's line, nor for an older phone's lines (no notification named).
2204        let older = phonemsgs::Line { notice: String::new(), ..line.clone() };
2205        let o = Offer { live: &open, asks: &[], outcomes: &[], tr: &tr, taken_back: &nobody, lines: &all, texts_from: &no_one, shared: &shared, time_of: &time, sent_texts: &none };
2206        assert!(answer_of(&porch, &[&older], &o).is_none());
2207        assert!(answer_of(&phonemsgs::PorchLine { code: true, ..porch.clone() }, &[&line], &o).is_none());
2208        // A Mark as read handed: the line seen too.
2209        let read = Ask { verb: "read".into(), words: String::new(), ..mine.clone() };
2210        assert!(read_handed(std::slice::from_ref(&read), std::slice::from_ref(&handed), "phone", &line.notice));
2211        assert!(!read_handed(std::slice::from_ref(&mine), std::slice::from_ref(&handed), "phone", &line.notice), "a reply is no Mark as read");
2212    }
2213
2214    /// From the Porch to the phone: the request a Porch's answer writes is
2215    /// one the phone's verdict sends, its notification's own Reply or the
2216    /// conversation's newer notification's, each by its own line.
2217    #[test]
2218    fn what_the_porch_offers_the_phone_sends() {
2219        let tr = crate::i18n::Translator::new("en");
2220        let line = phonemsgs::Line { device: "phone".into(), ..alice_line() };
2221        let newer = phonemsgs::Line { at: NOW - MINUTE, id: format!("{}-bb22", NOW - MINUTE), notice: "1111222233334444".into(), text: "Tu es où ?".into(), ..line.clone() };
2222        let lines = vec![line.clone(), newer.clone()];
2223        let porch = phonemsgs::PorchLine { ids: vec![line.id.clone()], head: String::new(), said: Vec::new(), note: String::new(), number: String::new(), dial: String::new(), known: true, texted: false, code: false, who: "Alice".into(), answer: None };
2224        let live = |notice: &str, reply: bool, gone: i64| (("phone".to_string(), notice.to_string()), Live { notice: notice.into(), app: line.app.clone(), talk: line.talk.clone(), reply, read: reply, gone, at: NOW, device: "phone".into() });
2225        let (shared, time, none, no_one, nobody) = (|_: &str| Some(NOW), |_: i64| "14:03".to_string(), |_: &str, _: &str, _: i64| Vec::new(), |_: &str| false, BTreeSet::new());
2226        let dir = scratch("porch-to-phone");
2227        mark_ledger(&dir, NOW - 30 * MINUTE).unwrap();
2228        let ledger = open_ledger(&dir, NOW).unwrap();
2229        let (cancelled, unsent, yes) = (BTreeSet::new(), BTreeSet::new(), |_: &str| true);
2230        for (lives, expect) in [(vec![live(&line.notice, true, 0)], line.id.clone()), (vec![live(&line.notice, false, NOW), live(&newer.notice, true, 0)], newer.id.clone())] {
2231            let lives: BTreeMap<_, _> = lives.into_iter().collect();
2232            let o = Offer { live: &lives, asks: &[], outcomes: &[], tr: &tr, taken_back: &nobody, lines: &lines, texts_from: &no_one, shared: &shared, time_of: &time, sent_texts: &none };
2233            let answer = answer_of(&porch, &[&line], &o).unwrap();
2234            assert!(answer.offered);
2235            assert_eq!(answer.line, expect);
2236            let request = Ask { key: "k1".into(), phone: answer.phone.clone(), verb: "reply".into(), notice: answer.notice.clone(), app: answer.app.clone(), line: answer.line.clone(), words: "J'arrive.".into(), written: NOW - MINUTE, device: "desk".into(), ..Ask::default() };
2237            let judge = Judge { here: "phone", now_ms: NOW, ledger: &ledger, lines: &lines, standing: &|_| crate::texts::Standing::Counted, cancelled: &cancelled, answers: &yes, unsent: &unsent, went: &unsent };
2238            assert_eq!(verdict(&request, &judge), Verdict::Send, "{}", answer.notice);
2239        }
2240        let _ = std::fs::remove_dir_all(&dir);
2241    }
2242}