Skip to main content

sioul_core/
texts.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Texts on your computers, read and sent through your phone, without Sioul
5//! being the phone's SMS app (docs/texts.md; research: docs/research/sms.md,
6//! phase b). The phone's SMS app stays the record: a fault of Sioul's can
7//! delay a text on the computer, never lose one on the phone.
8//!
9//! **Reading, the whole history.** With `READ_SMS`, the phone reads its
10//! texts from Android's provider (the inbox and the sent box, multimedia
11//! messages and group threads included: Android shows no other app the
12//! drafts, the failed or queued texts, nor a multimedia message still to
13//! download) and writes a line per message in its own log,
14//! `texts/log/<phone>.jsonl` (`Text`), every column the provider gives kept
15//! for a faithful restore. The first import goes a batch at a time. The log
16//! only grows: a text deleted on the phone gets a line of its own saying so
17//! (`Text::deleted`), and every device keeps what it holds.
18//!
19//! **Media.** Each part of a multimedia message the phone has downloaded
20//! (pictures, sound, video, cards, words) under a size you set travels as a
21//! sealed blob of the sharing (`blobs/`), never inside a line (`Part`).
22//!
23//! **An archive.** The texts and their media are kept in Sioul's data
24//! folder on every device that shares the part, never trimmed by age, with
25//! every column a later restore of a new phone would need; that restore is
26//! not built.
27//!
28//! **Sending.** A computer writes a `Request` in its own file
29//! (`texts/send/<device>.jsonl`): a random key, the phone it must leave from,
30//! the number, the words, the SIM, when it was written. The phone decides at
31//! its step (`verdict`), claims the key in its private `Ledger` (written and
32//! flushed before Android is asked), hands the text to Android, and says what
33//! happened in its own `texts/outcome/<phone>.jsonl` (`Outcome`).
34//!
35//! **Never twice** (`verdict`): a key already in the ledger is never sent
36//! again, whatever the sharing reads again; a request written before the
37//! ledger's mark is refused (a ledger lost with the phone's data cannot make
38//! old requests new); a request names its phone, and another phone never
39//! sends it; one older than `EXPIRY_MS` on the phone's clock, corrected by
40//! how far the writer's clock may run ahead, is not sent; Sioul never
41//! retries, Android does (three times, "reject duplicates"); after a crash
42//! between the claim and the result, Sioul looks for the row Android filed
43//! for it, and says "Your phone may not have sent it" when there is none
44//! (`doubt_after`), never sending again by itself. **Send again** makes a new key.
45//!
46//! **Sealed at rest**: every line of these stores, and every file of their
47//! media, is sealed on the device that writes it (`Sealer`, with a key made
48//! from the sharing key, in each device's keyring), lines padded to steps of
49//! `PAD_STEP` bytes inside: a computer's copy is unreadable without the key.
50
51use crate::calls;
52use crate::i18n::Translator;
53use crate::phones::{self, Region};
54use jiff::Zoned;
55use serde::{Deserialize, Serialize};
56use serde_json::Value;
57use std::collections::{BTreeMap, BTreeSet};
58use std::path::{Path, PathBuf};
59
60/// The sharing's part that carries them.
61pub const PART: &str = "texts";
62/// Their folder, in the data folder (an archive, never a cache).
63pub const FOLDER: &str = "texts";
64/// Each phone's texts, a line each, the whole history.
65pub const LOG: &str = "log";
66/// Each computer's texts to send.
67pub const SEND: &str = "send";
68/// Each phone's word on each text it was asked to send.
69pub const OUTCOME: &str = "outcome";
70/// The shared stores, in that order.
71pub const STORES: [&str; 3] = [LOG, SEND, OUTCOME];
72/// Each device's marks on conversations: read up to a moment, hidden up to
73/// a moment (a conversation deleted in Sioul; 23 October 2026). Never trimmed.
74pub const MARKS: &str = "marks";
75/// A text newer than this, in, not read since: unread (older ones count as read).
76pub const UNREAD_DAYS: i64 = 7;
77/// A phone's notification and a text are one message within this time, by number (`folded_into`).
78pub const FOLD_MS: i64 = 3 * 60_000;
79/// The media of multimedia messages, each sealed at rest by its content's hash; never shared as a store (they travel as blobs).
80pub const MEDIA: &str = "media";
81/// The phone's private files, in the state folder, never shared: the ledger,
82/// its mark, the reader's marks and the import's progress, Java's results.
83pub const LEDGER: &str = "ledger.jsonl";
84pub const MARK: &str = "ledger-mark.json";
85pub const READER: &str = "reader.json";
86pub const RESULTS: &str = "results.jsonl";
87/// Requests and outcomes are taken out by their device after this many days…
88pub const KEPT_DAYS: i64 = 31;
89/// …and by any device this many days later (45 in all). Texts never are.
90pub const LATE_DAYS: i64 = 14;
91/// A request older than this on the phone's clock is not sent.
92pub const EXPIRY_MS: i64 = 15 * 60_000;
93/// A request dated further ahead of the phone's clock than this is refused: the clocks disagree.
94pub const AHEAD_MS: i64 = 2 * 60_000;
95/// The ledger keeps a key this long, past any request's life.
96pub const LEDGER_KEPT_MS: i64 = 30 * DAY_MS;
97/// A claim with no word from Android this long after is looked for in the provider.
98pub const LOOK_AFTER_MS: i64 = 2 * 60_000;
99/// …a multimedia message's, handed or not, from this long after: Android
100/// may wait 30 minutes for a mobile network before its first try, and loses
101/// its queue if the phone restarts meanwhile (docs/texts.md)…
102pub const LOOK_AFTER_MMS_MS: i64 = 40 * 60_000;
103/// …until this long after, Android's last word at the latest: each of its
104/// three tries may wait 30 minutes and 5 seconds for a network, then two
105/// minutes on it, with 2 and 4 seconds between (`MmsRequest`,
106/// `MmsNetworkManager`, android16-release: some 97 minutes). Not found by
107/// then: the doubt; until then, it may still go.
108pub const MMS_LAST_WORD_MS: i64 = 100 * 60_000;
109/// A phone's "limits" line dated further ahead of the reader's clock than
110/// this is passed over (a phone whose clock was once set ahead).
111pub const LIMITS_AHEAD_MS: i64 = DAY_MS;
112/// The most a multimedia message may weigh, whatever a phone said: Android's
113/// own ceiling for one (`MmsService.MAX_MMS_FILE_SIZE`, 8 MB).
114pub const MMS_MAX_EVER: u64 = 8 * 1024 * 1024;
115/// A multimedia message from a computer: at most this many media.
116pub const MEDIA_MAX: usize = 5;
117/// A text written on a computer: at most this many characters.
118pub const WRITE_MAX: usize = 1600;
119/// Each line's plain length is a multiple of this before it is sealed.
120pub const PAD_STEP: usize = 256;
121/// The first import, and each step after: at most this many texts at a time.
122pub const BATCH: usize = 500;
123/// …and this many bytes of media.
124pub const MEDIA_BATCH: u64 = 24 << 20;
125/// A part over this many bytes stays on the phone, said, unless you set another size (MB, `texts.toml`).
126pub const MEDIA_CAP_MB: u64 = 10;
127const DAY_MS: i64 = 86_400_000;
128
129/// The folder of the texts: the phones' logs, the requests and outcomes, the media; in the data folder.
130pub fn folder() -> PathBuf {
131    crate::config::data_dir().join(FOLDER)
132}
133
134/// The phone's private texts folder, in the state folder: never shared.
135pub fn private_folder() -> PathBuf {
136    crate::config::state_dir().join(FOLDER)
137}
138
139/// What seals a line at rest and opens it: the caller's, made from the sharing key.
140pub trait Sealer {
141    /// A line, sealed: no newline in it.
142    fn seal(&self, plain: &str) -> String;
143    /// A sealed line opened; none when it does not open (another key, broken).
144    fn open(&self, sealed: &str) -> Option<String>;
145}
146
147// ---------------------------------------------------------------- the lines
148
149/// One part of a multimedia message: its own columns, and where its content is.
150#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
151pub struct Part {
152    /// Its place in the message (`seq`; -1 for the SMIL that lays it out).
153    #[serde(default)]
154    pub seq: i64,
155    /// Its content type: "image/jpeg", "text/plain", "application/smil"…
156    #[serde(default)]
157    pub ct: String,
158    /// Its name, as the message gives one (`name`, else `cl`, else `fn`).
159    #[serde(default, skip_serializing_if = "String::is_empty")]
160    pub name: String,
161    /// Its size in bytes, as Android says it; 0 unknown.
162    #[serde(default, skip_serializing_if = "is_zero_u64")]
163    pub size: u64,
164    /// Its words, for a text part and the SMIL.
165    #[serde(default, skip_serializing_if = "String::is_empty")]
166    pub text: String,
167    /// Its content's hash (SHA-256, hex): its blob in the sharing, its sealed file here.
168    #[serde(default, skip_serializing_if = "String::is_empty")]
169    pub hash: String,
170    /// "here" (its content travels), "text" (its words are in the line), "too-big" (over your size: on your phone), "not-downloaded" (Android has not fetched it).
171    #[serde(default)]
172    pub state: String,
173    /// Every other column of the part as the provider gives it (`chset`, `cd`, `fn`, `cid`, `cl`, `ctt_s`, `ctt_t`…).
174    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
175    pub fields: BTreeMap<String, Value>,
176}
177
178fn is_zero_u64(value: &u64) -> bool {
179    *value == 0
180}
181
182/// A text as the phone's provider holds it, as it travels.
183#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
184pub struct Text {
185    /// Its row's name on that phone (`Row::name`): `sms-<_id>@<date>`,
186    /// `mms-<_id>@<date>`; `sms-<_id>`, `mms-<_id>` for a line written
187    /// before 16 October 2026.
188    pub id: String,
189    /// When (ms): received, or sent.
190    pub at: i64,
191    /// The provider's conversation, on that phone.
192    #[serde(default, skip_serializing_if = "String::is_empty")]
193    pub thread: String,
194    /// "in" or "out".
195    #[serde(rename = "box")]
196    pub direction: String,
197    /// The conversation's other people's numbers, as Sioul keys them (`phones::key`); several: a group.
198    #[serde(default)]
199    pub with: Vec<String>,
200    /// Who wrote it, in a group: their number's key.
201    #[serde(default, skip_serializing_if = "String::is_empty")]
202    pub from: String,
203    #[serde(default, skip_serializing_if = "String::is_empty")]
204    pub body: String,
205    /// The SIM it came by or left from (Android's `sub_id`); -1 unknown.
206    #[serde(default = "no_sim")]
207    pub sub: i32,
208    /// It carries a picture.
209    #[serde(default, skip_serializing_if = "is_false")]
210    pub picture: bool,
211    /// A multimedia message's parts, in order.
212    #[serde(default, skip_serializing_if = "Vec::is_empty")]
213    pub parts: Vec<Part>,
214    /// Every column of the provider's row, as it gives them (a faithful restore needs them): docs/texts.md, "What is kept".
215    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
216    pub fields: BTreeMap<String, Value>,
217    /// A multimedia message's addresses, each row as the provider gives it (`address`, `type`, `charset`).
218    #[serde(default, skip_serializing_if = "Vec::is_empty")]
219    pub addrs: Vec<BTreeMap<String, Value>>,
220    /// Sioul sent it, for this key (the provider's `creator`, matched).
221    #[serde(default, skip_serializing_if = "String::is_empty")]
222    pub key: String,
223    /// When the phone found it deleted (ms): a line of its own, beside the text's; the archive keeps the text.
224    #[serde(default, skip_serializing_if = "is_zero")]
225    pub deleted: i64,
226    /// On a deletion's line: the name of the line that holds the same
227    /// message under the date its provider rewrote (an MMS downloaded or sent
228    /// writes its date after its row; a resend): computers show it once,
229    /// under that name (`fold_logs`). "" for a message deleted on the phone.
230    #[serde(default, skip_serializing_if = "String::is_empty")]
231    pub moved: String,
232    /// The phone that wrote it (its file's name). Never written.
233    #[serde(skip)]
234    pub device: String,
235    /// The same text in other phones' logs (`<phone>/<row>`), when `one_each`
236    /// kept this copy for them all (a restore onto a new phone). Never written.
237    #[serde(skip)]
238    pub also: Vec<String>,
239}
240
241fn no_sim() -> i32 {
242    -1
243}
244
245fn is_false(value: &bool) -> bool {
246    !*value
247}
248
249fn is_zero(value: &i64) -> bool {
250    *value == 0
251}
252
253impl Text {
254    /// Its name in its phone's log: the deletion's line is another.
255    pub fn line_id(&self) -> String {
256        if self.deleted > 0 { format!("{}#deleted", self.id) } else { self.id.clone() }
257    }
258
259    /// The media it holds here, in bytes.
260    pub fn media_bytes(&self) -> u64 {
261        self.parts.iter().filter(|p| p.state == "here").map(|p| p.size).sum()
262    }
263
264    /// Its row on its phone: table, id and the row's own date (`Row`).
265    pub fn row(&self) -> Option<Row> {
266        Row::of(&self.id, self.at)
267    }
268}
269
270/// A text's row in its phone's provider of texts: its table, Android's
271/// `_id`, and the row's own date there (milliseconds for a plain text,
272/// seconds for a multimedia message, as the provider counts them): what
273/// tells two texts apart on one phone. The plain texts' table has no
274/// AUTOINCREMENT, so the next text takes a deleted newest text's id again,
275/// and a provider emptied starts again at 1; the date tells those rows
276/// apart (docs/texts.md, "A row's name").
277#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
278pub struct Row {
279    pub mms: bool,
280    pub id: i64,
281    pub date: i64,
282}
283
284impl Row {
285    /// Its name in the log, the text's `id`: `sms-12@1791446340000`, `mms-31@1791446340`.
286    pub fn name(&self) -> String {
287        format!("{}-{}@{}", if self.mms { "mms" } else { "sms" }, self.id, self.date)
288    }
289
290    /// A line's row, from its name and its time (`at`, ms): `sms-12@1791…`;
291    /// or, for a line named before 16 October 2026 (`sms-12`, `mms-31`), the
292    /// date its time holds (a multimedia message's in seconds). None for a
293    /// name of no row.
294    pub fn of(name: &str, at: i64) -> Option<Row> {
295        let (table, rest) = name.split_once('-')?;
296        let mms = match table {
297            "sms" => false,
298            "mms" => true,
299            _ => return None,
300        };
301        let (id, date) = match rest.split_once('@') {
302            Some((id, date)) => (id.parse().ok()?, date.parse().ok()?),
303            None => (rest.parse().ok()?, if mms { at.div_euclid(1000) } else { at }),
304        };
305        Some(Row { mms, id, date })
306    }
307
308    /// Its time, in milliseconds: a multimedia message's seconds made so.
309    pub fn at(&self) -> i64 {
310        if self.mms { self.date.saturating_mul(1000) } else { self.date }
311    }
312}
313
314/// A text to send, as a computer writes it.
315#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
316pub struct Request {
317    /// Random, 32 hex digits: the text's one name everywhere.
318    pub key: String,
319    /// The phone it must leave from (its name in the sharing).
320    pub phone: String,
321    /// The number, as written; the phone keys it.
322    pub to: String,
323    pub body: String,
324    /// The SIM (`sub_id`); -1: the conversation's last, else the phone's default.
325    #[serde(default = "no_sim")]
326    pub sub: i32,
327    /// When it was written (ms, the writer's clock).
328    pub written: i64,
329    /// How far the writer's clock may run ahead of the phone's (ms): the
330    /// least of the gaps the writer saw between the phone's entry's time and
331    /// its own clock when that entry came, over the last day; an upper bound,
332    /// transit included, so a request looks older, never younger. 0 unknown.
333    #[serde(default, skip_serializing_if = "is_zero")]
334    pub ahead: i64,
335    /// The key of the request it sends again, when it does.
336    #[serde(default, skip_serializing_if = "String::is_empty")]
337    pub again: String,
338    /// A multimedia message's media, in their order (docs/texts.md,
339    /// "Multimedia messages sent from a computer"): each a sealed blob of the
340    /// sharing, which travels ahead of the request. Its words are then in
341    /// `words` and `body` stays empty, so that a phone whose Sioul predates
342    /// them refuses it ("empty") rather than sending its words alone.
343    #[serde(default, skip_serializing_if = "Vec::is_empty")]
344    pub media: Vec<Medium>,
345    /// A multimedia message's words.
346    #[serde(default, skip_serializing_if = "String::is_empty")]
347    pub words: String,
348    /// A group's other numbers, beside `to` (a later step: refused, "several", until then).
349    #[serde(default, skip_serializing_if = "Vec::is_empty")]
350    pub also: Vec<String>,
351    /// The device that wrote it, its name in the sharing, sealed with it
352    /// (`write_request`), as a reply's is: a line read from another
353    /// device's file than its writer's is passed over (`read_requests`), so
354    /// that nothing (the server, a stray file) moves a request from one
355    /// device's name to another's; the phone sends only for a device the
356    /// sharing counts (`verdict`). Empty in a request of a Sioul before 19
357    /// October 2026: judged by its file's device while this phone's ledger
358    /// says writers were not checked yet when it was written (`Mark::writers`),
359    /// refused after.
360    #[serde(default, skip_serializing_if = "String::is_empty")]
361    pub writer: String,
362    /// The device that wrote it: its sealed writer, else its file's name. Never written.
363    #[serde(skip)]
364    pub device: String,
365}
366
367impl Request {
368    /// Whether it is a multimedia message: media of its own.
369    pub fn multimedia(&self) -> bool {
370        !self.media.is_empty()
371    }
372
373    /// Its words, a text's or a multimedia message's.
374    pub fn said_words(&self) -> &str {
375        if self.multimedia() { &self.words } else { &self.body }
376    }
377}
378
379/// A medium of a multimedia message written on a computer: its content's
380/// hash (SHA-256, hex: its sealed blob in the sharing, and its sealed file
381/// on the computers), its content type, its size. Never its file's name.
382#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
383pub struct Medium {
384    pub hash: String,
385    pub ct: String,
386    #[serde(default)]
387    pub size: u64,
388}
389
390/// What a SIM's carrier lets a phone send (Android's carrier configuration,
391/// `SmsManager.getCarrierConfigValues`, which asks no permission), as the
392/// phone says it in its outcomes (a "limits" line): numbers only, never the
393/// carrier's name.
394#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
395pub struct Limits {
396    /// The SIM (`sub_id`); -1 the phone's only or default one.
397    pub sub: i32,
398    /// The largest multimedia message, whole (`maxMessageSize`, bytes).
399    pub max: u64,
400    /// The largest picture it advises (`maxImageWidth`, `maxImageHeight`, pixels).
401    #[serde(default)]
402    pub width: u32,
403    #[serde(default)]
404    pub height: u32,
405    /// How many people one message may go to (`recipientLimit`).
406    #[serde(default)]
407    pub recipients: u32,
408    /// Group messages allowed (`enableGroupMms`), multimedia messages at all (`enabledMMS`).
409    #[serde(default)]
410    pub group: bool,
411    #[serde(default)]
412    pub mms: bool,
413}
414
415/// What became of a request, as its phone says it.
416#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
417pub struct Outcome {
418    pub key: String,
419    /// "taken" (claimed, about to be handed to Android), "handed", "sent",
420    /// "delivered", "failed", "expired", "refused", "clocks", "doubt".
421    pub state: String,
422    /// When (ms, the phone's clock).
423    pub at: i64,
424    /// Why, for "failed" and "refused": "radio-off", "no-service", "limit",
425    /// "short-number", "several", "empty", "too-long", "before-mark"…
426    #[serde(default, skip_serializing_if = "String::is_empty")]
427    pub reason: String,
428    /// The provider's row Android filed it as, by its name in the log
429    /// (`Row::name`, "sms-123@1791446340000"; "sms-123" before 16 October
430    /// 2026), when known: never a bare id an older text may hold.
431    #[serde(default, skip_serializing_if = "String::is_empty")]
432    pub row: String,
433    /// A "limits" line's (its key empty): what each SIM's carrier lets the
434    /// phone send, once a day and when it changes (`limits_of`). A computer
435    /// of an older Sioul passes it over: no request has its key.
436    #[serde(default, skip_serializing_if = "Vec::is_empty")]
437    pub limits: Vec<Limits>,
438    /// The phone that wrote it. Never written.
439    #[serde(skip)]
440    pub device: String,
441}
442
443// ---------------------------------------------------------------- the lines, padded and sealed
444
445/// A value as written: its JSON padded with spaces inside a `pad` field to a
446/// multiple of `PAD_STEP` bytes, then sealed. No line says how long its words are.
447pub fn written<T: Serialize>(value: &T, sealer: &dyn Sealer) -> String {
448    let text = serde_json::to_string(value).unwrap_or_default();
449    sealer.seal(&padded(&text))
450}
451
452/// A JSON object padded inside: `{…,"pad":"   "}`, its length a multiple of `PAD_STEP`.
453pub fn padded(json: &str) -> String {
454    let Some(body) = json.strip_suffix('}') else { return json.to_string() };
455    let joint = if body.ends_with('{') { "" } else { "," };
456    let bare = format!("{body}{joint}\"pad\":\"\"}}");
457    let spaces = (PAD_STEP - bare.len() % PAD_STEP) % PAD_STEP;
458    format!("{body}{joint}\"pad\":\"{}\"}}", " ".repeat(spaces))
459}
460
461/// A device's name as its files name it (a line's `device`).
462pub fn file_name(device: &str) -> String {
463    calls::file_safe(device)
464}
465
466/// "14:02": a moment (ms) as a time of day, in `now`'s zone.
467pub fn time_of_day(at_ms: i64, now: &Zoned) -> String {
468    calls::local(at_ms, now).strftime("%H:%M").to_string()
469}
470
471/// A device's file in one of the stores under `root`: `<store>/<device>.jsonl`.
472pub fn own_file(root: &Path, store: &str, device: &str) -> PathBuf {
473    calls::own_file(root, store, device)
474}
475
476/// Every line of a file that opens and reads, in order; the rest passed over.
477pub fn read_file<T: for<'de> Deserialize<'de>>(path: &Path, sealer: &dyn Sealer) -> Vec<T> {
478    read_lines(&std::fs::read_to_string(path).unwrap_or_default(), sealer)
479}
480
481/// Every line of a file's text that opens and reads, in order; the rest passed over.
482pub fn read_lines<T: for<'de> Deserialize<'de>>(text: &str, sealer: &dyn Sealer) -> Vec<T> {
483    text.lines().filter(|l| !l.trim().is_empty()).filter_map(|l| sealer.open(l.trim())).filter_map(|plain| serde_json::from_str(&plain).ok()).collect()
484}
485
486/// Every device's lines of a store under `root`, each with its device.
487fn read_store<T: for<'de> Deserialize<'de>>(root: &Path, store: &str, sealer: &dyn Sealer, mut set: impl FnMut(&mut T, &str)) -> Vec<T> {
488    let mut all = Vec::new();
489    for (device, path) in calls::device_files(&root.join(store)) {
490        for mut value in read_file::<T>(&path, sealer) {
491            set(&mut value, &device);
492            all.push(value);
493        }
494    }
495    all
496}
497
498/// Every phone's texts, oldest first, each once (by phone and row), a
499/// deletion's line folded into its text (`deleted`); the whole history.
500pub fn read_logs(root: &Path, sealer: &dyn Sealer) -> Vec<Text> {
501    fold_logs(read_store(root, LOG, sealer, |t: &mut Text, d| t.device = d.to_string()))
502}
503
504/// A line's place in its phone's log: its row (`Row::of`), so that a line
505/// named by its id alone (before 16 October 2026) and its row's name now
506/// are one text; its name for a line of no row.
507fn place_of(name: &str, at: i64) -> String {
508    Row::of(name, at).map_or_else(|| name.to_string(), |r| r.name())
509}
510
511/// The phones' log lines (each with its `device`) as `read_logs` gives
512/// them: oldest first, each text once by phone and row (a line named by
513/// its id alone and the same row named with its date are one: a phone put
514/// back on an older Sioul, which writes again under the old names what a
515/// newer one read, never doubles a text on the computers; the review of 16
516/// October 2026, MEDIUM 1), a deletion's line folded into its text, a
517/// line moved under its new name (its row's date rewritten) shown under
518/// that name alone, its old name kept beside it (`also`), so that a request
519/// sent from a computer still finds its text (review of 18 October 2026, L-4).
520pub fn fold_logs(lines: Vec<Text>) -> Vec<Text> {
521    let deletions: BTreeMap<(String, String), i64> = lines.iter().filter(|t| t.deleted > 0).map(|t| ((t.device.clone(), place_of(&t.id, t.at)), t.deleted)).collect();
522    let places: BTreeSet<(String, String)> = lines.iter().filter(|t| t.deleted == 0).map(|t| (t.device.clone(), place_of(&t.id, t.at))).collect();
523    let mut moved: BTreeSet<(String, String)> = BTreeSet::new();
524    let mut old_names: BTreeMap<(String, String), Vec<String>> = BTreeMap::new();
525    for t in lines.iter().filter(|t| t.deleted > 0 && !t.moved.is_empty()) {
526        let to = (t.device.clone(), place_of(&t.moved, 0));
527        if places.contains(&to) {
528            moved.insert((t.device.clone(), place_of(&t.id, t.at)));
529            old_names.entry(to).or_default().push(format!("{}/{}", t.device, t.id));
530        }
531    }
532    let mut seen = BTreeSet::new();
533    let mut texts: Vec<Text> = lines
534        .into_iter()
535        .filter(|t| t.deleted == 0 && t.at > 0)
536        .filter_map(|t| {
537            let place = (t.device.clone(), place_of(&t.id, t.at));
538            if moved.contains(&place) || !seen.insert(place.clone()) {
539                return None;
540            }
541            let deleted = deletions.get(&place).copied().unwrap_or(0);
542            let mut also = t.also.clone();
543            also.extend(old_names.get(&place).into_iter().flatten().cloned());
544            Some(Text { deleted, also, ..t })
545        })
546        .collect();
547    texts.sort_by(|a, b| (a.at, &a.device, &a.id).cmp(&(b.at, &b.device, &b.id)));
548    texts
549}
550
551/// A phone's log as `Logs` read it: its lines, how far, the bytes just
552/// before that place (to tell the same file grown from another one), and
553/// the file's stamp then (`Stamp`).
554#[derive(Debug, Default)]
555struct LogRead {
556    /// Its lines without their raw columns, and what each text's fingerprint is made of (`Print`), by its name.
557    lines: Vec<Text>,
558    plain: BTreeMap<String, Print>,
559    upto: u64,
560    before: Vec<u8>,
561    seen: Stamp,
562}
563
564/// What tells a file unchanged since it was read: its size and time, and on
565/// Unix its inode and change time, which no tool sets back (a file of the
566/// same size whose time was put back, by rsync -t or a restore, is read
567/// again: the review of 16 October 2026, LOW 6). On Windows, Rust's
568/// standard library gives neither (NTFS keeps a file ID and a change time,
569/// which only its unstable parts read): such a file is read again there
570/// once its size or time changes. NTFS's tunnelling gives a file made again
571/// within seconds under the same name the old one's creation time, so
572/// `created()` cannot be relied on either.
573#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
574struct Stamp {
575    len: u64,
576    modified: Option<std::time::SystemTime>,
577    inode: u64,
578    changed: i128,
579}
580
581impl Stamp {
582    fn of(meta: &std::fs::Metadata) -> Stamp {
583        #[cfg(unix)]
584        {
585            use std::os::unix::fs::MetadataExt;
586            Stamp { len: meta.len(), modified: meta.modified().ok(), inode: meta.ino(), changed: i128::from(meta.ctime()) * 1_000_000_000 + i128::from(meta.ctime_nsec()) }
587        }
588        #[cfg(not(unix))]
589        {
590            Stamp { len: meta.len(), modified: meta.modified().ok(), inode: 0, changed: 0 }
591        }
592    }
593
594    /// Read up to `len` with a line still being written after it: never taken for unchanged.
595    fn partial(len: u64) -> Stamp {
596        Stamp { len, ..Stamp::default() }
597    }
598}
599
600/// How many bytes before the place a log was read to tell the same file grown.
601const LOG_CHECK: u64 = 64;
602
603/// Every phone's log under a root as `read_logs` gives it, kept between two
604/// reads and read again only where a file grew (a log only grows: a line is
605/// added at its end, never changed); a file that changed otherwise (another
606/// file under its name, cut short) is read again whole. With each text's
607/// fingerprint kept by phone and row (`one_each`): the window's refresh after
608/// an exchange that brought one text opened 20,000 sealed lines and hashed
609/// them all, 0.3 s on its thread (the review of 16 October 2026, LOW 3).
610#[derive(Debug, Default)]
611pub struct Logs {
612    files: BTreeMap<String, (PathBuf, LogRead)>,
613    prints: BTreeMap<(String, String), String>,
614    region: Option<&'static str>,
615}
616
617impl Logs {
618    /// Each phone's log under `root` read again where it changed; whether one did.
619    pub fn refresh(&mut self, root: &Path, sealer: &dyn Sealer) -> bool {
620        let found = calls::device_files(&root.join(LOG));
621        let mut changed = self.files.len() != found.len() || self.files.keys().zip(&found).any(|(kept, (device, _))| kept != device);
622        self.files.retain(|device, _| found.iter().any(|(d, _)| d == device));
623        for (device, path) in found {
624            let entry = self.files.entry(device.clone()).or_insert_with(|| (path.clone(), LogRead::default()));
625            if entry.0 != path {
626                *entry = (path.clone(), LogRead::default());
627            }
628            changed |= grow(&path, &device, &mut entry.1, sealer);
629        }
630        changed
631    }
632
633    /// Every phone's texts, oldest first, each once by phone and row, a
634    /// deletion folded into its text (`fold_logs`), without their raw columns
635    /// (`fields`, `addrs`): only the fingerprints read those, and each is made
636    /// as its line is read (review of 16 October 2026, LOW 7: cloning them at
637    /// each refresh cost the window's thread 0.14 s at 20,000 lines).
638    pub fn texts(&self) -> Vec<Text> {
639        fold_logs(self.files.values().flat_map(|(_, read)| read.lines.iter().cloned()).collect())
640    }
641
642    /// `texts`, each text once across phones (`one_each`), the numbers keyed
643    /// for `region`: each fingerprint made once for a phone's row and kept.
644    pub fn one_each(&mut self, region: Option<&'static Region>) -> Vec<Text> {
645        let code = region.map(|r| r.code);
646        if code != self.region {
647            self.prints.clear();
648            self.region = code;
649        }
650        let (files, prints) = (&self.files, &mut self.prints);
651        let texts = fold_logs(files.values().flat_map(|(_, read)| read.lines.iter().cloned()).collect());
652        one_each_by(texts, &mut |t: &Text| {
653            prints
654                .entry((t.device.clone(), t.id.clone()))
655                .or_insert_with(|| files.get(&t.device).and_then(|(_, read)| read.plain.get(&t.id)).map_or_else(|| Print::of(t), Clone::clone).key(region))
656                .clone()
657        })
658    }
659}
660
661/// A log read on from where it was read (`LogRead`), or again whole when it
662/// is not that file grown; only whole lines. Whether its lines changed.
663fn grow(path: &Path, device: &str, read: &mut LogRead, sealer: &dyn Sealer) -> bool {
664    use std::io::{Read, Seek, SeekFrom};
665    let Ok(meta) = std::fs::metadata(path) else {
666        let had = !read.lines.is_empty();
667        *read = LogRead::default();
668        return had;
669    };
670    let seen = Stamp::of(&meta);
671    if seen == read.seen {
672        return false;
673    }
674    let Ok(mut file) = std::fs::File::open(path) else { return false };
675    // The bytes before the place it was read to, as they were: the same file, grown.
676    let from = read.upto - read.before.len() as u64;
677    let mut before = vec![0u8; read.before.len()];
678    let same = seen.len > read.upto && file.seek(SeekFrom::Start(from)).is_ok() && file.read_exact(&mut before).is_ok() && before == read.before;
679    let mut changed = false;
680    if !same {
681        changed = !read.lines.is_empty();
682        *read = LogRead::default();
683        if file.seek(SeekFrom::Start(0)).is_err() {
684            return changed;
685        }
686    }
687    let mut bytes = Vec::new();
688    if file.take(seen.len.saturating_sub(read.upto)).read_to_end(&mut bytes).is_err() {
689        return changed;
690    }
691    // Whole lines only: one being written is read at the next look.
692    let Some(end) = bytes.iter().rposition(|b| *b == b'\n') else {
693        read.seen = Stamp::partial(read.upto);
694        return changed;
695    };
696    let whole = &bytes[..=end];
697    let lines: Vec<Text> = read_lines(&String::from_utf8_lossy(whole), sealer);
698    changed |= !lines.is_empty();
699    for t in lines {
700        if t.deleted == 0 {
701            read.plain.entry(t.id.clone()).or_insert_with(|| Print::of(&t));
702        }
703        read.lines.push(Text { device: device.to_string(), fields: BTreeMap::new(), addrs: Vec::new(), ..t });
704    }
705    let mut tail = std::mem::take(&mut read.before);
706    tail.extend_from_slice(whole);
707    read.before = tail[tail.len().saturating_sub(LOG_CHECK as usize)..].to_vec();
708    read.upto += whole.len() as u64;
709    // A line still being written: the next look reads on.
710    read.seen = if read.upto == seen.len { seen } else { Stamp::partial(read.upto) };
711    changed
712}
713
714/// Every device's requests under `root`, each with its device: its sealed
715/// writer (a line whose writer is not its file's device is passed over:
716/// moved from another device's file), else, a request of an older Sioul,
717/// its file's name.
718pub fn read_requests(root: &Path, sealer: &dyn Sealer) -> Vec<Request> {
719    let mut all: Vec<Request> = read_store(root, SEND, sealer, |r: &mut Request, d| r.device = d.to_string());
720    all.retain(|r| r.writer.is_empty() || calls::file_safe(&r.writer) == r.device);
721    for r in all.iter_mut().filter(|r| !r.writer.is_empty()) {
722        r.device = r.writer.clone();
723    }
724    all
725}
726
727/// A request added to its device's own file under `root`, its writer
728/// (`device`) sealed in it, padded and sealed, under its lock.
729pub fn write_request(root: &Path, device: &str, request: &Request, sealer: &dyn Sealer) -> Result<usize, String> {
730    let request = Request { writer: device.to_string(), device: String::new(), ..request.clone() };
731    append(&own_file(root, SEND, device), std::slice::from_ref(&request), sealer, None)
732}
733
734pub fn read_outcomes(root: &Path, sealer: &dyn Sealer) -> Vec<Outcome> {
735    let mut all: Vec<Outcome> = read_store(root, OUTCOME, sealer, |o: &mut Outcome, d| o.device = d.to_string());
736    all.sort_by_key(|o| o.at);
737    all
738}
739
740/// Lines added at the end of a device's own file, under its lock, each
741/// written whole (padded, sealed), those whose `id` the file holds already
742/// left out (`id_of`; none: every line written). Returns how many were written.
743pub fn append<T: Serialize + for<'de> Deserialize<'de>>(own: &Path, values: &[T], sealer: &dyn Sealer, id_of: Option<&dyn Fn(&T) -> String>) -> Result<usize, String> {
744    if values.is_empty() {
745        return Ok(0);
746    }
747    crate::filelock::with_lock(own, || {
748        let mut known: BTreeSet<String> = match id_of {
749            Some(id) => read_file::<T>(own, sealer).iter().map(id).collect(),
750            None => BTreeSet::new(),
751        };
752        let mut text = String::new();
753        let mut count = 0;
754        for value in values {
755            if let Some(id) = id_of
756                && !known.insert(id(value))
757            {
758                continue;
759            }
760            text.push_str(&written(value, sealer));
761            text.push('\n');
762            count += 1;
763        }
764        if count > 0 {
765            calls::append_private(own, &text)?;
766        }
767        Ok(count)
768    })
769}
770
771/// The time a line says (`at`, else `written`), opened; none when it does not open.
772fn time_of(sealer: &dyn Sealer, line: &str) -> Option<i64> {
773    let value: Value = serde_json::from_str(&sealer.open(line.trim())?).ok()?;
774    value["at"].as_i64().or_else(|| value["written"].as_i64())
775}
776
777/// This device's own requests and outcomes past `KEPT_DAYS` taken out; its
778/// texts never (an archive). A line that does not open stays. Returns how many went.
779pub fn trim_own(root: &Path, device: &str, sealer: &dyn Sealer, now_ms: i64) -> Result<usize, String> {
780    let mut gone = 0;
781    for store in [SEND, OUTCOME] {
782        gone += calls::rewrite_lines(&own_file(root, store, device), |line| time_of(sealer, line).is_some_and(|at| now_ms - at > KEPT_DAYS * DAY_MS))?;
783    }
784    Ok(gone)
785}
786
787/// Another device's requests and outcomes `LATE_DAYS` past their time taken out here: texts never.
788pub fn trim_others(root: &Path, here: &str, sealer: &dyn Sealer, now_ms: i64) -> Result<usize, String> {
789    let mut gone = 0;
790    for store in [SEND, OUTCOME] {
791        for (device, path) in calls::device_files(&root.join(store)) {
792            if device != calls::file_safe(here) {
793                gone += calls::rewrite_lines(&path, |line| time_of(sealer, line).is_some_and(|at| now_ms - at > (KEPT_DAYS + LATE_DAYS) * DAY_MS))?;
794            }
795        }
796    }
797    Ok(gone)
798}
799
800/// What a device keeps: how many texts, and how many bytes of media here (`media`: those whose sealed file is on this device).
801pub fn kept(texts: &[Text], media_here: &dyn Fn(&str) -> bool) -> (usize, u64) {
802    let mut hashes = BTreeSet::new();
803    let bytes = texts.iter().flat_map(|t| t.parts.iter()).filter(|p| p.state == "here" && !p.hash.is_empty() && media_here(&p.hash) && hashes.insert(p.hash.clone())).map(|p| p.size).sum();
804    (texts.len(), bytes)
805}
806
807// ---------------------------------------------------------------- read and hidden
808
809/// A device's mark on a conversation (`MARKS`): read up to a moment (ms),
810/// or hidden up to a moment, its texts until then gone from Sioul on every
811/// device (a conversation deleted in Sioul while it is not the phone's SMS
812/// app: the phone's own app keeps them).
813#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
814pub struct TextMark {
815    /// When it was written (ms).
816    pub at: i64,
817    /// The conversation (`conversation_id`).
818    pub conversation: String,
819    #[serde(default, skip_serializing_if = "is_zero")]
820    pub read: i64,
821    #[serde(default, skip_serializing_if = "is_zero")]
822    pub hidden: i64,
823}
824
825/// Every device's marks, by conversation: the latest moment read up to, and hidden up to.
826#[derive(Debug, Clone, Default, PartialEq, Eq)]
827pub struct Marks {
828    pub read: BTreeMap<String, i64>,
829    pub hidden: BTreeMap<String, i64>,
830}
831
832impl Marks {
833    /// Every device's marks under `root`.
834    pub fn read_all(root: &Path, sealer: &dyn Sealer) -> Marks {
835        let mut marks = Marks::default();
836        for (_, path) in calls::device_files(&root.join(MARKS)) {
837            for m in read_file::<TextMark>(&path, sealer) {
838                marks.note(&m);
839            }
840        }
841        marks
842    }
843
844    fn note(&mut self, m: &TextMark) {
845        for (moment, by) in [(m.read, &mut self.read), (m.hidden, &mut self.hidden)] {
846            if moment > 0 {
847                let kept = by.entry(m.conversation.clone()).or_insert(0);
848                *kept = (*kept).max(moment);
849            }
850        }
851    }
852
853    /// Whether a text is hidden: its conversation deleted in Sioul after it came.
854    pub fn hides(&self, t: &Text) -> bool {
855        self.hidden.get(&conversation_id(&t.with)).is_some_and(|upto| t.at <= *upto)
856    }
857}
858
859/// A mark written in this device's own file.
860pub fn write_mark(root: &Path, device: &str, mark: &TextMark, sealer: &dyn Sealer) -> Result<usize, String> {
861    append(&own_file(root, MARKS, device), std::slice::from_ref(mark), sealer, None)
862}
863
864/// The texts without those hidden (`Marks::hides`).
865pub fn visible(texts: &[Text], marks: &Marks) -> Vec<Text> {
866    texts.iter().filter(|t| !marks.hides(t)).cloned().collect()
867}
868
869/// A conversation's unread texts: in, newer than `UNREAD_DAYS`, after its
870/// last read mark, not deleted on the phone; newest first.
871pub fn unread<'a>(texts: &'a [Text], marks: &Marks, now_ms: i64) -> BTreeMap<String, Vec<&'a Text>> {
872    let mut by: BTreeMap<String, Vec<&Text>> = BTreeMap::new();
873    for t in texts.iter().filter(|t| t.direction == "in" && t.deleted == 0 && now_ms - t.at <= UNREAD_DAYS * DAY_MS && !marks.hides(t)) {
874        let id = conversation_id(&t.with);
875        if id.is_empty() || marks.read.get(&id).is_some_and(|upto| t.at <= *upto) {
876            continue;
877        }
878        by.entry(id).or_default().push(t);
879    }
880    for list in by.values_mut() {
881        list.sort_by(|a, b| b.at.cmp(&a.at));
882    }
883    by
884}
885
886/// A phone's own log without the texts hidden on any device (`Marks`):
887/// written again by its one writer, the phone, so that every computer's
888/// copy loses them too. A line that does not open stays. How many went.
889pub fn drop_hidden(own_log: &Path, marks: &Marks, sealer: &dyn Sealer) -> Result<usize, String> {
890    if marks.hidden.is_empty() {
891        return Ok(0);
892    }
893    calls::rewrite_lines(own_log, |line| sealer.open(line.trim()).and_then(|plain| serde_json::from_str::<Text>(&plain).ok()).is_some_and(|t| marks.hides(&t)))
894}
895
896/// The conversation a phone's notification of a text is the same message
897/// as (`phonemsgs::Line`: its sender's number's key, its time): a text in
898/// from that one number within `FOLD_MS`. None: it stays a line of its own.
899pub fn folded_into(key: &str, at_ms: i64, texts: &[Text]) -> Option<String> {
900    if key.is_empty() {
901        return None;
902    }
903    texts.iter().find(|t| t.direction == "in" && t.with.len() == 1 && t.with[0] == key && (t.at - at_ms).abs() <= FOLD_MS).map(|t| conversation_id(&t.with))
904}
905
906// ---------------------------------------------------------------- each text once
907
908/// What Android writes for "this phone" among a multimedia message's addresses.
909pub const OWN_ADDRESS: &str = "insert-address-token";
910/// A multimedia message's address types (`addr.type`, OMA's PduHeaders): from, to, copy, hidden copy.
911pub const ADDR_FROM: i64 = 137;
912pub const ADDR_TO: i64 = 151;
913pub const ADDR_CC: i64 = 130;
914pub const ADDR_BCC: i64 = 129;
915
916/// What a message is, whichever phone or backup holds it: what SMS Backup &
917/// Restore carries from one phone to another unchanged, and what its own
918/// backups hold of the rows Sioul's archive keeps (docs/texts.md, "Each text
919/// once"). Never the provider's row or conversation, which another phone
920/// numbers its own way.
921#[derive(Debug, Clone, Default, PartialEq, Eq)]
922pub struct Print {
923    /// A multimedia message (else a plain text).
924    pub mms: bool,
925    pub outgoing: bool,
926    /// When, to the second: the multimedia provider counts in seconds.
927    pub second: i64,
928    /// The other people, as written: a text's address; a multimedia
929    /// message's sender when received, those it went to when sent (this
930    /// phone's own address left out, as `OWN_ADDRESS` or as its number).
931    pub people: Vec<String>,
932    /// Its words: a text's; each text part's of a multimedia message.
933    pub words: Vec<String>,
934    /// Its media's content types ("image/jpeg").
935    pub media: Vec<String>,
936}
937
938/// Whether a multimedia message's addresses of this type are its other
939/// people for `Print`: a received one's sender; a sent one's recipients.
940pub fn print_people(outgoing: bool, kind: i64) -> bool {
941    if outgoing { matches!(kind, ADDR_TO | ADDR_CC | ADDR_BCC) } else { kind == ADDR_FROM }
942}
943
944/// A part's content type that is words, never a medium: the text and the SMIL that lays it out.
945pub fn is_words(ct: &str) -> bool {
946    ct.starts_with("text/plain") || ct == "application/smil"
947}
948
949/// Words as XML 1.0 can carry them: the control characters it refuses (all
950/// below a space but the tab, the line feed and the carriage return, and
951/// U+FFFE, U+FFFF) left out. SMS Backup & Restore's backups are XML, so a
952/// text restored from one never holds them.
953pub fn carried(words: &str) -> std::borrow::Cow<'_, str> {
954    if words.chars().any(|c| !xml_char(c)) { std::borrow::Cow::Owned(words.chars().filter(|c| xml_char(*c)).collect()) } else { std::borrow::Cow::Borrowed(words) }
955}
956
957/// A character XML 1.0 can carry (its `Char`; Rust's strings hold no lone surrogate).
958pub fn xml_char(c: char) -> bool {
959    matches!(c, '\t' | '\n' | '\r') || (c >= ' ' && c != '\u{fffe}' && c != '\u{ffff}')
960}
961
962impl Print {
963    /// A message of a phone's log: its kind, way, second, other people, words and media.
964    pub fn of(t: &Text) -> Print {
965        let mms = t.id.starts_with("mms-");
966        let outgoing = t.direction == "out";
967        let field = |name: &str| t.fields.get(name).and_then(Value::as_str).unwrap_or_default().to_string();
968        let (people, words, media) = if mms {
969            let people: Vec<String> = t.addrs.iter().filter(|a| print_people(outgoing, number(a.get("type")))).filter_map(|a| a.get("address").and_then(Value::as_str)).filter(|a| !a.trim().is_empty() && *a != OWN_ADDRESS).map(str::to_string).collect();
970            let people = if people.is_empty() { t.with.clone() } else { people };
971            let words = t.parts.iter().filter(|p| p.ct.starts_with("text/plain")).map(|p| p.text.clone()).collect();
972            let media = t.parts.iter().filter(|p| !is_words(&p.ct)).map(|p| p.ct.clone()).collect();
973            (people, words, media)
974        } else {
975            let address = field("address");
976            let people = if address.trim().is_empty() { t.with.clone() } else { vec![address] };
977            (people, vec![t.body.clone()], Vec::new())
978        };
979        Print { mms, outgoing, second: t.at.div_euclid(1000), people, words, media }
980    }
981
982    /// Its fingerprint: the same for the same message on any phone and in any
983    /// backup, whatever their rows and conversations (SHA-256, hex). The
984    /// people keyed as Sioul keys numbers (`phones::key`), in any order; the
985    /// words as XML carries them; an empty text part, or one SMS Backup &
986    /// Restore wrote "null", is none.
987    pub fn key(&self, region: Option<&Region>) -> String {
988        use sha2::{Digest, Sha256};
989        let mut people: Vec<String> = self.people.iter().flat_map(|p| p.split('~')).map(|p| phones::key(p, region)).filter(|k| !k.is_empty()).collect();
990        people.sort_unstable();
991        people.dedup();
992        let mut words: Vec<String> = self.words.iter().map(|w| carried(w).into_owned()).filter(|w| !w.is_empty() && !(self.mms && w == "null")).collect();
993        words.sort_unstable();
994        let mut media: Vec<String> = self.media.iter().map(|m| m.trim().to_ascii_lowercase()).collect();
995        media.sort_unstable();
996        let mut hasher = Sha256::new();
997        for piece in [if self.mms { "mms" } else { "sms" }, if self.outgoing { "out" } else { "in" }, &self.second.to_string(), &people.join("\u{1f}"), &words.join("\u{1e}"), &media.join("\u{1f}")] {
998            hasher.update(piece.as_bytes());
999            hasher.update([0u8]);
1000        }
1001        hasher.finalize().iter().map(|b| format!("{b:02x}")).collect()
1002    }
1003}
1004
1005/// How far apart a received text's two dates may be for a re-date (a
1006/// provider re-dates its own sending, an MMS downloaded or sent, a resend;
1007/// never a text received hours later under a deleted text's id).
1008pub const MOVED_WITHIN_MS: i64 = 10 * 60_000;
1009
1010/// Whether `new` is the message of `old` under a date its provider
1011/// rewrote: the same table and row id, another date, the same way and
1012/// people; a text's words the same, and either sent from the phone or
1013/// dated within `MOVED_WITHIN_MS` of the other (the plain texts' table
1014/// gives a deleted newest text's id to the next one: Alice's "Oui." deleted,
1015/// her next "Oui." is another text, said deleted as before: review of 18
1016/// October 2026, L-3); a multimedia message's media the same by their
1017/// hashes wherever the older has them, its words and media as complete or
1018/// more (one read before its parts were attached comes whole).
1019pub fn moved(old: &Text, new: &Text) -> bool {
1020    let (Some(a), Some(b)) = (old.row(), new.row()) else { return false };
1021    if a.mms != b.mms || a.id != b.id || a.date == b.date || old.direction != new.direction {
1022        return false;
1023    }
1024    let (was, is) = (Print::of(old), Print::of(new));
1025    let people = |p: &Print| {
1026        let mut people: Vec<String> = p.people.iter().map(|w| w.trim().to_string()).collect();
1027        people.sort_unstable();
1028        people.dedup();
1029        people
1030    };
1031    if people(&was) != people(&is) {
1032        return false;
1033    }
1034    if a.mms {
1035        let hashes = |t: &Text| t.parts.iter().filter(|p| !p.hash.is_empty()).map(|p| p.hash.clone()).collect::<BTreeSet<String>>();
1036        hashes(old).is_subset(&hashes(new)) && was.words.iter().all(|w| is.words.contains(w)) && was.media.iter().all(|m| is.media.contains(m))
1037    } else {
1038        old.body == new.body && (old.direction == "out" || (b.date - a.date).abs() <= MOVED_WITHIN_MS)
1039    }
1040}
1041
1042/// The marks of the lines whose row its provider re-dated (`moved`): each
1043/// line of a phone's log not marked yet whose row is no longer listed, while
1044/// its row id is listed under another date with a line of the log holding
1045/// the same message (the newest such): a deletion's line naming it
1046/// (`Text::moved`), never "deleted on your phone".
1047pub fn moved_marks(log: &[Text], listed: &BTreeSet<Row>, now: i64) -> Vec<Text> {
1048    let marked: BTreeSet<&str> = log.iter().filter(|t| t.deleted > 0).map(|t| t.id.as_str()).collect();
1049    let mut by_id: BTreeMap<(bool, i64), Vec<(&Text, Row)>> = BTreeMap::new();
1050    for t in log.iter().filter(|t| t.deleted == 0) {
1051        if let Some(row) = t.row().filter(|r| listed.contains(r)) {
1052            by_id.entry((row.mms, row.id)).or_default().push((t, row));
1053        }
1054    }
1055    log.iter()
1056        .filter(|t| t.deleted == 0 && !marked.contains(t.id.as_str()))
1057        .filter_map(|old| {
1058            let row = old.row().filter(|r| !listed.contains(r))?;
1059            let new = by_id.get(&(row.mms, row.id))?.iter().filter(|(t, _)| moved(old, t)).max_by_key(|(_, r)| r.date).map(|(t, _)| *t)?;
1060            Some(Text { id: old.id.clone(), at: old.at, direction: old.direction.clone(), with: old.with.clone(), deleted: now, moved: new.id.clone(), ..Text::default() })
1061        })
1062        .collect()
1063}
1064
1065/// A column's number, written as a number or as words.
1066pub fn number(value: Option<&Value>) -> i64 {
1067    value.and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))).unwrap_or(0)
1068}
1069
1070/// Every phone's texts with each text once (docs/texts.md, "Each text
1071/// once"): a text found in several phones' logs, as after a restore onto a
1072/// new phone, once; a text there twice in one phone's log (truly sent
1073/// twice), twice: for each fingerprint (`Print::key`), as many as the phone
1074/// holding most, its copies paired with the others' in their logs' order.
1075/// The copy kept of each pair has the most media here, else is not deleted,
1076/// else is the first phone's by name; it is said deleted only when every
1077/// copy is, and names the others (`Text::also`). Oldest first, as
1078/// `read_logs`. For what the page, the search and agents read; the phones'
1079/// logs themselves keep every copy (nothing is lost if one goes).
1080pub fn one_each(texts: Vec<Text>, region: Option<&Region>) -> Vec<Text> {
1081    one_each_by(texts, &mut |t: &Text| Print::of(t).key(region))
1082}
1083
1084/// `one_each` with each text's fingerprint as `print` gives it (`Print::key`
1085/// with the region of the numbers, kept by phone and row between two reads:
1086/// `Logs`).
1087pub fn one_each_by(texts: Vec<Text>, print: &mut dyn FnMut(&Text) -> String) -> Vec<Text> {
1088    let phones: BTreeSet<&str> = texts.iter().map(|t| t.device.as_str()).collect();
1089    if phones.len() < 2 {
1090        return texts;
1091    }
1092    // By fingerprint, then by phone: each copy's place, in its log's order.
1093    let mut groups: BTreeMap<String, BTreeMap<String, Vec<usize>>> = BTreeMap::new();
1094    for (i, t) in texts.iter().enumerate() {
1095        groups.entry(print(t)).or_default().entry(t.device.clone()).or_default().push(i);
1096    }
1097    let media = |t: &Text| t.parts.iter().filter(|p| p.state == "here" && !p.hash.is_empty()).count();
1098    let mut slots: Vec<Option<Text>> = texts.into_iter().map(Some).collect();
1099    let mut out: Vec<Text> = Vec::with_capacity(slots.len());
1100    for by_phone in groups.into_values() {
1101        let most = by_phone.values().map(Vec::len).max().unwrap_or(0);
1102        for k in 0..most {
1103            let copies: Vec<usize> = by_phone.values().filter_map(|list| list.get(k).copied()).collect();
1104            let best = copies
1105                .iter()
1106                .copied()
1107                .max_by(|&a, &b| {
1108                    let (a, b) = (slots[a].as_ref(), slots[b].as_ref());
1109                    let rank = |t: Option<&Text>| t.map(|t| (media(t), t.deleted == 0, !t.key.is_empty()));
1110                    // On a tie, the first phone's by name.
1111                    rank(a).cmp(&rank(b)).then_with(|| b.map(|t| &t.device).cmp(&a.map(|t| &t.device)))
1112                })
1113                .unwrap_or(copies[0]);
1114            let others: Vec<Text> = copies.iter().filter(|&&i| i != best).filter_map(|&i| slots[i].take()).collect();
1115            let Some(mut kept) = slots[best].take() else { continue };
1116            if others.iter().any(|t| t.deleted == 0) {
1117                kept.deleted = 0;
1118            } else if kept.deleted > 0 {
1119                kept.deleted = others.iter().map(|t| t.deleted).fold(kept.deleted, i64::max);
1120            }
1121            if kept.key.is_empty() {
1122                kept.key = others.iter().map(|t| t.key.clone()).find(|k| !k.is_empty()).unwrap_or_default();
1123            }
1124            kept.also.extend(others.iter().map(|t| format!("{}/{}", t.device, t.id)));
1125            out.push(kept);
1126        }
1127    }
1128    out.sort_by(|a, b| (a.at, &a.device, &a.id).cmp(&(b.at, &b.device, &b.id)));
1129    out
1130}
1131
1132// ---------------------------------------------------------------- the phone's ledger
1133
1134/// One line of the phone's private ledger: a key, when, what was done.
1135#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1136pub struct Claim {
1137    pub key: String,
1138    /// When (ms, the phone's clock).
1139    pub at: i64,
1140    /// "claimed" (written before Android is asked), "handed" (Android took
1141    /// it), "done" (its result came, or it was looked for), and the decisions
1142    /// that end a request: "expired", "refused", "clocks", "before-mark".
1143    pub state: String,
1144    /// What was sent, to find its row after a crash: the number's key and the words' hash.
1145    #[serde(default, skip_serializing_if = "String::is_empty")]
1146    pub to: String,
1147    #[serde(default, skip_serializing_if = "String::is_empty")]
1148    pub hash: String,
1149    /// A reply's conversation (`replies::claim_of`, its key): the same words
1150    /// to it are held back whichever of its notifications they go through.
1151    #[serde(default, skip_serializing_if = "String::is_empty")]
1152    pub talk: String,
1153    /// A multimedia message's claim: looked for by its key (the provider's
1154    /// `tr_id`) `LOOK_AFTER_MMS_MS` on, handed or not.
1155    #[serde(default, skip_serializing_if = "is_false")]
1156    pub mms: bool,
1157}
1158
1159/// The ledger's mark: requests written before it are never sent (a ledger lost cannot make them new).
1160#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1161pub struct Mark {
1162    pub since: i64,
1163    /// When this phone began to check who wrote each request (ms, its
1164    /// clock; `Request::writer`): one with no writer written before is judged
1165    /// by its file's device, one written after is refused, its computer's
1166    /// Sioul older ("old-computer"). Set at the first opening by a Sioul that
1167    /// checks; 0 in a ledger of an older one (none refused so).
1168    #[serde(default, skip_serializing_if = "is_zero")]
1169    pub writers: i64,
1170}
1171
1172/// The phone's private ledger, never shared: every key it decided, and its mark.
1173#[derive(Debug, Clone, Default, PartialEq, Eq)]
1174pub struct Ledger {
1175    pub mark: Mark,
1176    pub claims: Vec<Claim>,
1177    /// Every key the claims hold (`has`), once: a step judges thousands of
1178    /// requests already decided without a search each.
1179    keys: std::collections::BTreeSet<String>,
1180}
1181
1182impl Ledger {
1183    /// The ledger in `dir` (the phone's private texts folder): its mark made
1184    /// now when there is none (a first ledger, or one lost: requests written
1185    /// before now are refused).
1186    pub fn open(dir: &Path, now_ms: i64) -> Result<Ledger, String> {
1187        let mark_path = dir.join(MARK);
1188        let mark = match std::fs::read_to_string(&mark_path).ok().and_then(|t| serde_json::from_str::<Mark>(&t).ok()) {
1189            Some(mark) if mark.writers > 0 => mark,
1190            // A mark of an older Sioul: its time kept, writers checked from now.
1191            Some(older) => {
1192                let mark = Mark { writers: now_ms.max(older.since), ..older };
1193                replace_flushed(&mark_path, &serde_json::to_string(&mark).map_err(|e| e.to_string())?)?;
1194                mark
1195            }
1196            None => {
1197                let mark = Mark { since: now_ms, writers: now_ms };
1198                write_flushed(&mark_path, &serde_json::to_string(&mark).map_err(|e| e.to_string())?, false)?;
1199                mark
1200            }
1201        };
1202        let claims: Vec<Claim> = std::fs::read_to_string(dir.join(LEDGER)).unwrap_or_default().lines().filter_map(|l| serde_json::from_str(l).ok()).collect();
1203        let keys = claims.iter().map(|c| c.key.clone()).collect();
1204        Ok(Ledger { mark, claims, keys })
1205    }
1206
1207    /// Whether a key was decided already, whatever was decided.
1208    pub fn has(&self, key: &str) -> bool {
1209        self.keys.contains(key)
1210    }
1211
1212    /// A ledger of these lines and this mark, held in memory (tests, stand-ins).
1213    pub fn with(mark: Mark, claims: Vec<Claim>) -> Ledger {
1214        let keys = claims.iter().map(|c| c.key.clone()).collect();
1215        Ledger { mark, claims, keys }
1216    }
1217
1218    /// A line added in memory, as the ledger will hold it once written (`record_all`).
1219    pub fn add(&mut self, claim: Claim) {
1220        self.keys.insert(claim.key.clone());
1221        self.claims.push(claim);
1222    }
1223
1224    /// A key's last state.
1225    pub fn state(&self, key: &str) -> Option<&str> {
1226        self.claims.iter().rev().find(|c| c.key == key).map(|c| c.state.as_str())
1227    }
1228}
1229
1230/// A line added to the ledger and flushed to the disk before this returns:
1231/// the claim exists before Android is asked to send.
1232pub fn record(dir: &Path, claim: &Claim) -> Result<(), String> {
1233    record_all(dir, std::slice::from_ref(claim))
1234}
1235
1236/// Lines added to the ledger together and flushed once before this returns
1237/// (a step's decisions: one flush, not one each); nothing for none.
1238pub fn record_all(dir: &Path, claims: &[Claim]) -> Result<(), String> {
1239    if claims.is_empty() {
1240        return Ok(());
1241    }
1242    let mut text = String::new();
1243    for claim in claims {
1244        text.push_str(&serde_json::to_string(claim).map_err(|e| e.to_string())?);
1245        text.push('\n');
1246    }
1247    write_flushed(&dir.join(LEDGER), &text, true)
1248}
1249
1250/// Written (or appended) and flushed to the disk, yours alone.
1251fn write_flushed(path: &Path, text: &str, append: bool) -> Result<(), String> {
1252    use std::io::Write;
1253    let fail = |e: std::io::Error| format!("{}: {e}", path.display());
1254    if let Some(dir) = path.parent() {
1255        std::fs::create_dir_all(dir).map_err(fail)?;
1256    }
1257    let mut options = std::fs::OpenOptions::new();
1258    options.create(true).write(true);
1259    if append {
1260        options.append(true);
1261    } else {
1262        options.truncate(true);
1263    }
1264    #[cfg(unix)]
1265    std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
1266    let mut file = options.open(path).map_err(fail)?;
1267    file.write_all(text.as_bytes()).map_err(fail)?;
1268    file.sync_all().map_err(fail)
1269}
1270
1271/// A file written whole beside, flushed, then put in place: a crash leaves
1272/// the old one or the new one, never an empty one.
1273fn replace_flushed(path: &Path, text: &str) -> Result<(), String> {
1274    let beside = path.with_extension("new");
1275    write_flushed(&beside, text, false)?;
1276    std::fs::rename(&beside, path).map_err(|e| format!("{}: {e}", path.display()))
1277}
1278
1279/// The ledger's old keys taken out, `LEDGER_KEPT_MS` on (any request expired
1280/// long before; the mark refuses those written before it anyway).
1281pub fn trim_ledger(dir: &Path, now_ms: i64) -> Result<usize, String> {
1282    calls::rewrite_lines(&dir.join(LEDGER), |line| serde_json::from_str::<Claim>(line).is_ok_and(|c| now_ms - c.at > LEDGER_KEPT_MS))
1283}
1284
1285/// A text's words, hashed as the ledger keeps them (FNV-1a, 16 hex digits).
1286pub fn words_hash(body: &str) -> String {
1287    crate::appnotes::talk_key("texts", body)
1288}
1289
1290// ---------------------------------------------------------------- never twice
1291
1292/// What the phone does with a request.
1293#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1294pub enum Verdict {
1295    /// Claim it, then hand it to Android.
1296    Send,
1297    /// Another phone's: nothing done, nothing said.
1298    NotMine,
1299    /// Decided already (sent, or not): nothing done again.
1300    Decided,
1301    /// Written before the ledger's mark: refused, said.
1302    BeforeMark,
1303    /// Older than `EXPIRY_MS`: not sent, said.
1304    Expired,
1305    /// Dated ahead of the phone's clock: the clocks disagree; not sent, said.
1306    Clocks,
1307    /// Not a text Sioul sends from a computer, or not asked by one of your
1308    /// devices ("not-counted", "old-computer"): said why.
1309    Refused(&'static str),
1310    /// Asked by a device the sharing does not know yet (a new computer's
1311    /// first text may come before any record of it): nothing done, nothing
1312    /// said, judged again at the next step while it may still go.
1313    Wait,
1314}
1315
1316/// Where a device that asks stands in the sharing (`verdict`, and the
1317/// replies' `Judge`): counted now; gone by a sealed fact (it left, you forgot
1318/// it, another device replaces it), refused; or not known yet (its records
1319/// have not come, a file alone, a device silent for weeks), its request
1320/// waiting while it is fresh (the review of 18 October 2026, I-a).
1321#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1322pub enum Standing {
1323    Counted,
1324    Gone,
1325    Unknown,
1326}
1327
1328/// Why a number or words are refused from a computer, if they are: several
1329/// recipients (an MMS group), a short number (premium risk; Android would ask
1330/// on the phone), no words, too many. None: it may go.
1331pub fn refusal(to: &str, body: &str, region: Option<&Region>) -> Option<&'static str> {
1332    if body.trim().is_empty() {
1333        return Some("empty");
1334    }
1335    if body.chars().count() > WRITE_MAX {
1336        return Some("too-long");
1337    }
1338    if to.contains([',', ';']) || to.chars().filter(char::is_ascii_digit).count() > 15 {
1339        return Some("several");
1340    }
1341    let key = phones::key(to, region);
1342    if !phones::is_whole(&key) || key.trim_start_matches('+').len() < 8 {
1343        return Some("short-number");
1344    }
1345    None
1346}
1347
1348/// Why a multimedia message is refused from a computer, if it is: its
1349/// words over `WRITE_MAX`; no medium, or more than `MEDIA_MAX`; a medium of
1350/// a kind the phone does not send (pictures, videos, sounds, contact cards
1351/// and calendar entries only: another file goes by mail) or not named by a
1352/// content's hash; other people beside its number (a group: a later step);
1353/// a number as `refusal` refuses one. None: it may go.
1354pub fn refusal_mms(to: &str, words: &str, media: &[Medium], also: &[String], region: Option<&Region>) -> Option<&'static str> {
1355    if words.chars().count() > WRITE_MAX {
1356        return Some("too-long");
1357    }
1358    if media.is_empty() {
1359        return Some("empty");
1360    }
1361    if media.len() > MEDIA_MAX {
1362        return Some("too-many-media");
1363    }
1364    let hashed = |hash: &str| hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit());
1365    if media.iter().any(|m| !hashed(&m.hash) || m.size == 0 || !sendable(&m.ct)) {
1366        return Some("media-kind");
1367    }
1368    if !also.is_empty() {
1369        return Some("several");
1370    }
1371    // A number as a text's: the words checked above, a stand-in for them here.
1372    refusal(to, "-", region)
1373}
1374
1375/// Whether a phone sends a medium of this content type in a multimedia
1376/// message (decisions 7 to 9): a picture, a video, a sound, a contact card
1377/// or a calendar entry.
1378pub fn sendable(ct: &str) -> bool {
1379    let ct = ct.trim().to_ascii_lowercase();
1380    let (top, sub) = ct.split_once('/').unwrap_or(("", ""));
1381    !sub.is_empty() && (matches!(top, "image" | "video" | "audio") || matches!(ct.as_str(), "text/x-vcard" | "text/vcard" | "text/x-vcalendar" | "text/calendar"))
1382}
1383
1384/// The never-twice decision, step for step: another phone's; decided already
1385/// (the ledger holds the key, whatever the sharing read again); written by
1386/// a computer of an older Sioul after this phone began to check writers
1387/// ("old-computer"); asked by a device gone from the sharing (`standing`:
1388/// it left, you forgot it, another device replaces it: "not-counted"), or
1389/// one it does not know yet, which waits while its request is fresh
1390/// (`Verdict::Wait`; then dated ahead or expired, as below); refused
1391/// (`refusal`, a multimedia message's
1392/// `refusal_mms`); written before the ledger's mark; dated ahead of the
1393/// phone's clock; older than `EXPIRY_MS`; else sent. The time written is
1394/// corrected by how far the writer's clock may run ahead (`Request::ahead`,
1395/// measured by the writer as an upper bound: a request looks older, never
1396/// younger). `standing` is asked only of a request not decided yet.
1397pub fn verdict(request: &Request, here: &str, now_ms: i64, ledger: &Ledger, region: Option<&Region>, standing: &dyn Fn(&str) -> Standing) -> Verdict {
1398    if request.phone != here || request.key.is_empty() {
1399        return Verdict::NotMine;
1400    }
1401    if ledger.has(&request.key) {
1402        return Verdict::Decided;
1403    }
1404    if request.writer.is_empty() && ledger.mark.writers > 0 && request.written - request.ahead >= ledger.mark.writers {
1405        return Verdict::Refused("old-computer");
1406    }
1407    match standing(&request.device) {
1408        Standing::Counted => {}
1409        Standing::Gone => return Verdict::Refused("not-counted"),
1410        Standing::Unknown => {
1411            let written = request.written - request.ahead;
1412            return if written > now_ms + AHEAD_MS {
1413                Verdict::Clocks
1414            } else if now_ms - written > EXPIRY_MS {
1415                Verdict::Expired
1416            } else {
1417                Verdict::Wait
1418            };
1419        }
1420    }
1421    let refused = if request.multimedia() { refusal_mms(&request.to, &request.words, &request.media, &request.also, region) } else { refusal(&request.to, &request.body, region) };
1422    if let Some(why) = refused {
1423        return Verdict::Refused(why);
1424    }
1425    let written = request.written - request.ahead;
1426    if written < ledger.mark.since {
1427        return Verdict::BeforeMark;
1428    }
1429    if written > now_ms + AHEAD_MS {
1430        return Verdict::Clocks;
1431    }
1432    if now_ms - written > EXPIRY_MS {
1433        return Verdict::Expired;
1434    }
1435    Verdict::Send
1436}
1437
1438/// After a crash between the claim and Android's word: what to say of a
1439/// claim `LOOK_AFTER_MS` old with no result, given whether the provider holds
1440/// a sent row Sioul filed for it (`found`: its row). Found: sent. Not found:
1441/// "doubt", never sent again.
1442/// A multimedia message's claim is looked for by its key from
1443/// `LOOK_AFTER_MMS_MS` on, and said in doubt only once Android's last word
1444/// is past (`MMS_LAST_WORD_MS`): until then it may still go.
1445pub fn doubt_after(claim: &Claim, found: Option<String>, now_ms: i64) -> Option<Outcome> {
1446    let age = now_ms - claim.at;
1447    if age < if claim.mms { LOOK_AFTER_MMS_MS } else { LOOK_AFTER_MS } {
1448        return None;
1449    }
1450    match found {
1451        Some(row) => Some(Outcome { key: claim.key.clone(), state: "sent".into(), at: now_ms, row, ..Outcome::default() }),
1452        None if claim.mms && age < MMS_LAST_WORD_MS => None,
1453        None => Some(Outcome { key: claim.key.clone(), state: "doubt".into(), at: now_ms, ..Outcome::default() }),
1454    }
1455}
1456
1457/// What a multimedia message's result says (`TextSend.Result`): sent, when
1458/// Android took it to the carrier and the carrier answered Ok; not sent, and
1459/// why; or, Android's Ok with no answer of the carrier's, to be looked for
1460/// in the provider's sent box (a message Android moved to its failed box is
1461/// out of Sioul's sight: none there, not sent); or in doubt, and why
1462/// ("network": each of Android's tries ended on the network; "cancelled":
1463/// Android stopped it; "unexpected": an unexpected failure, or a code Sioul
1464/// does not know).
1465#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1466pub enum Took {
1467    Sent,
1468    Failed(&'static str),
1469    Look,
1470    Doubt(&'static str),
1471}
1472
1473/// A multimedia message's result: Android's code (-1, `Activity.RESULT_OK`;
1474/// else `SmsManager.MMS_ERROR_…`), the carrier's answer (M-Send.conf) when it
1475/// came, and the HTTP status Android adds to its code 4 (0 none). Android's
1476/// Ok alone is not the carrier's. Its code 4 with no HTTP status, or one
1477/// under 400, says that every try ended on the network (an I/O failure,
1478/// even a read timeout after the message was posted: `MmsHttpClient`,
1479/// `MmsRequest`, android16-release): the carrier may have taken it, once
1480/// or more; with a status of 400 or more, the carrier refused it. Its code
1481/// 0 (`Activity.RESULT_CANCELED`: Android stopped it) and 1 (an unexpected
1482/// failure, which may come after the message went), and any code Sioul does
1483/// not know, are said in doubt too.
1484pub fn took(code: i64, conf: Option<&crate::mms::Conf>, http: i64) -> Took {
1485    match (code, conf) {
1486        (-1, Some(conf)) if conf.ok() => Took::Sent,
1487        (-1, None) => Took::Look,
1488        (4, None) if http < 400 => Took::Doubt("network"),
1489        (0, _) => Took::Doubt("cancelled"),
1490        (1, _) => Took::Doubt("unexpected"),
1491        (code, _) if !(-1..=13).contains(&code) => Took::Doubt("unexpected"),
1492        (code, conf) => Took::Failed(mms_failure(code, conf.map(|c| c.status))),
1493    }
1494}
1495
1496/// Why a multimedia message was not sent, as the words say it: Android's
1497/// code (SmsManager's MMS_ERROR_…: 8 and 11 mobile data off, 3 no mobile
1498/// network within 30 minutes, 2, 7 and 12 no multimedia settings for the
1499/// carrier, 5 not read, 6 the carrier's messaging app asking to try later,
1500/// 9 and 10 an inactive SIM, 13 too large for a satellite link), else the carrier's
1501/// status (ENC 7.3.48: 0xC0 to 0xDF for a moment, 0xE1 service denied, 0xE5
1502/// content not accepted, 0xEB no credit, the rest refused; MMS 1.0's 0x82,
1503/// 0x87 and 0x88 alike).
1504pub fn mms_failure(code: i64, status: Option<u8>) -> &'static str {
1505    let carrier = |status: Option<u8>| match status {
1506        Some(0xC0..=0xDF) => "carrier-busy",
1507        Some(0xE1 | 0x82) => "carrier-denied",
1508        Some(0xE5 | 0x87 | 0x88) => "carrier-content",
1509        Some(0xEB) => "carrier-credit",
1510        _ => "carrier-refused",
1511    };
1512    match code {
1513        -1 | 4 => carrier(status),
1514        8 | 11 => "mms-data-off",
1515        3 => "mms-no-network",
1516        2 | 7 | 12 => "mms-settings",
1517        5 => "mms-unreadable",
1518        6 => "mms-retry",
1519        9 | 10 => "no-sim",
1520        13 => "mms-satellite",
1521        _ => "other",
1522    }
1523}
1524
1525/// What a phone last said its carriers let it send (its newest "limits"
1526/// line), and when (ms); none before it said. A line dated more than
1527/// `LIMITS_AHEAD_MS` ahead of `now_ms` (the reader's clock) is passed over:
1528/// a phone whose clock was once set ahead would otherwise be read by it
1529/// until that date, whatever it said since.
1530pub fn limits_of<'a>(outcomes: &'a [Outcome], phone: &str, now_ms: i64) -> Option<(i64, &'a [Limits])> {
1531    outcomes
1532        .iter()
1533        .filter(|o| o.key.is_empty() && o.state == "limits" && o.device == calls::file_safe(phone) && o.at <= now_ms + LIMITS_AHEAD_MS)
1534        .max_by_key(|o| o.at)
1535        .map(|o| (o.at, o.limits.as_slice()))
1536}
1537
1538/// A random key, 32 hex digits, from the system's randomness (`/dev/urandom`); the clock's when none.
1539pub fn new_key() -> String {
1540    use std::io::Read;
1541    let mut bytes = [0u8; 16];
1542    let read = std::fs::File::open("/dev/urandom").and_then(|mut f| f.read_exact(&mut bytes));
1543    if read.is_err() {
1544        let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos());
1545        bytes = (now ^ u128::from(std::process::id()).rotate_left(64)).to_le_bytes();
1546    }
1547    bytes.iter().map(|b| format!("{b:02x}")).collect()
1548}
1549
1550// ---------------------------------------------------------------- parts
1551
1552/// GSM 03.38's basic alphabet: one septet each.
1553const GSM_BASIC: &str = "@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !\"#¤%&'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà";
1554/// Its extension table: two septets each.
1555const GSM_EXTENDED: &str = "^{}\\[~]|€\u{c}";
1556
1557/// How many parts a text takes, and in which alphabet: GSM's 7 bits (160
1558/// characters in one part, 153 in each of several) or UCS-2 (70, 67) as soon
1559/// as one character is outside it. Android chooses alike (`divideMessage`).
1560pub fn parts(body: &str) -> (usize, bool) {
1561    let gsm = body.chars().all(|c| GSM_BASIC.contains(c) || GSM_EXTENDED.contains(c));
1562    if gsm {
1563        let septets: usize = body.chars().map(|c| if GSM_EXTENDED.contains(c) { 2 } else { 1 }).sum();
1564        (if septets <= 160 { usize::from(septets > 0) } else { septets.div_ceil(153) }, true)
1565    } else {
1566        let units: usize = body.chars().map(char::len_utf16).sum();
1567        (if units <= 70 { 1 } else { units.div_ceil(67) }, false)
1568    }
1569}
1570
1571// ---------------------------------------------------------------- the view
1572
1573/// A request's state, as the computer says it.
1574#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1575pub struct Said {
1576    /// "waiting", "sending", "sent", "delivered", "failed", "expired", "refused", "clocks", "doubt".
1577    pub state: String,
1578    /// "Sent at 14:03."
1579    pub text: String,
1580    /// Send again offered (a new key).
1581    pub again: bool,
1582}
1583
1584/// What a request's outcomes say: its latest meaningful one (delivered over sent over handed…).
1585pub fn state_of<'a>(key: &str, outcomes: &'a [Outcome]) -> Option<&'a Outcome> {
1586    let rank = |s: &str| match s {
1587        "taken" => 1,
1588        "handed" => 2,
1589        "doubt" => 3,
1590        "sent" => 4,
1591        "delivered" => 5,
1592        "failed" | "expired" | "refused" | "clocks" => 6,
1593        _ => 0,
1594    };
1595    // Never a line of no request's (a phone's "limits").
1596    outcomes.iter().filter(|o| o.key == key && !o.key.is_empty()).max_by_key(|o| (rank(&o.state), o.at))
1597}
1598
1599/// A request's state in words, never red, never "late": waiting for the phone
1600/// (and when it last shared), sending, sent at, delivered at (only when the
1601/// carrier said so), not sent and why, expired, may not have been sent. A
1602/// multimedia message (`media`) refused "empty" was read by a phone whose
1603/// Sioul cannot send one yet: said so; its media not there in time, said.
1604pub fn said(outcome: Option<&Outcome>, phone_shared: Option<i64>, tr: &Translator, now: &Zoned, media: bool) -> Said {
1605    let time = |ms: i64| calls::local(ms, now).strftime("%H:%M").to_string();
1606    let with = |id: &str, key: &str, value: String| {
1607        let mut args = crate::i18n::args();
1608        args.set(key, value);
1609        tr.text(id, Some(&args))
1610    };
1611    let (state, text, again) = match outcome {
1612        None => match phone_shared {
1613            Some(at) => ("waiting", with("texts-waiting-since", "time", time(at)), false),
1614            None => ("waiting", tr.text("texts-waiting", None), false),
1615        },
1616        Some(o) => match o.state.as_str() {
1617            // A multimedia message Android still holds past its first look: it may still go, until its last word;
1618            // past it with no word yet, the phone's next step will say (the review of 18 October 2026, I-b).
1619            "taken" | "handed" if media && now.timestamp().as_millisecond() - o.at >= MMS_LAST_WORD_MS => match phone_shared {
1620                Some(at) => ("waiting", with("texts-waiting-since", "time", time(at)), false),
1621                None => ("waiting", tr.text("texts-waiting", None), false),
1622            },
1623            "taken" | "handed" if media && now.timestamp().as_millisecond() - o.at >= LOOK_AFTER_MMS_MS => ("sending", with("texts-sending-still", "time", time(o.at + MMS_LAST_WORD_MS)), false),
1624            "taken" | "handed" => ("sending", tr.text("texts-sending", None), false),
1625            "sent" => ("sent", with("texts-sent-at", "time", time(o.at)), false),
1626            "delivered" => ("delivered", with("texts-delivered-at", "time", time(o.at)), false),
1627            "failed" => ("failed", with("texts-failed", "why", tr.text(&format!("texts-why-{}", known_reason(&o.reason)), None)), true),
1628            "expired" if o.reason == "media" => ("expired", tr.text("texts-expired-media", None), true),
1629            "expired" => ("expired", tr.text("texts-expired", None), true),
1630            "refused" => {
1631                let why = if media && o.reason == "empty" { "old-phone" } else { known_refusal(&o.reason) };
1632                ("refused", tr.text(&format!("texts-refused-{why}"), None), matches!(why, "before-mark" | "old-phone" | "old-computer"))
1633            }
1634            "clocks" => ("clocks", tr.text("texts-clocks", None), true),
1635            // The carrier may have it (each of Android's tries ended on the network, or it answered in a way
1636            // nobody could read): no Send again here. Android stopped it, or failed unexpectedly: as a crash's doubt.
1637            "doubt" if matches!(o.reason.as_str(), "network" | "answer") => ("doubt", tr.text(&format!("texts-doubt-{}", o.reason), None), false),
1638            "doubt" if matches!(o.reason.as_str(), "cancelled" | "unexpected") => ("doubt", tr.text(&format!("texts-doubt-{}", o.reason), None), true),
1639            _ => ("doubt", tr.text("texts-doubt", None), true),
1640        },
1641    };
1642    Said { state: state.into(), text, again }
1643}
1644
1645/// A failure's reason as the words know it.
1646fn known_reason(reason: &str) -> &'static str {
1647    match reason {
1648        "radio-off" => "radio-off",
1649        "no-service" => "no-service",
1650        "limit" => "limit",
1651        "no-sim" => "no-sim",
1652        "mms-data-off" => "mms-data-off",
1653        "mms-no-network" => "mms-no-network",
1654        "mms-settings" => "mms-settings",
1655        "mms-unreadable" => "mms-unreadable",
1656        "mms-retry" => "mms-retry",
1657        "mms-satellite" => "mms-satellite",
1658        "carrier-refused" => "carrier-refused",
1659        "carrier-content" => "carrier-content",
1660        "carrier-denied" => "carrier-denied",
1661        "carrier-credit" => "carrier-credit",
1662        "carrier-busy" => "carrier-busy",
1663        _ => "other",
1664    }
1665}
1666
1667/// A refusal's reason as the words know it.
1668fn known_refusal(reason: &str) -> &'static str {
1669    match reason {
1670        "short-number" => "short-number",
1671        "several" => "several",
1672        "empty" => "empty",
1673        "too-long" => "too-long",
1674        "before-mark" => "before-mark",
1675        "too-large" => "too-large",
1676        "too-many-media" => "too-many-media",
1677        "media-kind" => "media-kind",
1678        "no-mms" => "no-mms",
1679        "not-counted" => "not-counted",
1680        "old-computer" => "old-computer",
1681        _ => "other",
1682    }
1683}
1684
1685/// A conversation, as the Texts page lists it.
1686#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1687pub struct Conversation {
1688    /// Its people's numbers' keys, joined by ",": its name on the page.
1689    pub id: String,
1690    pub with: Vec<String>,
1691    /// Your address books' names, else the numbers as you write them.
1692    pub title: String,
1693    /// The last message's first words, and when.
1694    pub last: String,
1695    pub when: String,
1696    pub at: i64,
1697    pub group: bool,
1698}
1699
1700/// A part of a message, as the page shows it.
1701#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1702pub struct Shown {
1703    /// "picture", "sound", "video", "card", "file", "text".
1704    pub kind: String,
1705    pub name: String,
1706    /// "A picture (220 KB)"; "Too large to bring here: it is on your phone."; "Your phone has not downloaded it yet."
1707    pub said: String,
1708    /// Its content's hash, to open it (`texts::call` "media"); "" when it is not here.
1709    pub hash: String,
1710    pub ct: String,
1711}
1712
1713/// One message of a conversation, or a text being sent.
1714#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1715pub struct Message {
1716    pub id: String,
1717    pub at: i64,
1718    /// "09:41", or "Yesterday 09:41".
1719    pub when: String,
1720    /// Its day, for the thread's separators: "Today", "Yesterday", "Monday 5
1721    /// October", the year added when it is another; and its time, "09:41".
1722    pub day: String,
1723    pub time: String,
1724    /// "in" or "out".
1725    pub direction: String,
1726    /// Who wrote it, in a group.
1727    pub from: String,
1728    pub body: String,
1729    pub picture: bool,
1730    pub parts: Vec<Shown>,
1731    /// Deleted on the phone, kept here: said quietly.
1732    pub deleted: bool,
1733    /// A text sent from a computer: its key, and its state.
1734    pub key: String,
1735    pub said: Option<Said>,
1736    /// What it was written to, for Send again.
1737    pub to: String,
1738}
1739
1740/// What the view needs besides the lines.
1741pub struct Viewer<'a> {
1742    pub now: &'a Zoned,
1743    pub tr: &'a Translator,
1744    pub region: Option<&'static Region>,
1745    /// Your address books' name for a number's key.
1746    pub name_of: &'a dyn Fn(&str) -> Option<String>,
1747    /// When the sending phone last shared (ms), for "waiting since".
1748    pub phone_shared: Option<i64>,
1749    /// Whether a part's sealed file is on this device.
1750    pub media_here: &'a dyn Fn(&str) -> bool,
1751}
1752
1753/// A conversation's id: its people's keys, sorted, joined.
1754pub fn conversation_id(with: &[String]) -> String {
1755    let mut keys: Vec<&str> = with.iter().map(String::as_str).filter(|k| !k.is_empty()).collect();
1756    keys.sort_unstable();
1757    keys.dedup();
1758    keys.join(",")
1759}
1760
1761/// "Dr Martin", "Dr Martin, Alice", "01 99 00 12 34".
1762fn title_of(with: &[String], v: &Viewer) -> String {
1763    with.iter().map(|k| (v.name_of)(k).unwrap_or_else(|| calls::shown_number(k, v.region))).collect::<Vec<_>>().join(", ")
1764}
1765
1766/// A moment's day and time, for a thread: ("Today", "14:02"), ("Monday 5
1767/// October", "14:02"), ("Friday 3 May 2024", "09:15").
1768fn day_and_time(at: i64, v: &Viewer) -> (String, String) {
1769    let local = calls::local(at, v.now);
1770    let mut day = calls::capitalized(&calls::day_words(v.tr, local.date(), v.now.date()));
1771    if local.year() != v.now.year() && local.date() < v.now.date().yesterday().unwrap_or(local.date()) {
1772        day = format!("{day} {}", local.year());
1773    }
1774    (day, local.strftime("%H:%M").to_string())
1775}
1776
1777/// "14:02" today, "Yesterday 14:02", "Monday 5 October 14:02".
1778fn when_words(at: i64, v: &Viewer) -> String {
1779    let local = calls::local(at, v.now);
1780    let time = local.strftime("%H:%M").to_string();
1781    if local.date() == v.now.date() {
1782        time
1783    } else {
1784        format!("{} {time}", calls::capitalized(&calls::day_words(v.tr, local.date(), v.now.date())))
1785    }
1786}
1787
1788/// What a part is, in a word for the page.
1789fn kind_of(ct: &str) -> &'static str {
1790    match ct.split('/').next().unwrap_or("") {
1791        "image" => "picture",
1792        "audio" => "sound",
1793        "video" => "video",
1794        "text" if ct.contains("vcard") || ct.contains("x-vcard") => "card",
1795        "text" => "text",
1796        _ if ct.contains("vcard") => "card",
1797        _ => "file",
1798    }
1799}
1800
1801/// A part as the page shows it: what it is and its size; too large, or not
1802/// downloaded, said; on its way when its file has not come yet. The SMIL,
1803/// which only lays the message out, and the text parts (in the words) are not shown apart.
1804fn shown(part: &Part, v: &Viewer) -> Option<Shown> {
1805    if part.ct == "application/smil" || part.state == "text" || (kind_of(&part.ct) == "text" && part.hash.is_empty()) {
1806        return None;
1807    }
1808    let kind = kind_of(&part.ct);
1809    let size = crate::view::size(v.tr, usize::try_from(part.size).unwrap_or(usize::MAX));
1810    let mut args = crate::i18n::args();
1811    args.set("size", size);
1812    let (said, hash) = match part.state.as_str() {
1813        "too-big" => (v.tr.text("texts-part-too-big", Some(&args)), String::new()),
1814        "not-downloaded" => (v.tr.text("texts-part-not-downloaded", None), String::new()),
1815        _ if !(v.media_here)(&part.hash) => (v.tr.text("texts-part-coming", Some(&args)), String::new()),
1816        _ => (v.tr.text(&format!("texts-part-{kind}"), Some(&args)), part.hash.clone()),
1817    };
1818    Some(Shown { kind: kind.into(), name: part.name.clone(), said, hash, ct: part.ct.clone() })
1819}
1820
1821/// The conversations, newest first: every text's, and those you wrote to from a computer.
1822pub fn conversations(texts: &[Text], requests: &[Request], v: &Viewer) -> Vec<Conversation> {
1823    let mut by: BTreeMap<String, Conversation> = BTreeMap::new();
1824    let mut add = |with: Vec<String>, at: i64, last: String| {
1825        let id = conversation_id(&with);
1826        if id.is_empty() {
1827            return;
1828        }
1829        let entry = by.entry(id.clone()).or_insert_with(|| Conversation { id: id.clone(), group: with.len() > 1, title: title_of(&with, v), with, last: String::new(), when: String::new(), at: 0 });
1830        if at >= entry.at {
1831            entry.at = at;
1832            entry.last = last;
1833            entry.when = when_words(at, v);
1834        }
1835    };
1836    for t in texts {
1837        let first: String = if t.body.is_empty() && (t.picture || !t.parts.is_empty()) { v.tr.text("phonemsgs-picture", None) } else { t.body.chars().take(80).collect() };
1838        add(t.with.clone(), t.at, first);
1839    }
1840    for r in requests {
1841        let first: String = if r.said_words().is_empty() && r.multimedia() { v.tr.text("phonemsgs-picture", None) } else { r.said_words().chars().take(80).collect() };
1842        add(vec![phones::key(&r.to, v.region)], r.written, first);
1843    }
1844    let mut list: Vec<Conversation> = by.into_values().collect();
1845    list.sort_by(|a, b| b.at.cmp(&a.at).then_with(|| a.id.cmp(&b.id)));
1846    list
1847}
1848
1849/// The key of the request a text is, by the row its phone filed it as
1850/// (`Outcome::row`): its own row or one of its copies' (`Text::also`), each
1851/// on its own phone, never another phone's row of the same number. An
1852/// outcome that names no phone matches the row on any (as before phones were
1853/// two). A row is its name with its date (`Row::name`); an outcome written
1854/// before 16 October 2026 names it by its id alone, and matches that id's
1855/// text only from when its request could be sent (`from`) to a little after
1856/// the outcome itself (Android files the row before its result comes:
1857/// `AHEAD_MS`), never an older text whose id the text sent took again, nor
1858/// a text received days later under that id (review of 16 October 2026, LOW 5).
1859fn row_key(t: &Text, key_of_row: &BTreeMap<String, Vec<(&str, &str, i64, i64)>>) -> String {
1860    let own = (t.device.as_str(), t.id.as_str());
1861    std::iter::once(own)
1862        .chain(t.also.iter().filter_map(|copy| copy.split_once('/')))
1863        .find_map(|(phone, id)| {
1864            let row = Row::of(id, t.at)?;
1865            let bare = format!("{}-{}", if row.mms { "mms" } else { "sms" }, row.id);
1866            [row.name(), bare].iter().find_map(|name| key_of_row.get(name)?.iter().find(|(device, _, from, until)| (device.is_empty() || *device == phone) && t.at >= *from && t.at <= *until).map(|(_, key, _, _)| key.to_string()))
1867        })
1868        .unwrap_or_default()
1869}
1870
1871/// A conversation's messages, oldest first: its texts, deleted ones said, and
1872/// the texts you wrote to it from a computer with their states; a request
1873/// Android filed is said on its row, once. A row is its phone's own: another
1874/// phone's row of the same number is another text (`row_key`).
1875pub fn messages(id: &str, texts: &[Text], requests: &[Request], outcomes: &[Outcome], v: &Viewer) -> Vec<Message> {
1876    // A text Android filed for a request is dated no earlier than the
1877    // request's time on the phone's clock, less what `verdict` lets pass.
1878    let from: BTreeMap<&str, i64> = requests.iter().map(|r| (r.key.as_str(), r.written - r.ahead - AHEAD_MS)).collect();
1879    let mut key_of_row: BTreeMap<String, Vec<(&str, &str, i64, i64)>> = BTreeMap::new();
1880    for o in outcomes.iter().filter(|o| !o.row.is_empty()) {
1881        let named = o.row.contains('@');
1882        let (since, until) = if named { (i64::MIN, i64::MAX) } else { (from.get(o.key.as_str()).copied().unwrap_or(i64::MIN), o.at.saturating_add(AHEAD_MS)) };
1883        let name = if named { Row::of(&o.row, 0).map_or_else(|| o.row.clone(), |r| r.name()) } else { o.row.clone() };
1884        key_of_row.entry(name).or_default().push((o.device.as_str(), o.key.as_str(), since, until));
1885    }
1886    let mut out: Vec<Message> = Vec::new();
1887    let mut shown_keys = BTreeSet::new();
1888    for t in texts.iter().filter(|t| conversation_id(&t.with) == id) {
1889        let key = if t.key.is_empty() { row_key(t, &key_of_row) } else { t.key.clone() };
1890        let said = requests.iter().find(|r| !key.is_empty() && r.key == key).map(|r| self::said(state_of(&key, outcomes), v.phone_shared, v.tr, v.now, r.multimedia()));
1891        if !key.is_empty() {
1892            shown_keys.insert(key.clone());
1893        }
1894        let (day, time) = day_and_time(t.at, v);
1895        out.push(Message {
1896            id: format!("{}/{}", t.device, t.id),
1897            at: t.at,
1898            when: when_words(t.at, v),
1899            day,
1900            time,
1901            direction: t.direction.clone(),
1902            from: if t.with.len() > 1 && t.direction == "in" && !t.from.is_empty() { (v.name_of)(&t.from).unwrap_or_else(|| calls::shown_number(&t.from, v.region)) } else { String::new() },
1903            body: t.body.clone(),
1904            picture: t.picture,
1905            parts: t.parts.iter().filter_map(|p| shown(p, v)).collect(),
1906            deleted: t.deleted > 0,
1907            key,
1908            said,
1909            to: String::new(),
1910        });
1911    }
1912    for r in requests.iter().filter(|r| conversation_id(&[phones::key(&r.to, v.region)]) == id && !shown_keys.contains(&r.key)) {
1913        let outcome = state_of(&r.key, outcomes);
1914        let (day, time) = day_and_time(r.written, v);
1915        out.push(Message {
1916            id: format!("request/{}", r.key),
1917            at: r.written,
1918            when: when_words(r.written, v),
1919            day,
1920            time,
1921            direction: "out".into(),
1922            from: String::new(),
1923            body: r.said_words().to_string(),
1924            picture: r.media.iter().any(|m| kind_of(&m.ct) == "picture"),
1925            // Its media as the message shows them from the first moment: sealed here when written here, else brought.
1926            parts: r.media.iter().filter_map(|m| shown(&Part { ct: m.ct.clone(), size: m.size, hash: m.hash.clone(), state: "here".into(), ..Part::default() }, v)).collect(),
1927            deleted: false,
1928            key: r.key.clone(),
1929            said: Some(said(outcome, v.phone_shared, v.tr, v.now, r.multimedia())),
1930            to: r.to.clone(),
1931        });
1932    }
1933    out.sort_by(|a, b| (a.at, &a.id).cmp(&(b.at, &b.id)));
1934    out
1935}
1936
1937/// One text found by `search`, with its conversation.
1938#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1939pub struct Found {
1940    /// Its conversation's id (`conversation_id`), as `messages` takes it, and its title.
1941    pub conversation: String,
1942    pub title: String,
1943    pub message: Message,
1944}
1945
1946/// The texts holding every word of `query`, case and accents aside, in their
1947/// words, a part's name, or their people's names or numbers; the newest
1948/// first, `limit` at most. For a search of the archive (the MCP server's).
1949pub fn search(query: &str, texts: &[Text], requests: &[Request], outcomes: &[Outcome], v: &Viewer, limit: usize) -> Vec<Found> {
1950    let words: Vec<String> = crate::rules::fold(query).split(' ').filter(|w| !w.is_empty()).map(str::to_string).collect();
1951    if words.is_empty() {
1952        return Vec::new();
1953    }
1954    // Each conversation's people, said once: names and numbers as written and as keyed.
1955    let mut people: BTreeMap<String, (String, String)> = BTreeMap::new();
1956    let mut found: Vec<&Text> = texts
1957        .iter()
1958        .filter(|t| {
1959            let id = conversation_id(&t.with);
1960            let (_, said) = people.entry(id).or_insert_with(|| {
1961                let title = title_of(&t.with, v);
1962                let numbers = t.with.iter().map(|k| format!("{k} {}", calls::shown_number(k, v.region))).collect::<Vec<_>>().join(" ");
1963                let said = crate::rules::fold(&format!("{title} {numbers}"));
1964                (title, said)
1965            });
1966            let own = crate::rules::fold(&std::iter::once(t.body.as_str()).chain(t.parts.iter().map(|p| p.name.as_str())).collect::<Vec<_>>().join(" "));
1967            words.iter().all(|w| own.contains(w.as_str()) || said.contains(w.as_str()))
1968        })
1969        .collect();
1970    found.sort_by(|a, b| b.at.cmp(&a.at).then_with(|| a.id.cmp(&b.id)));
1971    found.truncate(limit);
1972    found
1973        .into_iter()
1974        .filter_map(|t| {
1975            let id = conversation_id(&t.with);
1976            let row = format!("{}/{}", t.device, t.id);
1977            let message = messages(&id, std::slice::from_ref(t), requests, outcomes, v).into_iter().find(|m| m.id == row)?;
1978            Some(Found { title: people.get(&id).map(|(title, _)| title.clone()).unwrap_or_default(), conversation: id, message })
1979        })
1980        .collect()
1981}
1982
1983/// The SIM a conversation last used, on the phone that sends: its texts' `sub`, the newest; -1 none.
1984pub fn last_sim(id: &str, phone: &str, texts: &[Text]) -> i32 {
1985    texts.iter().rev().find(|t| t.device == calls::file_safe(phone) && conversation_id(&t.with) == id && t.sub >= 0).map_or(-1, |t| t.sub)
1986}
1987
1988/// A part's words for its kind: "texts-part-picture" and the others take a `size`.
1989pub const PART_KINDS: [&str; 6] = ["picture", "sound", "video", "card", "file", "text"];
1990
1991#[cfg(test)]
1992mod tests {
1993    use super::*;
1994
1995    /// A text read once opened, on every device; a conversation deleted in
1996    /// Sioul hides its texts until then, a new one shows; a phone's
1997    /// notification of a text folds into it by number and time (23 October 2026).
1998    #[test]
1999    fn marks_read_hide_and_fold() {
2000        let now = 1_792_000_000_000;
2001        let text = |id: &str, with: &str, at: i64, direction: &str| Text { id: id.into(), at, direction: direction.into(), with: vec![with.into()], body: "Votre code 123".into(), ..Text::default() };
2002        let texts = vec![text("sms-1@1", "+33600000001", now - 60_000, "in"), text("sms-2@2", "+33600000001", now - 30_000, "in"), text("sms-3@3", "+33600000002", now - 9 * DAY_MS, "in"), text("sms-4@4", "+33600000003", now - 10_000, "out")];
2003        let none = Marks::default();
2004        let unread = unread(&texts, &none, now);
2005        assert_eq!(unread.keys().cloned().collect::<Vec<_>>(), ["+33600000001"], "in, recent, unread; an old one and one sent are not");
2006        assert_eq!(unread["+33600000001"].iter().map(|t| t.id.as_str()).collect::<Vec<_>>(), ["sms-2@2", "sms-1@1"]);
2007        let mut marks = Marks::default();
2008        marks.note(&TextMark { at: now, conversation: "+33600000001".into(), read: now - 30_000, ..TextMark::default() });
2009        assert!(super::unread(&texts, &marks, now).is_empty(), "read once opened");
2010        marks.note(&TextMark { at: now, conversation: "+33600000001".into(), hidden: now - 45_000, ..TextMark::default() });
2011        assert_eq!(visible(&texts, &marks).iter().map(|t| t.id.as_str()).collect::<Vec<_>>(), ["sms-2@2", "sms-3@3", "sms-4@4"], "hidden until then; a newer one shows");
2012        assert_eq!(folded_into("+33600000001", now - 60_000 + 90_000, &texts).as_deref(), Some("+33600000001"), "the same number, within three minutes");
2013        assert_eq!(folded_into("+33600000001", now + 10 * 60_000, &texts), None, "later: another message");
2014        assert_eq!(folded_into("+33600000009", now - 60_000, &texts), None, "another number");
2015    }
2016
2017    /// A conversation deleted in Sioul leaves the phone's own log, which
2018    /// every computer's copy follows; its other texts stay (23 October 2026).
2019    #[test]
2020    fn a_hidden_conversation_leaves_the_phones_log() {
2021        struct Plain;
2022        impl Sealer for Plain {
2023            fn seal(&self, plain: &str) -> String {
2024                plain.to_string()
2025            }
2026            fn open(&self, sealed: &str) -> Option<String> {
2027                Some(sealed.to_string())
2028            }
2029        }
2030        let dir = std::env::temp_dir().join(format!("sioul-texts-hidden-{}", std::process::id()));
2031        let log = dir.join("log").join("phone.jsonl");
2032        std::fs::create_dir_all(log.parent().unwrap()).unwrap();
2033        let text = |id: &str, with: &str, at: i64| Text { id: id.into(), at, direction: "in".into(), with: vec![with.into()], ..Text::default() };
2034        append(&log, &[text("sms-1@1", "+33600000001", 1_000), text("sms-2@2", "+33600000002", 2_000), text("sms-3@3", "+33600000001", 5_000)], &Plain, None).unwrap();
2035        let mut marks = Marks::default();
2036        marks.note(&TextMark { at: 4_000, conversation: "+33600000001".into(), hidden: 4_000, ..TextMark::default() });
2037        assert_eq!(drop_hidden(&log, &marks, &Plain).unwrap(), 1);
2038        let left: Vec<String> = read_file::<Text>(&log, &Plain).into_iter().map(|t| t.id).collect();
2039        assert_eq!(left, ["sms-2@2", "sms-3@3"], "the hidden one gone, a later one and another's stay");
2040        let _ = std::fs::remove_dir_all(&dir);
2041    }
2042
2043    /// A stand-in for the sealer: reversible, and visibly not plain.
2044    struct Stand;
2045
2046    impl Sealer for Stand {
2047        fn seal(&self, plain: &str) -> String {
2048            format!("sealed:{}", plain.bytes().map(|b| format!("{:02x}", b ^ 0x5a)).collect::<String>())
2049        }
2050        fn open(&self, sealed: &str) -> Option<String> {
2051            let hex = sealed.strip_prefix("sealed:")?;
2052            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();
2053            String::from_utf8(bytes?).ok()
2054        }
2055    }
2056
2057    /// Thursday 8 October 2026, 10:00 in Paris.
2058    const NOW: i64 = 1_791_446_400_000;
2059    const MINUTE: i64 = 60_000;
2060
2061    fn fr() -> Option<&'static Region> {
2062        phones::region_named("FR")
2063    }
2064
2065    fn scratch(name: &str) -> PathBuf {
2066        let dir = std::env::temp_dir().join(format!("sioul-texts-{name}-{}", std::process::id()));
2067        let _ = std::fs::remove_dir_all(&dir);
2068        dir
2069    }
2070
2071    fn request(key: &str, phone: &str, written: i64) -> Request {
2072        Request { key: key.into(), phone: phone.into(), to: "01 99 00 12 34".into(), body: "J'arrive dans dix minutes.".into(), sub: -1, written, writer: "desk".into(), device: "desk".into(), ..Request::default() }
2073    }
2074
2075    /// The phone's steps, as `texts.rs` in the app takes them: read every
2076    /// request, decide, claim before sending; returns the keys sent.
2077    fn step(dir: &Path, requests: &[Request], here: &str, now: i64, ahead: i64) -> Vec<String> {
2078        let ledger = Ledger::open(dir, now).unwrap();
2079        let mut sent = Vec::new();
2080        for r in requests {
2081            if verdict(&Request { ahead, ..r.clone() }, here, now, &ledger, fr(), &|_| Standing::Counted) == Verdict::Send {
2082                record(dir, &Claim { key: r.key.clone(), at: now, state: "claimed".into(), to: phones::key(&r.to, fr()), hash: words_hash(&r.body), ..Claim::default() }).unwrap();
2083                sent.push(r.key.clone());
2084            }
2085        }
2086        sent
2087    }
2088
2089    #[test]
2090    fn a_request_is_sent_once_whatever_is_read_again() {
2091        let dir = scratch("once");
2092        Ledger::open(&dir, NOW - 10 * MINUTE).unwrap();
2093        let r = request("a1", "phone", NOW - MINUTE);
2094        assert_eq!(step(&dir, std::slice::from_ref(&r), "phone", NOW, 0), ["a1"]);
2095        for later in [NOW + MINUTE, NOW + 2 * MINUTE] {
2096            assert!(step(&dir, &[r.clone(), r.clone()], "phone", later, 0).is_empty());
2097        }
2098        assert_eq!(Ledger::open(&dir, NOW).unwrap().state("a1"), Some("claimed"));
2099        let _ = std::fs::remove_dir_all(&dir);
2100    }
2101
2102    /// Who asks (the review of 18 October 2026, I-4): each request names its
2103    /// writer inside its seal; a line moved to another device's file is
2104    /// passed over; the phone sends only for a device the sharing counts. A
2105    /// request of an older Sioul, with no writer, is judged by its file's
2106    /// device while the phone's ledger had not begun to check writers when
2107    /// it was written, and refused after ("old-computer"); a ledger of an
2108    /// older Sioul keeps its mark and begins to check now.
2109    #[test]
2110    fn requests_name_their_writer_and_only_counted_devices_ask() {
2111        let dir = scratch("writers");
2112        let seal = Stand;
2113        let root = dir.join("data");
2114        let mine = Request { writer: String::new(), device: String::new(), ..request("w1", "phone", NOW) };
2115        write_request(&root, "desk", &mine, &seal).unwrap();
2116        // The same line, as the server could move it into another device's file.
2117        let line = std::fs::read_to_string(own_file(&root, SEND, "desk")).unwrap();
2118        std::fs::create_dir_all(root.join(SEND)).unwrap();
2119        std::fs::write(own_file(&root, SEND, "laptop"), &line).unwrap();
2120        let read = read_requests(&root, &seal);
2121        assert_eq!(read.iter().map(|r| (r.key.as_str(), r.writer.as_str(), r.device.as_str())).collect::<Vec<_>>(), [("w1", "desk", "desk")], "the moved line passed over");
2122        // A ledger of an older Sioul: its mark kept, writers checked from now.
2123        let private = dir.join("private");
2124        std::fs::create_dir_all(&private).unwrap();
2125        std::fs::write(private.join(MARK), r#"{"since":1000}"#).unwrap();
2126        let ledger = Ledger::open(&private, NOW).unwrap();
2127        assert_eq!(ledger.mark, Mark { since: 1000, writers: NOW });
2128        assert_eq!(Ledger::open(&private, NOW + DAY_MS).unwrap().mark, Mark { since: 1000, writers: NOW }, "once");
2129        let counted = |device: &str| if device == "desk" { Standing::Counted } else { Standing::Gone };
2130        assert_eq!(verdict(&read[0], "phone", NOW, &ledger, fr(), &counted), Verdict::Send);
2131        let forgotten = Request { writer: "old-laptop".into(), device: "old-laptop".into(), ..request("w2", "phone", NOW) };
2132        assert_eq!(verdict(&forgotten, "phone", NOW, &ledger, fr(), &counted), Verdict::Refused("not-counted"));
2133        let older = Request { writer: String::new(), ..request("w3", "phone", NOW - MINUTE) };
2134        assert_eq!(verdict(&older, "phone", NOW, &ledger, fr(), &counted), Verdict::Send, "written before the phone checked: by its file's device");
2135        assert_eq!(verdict(&Request { device: "old-laptop".into(), ..older.clone() }, "phone", NOW, &ledger, fr(), &counted), Verdict::Refused("not-counted"));
2136        let after = Request { writer: String::new(), ..request("w4", "phone", NOW + MINUTE) };
2137        assert_eq!(verdict(&after, "phone", NOW + MINUTE, &ledger, fr(), &counted), Verdict::Refused("old-computer"));
2138        // A device the sharing does not know yet (its records not come): waits while fresh, then expires (I-a).
2139        let unknown = |_: &str| Standing::Unknown;
2140        assert_eq!(verdict(&read[0], "phone", NOW, &ledger, fr(), &unknown), Verdict::Wait);
2141        assert_eq!(verdict(&read[0], "phone", NOW + 16 * MINUTE, &ledger, fr(), &unknown), Verdict::Expired);
2142        assert_eq!(verdict(&Request { written: NOW + 30 * MINUTE, ..read[0].clone() }, "phone", NOW, &ledger, fr(), &unknown), Verdict::Clocks);
2143        let decided = Ledger::with(Mark { since: 0, writers: NOW }, vec![Claim { key: "w4".into(), at: NOW, state: "done".into(), ..Claim::default() }]);
2144        assert_eq!(verdict(&after, "phone", NOW + MINUTE, &decided, fr(), &|_| panic!("asked only of a request not decided yet")), Verdict::Decided);
2145        // As an older phone reads a request: its writer passed over, its words there.
2146        let old: Request = serde_json::from_str(&serde_json::to_string(&read[0]).unwrap()).unwrap();
2147        assert_eq!((old.writer.as_str(), old.body.as_str()), ("desk", read[0].body.as_str()));
2148        let tr = Translator::new("en");
2149        for why in ["not-counted", "old-computer"] {
2150            let words = said(Some(&Outcome { state: "refused".into(), reason: why.into(), ..Outcome::default() }), None, &tr, &paris(NOW), false);
2151            assert!(words.text != tr.text("texts-refused-other", None) && words.again == (why == "old-computer"), "{why}: {}", words.text);
2152        }
2153        let _ = std::fs::remove_dir_all(&dir);
2154    }
2155
2156    #[test]
2157    fn a_lost_ledger_s_mark_refuses_the_requests_written_before_it() {
2158        let dir = scratch("lost");
2159        Ledger::open(&dir, NOW - 10 * MINUTE).unwrap();
2160        let r = request("b1", "phone", NOW - 3 * MINUTE);
2161        assert_eq!(step(&dir, std::slice::from_ref(&r), "phone", NOW - 2 * MINUTE, 0), ["b1"]);
2162        std::fs::remove_dir_all(&dir).unwrap();
2163        let ledger = Ledger::open(&dir, NOW).unwrap();
2164        assert_eq!(verdict(&r, "phone", NOW, &ledger, fr(), &|_| Standing::Counted), Verdict::BeforeMark, "within its 15 minutes, still never again");
2165        assert!(step(&dir, &[r], "phone", NOW, 0).is_empty());
2166        assert_eq!(step(&dir, &[request("b2", "phone", NOW + MINUTE)], "phone", NOW + MINUTE, 0), ["b2"]);
2167        let _ = std::fs::remove_dir_all(&dir);
2168    }
2169
2170    #[test]
2171    fn two_phones_each_send_only_their_own_and_two_computers_both_send() {
2172        let (a, b) = (scratch("phone-a"), scratch("phone-b"));
2173        Ledger::open(&a, NOW - 10 * MINUTE).unwrap();
2174        Ledger::open(&b, NOW - 10 * MINUTE).unwrap();
2175        let from_desk = request("c1", "phone-a", NOW - MINUTE);
2176        let from_laptop = Request { device: "laptop".into(), ..request("c2", "phone-a", NOW - MINUTE) };
2177        let to_b = request("c3", "phone-b", NOW - MINUTE);
2178        let all = [from_desk, from_laptop, to_b];
2179        assert_eq!(step(&a, &all, "phone-a", NOW, 0), ["c1", "c2"], "two computers, two keys, two texts");
2180        assert_eq!(step(&b, &all, "phone-b", NOW, 0), ["c3"]);
2181        let _ = (std::fs::remove_dir_all(&a), std::fs::remove_dir_all(&b));
2182    }
2183
2184    #[test]
2185    fn an_expired_request_and_clocks_apart() {
2186        let dir = scratch("expired");
2187        Ledger::open(&dir, NOW - DAY_MS).unwrap();
2188        let ledger = Ledger::open(&dir, NOW).unwrap();
2189        assert_eq!(verdict(&request("d1", "phone", NOW - 16 * MINUTE), "phone", NOW, &ledger, fr(), &|_| Standing::Counted), Verdict::Expired);
2190        assert_eq!(verdict(&request("d2", "phone", NOW - 14 * MINUTE), "phone", NOW, &ledger, fr(), &|_| Standing::Counted), Verdict::Send);
2191        // The computer's clock an hour ahead, as it measured: written "15:00" is 14:00 here.
2192        let ahead = 60 * MINUTE;
2193        assert_eq!(verdict(&Request { ahead, ..request("d3", "phone", NOW + ahead - 20 * MINUTE) }, "phone", NOW, &ledger, fr(), &|_| Standing::Counted), Verdict::Expired);
2194        assert_eq!(verdict(&Request { ahead, ..request("d4", "phone", NOW + ahead - 5 * MINUTE) }, "phone", NOW, &ledger, fr(), &|_| Standing::Counted), Verdict::Send);
2195        // Not measured, and dated well ahead: the clocks disagree, never sent late.
2196        assert_eq!(verdict(&request("d5", "phone", NOW + 30 * MINUTE), "phone", NOW, &ledger, fr(), &|_| Standing::Counted), Verdict::Clocks);
2197        let _ = std::fs::remove_dir_all(&dir);
2198    }
2199
2200    #[test]
2201    fn short_numbers_groups_and_empty_words_are_refused() {
2202        let ledger = Ledger::with(Mark { since: 0, writers: 0 }, Vec::new());
2203        let with = |to: &str, body: &str| verdict(&Request { to: to.into(), body: body.into(), ..request("e", "phone", NOW) }, "phone", NOW, &ledger, fr(), &|_| Standing::Counted);
2204        assert_eq!(with("36179", "STOP"), Verdict::Refused("short-number"));
2205        assert_eq!(with("01 99 00 12 34, 04 65 71 00 42", "Bonjour"), Verdict::Refused("several"));
2206        assert_eq!(with("01 99 00 12 34", "   "), Verdict::Refused("empty"));
2207        assert_eq!(with("01 99 00 12 34", &"x".repeat(WRITE_MAX + 1)), Verdict::Refused("too-long"));
2208        assert_eq!(with("+33 1 99 00 12 34", "Bonjour"), Verdict::Send);
2209    }
2210
2211    #[test]
2212    fn a_crash_between_the_claim_and_the_result_is_said_never_sent_again() {
2213        let claim = Claim { key: "f1".into(), at: NOW, state: "claimed".into(), to: "+33199001234".into(), hash: words_hash("Bonjour"), ..Claim::default() };
2214        assert!(doubt_after(&claim, None, NOW + MINUTE).is_none(), "Android's word may still come");
2215        let found = doubt_after(&claim, Some("sms-412".into()), NOW + 3 * MINUTE).unwrap();
2216        assert_eq!((found.state.as_str(), found.row.as_str()), ("sent", "sms-412"));
2217        assert_eq!(doubt_after(&claim, None, NOW + 3 * MINUTE).unwrap().state, "doubt");
2218        let ledger = Ledger::with(Mark { since: 0, writers: 0 }, vec![claim]);
2219        assert_eq!(verdict(&request("f1", "phone", NOW + 3 * MINUTE), "phone", NOW + 3 * MINUTE, &ledger, fr(), &|_| Standing::Counted), Verdict::Decided);
2220    }
2221
2222    #[test]
2223    fn the_ledger_is_flushed_before_android_is_asked_and_trimmed_after_a_month() {
2224        let dir = scratch("ledger");
2225        record(&dir, &Claim { key: "g1".into(), at: NOW - 31 * DAY_MS, state: "done".into(), ..Claim::default() }).unwrap();
2226        record(&dir, &Claim { key: "g2".into(), at: NOW, state: "claimed".into(), ..Claim::default() }).unwrap();
2227        assert!(std::fs::read_to_string(dir.join(LEDGER)).unwrap().contains("\"g2\""));
2228        assert_eq!(trim_ledger(&dir, NOW).unwrap(), 1);
2229        let ledger = Ledger::open(&dir, NOW).unwrap();
2230        assert!(!ledger.has("g1") && ledger.has("g2"));
2231        let _ = std::fs::remove_dir_all(&dir);
2232    }
2233
2234    fn message(id: &str, at: i64, body: &str) -> Text {
2235        Text { id: id.into(), at, thread: "7".into(), direction: "in".into(), with: vec!["+33199001234".into()], body: body.into(), sub: 1, ..Text::default() }
2236    }
2237
2238    #[test]
2239    fn the_history_is_sealed_padded_kept_whole_and_a_deletion_marks_it() {
2240        let dir = scratch("lines");
2241        let own = own_file(&dir, LOG, "phone");
2242        let id = |t: &Text| t.line_id();
2243        let old = NOW - 3 * 365 * DAY_MS;
2244        assert_eq!(append(&own, &[message("sms-1", old, "Bonjour"), message("sms-2", NOW, "Votre rendez-vous est déplacé.")], &Stand, Some(&id)).unwrap(), 2);
2245        assert_eq!(append(&own, &[message("sms-2", NOW, "Votre rendez-vous est déplacé.")], &Stand, Some(&id)).unwrap(), 0, "read again: once");
2246        let raw = std::fs::read_to_string(&own).unwrap();
2247        assert!(!raw.contains("Bonjour") && !raw.contains("199001234"), "sealed at rest");
2248        for line in raw.lines() {
2249            assert_eq!(Stand.open(line).unwrap().len() % PAD_STEP, 0);
2250        }
2251        // Three years old, and still there: no window, no trim by age.
2252        let later = NOW + 400 * DAY_MS;
2253        assert_eq!(trim_own(&dir, "phone", &Stand, later).unwrap() + trim_others(&dir, "desk", &Stand, later).unwrap(), 0);
2254        assert_eq!(read_logs(&dir, &Stand).len(), 2);
2255        // Deleted on the phone: a line of its own; the text stays, marked.
2256        let deletion = Text { deleted: NOW + MINUTE, ..message("sms-1", old, "") };
2257        assert_eq!(append(&own, &[deletion], &Stand, Some(&id)).unwrap(), 1);
2258        let back = read_logs(&dir, &Stand);
2259        assert_eq!(back.iter().map(|t| (t.id.as_str(), t.body.as_str(), t.deleted > 0)).collect::<Vec<_>>(), [("sms-1", "Bonjour", true), ("sms-2", "Votre rendez-vous est déplacé.", false)]);
2260        let _ = std::fs::remove_dir_all(&dir);
2261    }
2262
2263    #[test]
2264    fn parts_are_counted_as_android_counts_them() {
2265        assert_eq!(parts(""), (0, true));
2266        assert_eq!(parts("Bonjour, à demain !"), (1, true));
2267        assert_eq!(parts(&"a".repeat(160)), (1, true));
2268        assert_eq!(parts(&"a".repeat(161)), (2, true));
2269        assert_eq!(parts(&"€".repeat(80)), (1, true), "two septets each: 160");
2270        assert_eq!(parts(&"€".repeat(81)), (2, true));
2271        assert_eq!(parts("Ça va ? 🙂"), (1, false), "an emoji: UCS-2");
2272        assert_eq!(parts(&"ç".repeat(71)), (2, false), "ç is no GSM letter (Ç is)");
2273    }
2274
2275    /// A thread's separators: each message's day in words, the year only when another, and its time.
2276    #[test]
2277    fn a_thread_says_each_message_s_day_and_time() {
2278        let tr = Translator::new("en");
2279        let now = paris(NOW);
2280        let names = |_: &str| None;
2281        let here = |_: &str| false;
2282        let v = viewer(&now, &tr, &names, &here);
2283        let row = |id: &str, at: i64| Text { id: id.into(), at, direction: "in".into(), with: vec!["+33199001234".into()], body: "Bonjour".into(), device: "phone".into(), ..Text::default() };
2284        let texts = [row("sms-1", NOW - 400 * 86_400_000), row("sms-2", NOW - 86_400_000), row("sms-3", NOW - MINUTE)];
2285        let said: Vec<(String, String)> = messages("+33199001234", &texts, &[], &[], &v).into_iter().map(|m| (m.day, m.time)).collect();
2286        assert_eq!(said[1], ("Yesterday".to_string(), "10:00".to_string()));
2287        assert_eq!(said[2], ("Today".to_string(), "09:59".to_string()));
2288        assert!(said[0].0.ends_with(" 2025") && said[0].0.contains("September"), "another year said: {:?}", said[0]);
2289    }
2290
2291    #[test]
2292    fn the_archive_is_searched_by_words_names_and_numbers_newest_first() {
2293        let tr = Translator::new("en");
2294        let now = paris(NOW);
2295        let names = |k: &str| (k == "+33199001234").then(|| "Pharmacie Sainte-Hélène".to_string());
2296        let here = |_: &str| false;
2297        let v = viewer(&now, &tr, &names, &here);
2298        let row = |id: &str, at: i64, with: &str, body: &str| Text { id: id.into(), at, direction: "in".into(), with: vec![with.into()], body: body.into(), device: "phone".into(), ..Text::default() };
2299        let texts = [
2300            row("sms-1", NOW - 3 * MINUTE, "+33199001234", "Votre ordonnance est prête."),
2301            row("sms-2", NOW - 2 * MINUTE, "+33465710042", "Le colis est arrivé au relais."),
2302            row("sms-3", NOW - MINUTE, "+33199001234", "Ordonnance renouvelée, à retirer demain."),
2303        ];
2304        let ids = |query: &str, limit: usize| search(query, &texts, &[], &[], &v, limit).into_iter().map(|f| f.message.id).collect::<Vec<_>>();
2305        assert_eq!(ids("ORDONNANCE", 10), ["phone/sms-3", "phone/sms-1"], "case aside, newest first");
2306        assert_eq!(ids("arrive", 10), ["phone/sms-2"], "accents aside");
2307        assert_eq!(ids("helene pret", 10), ["phone/sms-1"], "a name and a word, both");
2308        assert_eq!(ids("04 65 71", 10), ["phone/sms-2"], "a number as written");
2309        assert_eq!(ids("ordonnance", 1), ["phone/sms-3"]);
2310        assert!(ids("  ", 10).is_empty());
2311        let found = &search("colis", &texts, &[], &[], &v, 10)[0];
2312        assert_eq!((found.conversation.as_str(), found.title.as_str()), ("+33465710042", "04 65 71 00 42"));
2313    }
2314
2315    fn viewer<'a>(now: &'a Zoned, tr: &'a Translator, names: &'a dyn Fn(&str) -> Option<String>, here: &'a dyn Fn(&str) -> bool) -> Viewer<'a> {
2316        Viewer { now, tr, region: fr(), name_of: names, phone_shared: Some(NOW - 2 * MINUTE), media_here: here }
2317    }
2318
2319    fn paris(ms: i64) -> Zoned {
2320        jiff::Timestamp::from_millisecond(ms).unwrap().to_zoned(jiff::tz::TimeZone::get("Europe/Paris").unwrap())
2321    }
2322
2323    #[test]
2324    fn the_page_shows_conversations_and_each_request_s_state_in_words() {
2325        let tr = Translator::new("en");
2326        let now = paris(NOW);
2327        let names = |k: &str| (k == "+33199001234").then(|| "Dr Martin".to_string());
2328        let here = |_: &str| true;
2329        let v = viewer(&now, &tr, &names, &here);
2330        let texts = vec![
2331            Text { id: "sms-1".into(), at: NOW - 60 * MINUTE, direction: "in".into(), with: vec!["+33199001234".into()], body: "Votre rendez-vous est déplacé.".into(), sub: 2, device: "phone".into(), ..Text::default() },
2332            Text { id: "sms-2".into(), at: NOW - 30 * MINUTE, direction: "in".into(), with: vec!["+33465710042".into()], body: "Le colis est arrivé.".into(), sub: 1, device: "phone".into(), ..Text::default() },
2333            Text { id: "sms-9".into(), at: NOW - 5 * MINUTE, direction: "out".into(), with: vec!["+33199001234".into()], body: "Merci, c'est noté.".into(), sub: 2, device: "phone".into(), ..Text::default() },
2334        ];
2335        let requests = vec![Request { body: "Merci, c'est noté.".into(), ..request("k1", "phone", NOW - 6 * MINUTE) }, request("k2", "phone", NOW - MINUTE), Request { to: "04 65 71 00 42".into(), ..request("k3", "phone", NOW - 20 * MINUTE) }];
2336        let outcomes = vec![
2337            Outcome { key: "k1".into(), state: "handed".into(), at: NOW - 5 * MINUTE, ..Outcome::default() },
2338            Outcome { key: "k1".into(), state: "sent".into(), at: NOW - 5 * MINUTE, row: "sms-9".into(), ..Outcome::default() },
2339            Outcome { key: "k3".into(), state: "expired".into(), at: NOW - 2 * MINUTE, ..Outcome::default() },
2340        ];
2341        let list = conversations(&texts, &requests, &v);
2342        assert_eq!(list.iter().map(|c| c.title.as_str()).collect::<Vec<_>>(), ["Dr Martin", "04 65 71 00 42"]);
2343        let shown = messages("+33199001234", &texts, &requests, &outcomes, &v);
2344        assert_eq!(shown.iter().map(|m| (m.direction.as_str(), m.key.as_str())).collect::<Vec<_>>(), [("in", ""), ("out", "k1"), ("out", "k2")]);
2345        assert_eq!(shown[1].said.as_ref().unwrap().text, "Sent at 09:55.");
2346        assert_eq!(shown[2].said.as_ref().unwrap().text, "Waiting for your phone (it last shared at 09:58).");
2347        let other = messages("+33465710042", &texts, &requests, &outcomes, &v);
2348        let expired = other.iter().find(|m| m.key == "k3").unwrap().said.clone().unwrap();
2349        assert!(expired.again && expired.state == "expired", "{expired:?}");
2350        assert_eq!(last_sim("+33199001234", "phone", &texts), 2);
2351    }
2352
2353    #[test]
2354    fn a_message_s_media_are_said_with_their_sizes_or_why_they_are_not_here() {
2355        let tr = Translator::new("en");
2356        let now = paris(NOW);
2357        let names = |_: &str| None;
2358        let here = |hash: &str| hash == "aa";
2359        let v = viewer(&now, &tr, &names, &here);
2360        let part = |seq: i64, ct: &str, size: u64, hash: &str, state: &str| Part { seq, ct: ct.into(), size, hash: hash.into(), state: state.into(), ..Part::default() };
2361        let mms = Text {
2362            id: "mms-31".into(),
2363            at: NOW - MINUTE,
2364            direction: "in".into(),
2365            with: vec!["+33465710042".into(), "+33199001234".into()],
2366            from: "+33465710042".into(),
2367            body: "Le colis.".into(),
2368            parts: vec![part(-1, "application/smil", 300, "", "text"), part(0, "image/jpeg", 220_000, "aa", "here"), part(1, "video/mp4", 30_000_000, "", "too-big"), part(2, "audio/amr", 9_000, "bb", "here"), part(3, "image/png", 0, "", "not-downloaded"), part(4, "text/plain", 9, "", "text")],
2369            device: "phone".into(),
2370            ..Text::default()
2371        };
2372        let shown = messages(&conversation_id(&mms.with), std::slice::from_ref(&mms), &[], &[], &v);
2373        let said: Vec<(&str, &str, bool)> = shown[0].parts.iter().map(|p| (p.kind.as_str(), p.said.as_str(), !p.hash.is_empty())).collect();
2374        assert_eq!(said.len(), 4, "the SMIL and the words are not shown apart: {said:?}");
2375        assert_eq!(said[0].0, "picture");
2376        assert!(said[0].2, "a picture here opens");
2377        assert!(said[1].1.contains("on your phone") && !said[1].2, "{said:?}");
2378        assert!(said[2].1.contains("its way") && !said[2].2, "its file not come yet: {said:?}");
2379        assert!(said[3].1.contains("not downloaded"), "{said:?}");
2380        assert_eq!(shown[0].from, "04 65 71 00 42", "a group says who wrote");
2381        assert_eq!(kept(std::slice::from_ref(&mms), &here), (1, 220_000), "only what is here is counted");
2382    }
2383
2384    #[test]
2385    fn delivered_is_said_only_when_the_carrier_says_so() {
2386        let tr = Translator::new("en");
2387        let now = paris(NOW);
2388        let sent = [Outcome { key: "h1".into(), state: "sent".into(), at: NOW + MINUTE, ..Outcome::default() }];
2389        assert_eq!(said(state_of("h1", &sent), None, &tr, &now, false).state, "sent", "no report: sent, never 'not delivered'");
2390        let delivered = [sent[0].clone(), Outcome { key: "h1".into(), state: "delivered".into(), at: NOW + 2 * MINUTE, ..Outcome::default() }];
2391        assert_eq!(said(state_of("h1", &delivered), None, &tr, &now, false).text, "Delivered at 10:02.");
2392    }
2393
2394    /// A text of a phone's log with its provider columns, as Java reads them.
2395    fn row(phone: &str, id: &str, at: i64, direction: &str, address: &str, body: &str) -> Text {
2396        let fields = [("address", serde_json::json!(address)), ("body", serde_json::json!(body)), ("date", serde_json::json!(at))].into_iter().map(|(k, v)| (k.to_string(), v)).collect();
2397        Text { id: id.into(), at, thread: "7".into(), direction: direction.into(), with: vec![phones::key(address, fr())], body: body.into(), sub: 1, fields, device: phone.into(), ..Text::default() }
2398    }
2399
2400    /// A multimedia message's address row, as the provider gives it.
2401    fn addr(address: &str, kind: i64) -> BTreeMap<String, Value> {
2402        [("address", serde_json::json!(address)), ("type", serde_json::json!(kind)), ("charset", serde_json::json!(106))].into_iter().map(|(k, v)| (k.to_string(), v)).collect()
2403    }
2404
2405    /// A courier's multimedia message with its picture (here, or over the
2406    /// phone's size), its words, and this phone's own address as given.
2407    fn picture_message(phone: &str, id: &str, at: i64, own: &str, state: &str) -> Text {
2408        let part = |seq: i64, ct: &str, text: &str, hash: &str, state: &str| Part { seq, ct: ct.into(), text: text.into(), hash: hash.into(), state: state.into(), size: if text.is_empty() { 220_000 } else { 0 }, ..Part::default() };
2409        let picture = if state == "here" { "aa".repeat(32) } else { String::new() };
2410        Text {
2411            id: id.into(),
2412            at,
2413            thread: "9".into(),
2414            direction: "in".into(),
2415            with: vec!["+33465710042".into()],
2416            body: "Le colis.".into(),
2417            picture: true,
2418            parts: vec![part(-1, "application/smil", "<smil/>", "", "text"), part(0, "image/jpeg", "", &picture, state), part(1, "text/plain", "Le colis.", "", "text")],
2419            addrs: vec![addr("+33465710042", ADDR_FROM), addr(own, ADDR_TO)],
2420            device: phone.into(),
2421            ..Text::default()
2422        }
2423    }
2424
2425    /// Two phones' logs as `read_logs` gives them: oldest first.
2426    fn logs(mut all: Vec<Text>) -> Vec<Text> {
2427        all.sort_by(|a, b| (a.at, &a.device, &a.id).cmp(&(b.at, &b.device, &b.id)));
2428        all
2429    }
2430
2431    /// The old phone's history, and the new phone's after SMS Backup &
2432    /// Restore put it back there (other rows, other conversations; a picture
2433    /// over the new phone's size; a text deleted on the old phone, there
2434    /// again), with texts of the new phone's own: one row number the same as
2435    /// an old one's, for another text.
2436    fn two_phones(old: &str, new: &str) -> (Vec<Text>, Vec<Text>) {
2437        let second = |s: i64| NOW - 2 * DAY_MS + s * 1000;
2438        let at = NOW - 3 * DAY_MS + 123;
2439        let old_log = vec![
2440            row(old, "sms-10", at, "in", "01 99 00 12 34", "Le rendez-vous est à 9 h."),
2441            // The same words sent twice within one second: two texts.
2442            row(old, "sms-11", second(0) + 100, "out", "0199001234", "OK"),
2443            row(old, "sms-12", second(0) + 400, "out", "0199001234", "OK"),
2444            picture_message(old, "mms-3", second(60), "+33536490000", "here"),
2445            Text { deleted: NOW - DAY_MS, ..row(old, "sms-13", second(120), "in", "+33465710042", "Votre colis arrive demain.") },
2446        ];
2447        let new_log = vec![
2448            Text { thread: "2".into(), ..row(new, "sms-501", at, "in", "01 99 00 12 34", "Le rendez-vous est à 9 h.") },
2449            row(new, "sms-502", second(0) + 100, "out", "0199001234", "OK"),
2450            row(new, "sms-503", second(0) + 400, "out", "0199001234", "OK"),
2451            picture_message(new, "mms-77", second(60), OWN_ADDRESS, "too-big"),
2452            row(new, "sms-504", second(120), "in", "+33465710042", "Votre colis arrive demain."),
2453            row(new, "sms-600", NOW - MINUTE, "in", "0199001234", "À tout à l'heure."),
2454            row(new, "sms-11", second(30), "out", "0199001234", "OK"),
2455        ];
2456        (old_log, new_log)
2457    }
2458
2459    /// The matching rule: what a restore keeps (the way, the second, the
2460    /// other people however written, the words, the media's types), never the
2461    /// rows, the conversations, this phone's own address, nor a character XML
2462    /// cannot carry.
2463    #[test]
2464    fn the_matching_rule_is_what_a_restore_keeps() {
2465        let key = |t: &Text| Print::of(t).key(fr());
2466        let text = row("a", "sms-1", NOW + 250, "in", "01 99 00 12 34", "Bonjour");
2467        assert_eq!(key(&text), key(&Text { id: "sms-900".into(), thread: "41".into(), device: "b".into(), ..row("b", "sms-900", NOW + 900, "in", "+33 1 99 00 12 34", "Bonjour") }), "another row and number form, the same second");
2468        for other in [row("a", "sms-1", NOW + 1000, "in", "01 99 00 12 34", "Bonjour"), row("a", "sms-1", NOW, "out", "01 99 00 12 34", "Bonjour"), row("a", "sms-1", NOW, "in", "01 99 00 12 35", "Bonjour"), row("a", "sms-1", NOW, "in", "01 99 00 12 34", "Bonjour !")] {
2469            assert_ne!(key(&text), key(&other), "{other:?}");
2470        }
2471        assert_eq!(key(&row("a", "sms-1", NOW, "in", "0199001234", "Page\u{c}break")), key(&row("b", "sms-2", NOW, "in", "0199001234", "Pagebreak")), "a form feed no backup can hold");
2472        // A multimedia message: this phone's own address as a token or as its number; the media's types in any case.
2473        let picture = picture_message("a", "mms-3", NOW, "+33536490000", "here");
2474        let restored = picture_message("b", "mms-77", NOW, OWN_ADDRESS, "too-big");
2475        assert_eq!(key(&picture), key(&restored), "here on one phone, too large on the other: the same");
2476        let upper = Text { parts: picture.parts.iter().map(|p| if p.ct == "image/jpeg" { Part { ct: "IMAGE/JPEG".into(), ..p.clone() } } else { p.clone() }).collect(), ..picture.clone() };
2477        assert_eq!(key(&upper), key(&picture), "a type's case aside");
2478        let other_words = Text { parts: picture.parts.iter().map(|p| if p.ct == "text/plain" { Part { text: "Le colis est là.".into(), ..p.clone() } } else { p.clone() }).collect(), ..picture.clone() };
2479        assert_ne!(key(&other_words), key(&picture));
2480        assert_eq!(Print::of(&picture).people, ["+33465710042"], "received: its sender");
2481        // Sent: those it went to, this phone's own address aside however written.
2482        let sent = |own: &str| Text { direction: "out".into(), addrs: vec![addr(own, ADDR_FROM), addr("04 65 71 00 42", ADDR_TO), addr("+33199001234", ADDR_CC)], ..picture.clone() };
2483        assert_eq!(Print::of(&sent(OWN_ADDRESS)).people, ["04 65 71 00 42", "+33199001234"]);
2484        assert_eq!(key(&sent(OWN_ADDRESS)), key(&sent("+33536490000")));
2485        assert_ne!(key(&picture), key(&sent(OWN_ADDRESS)), "sent, it is another");
2486        let backup_null = Text { parts: [picture.parts.clone(), vec![Part { seq: 2, ct: "text/plain".into(), text: "null".into(), state: "text".into(), ..Part::default() }]].concat(), ..picture.clone() };
2487        assert_eq!(key(&backup_null), key(&picture), "a text part SMS Backup & Restore wrote \"null\": none");
2488    }
2489
2490    /// The same history in two phones' logs, either way round: each text
2491    /// once, a text truly sent twice twice, the copy with its picture kept,
2492    /// deleted only when every copy is; what was sent from a computer said on
2493    /// its own phone's row, never on another phone's row of the same number.
2494    #[test]
2495    fn a_text_in_two_phones_logs_shows_once_and_one_sent_twice_stays_twice() {
2496        for (old, new) in [("old-phone", "new-phone"), ("zz-old", "new-phone")] {
2497            let (old_log, new_log) = two_phones(old, new);
2498            assert_eq!(one_each(logs(old_log.clone()), fr()), logs(old_log.clone()), "one phone: as it is");
2499            let shown = one_each(logs([old_log.clone(), new_log.clone()].concat()), fr());
2500            assert_eq!(shown.len(), 7, "{old}: {:?}", shown.iter().map(|t| format!("{}/{}", t.device, t.id)).collect::<Vec<_>>());
2501            let twice: Vec<&Text> = shown.iter().filter(|t| t.body == "OK" && t.at < NOW - 2 * DAY_MS + 1000).collect();
2502            assert_eq!(twice.len(), 2, "sent twice, twice");
2503            assert!(twice.iter().all(|t| t.also.len() == 1));
2504            let mms = shown.iter().find(|t| t.id.starts_with("mms-")).unwrap();
2505            assert_eq!((mms.device.as_str(), mms.parts[1].state.as_str()), (old, "here"), "the copy with its picture");
2506            assert_eq!(mms.also, [format!("{new}/mms-77")]);
2507            let deleted = shown.iter().find(|t| t.body.starts_with("Votre colis")).unwrap();
2508            assert_eq!(deleted.deleted, 0, "still on the new phone");
2509            assert_eq!(shown.iter().filter(|t| t.body == "OK").count(), 3, "the new phone's own OK, another second");
2510            // Deleted on both: said so.
2511            let both_gone = one_each(logs(vec![Text { deleted: NOW - DAY_MS, ..row(old, "sms-1", NOW, "in", "0199001234", "x") }, Text { deleted: NOW - MINUTE, ..row(new, "sms-9", NOW, "in", "0199001234", "x") }]), fr());
2512            assert_eq!(both_gone.iter().map(|t| t.deleted).collect::<Vec<_>>(), [NOW - MINUTE]);
2513            // Twice in one log, once in the other: twice.
2514            let once_there = one_each(logs([old_log.clone(), vec![row(new, "sms-502", NOW - 2 * DAY_MS + 100, "out", "0199001234", "OK")]].concat()), fr());
2515            assert_eq!(once_there.iter().filter(|t| t.body == "OK").count(), 2);
2516        }
2517        // The page: one conversation each; a text sent from here said on its own phone's row.
2518        let (old_log, new_log) = two_phones("old-phone", "new-phone");
2519        let shown = one_each(logs([old_log, new_log].concat()), fr());
2520        let tr = Translator::new("en");
2521        let now = paris(NOW);
2522        let names = |_: &str| None;
2523        let here = |_: &str| true;
2524        let v = viewer(&now, &tr, &names, &here);
2525        let requests = [Request { to: "0199001234".into(), body: "OK".into(), ..request("k1", "old-phone", NOW - 2 * DAY_MS - 30_000) }];
2526        let outcomes = [Outcome { key: "k1".into(), state: "sent".into(), at: NOW - 2 * DAY_MS + 100, row: "sms-11".into(), device: "old-phone".into(), ..Outcome::default() }];
2527        assert_eq!(conversations(&shown, &requests, &v).len(), 2);
2528        let thread = messages("+33199001234", &shown, &requests, &outcomes, &v);
2529        assert_eq!(thread.len(), 5, "{:?}", thread.iter().map(|m| (&m.id, &m.body, &m.key)).collect::<Vec<_>>());
2530        let sent: Vec<&Message> = thread.iter().filter(|m| m.key == "k1").collect();
2531        assert_eq!(sent.len(), 1, "the request said once, on its row");
2532        assert!(sent[0].id.starts_with("new-phone/sms-50") && sent[0].said.as_ref().is_some_and(|s| s.state == "sent"), "{:?}", sent[0]);
2533        assert!(thread.iter().any(|m| m.id == "new-phone/sms-11" && m.key.is_empty()), "another phone's row of that number: another text");
2534        assert_eq!(search("rendez", &shown, &requests, &outcomes, &v, 10).len(), 1);
2535    }
2536
2537    /// A row's name: the provider's id with the row's own date, so that a
2538    /// text taking a deleted newest text's id (the plain texts' table has no
2539    /// AUTOINCREMENT) is a line of its own; a line named before (`sms-12`)
2540    /// reads its date from its time; a request's state goes on its own row.
2541    #[test]
2542    fn a_row_is_its_id_with_its_date() {
2543        let first = Row { mms: false, id: 12, date: 1_791_446_340_000 };
2544        assert_eq!(first.name(), "sms-12@1791446340000");
2545        assert_eq!(Row::of("sms-12@1791446340000", 0), Some(first));
2546        assert_eq!(Row::of("sms-12", 1_791_446_340_000), Some(first), "a line named before: its date from its time");
2547        let picture = Row { mms: true, id: 31, date: 1_791_446_340 };
2548        assert_eq!((Row::of("mms-31", 1_791_446_340_000), Row::of(&picture.name(), 0), picture.at()), (Some(picture), Some(picture), 1_791_446_340_000));
2549        assert_ne!(Row::of("sms-12@1791446400000", 0), Some(first), "the same id, another text");
2550        assert_eq!((Row::of("request/abc", 0), Row::of("sms-x", 0), Row::of("sms-12@x", 0)), (None, None, None));
2551        // Two texts of one id: each its line; the first's deletion its own.
2552        let second = Row { mms: false, id: 12, date: 1_791_446_400_000 };
2553        let line = |row: Row, deleted: i64| Text { id: row.name(), at: row.at(), device: "p".into(), deleted, with: vec!["+33199001234".into()], ..Text::default() };
2554        let folded = fold_logs(vec![line(first, 0), line(second, 0), line(first, 1_791_446_500_000)]);
2555        assert_eq!(folded.iter().map(|t| (t.id.as_str(), t.deleted > 0)).collect::<Vec<_>>(), [("sms-12@1791446340000", true), ("sms-12@1791446400000", false)]);
2556        // The state of a text sent from here names its own row, never the older text of that id.
2557        let tr = Translator::new("en");
2558        let now = paris(NOW);
2559        let names = |_: &str| None;
2560        let here = |_: &str| true;
2561        let v = viewer(&now, &tr, &names, &here);
2562        let requests = [request("k9", "p", 1_791_446_390_000)];
2563        let outcomes = [Outcome { key: "k9".into(), state: "sent".into(), at: 1_791_446_400_000, row: second.name(), device: "p".into(), ..Outcome::default() }];
2564        let thread = messages("+33199001234", &folded, &requests, &outcomes, &v);
2565        assert_eq!(thread.iter().map(|m| (m.id.as_str(), m.key.as_str())).collect::<Vec<_>>(), [("p/sms-12@1791446340000", ""), ("p/sms-12@1791446400000", "k9")]);
2566        // An outcome written before (its row by its id alone): never the older text of that id
2567        // (named before too), whether the text sent is read yet or not.
2568        let late = Row { mms: false, id: 12, date: 1_791_446_530_000 };
2569        let requests = [request("k8", "p", 1_791_446_520_000)];
2570        let bare = [Outcome { key: "k8".into(), state: "sent".into(), at: 1_791_446_535_000, row: "sms-12".into(), device: "p".into(), ..Outcome::default() }];
2571        let older = Text { id: "sms-12".into(), ..line(first, 0) };
2572        let thread = messages("+33199001234", &fold_logs(vec![older.clone(), line(late, 0)]), &requests, &bare, &v);
2573        assert_eq!(thread.iter().map(|m| (m.id.as_str(), m.key.as_str())).collect::<Vec<_>>(), [("p/sms-12", ""), ("p/sms-12@1791446530000", "k8")]);
2574        let thread = messages("+33199001234", &fold_logs(vec![older]), &requests, &bare, &v);
2575        assert!(thread.iter().find(|m| m.id == "p/sms-12").is_some_and(|m| m.key.is_empty()), "the text sent not read yet: not the older one's");
2576    }
2577
2578    /// The logs read again only where they grew: the lines added read alone,
2579    /// a line being written left for the next look, a file put in another's
2580    /// place or cut short read again whole, a phone's file gone taken out;
2581    /// each time as `read_logs` and `one_each` give them.
2582    #[test]
2583    fn the_logs_are_read_again_only_where_they_grew() {
2584        use std::sync::atomic::{AtomicUsize, Ordering};
2585        /// The stand-in sealer, counting the lines it opens.
2586        struct Counted(AtomicUsize);
2587        impl Sealer for Counted {
2588            fn seal(&self, plain: &str) -> String {
2589                Stand.seal(plain)
2590            }
2591            fn open(&self, sealed: &str) -> Option<String> {
2592                self.0.fetch_add(1, Ordering::Relaxed);
2593                Stand.open(sealed)
2594            }
2595        }
2596        let dir = scratch("logs");
2597        let seal = Counted(AtomicUsize::new(0));
2598        let opened = || seal.0.swap(0, Ordering::Relaxed);
2599        let text = |id: i64, body: &str| Text { id: Row { mms: false, id, date: NOW + id }.name(), at: NOW + id, direction: "in".into(), with: vec!["+33199001234".into()], body: body.into(), ..Text::default() };
2600        let (a, b) = (own_file(&dir, LOG, "a"), own_file(&dir, LOG, "b"));
2601        append(&a, &[text(1, "Un."), text(2, "Deux.")], &seal, None).unwrap();
2602        // A text as Java reads it, with its raw columns: the fingerprint made of them, the columns not kept.
2603        let mut raw = text(8, "Deux.");
2604        raw.fields.insert("address".into(), Value::from("04 65 71 00 42"));
2605        append(&b, &[text(7, "Un."), raw], &seal, None).unwrap();
2606        opened();
2607        let mut logs = Logs::default();
2608        let bare = |texts: Vec<Text>| texts.into_iter().map(|t| Text { fields: BTreeMap::new(), addrs: Vec::new(), ..t }).collect::<Vec<_>>();
2609        let same = |logs: &mut Logs| {
2610            let n = seal.0.load(Ordering::Relaxed);
2611            assert_eq!(logs.texts(), bare(read_logs(&dir, &seal)));
2612            assert_eq!(logs.one_each(fr()), bare(one_each(read_logs(&dir, &seal), fr())));
2613            seal.0.store(n, Ordering::Relaxed);
2614        };
2615        assert!(logs.refresh(&dir, &seal));
2616        assert_eq!(opened(), 4);
2617        assert!(logs.texts().iter().all(|t| t.fields.is_empty() && t.addrs.is_empty()), "no raw columns kept");
2618        // The same texts and fingerprints as a whole read, the raw columns aside.
2619        same(&mut logs);
2620        assert!(!logs.refresh(&dir, &seal), "nothing changed");
2621        assert_eq!(opened(), 0);
2622        // Two lines added, then half a line being written: the two alone, then the rest.
2623        append(&a, &[text(3, "Trois."), Text { deleted: NOW + 9, ..text(1, "") }], &seal, None).unwrap();
2624        let line = format!("{}\n", seal.seal(&serde_json::to_string(&text(4, "Quatre.")).unwrap()));
2625        let (half, rest) = line.split_at(line.len() / 2);
2626        calls::append_private(&a, half).unwrap();
2627        assert!(logs.refresh(&dir, &seal));
2628        assert_eq!(opened(), 2, "the lines added, alone");
2629        same(&mut logs);
2630        calls::append_private(&a, rest).unwrap();
2631        assert!(logs.refresh(&dir, &seal));
2632        assert_eq!(opened(), 1);
2633        same(&mut logs);
2634        assert_eq!(logs.texts().iter().filter(|t| t.device == "a").count(), 4);
2635        // Another file put in its place, longer; then cut short: read again whole.
2636        std::fs::remove_file(&a).unwrap();
2637        append(&a, &(10..16).map(|id| text(id, "Autre.")).collect::<Vec<_>>(), &seal, None).unwrap();
2638        assert!(logs.refresh(&dir, &seal));
2639        assert_eq!(opened(), 6);
2640        same(&mut logs);
2641        std::fs::remove_file(&a).unwrap();
2642        append(&a, &[text(20, "Court.")], &seal, None).unwrap();
2643        assert!(logs.refresh(&dir, &seal));
2644        same(&mut logs);
2645        // The same size, other words, its time put back (rsync -t, a restore): read again all the same, by
2646        // its inode and change time, which Unix alone gives (on Windows, once its size or time changes).
2647        #[cfg(unix)]
2648        {
2649            let modified = std::fs::metadata(&a).unwrap().modified().unwrap();
2650            let body = std::fs::read(&a).unwrap();
2651            std::fs::remove_file(&a).unwrap();
2652            append(&a, &[text(20, "Kourt.")], &seal, None).unwrap();
2653            assert_eq!(std::fs::read(&a).unwrap().len(), body.len(), "the same size");
2654            std::fs::File::options().write(true).open(&a).unwrap().set_modified(modified).unwrap();
2655            assert!(logs.refresh(&dir, &seal), "another file, though its size and time are the old one's");
2656            same(&mut logs);
2657            assert!(logs.texts().iter().any(|t| t.body == "Kourt."));
2658        }
2659        // A phone's file gone: its texts too.
2660        std::fs::remove_file(&b).unwrap();
2661        assert!(logs.refresh(&dir, &seal));
2662        same(&mut logs);
2663        assert!(logs.texts().iter().all(|t| t.device == "a"));
2664        let _ = std::fs::remove_dir_all(&dir);
2665    }
2666
2667    /// Each row once on a computer: a line named by its id alone and the
2668    /// same row named with its date are one text (a phone put back on an
2669    /// older Sioul writes again under the old names: MEDIUM 1); a row its
2670    /// provider re-dated is one text, its old name beside it, so that the
2671    /// request it was still finds it (L-4); a reused id with the same words,
2672    /// received hours later, is another text, the older one said deleted
2673    /// (L-3); an outcome named by its id alone binds no text received after
2674    /// it (LOW 5).
2675    #[test]
2676    fn each_row_is_one_text_on_a_computer() {
2677        const HOUR: i64 = 60 * MINUTE;
2678        let tr = Translator::new("en");
2679        let now = paris(NOW);
2680        let names = |_: &str| None;
2681        let here = |_: &str| true;
2682        let v = viewer(&now, &tr, &names, &here);
2683        let line = |id: &str, at: i64, body: &str| Text { id: id.into(), at, direction: "in".into(), with: vec!["+33199001234".into()], body: body.into(), device: "phone".into(), ..Text::default() };
2684        // MEDIUM 1: the same row under its old name and its name now, a deletion under the old name.
2685        let t5 = NOW - 9 * HOUR;
2686        let t7 = NOW - 8 * HOUR;
2687        let lines = vec![line("sms-5", t5, "Bonjour."), line(&format!("sms-5@{t5}"), t5, "Bonjour."), line(&format!("sms-7@{t7}"), t7, "Au revoir."), Text { deleted: NOW - HOUR, ..line("sms-7", t7, "") }];
2688        let folded = fold_logs(lines);
2689        assert_eq!(folded.iter().map(|t| (t.body.as_str(), t.deleted > 0)).collect::<Vec<_>>(), [("Bonjour.", false), ("Au revoir.", true)]);
2690        // L-4: a text sent from a computer (r1, its outcome naming its first row), then re-dated: one text, bound to r1.
2691        let t9 = NOW - 2 * HOUR;
2692        let first = Text { direction: "out".into(), ..line(&format!("sms-9@{t9}"), t9, "J'arrive.") };
2693        let then = Text { id: format!("sms-9@{}", t9 + 1), at: t9 + 1, ..first.clone() };
2694        let mark = Text { id: first.id.clone(), at: first.at, deleted: NOW - HOUR, moved: then.id.clone(), direction: "out".into(), with: first.with.clone(), device: "phone".into(), ..Text::default() };
2695        let folded = fold_logs(vec![first.clone(), then.clone(), mark]);
2696        assert_eq!(folded.len(), 1);
2697        assert_eq!((folded[0].id.as_str(), folded[0].also.clone()), (then.id.as_str(), vec![format!("phone/{}", first.id)]));
2698        let request = Request { key: "r1".into(), phone: "phone".into(), to: "+33199001234".into(), body: "J'arrive.".into(), written: t9 - 1_000, ..Request::default() };
2699        let sent = Outcome { key: "r1".into(), state: "sent".into(), at: t9 + 5_000, row: first.id.clone(), device: "phone".into(), ..Outcome::default() };
2700        let shown = messages(&conversation_id(&first.with), &folded, std::slice::from_ref(&request), std::slice::from_ref(&sent), &v);
2701        assert_eq!(shown.iter().map(|m| (m.id.clone(), m.key.clone())).collect::<Vec<_>>(), [(format!("phone/{}", then.id), "r1".to_string())], "one text, the request found by its old name");
2702        // L-3: Alice's "Oui." deleted, her next "Oui." hours later under its id: another text; a millisecond later, or sent from the phone: the same.
2703        let oui = line(&format!("sms-11@{}", NOW - 6 * HOUR), NOW - 6 * HOUR, "Oui.");
2704        let later = line(&format!("sms-11@{}", NOW - 3 * HOUR), NOW - 3 * HOUR, "Oui.");
2705        assert!(!moved(&oui, &later), "a reused id, hours later: another text");
2706        assert!(moved(&oui, &line(&format!("sms-11@{}", NOW - 6 * HOUR + 1), NOW - 6 * HOUR + 1, "Oui.")), "re-dated by a millisecond");
2707        let out = |t: &Text| Text { direction: "out".into(), ..t.clone() };
2708        assert!(moved(&out(&oui), &out(&later)), "sent from the phone: its provider re-dates its own sending");
2709        let picture = |hash: &str| Text { id: format!("mms-3@{}", (NOW - 7 * HOUR) / 1000), at: NOW - 7 * HOUR, direction: "in".into(), with: vec!["+33199001234".into()], parts: vec![Part { ct: "image/jpeg".into(), hash: hash.into(), state: "here".into(), ..Part::default() }], device: "phone".into(), ..Text::default() };
2710        let again = |hash: &str| Text { id: format!("mms-3@{}", (NOW - 7 * HOUR) / 1000 + 1), at: NOW - 7 * HOUR + 1_000, ..picture(hash) };
2711        assert!(moved(&picture("aa11"), &again("aa11")), "the same picture re-dated");
2712        assert!(!moved(&picture("aa11"), &again("bb22")), "another picture under a reused id");
2713        assert!(moved(&picture(""), &again("bb22")), "read before its parts were attached: comes whole");
2714        // LOW 5: an outcome of before 16 October naming its row by its id alone binds no text received days after it.
2715        let bare = Outcome { key: "r2".into(), state: "sent".into(), at: NOW - 5 * HOUR, row: "sms-12".into(), device: "phone".into(), ..Outcome::default() };
2716        let asked = Request { key: "r2".into(), phone: "phone".into(), to: "+33199001234".into(), body: "Merci.".into(), written: NOW - 5 * HOUR - 30_000, ..Request::default() };
2717        let filed = Text { direction: "out".into(), ..line("sms-12", NOW - 5 * HOUR - 10_000, "Merci.") };
2718        let reused = line("sms-12", NOW + 3 * DAY_MS, "Autre.");
2719        let bound = |t: &Text| messages(&conversation_id(&t.with), std::slice::from_ref(t), std::slice::from_ref(&asked), std::slice::from_ref(&bare), &v).into_iter().find(|m| m.id.starts_with("phone/")).map(|m| m.key).unwrap_or_default();
2720        assert_eq!(bound(&filed), "r2", "the text it sent, filed before its result");
2721        assert_eq!(bound(&reused), "", "a text received days later under its id");
2722    }
2723
2724    /// A picture's medium as a request names it.
2725    fn medium(n: u8, ct: &str) -> Medium {
2726        Medium { hash: format!("{n:02x}").repeat(32), ct: ct.into(), size: 4_096 }
2727    }
2728
2729    /// A multimedia message from a computer: refused in its own words, and
2730    /// sent when it may go; an older phone, which reads its words empty,
2731    /// refuses it rather than sending its words alone (decision 11).
2732    #[test]
2733    fn a_multimedia_request_is_refused_in_its_own_words() {
2734        let ledger = Ledger::with(Mark { since: 0, writers: 0 }, Vec::new());
2735        let mms = |media: Vec<Medium>, words: &str, also: Vec<String>, to: &str| Request { body: String::new(), words: words.into(), media, also, to: to.into(), ..request("m", "phone", NOW) };
2736        let v = |r: &Request| verdict(r, "phone", NOW, &ledger, fr(), &|_| Standing::Counted);
2737        let picture = || vec![medium(1, "image/jpeg")];
2738        assert_eq!(v(&mms(picture(), "Voilà l’entrée.", vec![], "01 99 00 12 34")), Verdict::Send);
2739        assert_eq!(v(&mms(picture(), "", vec![], "01 99 00 12 34")), Verdict::Send, "a picture alone");
2740        assert_eq!(v(&mms((1..=6).map(|n| medium(n, "image/png")).collect(), "", vec![], "01 99 00 12 34")), Verdict::Refused("too-many-media"));
2741        assert_eq!(v(&mms(vec![medium(2, "application/pdf")], "", vec![], "01 99 00 12 34")), Verdict::Refused("media-kind"), "another file goes by mail");
2742        assert_eq!(v(&mms(vec![Medium { hash: "abc".into(), ..medium(3, "image/jpeg") }], "", vec![], "01 99 00 12 34")), Verdict::Refused("media-kind"), "no content's hash");
2743        assert_eq!(v(&mms(vec![Medium { size: 0, ..medium(3, "image/jpeg") }], "", vec![], "01 99 00 12 34")), Verdict::Refused("media-kind"), "an empty one");
2744        for ct in ["video/mp4", "audio/amr", "text/x-vCard", "text/calendar"] {
2745            assert_eq!(v(&mms(vec![medium(4, ct)], "", vec![], "01 99 00 12 34")), Verdict::Send, "{ct}");
2746        }
2747        assert_eq!(v(&mms(picture(), "", vec!["04 65 71 00 42".into()], "01 99 00 12 34")), Verdict::Refused("several"), "a group: a later step");
2748        assert_eq!(v(&mms(picture(), "", vec![], "36179")), Verdict::Refused("short-number"));
2749        assert_eq!(v(&mms(picture(), &"x".repeat(WRITE_MAX + 1), vec![], "01 99 00 12 34")), Verdict::Refused("too-long"));
2750        // An older phone's own rule (a text's), on the same request: refused, never its words alone.
2751        let asked = mms(picture(), "Voilà l’entrée.", vec![], "01 99 00 12 34");
2752        assert_eq!(refusal(&asked.to, &asked.body, fr()), Some("empty"));
2753        // Written so: an older Sioul's reader of the line keeps its words out of `body`.
2754        let line = serde_json::to_value(&asked).unwrap();
2755        assert_eq!((line["body"].as_str(), line["words"].as_str(), line["media"].as_array().map(Vec::len)), (Some(""), Some("Voilà l’entrée."), Some(1)));
2756        let plain = serde_json::to_value(request("p", "phone", NOW)).unwrap();
2757        assert!(plain.get("media").is_none() && plain.get("words").is_none() && plain.get("also").is_none(), "a text's line as before: {plain}");
2758        // The computer says the older phone's refusal as such, Send again offered.
2759        let tr = Translator::new("en");
2760        let now = paris(NOW);
2761        let refused = Outcome { key: "m".into(), state: "refused".into(), at: NOW, reason: "empty".into(), ..Outcome::default() };
2762        let said_of = |media: bool| said(Some(&refused), None, &tr, &now, media);
2763        assert_eq!(said_of(false).text, "Not sent: it has no words.");
2764        assert!(said_of(true).text.contains("cannot send pictures yet") && said_of(true).again, "{}", said_of(true).text);
2765    }
2766
2767    /// A multimedia message's claim with no word: looked for by its key from
2768    /// 40 minutes on, handed or not (Android waits up to 30 for a network
2769    /// before its first try), and said in doubt only once Android's last
2770    /// word is past (its three tries, each of which may wait so: 100
2771    /// minutes); until then, said as it may still go (the review of 18
2772    /// October 2026, L-3).
2773    #[test]
2774    fn a_multimedia_claim_is_looked_for_until_android_s_last_word() {
2775        let claim = Claim { key: "n1".into(), at: NOW, state: "handed".into(), mms: true, ..Claim::default() };
2776        assert!(doubt_after(&claim, None, NOW + 39 * MINUTE).is_none(), "Android may still be waiting for a network");
2777        let found = doubt_after(&claim, Some("mms-31@1791446400".into()), NOW + 41 * MINUTE).unwrap();
2778        assert_eq!((found.state.as_str(), found.row.as_str()), ("sent", "mms-31@1791446400"));
2779        assert!(doubt_after(&claim, None, NOW + 41 * MINUTE).is_none(), "not found at 41 minutes: Android may still try");
2780        assert!(doubt_after(&claim, None, NOW + 99 * MINUTE).is_none());
2781        assert_eq!(doubt_after(&claim, None, NOW + 100 * MINUTE).unwrap().state, "doubt", "Android's last word past");
2782        let tr = Translator::new("en");
2783        let handed = Outcome { key: "n1".into(), state: "handed".into(), at: NOW, ..Outcome::default() };
2784        assert_eq!(said(Some(&handed), None, &tr, &paris(NOW + 10 * MINUTE), true).text, "Your phone is sending it.");
2785        let still = said(Some(&handed), None, &tr, &paris(NOW + 41 * MINUTE), true);
2786        assert!(still.state == "sending" && !still.again && still.text.contains("11:40"), "until about 100 minutes after 10:00: {}", still.text);
2787        assert_eq!(said(Some(&handed), None, &tr, &paris(NOW + 41 * MINUTE), false).text, "Your phone is sending it.", "a text: as before");
2788        // Past Android's last word with no word from the phone: waiting for it, never a time already past (I-b).
2789        let past = said(Some(&handed), Some(NOW + 20 * MINUTE), &tr, &paris(NOW + 110 * MINUTE), true);
2790        assert!(past.state == "waiting" && past.text.contains("10:20") && !past.text.contains("11:40"), "{}", past.text);
2791        let text = Claim { mms: false, ..claim.clone() };
2792        assert!(doubt_after(&text, None, NOW + 3 * MINUTE).is_some(), "a text's, two minutes on as before");
2793        let line = serde_json::to_string(&Claim { mms: false, ..claim }).unwrap();
2794        assert!(!line.contains("mms"), "a text's claim as before: {line}");
2795    }
2796
2797    /// A multimedia message's result: sent only on Android's Ok and the
2798    /// carrier's; Android's Ok alone looked for in the sent box; each
2799    /// failure in its own words.
2800    #[test]
2801    fn a_multimedia_result_says_the_carrier_s_answer() {
2802        let conf = |status: u8| crate::mms::Conf { status, ..crate::mms::Conf::default() };
2803        assert_eq!(took(-1, Some(&conf(crate::mms::OK)), 0), Took::Sent);
2804        assert_eq!(took(-1, None, 0), Took::Look, "Android's Ok, the carrier's answer missing");
2805        // Android's code 4: every try ended on the network, no answer of the carrier's (the review of 18 October 2026, M-2).
2806        assert_eq!(took(4, None, 0), Took::Doubt("network"), "no HTTP status: an I/O failure, perhaps after the message was posted");
2807        assert_eq!(took(4, None, 302), Took::Doubt("network"), "an answer under 400: not a refusal");
2808        // Codes 0, 1, 6 and 13 said exactly, and one Sioul does not know (the review of 18 October 2026, I-h).
2809        assert_eq!(took(0, None, 0), Took::Doubt("cancelled"));
2810        assert_eq!(took(1, None, 0), Took::Doubt("unexpected"));
2811        assert_eq!(took(6, None, 0), Took::Failed("mms-retry"));
2812        assert_eq!(took(13, None, 0), Took::Failed("mms-satellite"));
2813        assert_eq!(took(99, None, 0), Took::Doubt("unexpected"));
2814        assert_eq!(took(4, None, 404), Took::Failed("carrier-refused"));
2815        assert_eq!(took(4, None, 503), Took::Failed("carrier-refused"));
2816        let cases: [(i64, Option<u8>, &str); 14] = [
2817            (-1, Some(0xE5), "carrier-content"),
2818            (-1, Some(0xE1), "carrier-denied"),
2819            (-1, Some(0xEB), "carrier-credit"),
2820            (-1, Some(0xC3), "carrier-busy"),
2821            (-1, Some(0xE0), "carrier-refused"),
2822            (-1, Some(0x87), "carrier-content"),
2823            (5, None, "mms-unreadable"),
2824            (4, Some(0xC0), "carrier-busy"),
2825            (11, None, "mms-data-off"),
2826            (8, None, "mms-data-off"),
2827            (3, None, "mms-no-network"),
2828            (12, None, "mms-settings"),
2829            (5, None, "mms-unreadable"),
2830            (10, None, "no-sim"),
2831        ];
2832        for (code, status, why) in cases {
2833            assert_eq!(took(code, status.map(conf).as_ref(), 0), Took::Failed(why), "{code} {status:?}");
2834        }
2835        // Each in its own words, never "Android refused it".
2836        let tr = Translator::new("en");
2837        let now = paris(NOW);
2838        let other = said(Some(&Outcome { state: "failed".into(), reason: "other".into(), ..Outcome::default() }), None, &tr, &now, true).text;
2839        for (_, _, why) in cases {
2840            let words = said(Some(&Outcome { state: "failed".into(), reason: why.into(), ..Outcome::default() }), None, &tr, &now, true);
2841            assert!(words.text != other && words.again, "{why}: {}", words.text);
2842        }
2843        let late = said(Some(&Outcome { state: "expired".into(), reason: "media".into(), ..Outcome::default() }), None, &tr, &now, true);
2844        assert!(late.text.contains("did not reach your phone") && late.again, "{}", late.text);
2845        // The doubts: the carrier may have it (no Send again), or Android stopped (Send again, as after a crash).
2846        for (why, again) in [("network", false), ("answer", false), ("cancelled", true), ("unexpected", true)] {
2847            let words = said(Some(&Outcome { state: "doubt".into(), reason: why.into(), ..Outcome::default() }), None, &tr, &now, true);
2848            assert!(words.state == "doubt" && words.again == again && words.text != tr.text("texts-doubt", None), "{why}: {}", words.text);
2849        }
2850        assert!(said(Some(&Outcome { state: "doubt".into(), ..Outcome::default() }), None, &tr, &now, true).again);
2851        for why in ["mms-retry", "mms-satellite"] {
2852            let words = said(Some(&Outcome { state: "failed".into(), reason: why.into(), ..Outcome::default() }), None, &tr, &now, true);
2853            assert!(words.text != other && words.again, "{why}: {}", words.text);
2854        }
2855        for why in ["too-large", "too-many-media", "media-kind", "no-mms"] {
2856            let words = said(Some(&Outcome { state: "refused".into(), reason: why.into(), ..Outcome::default() }), None, &tr, &now, true);
2857            assert!(words.text != tr.text("texts-refused-other", None) && !words.again, "{why}: {}", words.text);
2858        }
2859    }
2860
2861    /// What a phone's carriers let it send: its newest "limits" line, by
2862    /// phone; a computer of an older Sioul reads it as an outcome of no
2863    /// request, and passes it over.
2864    #[test]
2865    fn a_phone_says_what_its_carriers_let_it_send() {
2866        let line = |at: i64, max: u64, device: &str| Outcome { key: String::new(), state: "limits".into(), at, limits: vec![Limits { sub: 3, max, width: 2592, height: 1944, recipients: 20, group: true, mms: true }], device: device.into(), ..Outcome::default() };
2867        let outcomes = vec![line(NOW - DAY_MS, 307_200, "phone"), line(NOW, 614_400, "phone"), line(NOW + MINUTE, 102_400, "other-phone"), Outcome { key: "k1".into(), state: "sent".into(), at: NOW, device: "phone".into(), ..Outcome::default() }];
2868        let (at, limits) = limits_of(&outcomes, "phone", NOW).unwrap();
2869        assert_eq!((at, limits[0].max), (NOW, 614_400));
2870        assert_eq!(limits_of(&outcomes, "third-phone", NOW), None);
2871        // A line dated a year ahead (a phone whose clock once was): passed over, the lines since read (the review's I-1).
2872        let ahead = [outcomes.clone(), vec![line(NOW + 365 * DAY_MS, 100 << 20, "phone")]].concat();
2873        assert_eq!(limits_of(&ahead, "phone", NOW).map(|(at, l)| (at, l[0].max)), Some((NOW, 614_400)));
2874        assert_eq!(limits_of(&ahead, "phone", NOW + 366 * DAY_MS).map(|(_, l)| l[0].max), Some(100 << 20), "once its date is past");
2875        assert_eq!(state_of("k1", &outcomes).map(|o| o.state.as_str()), Some("sent"), "a request's state never a limits line");
2876        assert_eq!(state_of("", &outcomes).map(|o| o.state.as_str()), None, "a limits line is no request's state");
2877        // As an older Sioul writes and reads its outcomes.
2878        let written = serde_json::to_string(&outcomes[1]).unwrap();
2879        assert!(written.contains("\"limits\":[{"), "{written}");
2880        let old: Outcome = serde_json::from_str(r#"{"key":"k1","state":"sent","at":1}"#).unwrap();
2881        assert!(old.limits.is_empty());
2882    }
2883
2884    /// A multimedia message written here shows its words and its media from
2885    /// the first moment, the picture once it is here; its conversation's line says it.
2886    #[test]
2887    fn a_multimedia_request_shows_its_words_and_media() {
2888        let tr = Translator::new("en");
2889        let now = paris(NOW);
2890        let names = |_: &str| None;
2891        let picture = medium(7, "image/jpeg");
2892        let here_hash = picture.hash.clone();
2893        let here = move |hash: &str| hash == here_hash;
2894        let v = viewer(&now, &tr, &names, &here);
2895        let mms = Request { body: String::new(), words: "Voilà l’entrée.".into(), media: vec![picture.clone(), medium(8, "video/mp4")], ..request("q1", "phone", NOW - MINUTE) };
2896        let quiet = Request { words: String::new(), media: vec![picture], ..request("q2", "phone", NOW) };
2897        let id = conversation_id(&[phones::key(&mms.to, fr())]);
2898        let shown = messages(&id, &[], &[mms.clone(), quiet.clone()], &[], &v);
2899        assert_eq!(shown.len(), 2);
2900        assert_eq!((shown[0].body.as_str(), shown[0].picture, shown[0].parts.len()), ("Voilà l’entrée.", true, 2));
2901        assert_eq!((shown[0].parts[0].kind.as_str(), shown[0].parts[0].hash.is_empty()), ("picture", false), "the picture here");
2902        assert_eq!((shown[0].parts[1].kind.as_str(), shown[0].parts[1].hash.is_empty()), ("video", true), "the video not here yet: on its way");
2903        let list = conversations(&[], &[quiet], &v);
2904        assert_eq!(list[0].last, "a picture");
2905    }
2906}