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/// The media of multimedia messages, each sealed at rest by its content's hash; never shared as a store (they travel as blobs).
73pub const MEDIA: &str = "media";
74/// The phone's private files, in the state folder, never shared: the ledger,
75/// its mark, the reader's marks and the import's progress, Java's results.
76pub const LEDGER: &str = "ledger.jsonl";
77pub const MARK: &str = "ledger-mark.json";
78pub const READER: &str = "reader.json";
79pub const RESULTS: &str = "results.jsonl";
80/// Requests and outcomes are taken out by their device after this many days…
81pub const KEPT_DAYS: i64 = 31;
82/// …and by any device this many days later (45 in all). Texts never are.
83pub const LATE_DAYS: i64 = 14;
84/// A request older than this on the phone's clock is not sent.
85pub const EXPIRY_MS: i64 = 15 * 60_000;
86/// A request dated further ahead of the phone's clock than this is refused: the clocks disagree.
87pub const AHEAD_MS: i64 = 2 * 60_000;
88/// The ledger keeps a key this long, past any request's life.
89pub const LEDGER_KEPT_MS: i64 = 30 * DAY_MS;
90/// A claim with no word from Android this long after is looked for in the provider.
91pub const LOOK_AFTER_MS: i64 = 2 * 60_000;
92/// A text written on a computer: at most this many characters.
93pub const WRITE_MAX: usize = 1600;
94/// Each line's plain length is a multiple of this before it is sealed.
95pub const PAD_STEP: usize = 256;
96/// The first import, and each step after: at most this many texts at a time.
97pub const BATCH: usize = 500;
98/// …and this many bytes of media.
99pub const MEDIA_BATCH: u64 = 24 << 20;
100/// A part over this many bytes stays on the phone, said, unless you set another size (MB, `texts.toml`).
101pub const MEDIA_CAP_MB: u64 = 10;
102const DAY_MS: i64 = 86_400_000;
103
104/// The folder of the texts: the phones' logs, the requests and outcomes, the media; in the data folder.
105pub fn folder() -> PathBuf {
106    crate::config::data_dir().join(FOLDER)
107}
108
109/// The phone's private texts folder, in the state folder: never shared.
110pub fn private_folder() -> PathBuf {
111    crate::config::state_dir().join(FOLDER)
112}
113
114/// What seals a line at rest and opens it: the caller's, made from the sharing key.
115pub trait Sealer {
116    /// A line, sealed: no newline in it.
117    fn seal(&self, plain: &str) -> String;
118    /// A sealed line opened; none when it does not open (another key, broken).
119    fn open(&self, sealed: &str) -> Option<String>;
120}
121
122// ---------------------------------------------------------------- the lines
123
124/// One part of a multimedia message: its own columns, and where its content is.
125#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
126pub struct Part {
127    /// Its place in the message (`seq`; -1 for the SMIL that lays it out).
128    #[serde(default)]
129    pub seq: i64,
130    /// Its content type: "image/jpeg", "text/plain", "application/smil"…
131    #[serde(default)]
132    pub ct: String,
133    /// Its name, as the message gives one (`name`, else `cl`, else `fn`).
134    #[serde(default, skip_serializing_if = "String::is_empty")]
135    pub name: String,
136    /// Its size in bytes, as Android says it; 0 unknown.
137    #[serde(default, skip_serializing_if = "is_zero_u64")]
138    pub size: u64,
139    /// Its words, for a text part and the SMIL.
140    #[serde(default, skip_serializing_if = "String::is_empty")]
141    pub text: String,
142    /// Its content's hash (SHA-256, hex): its blob in the sharing, its sealed file here.
143    #[serde(default, skip_serializing_if = "String::is_empty")]
144    pub hash: String,
145    /// "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).
146    #[serde(default)]
147    pub state: String,
148    /// Every other column of the part as the provider gives it (`chset`, `cd`, `fn`, `cid`, `cl`, `ctt_s`, `ctt_t`…).
149    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
150    pub fields: BTreeMap<String, Value>,
151}
152
153fn is_zero_u64(value: &u64) -> bool {
154    *value == 0
155}
156
157/// A text as the phone's provider holds it, as it travels.
158#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
159pub struct Text {
160    /// "sms-<_id>", "mms-<_id>": the provider's own row on that phone.
161    pub id: String,
162    /// When (ms): received, or sent.
163    pub at: i64,
164    /// The provider's conversation, on that phone.
165    #[serde(default, skip_serializing_if = "String::is_empty")]
166    pub thread: String,
167    /// "in" or "out".
168    #[serde(rename = "box")]
169    pub direction: String,
170    /// The conversation's other people's numbers, as Sioul keys them (`phones::key`); several: a group.
171    #[serde(default)]
172    pub with: Vec<String>,
173    /// Who wrote it, in a group: their number's key.
174    #[serde(default, skip_serializing_if = "String::is_empty")]
175    pub from: String,
176    #[serde(default, skip_serializing_if = "String::is_empty")]
177    pub body: String,
178    /// The SIM it came by or left from (Android's `sub_id`); -1 unknown.
179    #[serde(default = "no_sim")]
180    pub sub: i32,
181    /// It carries a picture.
182    #[serde(default, skip_serializing_if = "is_false")]
183    pub picture: bool,
184    /// A multimedia message's parts, in order.
185    #[serde(default, skip_serializing_if = "Vec::is_empty")]
186    pub parts: Vec<Part>,
187    /// Every column of the provider's row, as it gives them (a faithful restore needs them): docs/texts.md, "What is kept".
188    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
189    pub fields: BTreeMap<String, Value>,
190    /// A multimedia message's addresses, each row as the provider gives it (`address`, `type`, `charset`).
191    #[serde(default, skip_serializing_if = "Vec::is_empty")]
192    pub addrs: Vec<BTreeMap<String, Value>>,
193    /// Sioul sent it, for this key (the provider's `creator`, matched).
194    #[serde(default, skip_serializing_if = "String::is_empty")]
195    pub key: String,
196    /// When the phone found it deleted (ms): a line of its own, beside the text's; the archive keeps the text.
197    #[serde(default, skip_serializing_if = "is_zero")]
198    pub deleted: i64,
199    /// The phone that wrote it (its file's name). Never written.
200    #[serde(skip)]
201    pub device: String,
202}
203
204fn no_sim() -> i32 {
205    -1
206}
207
208fn is_false(value: &bool) -> bool {
209    !*value
210}
211
212fn is_zero(value: &i64) -> bool {
213    *value == 0
214}
215
216impl Text {
217    /// Its name in its phone's log: the deletion's line is another.
218    pub fn line_id(&self) -> String {
219        if self.deleted > 0 { format!("{}#deleted", self.id) } else { self.id.clone() }
220    }
221
222    /// The media it holds here, in bytes.
223    pub fn media_bytes(&self) -> u64 {
224        self.parts.iter().filter(|p| p.state == "here").map(|p| p.size).sum()
225    }
226}
227
228/// A text to send, as a computer writes it.
229#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
230pub struct Request {
231    /// Random, 32 hex digits: the text's one name everywhere.
232    pub key: String,
233    /// The phone it must leave from (its name in the sharing).
234    pub phone: String,
235    /// The number, as written; the phone keys it.
236    pub to: String,
237    pub body: String,
238    /// The SIM (`sub_id`); -1: the conversation's last, else the phone's default.
239    #[serde(default = "no_sim")]
240    pub sub: i32,
241    /// When it was written (ms, the writer's clock).
242    pub written: i64,
243    /// How far the writer's clock may run ahead of the phone's (ms): the
244    /// least of the gaps the writer saw between the phone's entry's time and
245    /// its own clock when that entry came, over the last day; an upper bound,
246    /// transit included, so a request looks older, never younger. 0 unknown.
247    #[serde(default, skip_serializing_if = "is_zero")]
248    pub ahead: i64,
249    /// The key of the request it sends again, when it does.
250    #[serde(default, skip_serializing_if = "String::is_empty")]
251    pub again: String,
252    /// The device that wrote it (its file's name). Never written.
253    #[serde(skip)]
254    pub device: String,
255}
256
257/// What became of a request, as its phone says it.
258#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
259pub struct Outcome {
260    pub key: String,
261    /// "taken" (claimed, about to be handed to Android), "handed", "sent",
262    /// "delivered", "failed", "expired", "refused", "clocks", "doubt".
263    pub state: String,
264    /// When (ms, the phone's clock).
265    pub at: i64,
266    /// Why, for "failed" and "refused": "radio-off", "no-service", "limit",
267    /// "short-number", "several", "empty", "too-long", "before-mark"…
268    #[serde(default, skip_serializing_if = "String::is_empty")]
269    pub reason: String,
270    /// The provider's row Android filed it as ("sms-123"), when known.
271    #[serde(default, skip_serializing_if = "String::is_empty")]
272    pub row: String,
273    /// The phone that wrote it. Never written.
274    #[serde(skip)]
275    pub device: String,
276}
277
278// ---------------------------------------------------------------- the lines, padded and sealed
279
280/// A value as written: its JSON padded with spaces inside a `pad` field to a
281/// multiple of `PAD_STEP` bytes, then sealed. No line says how long its words are.
282pub fn written<T: Serialize>(value: &T, sealer: &dyn Sealer) -> String {
283    let text = serde_json::to_string(value).unwrap_or_default();
284    sealer.seal(&padded(&text))
285}
286
287/// A JSON object padded inside: `{…,"pad":"   "}`, its length a multiple of `PAD_STEP`.
288pub fn padded(json: &str) -> String {
289    let Some(body) = json.strip_suffix('}') else { return json.to_string() };
290    let joint = if body.ends_with('{') { "" } else { "," };
291    let bare = format!("{body}{joint}\"pad\":\"\"}}");
292    let spaces = (PAD_STEP - bare.len() % PAD_STEP) % PAD_STEP;
293    format!("{body}{joint}\"pad\":\"{}\"}}", " ".repeat(spaces))
294}
295
296/// A device's name as its files name it (a line's `device`).
297pub fn file_name(device: &str) -> String {
298    calls::file_safe(device)
299}
300
301/// "14:02": a moment (ms) as a time of day, in `now`'s zone.
302pub fn time_of_day(at_ms: i64, now: &Zoned) -> String {
303    calls::local(at_ms, now).strftime("%H:%M").to_string()
304}
305
306/// A device's file in one of the stores under `root`: `<store>/<device>.jsonl`.
307pub fn own_file(root: &Path, store: &str, device: &str) -> PathBuf {
308    calls::own_file(root, store, device)
309}
310
311/// Every line of a file that opens and reads, in order; the rest passed over.
312pub fn read_file<T: for<'de> Deserialize<'de>>(path: &Path, sealer: &dyn Sealer) -> Vec<T> {
313    std::fs::read_to_string(path).unwrap_or_default().lines().filter(|l| !l.trim().is_empty()).filter_map(|l| sealer.open(l.trim())).filter_map(|plain| serde_json::from_str(&plain).ok()).collect()
314}
315
316/// Every device's lines of a store under `root`, each with its device.
317fn read_store<T: for<'de> Deserialize<'de>>(root: &Path, store: &str, sealer: &dyn Sealer, mut set: impl FnMut(&mut T, &str)) -> Vec<T> {
318    let mut all = Vec::new();
319    for (device, path) in calls::device_files(&root.join(store)) {
320        for mut value in read_file::<T>(&path, sealer) {
321            set(&mut value, &device);
322            all.push(value);
323        }
324    }
325    all
326}
327
328/// Every phone's texts, oldest first, each once (by phone and row), a
329/// deletion's line folded into its text (`deleted`); the whole history.
330pub fn read_logs(root: &Path, sealer: &dyn Sealer) -> Vec<Text> {
331    let lines: Vec<Text> = read_store(root, LOG, sealer, |t: &mut Text, d| t.device = d.to_string());
332    let deletions: BTreeMap<(String, String), i64> = lines.iter().filter(|t| t.deleted > 0).map(|t| ((t.device.clone(), t.id.clone()), t.deleted)).collect();
333    let mut seen = BTreeSet::new();
334    let mut texts: Vec<Text> = lines
335        .into_iter()
336        .filter(|t| t.deleted == 0 && t.at > 0 && seen.insert((t.device.clone(), t.id.clone())))
337        .map(|t| {
338            let deleted = deletions.get(&(t.device.clone(), t.id.clone())).copied().unwrap_or(0);
339            Text { deleted, ..t }
340        })
341        .collect();
342    texts.sort_by(|a, b| (a.at, &a.device, &a.id).cmp(&(b.at, &b.device, &b.id)));
343    texts
344}
345
346pub fn read_requests(root: &Path, sealer: &dyn Sealer) -> Vec<Request> {
347    read_store(root, SEND, sealer, |r: &mut Request, d| r.device = d.to_string())
348}
349
350pub fn read_outcomes(root: &Path, sealer: &dyn Sealer) -> Vec<Outcome> {
351    let mut all: Vec<Outcome> = read_store(root, OUTCOME, sealer, |o: &mut Outcome, d| o.device = d.to_string());
352    all.sort_by_key(|o| o.at);
353    all
354}
355
356/// Lines added at the end of a device's own file, under its lock, each
357/// written whole (padded, sealed), those whose `id` the file holds already
358/// left out (`id_of`; none: every line written). Returns how many were written.
359pub 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> {
360    if values.is_empty() {
361        return Ok(0);
362    }
363    crate::filelock::with_lock(own, || {
364        let mut known: BTreeSet<String> = match id_of {
365            Some(id) => read_file::<T>(own, sealer).iter().map(id).collect(),
366            None => BTreeSet::new(),
367        };
368        let mut text = String::new();
369        let mut count = 0;
370        for value in values {
371            if let Some(id) = id_of
372                && !known.insert(id(value))
373            {
374                continue;
375            }
376            text.push_str(&written(value, sealer));
377            text.push('\n');
378            count += 1;
379        }
380        if count > 0 {
381            calls::append_private(own, &text)?;
382        }
383        Ok(count)
384    })
385}
386
387/// The time a line says (`at`, else `written`), opened; none when it does not open.
388fn time_of(sealer: &dyn Sealer, line: &str) -> Option<i64> {
389    let value: Value = serde_json::from_str(&sealer.open(line.trim())?).ok()?;
390    value["at"].as_i64().or_else(|| value["written"].as_i64())
391}
392
393/// This device's own requests and outcomes past `KEPT_DAYS` taken out; its
394/// texts never (an archive). A line that does not open stays. Returns how many went.
395pub fn trim_own(root: &Path, device: &str, sealer: &dyn Sealer, now_ms: i64) -> Result<usize, String> {
396    let mut gone = 0;
397    for store in [SEND, OUTCOME] {
398        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))?;
399    }
400    Ok(gone)
401}
402
403/// Another device's requests and outcomes `LATE_DAYS` past their time taken out here: texts never.
404pub fn trim_others(root: &Path, here: &str, sealer: &dyn Sealer, now_ms: i64) -> Result<usize, String> {
405    let mut gone = 0;
406    for store in [SEND, OUTCOME] {
407        for (device, path) in calls::device_files(&root.join(store)) {
408            if device != calls::file_safe(here) {
409                gone += calls::rewrite_lines(&path, |line| time_of(sealer, line).is_some_and(|at| now_ms - at > (KEPT_DAYS + LATE_DAYS) * DAY_MS))?;
410            }
411        }
412    }
413    Ok(gone)
414}
415
416/// What a device keeps: how many texts, and how many bytes of media here (`media`: those whose sealed file is on this device).
417pub fn kept(texts: &[Text], media_here: &dyn Fn(&str) -> bool) -> (usize, u64) {
418    let mut hashes = BTreeSet::new();
419    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();
420    (texts.len(), bytes)
421}
422
423// ---------------------------------------------------------------- the phone's ledger
424
425/// One line of the phone's private ledger: a key, when, what was done.
426#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
427pub struct Claim {
428    pub key: String,
429    /// When (ms, the phone's clock).
430    pub at: i64,
431    /// "claimed" (written before Android is asked), "handed" (Android took
432    /// it), "done" (its result came, or it was looked for), and the decisions
433    /// that end a request: "expired", "refused", "clocks", "before-mark".
434    pub state: String,
435    /// What was sent, to find its row after a crash: the number's key and the words' hash.
436    #[serde(default, skip_serializing_if = "String::is_empty")]
437    pub to: String,
438    #[serde(default, skip_serializing_if = "String::is_empty")]
439    pub hash: String,
440}
441
442/// The ledger's mark: requests written before it are never sent (a ledger lost cannot make them new).
443#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
444pub struct Mark {
445    pub since: i64,
446}
447
448/// The phone's private ledger, never shared: every key it decided, and its mark.
449#[derive(Debug, Clone, Default, PartialEq, Eq)]
450pub struct Ledger {
451    pub mark: Mark,
452    pub claims: Vec<Claim>,
453}
454
455impl Ledger {
456    /// The ledger in `dir` (the phone's private texts folder): its mark made
457    /// now when there is none (a first ledger, or one lost: requests written
458    /// before now are refused).
459    pub fn open(dir: &Path, now_ms: i64) -> Result<Ledger, String> {
460        let mark_path = dir.join(MARK);
461        let mark = match std::fs::read_to_string(&mark_path).ok().and_then(|t| serde_json::from_str::<Mark>(&t).ok()) {
462            Some(mark) => mark,
463            None => {
464                let mark = Mark { since: now_ms };
465                write_flushed(&mark_path, &serde_json::to_string(&mark).map_err(|e| e.to_string())?, false)?;
466                mark
467            }
468        };
469        let claims = std::fs::read_to_string(dir.join(LEDGER)).unwrap_or_default().lines().filter_map(|l| serde_json::from_str(l).ok()).collect();
470        Ok(Ledger { mark, claims })
471    }
472
473    /// Whether a key was decided already, whatever was decided.
474    pub fn has(&self, key: &str) -> bool {
475        self.claims.iter().any(|c| c.key == key)
476    }
477
478    /// A key's last state.
479    pub fn state(&self, key: &str) -> Option<&str> {
480        self.claims.iter().rev().find(|c| c.key == key).map(|c| c.state.as_str())
481    }
482}
483
484/// A line added to the ledger and flushed to the disk before this returns:
485/// the claim exists before Android is asked to send.
486pub fn record(dir: &Path, claim: &Claim) -> Result<(), String> {
487    let line = serde_json::to_string(claim).map_err(|e| e.to_string())? + "\n";
488    write_flushed(&dir.join(LEDGER), &line, true)
489}
490
491/// Written (or appended) and flushed to the disk, yours alone.
492fn write_flushed(path: &Path, text: &str, append: bool) -> Result<(), String> {
493    use std::io::Write;
494    let fail = |e: std::io::Error| format!("{}: {e}", path.display());
495    if let Some(dir) = path.parent() {
496        std::fs::create_dir_all(dir).map_err(fail)?;
497    }
498    let mut options = std::fs::OpenOptions::new();
499    options.create(true).write(true);
500    if append {
501        options.append(true);
502    } else {
503        options.truncate(true);
504    }
505    #[cfg(unix)]
506    std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
507    let mut file = options.open(path).map_err(fail)?;
508    file.write_all(text.as_bytes()).map_err(fail)?;
509    file.sync_all().map_err(fail)
510}
511
512/// The ledger's old keys taken out, `LEDGER_KEPT_MS` on (any request expired
513/// long before; the mark refuses those written before it anyway).
514pub fn trim_ledger(dir: &Path, now_ms: i64) -> Result<usize, String> {
515    calls::rewrite_lines(&dir.join(LEDGER), |line| serde_json::from_str::<Claim>(line).is_ok_and(|c| now_ms - c.at > LEDGER_KEPT_MS))
516}
517
518/// A text's words, hashed as the ledger keeps them (FNV-1a, 16 hex digits).
519pub fn words_hash(body: &str) -> String {
520    crate::appnotes::talk_key("texts", body)
521}
522
523// ---------------------------------------------------------------- never twice
524
525/// What the phone does with a request.
526#[derive(Debug, Clone, Copy, PartialEq, Eq)]
527pub enum Verdict {
528    /// Claim it, then hand it to Android.
529    Send,
530    /// Another phone's: nothing done, nothing said.
531    NotMine,
532    /// Decided already (sent, or not): nothing done again.
533    Decided,
534    /// Written before the ledger's mark: refused, said.
535    BeforeMark,
536    /// Older than `EXPIRY_MS`: not sent, said.
537    Expired,
538    /// Dated ahead of the phone's clock: the clocks disagree; not sent, said.
539    Clocks,
540    /// Not a text Sioul sends from a computer: said why.
541    Refused(&'static str),
542}
543
544/// Why a number or words are refused from a computer, if they are: several
545/// recipients (an MMS group), a short number (premium risk; Android would ask
546/// on the phone), no words, too many. None: it may go.
547pub fn refusal(to: &str, body: &str, region: Option<&Region>) -> Option<&'static str> {
548    if body.trim().is_empty() {
549        return Some("empty");
550    }
551    if body.chars().count() > WRITE_MAX {
552        return Some("too-long");
553    }
554    if to.contains([',', ';']) || to.chars().filter(char::is_ascii_digit).count() > 15 {
555        return Some("several");
556    }
557    let key = phones::key(to, region);
558    if !phones::is_whole(&key) || key.trim_start_matches('+').len() < 8 {
559        return Some("short-number");
560    }
561    None
562}
563
564/// The never-twice decision, step for step: another phone's; decided already
565/// (the ledger holds the key, whatever the sharing read again); refused
566/// (`refusal`); written before the ledger's mark; dated ahead of the phone's
567/// clock; older than `EXPIRY_MS`; else sent. The time written is corrected by
568/// how far the writer's clock may run ahead (`Request::ahead`, measured by
569/// the writer as an upper bound: a request looks older, never younger).
570pub fn verdict(request: &Request, here: &str, now_ms: i64, ledger: &Ledger, region: Option<&Region>) -> Verdict {
571    if request.phone != here || request.key.is_empty() {
572        return Verdict::NotMine;
573    }
574    if ledger.has(&request.key) {
575        return Verdict::Decided;
576    }
577    if let Some(why) = refusal(&request.to, &request.body, region) {
578        return Verdict::Refused(why);
579    }
580    let written = request.written - request.ahead;
581    if written < ledger.mark.since {
582        return Verdict::BeforeMark;
583    }
584    if written > now_ms + AHEAD_MS {
585        return Verdict::Clocks;
586    }
587    if now_ms - written > EXPIRY_MS {
588        return Verdict::Expired;
589    }
590    Verdict::Send
591}
592
593/// After a crash between the claim and Android's word: what to say of a
594/// claim `LOOK_AFTER_MS` old with no result, given whether the provider holds
595/// a sent row Sioul filed for it (`found`: its row). Found: sent. Not found:
596/// "doubt", never sent again.
597pub fn doubt_after(claim: &Claim, found: Option<String>, now_ms: i64) -> Option<Outcome> {
598    if now_ms - claim.at < LOOK_AFTER_MS {
599        return None;
600    }
601    Some(match found {
602        Some(row) => Outcome { key: claim.key.clone(), state: "sent".into(), at: now_ms, reason: String::new(), row, device: String::new() },
603        None => Outcome { key: claim.key.clone(), state: "doubt".into(), at: now_ms, reason: String::new(), row: String::new(), device: String::new() },
604    })
605}
606
607/// A random key, 32 hex digits, from the system's randomness (`/dev/urandom`); the clock's when none.
608pub fn new_key() -> String {
609    use std::io::Read;
610    let mut bytes = [0u8; 16];
611    let read = std::fs::File::open("/dev/urandom").and_then(|mut f| f.read_exact(&mut bytes));
612    if read.is_err() {
613        let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos());
614        bytes = (now ^ u128::from(std::process::id()).rotate_left(64)).to_le_bytes();
615    }
616    bytes.iter().map(|b| format!("{b:02x}")).collect()
617}
618
619// ---------------------------------------------------------------- parts
620
621/// GSM 03.38's basic alphabet: one septet each.
622const GSM_BASIC: &str = "@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !\"#¤%&'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà";
623/// Its extension table: two septets each.
624const GSM_EXTENDED: &str = "^{}\\[~]|€\u{c}";
625
626/// How many parts a text takes, and in which alphabet: GSM's 7 bits (160
627/// characters in one part, 153 in each of several) or UCS-2 (70, 67) as soon
628/// as one character is outside it. Android chooses alike (`divideMessage`).
629pub fn parts(body: &str) -> (usize, bool) {
630    let gsm = body.chars().all(|c| GSM_BASIC.contains(c) || GSM_EXTENDED.contains(c));
631    if gsm {
632        let septets: usize = body.chars().map(|c| if GSM_EXTENDED.contains(c) { 2 } else { 1 }).sum();
633        (if septets <= 160 { usize::from(septets > 0) } else { septets.div_ceil(153) }, true)
634    } else {
635        let units: usize = body.chars().map(char::len_utf16).sum();
636        (if units <= 70 { 1 } else { units.div_ceil(67) }, false)
637    }
638}
639
640// ---------------------------------------------------------------- the view
641
642/// A request's state, as the computer says it.
643#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
644pub struct Said {
645    /// "waiting", "sending", "sent", "delivered", "failed", "expired", "refused", "clocks", "doubt".
646    pub state: String,
647    /// "Sent at 14:03."
648    pub text: String,
649    /// Send again offered (a new key).
650    pub again: bool,
651}
652
653/// What a request's outcomes say: its latest meaningful one (delivered over sent over handed…).
654pub fn state_of<'a>(key: &str, outcomes: &'a [Outcome]) -> Option<&'a Outcome> {
655    let rank = |s: &str| match s {
656        "taken" => 1,
657        "handed" => 2,
658        "doubt" => 3,
659        "sent" => 4,
660        "delivered" => 5,
661        "failed" | "expired" | "refused" | "clocks" => 6,
662        _ => 0,
663    };
664    outcomes.iter().filter(|o| o.key == key).max_by_key(|o| (rank(&o.state), o.at))
665}
666
667/// A request's state in words, never red, never "late": waiting for the phone
668/// (and when it last shared), sending, sent at, delivered at (only when the
669/// carrier said so), not sent and why, expired, may not have been sent.
670pub fn said(outcome: Option<&Outcome>, phone_shared: Option<i64>, tr: &Translator, now: &Zoned) -> Said {
671    let time = |ms: i64| calls::local(ms, now).strftime("%H:%M").to_string();
672    let with = |id: &str, key: &str, value: String| {
673        let mut args = crate::i18n::args();
674        args.set(key, value);
675        tr.text(id, Some(&args))
676    };
677    let (state, text, again) = match outcome {
678        None => match phone_shared {
679            Some(at) => ("waiting", with("texts-waiting-since", "time", time(at)), false),
680            None => ("waiting", tr.text("texts-waiting", None), false),
681        },
682        Some(o) => match o.state.as_str() {
683            "taken" | "handed" => ("sending", tr.text("texts-sending", None), false),
684            "sent" => ("sent", with("texts-sent-at", "time", time(o.at)), false),
685            "delivered" => ("delivered", with("texts-delivered-at", "time", time(o.at)), false),
686            "failed" => ("failed", with("texts-failed", "why", tr.text(&format!("texts-why-{}", known_reason(&o.reason)), None)), true),
687            "expired" => ("expired", tr.text("texts-expired", None), true),
688            "refused" => ("refused", tr.text(&format!("texts-refused-{}", known_refusal(&o.reason)), None), o.reason == "before-mark"),
689            "clocks" => ("clocks", tr.text("texts-clocks", None), true),
690            _ => ("doubt", tr.text("texts-doubt", None), true),
691        },
692    };
693    Said { state: state.into(), text, again }
694}
695
696/// A failure's reason as the words know it.
697fn known_reason(reason: &str) -> &'static str {
698    match reason {
699        "radio-off" => "radio-off",
700        "no-service" => "no-service",
701        "limit" => "limit",
702        "no-sim" => "no-sim",
703        _ => "other",
704    }
705}
706
707/// A refusal's reason as the words know it.
708fn known_refusal(reason: &str) -> &'static str {
709    match reason {
710        "short-number" => "short-number",
711        "several" => "several",
712        "empty" => "empty",
713        "too-long" => "too-long",
714        "before-mark" => "before-mark",
715        _ => "other",
716    }
717}
718
719/// A conversation, as the Texts page lists it.
720#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
721pub struct Conversation {
722    /// Its people's numbers' keys, joined by ",": its name on the page.
723    pub id: String,
724    pub with: Vec<String>,
725    /// Your address books' names, else the numbers as you write them.
726    pub title: String,
727    /// The last message's first words, and when.
728    pub last: String,
729    pub when: String,
730    pub at: i64,
731    pub group: bool,
732}
733
734/// A part of a message, as the page shows it.
735#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
736pub struct Shown {
737    /// "picture", "sound", "video", "card", "file", "text".
738    pub kind: String,
739    pub name: String,
740    /// "A picture (220 KB)"; "Too large to bring here: it is on your phone."; "Your phone has not downloaded it yet."
741    pub said: String,
742    /// Its content's hash, to open it (`texts::call` "media"); "" when it is not here.
743    pub hash: String,
744    pub ct: String,
745}
746
747/// One message of a conversation, or a text being sent.
748#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
749pub struct Message {
750    pub id: String,
751    pub at: i64,
752    /// "09:41", or "Yesterday 09:41".
753    pub when: String,
754    /// Its day, for the thread's separators: "Today", "Yesterday", "Monday 5
755    /// October", the year added when it is another; and its time, "09:41".
756    pub day: String,
757    pub time: String,
758    /// "in" or "out".
759    pub direction: String,
760    /// Who wrote it, in a group.
761    pub from: String,
762    pub body: String,
763    pub picture: bool,
764    pub parts: Vec<Shown>,
765    /// Deleted on the phone, kept here: said quietly.
766    pub deleted: bool,
767    /// A text sent from a computer: its key, and its state.
768    pub key: String,
769    pub said: Option<Said>,
770    /// What it was written to, for Send again.
771    pub to: String,
772}
773
774/// What the view needs besides the lines.
775pub struct Viewer<'a> {
776    pub now: &'a Zoned,
777    pub tr: &'a Translator,
778    pub region: Option<&'static Region>,
779    /// Your address books' name for a number's key.
780    pub name_of: &'a dyn Fn(&str) -> Option<String>,
781    /// When the sending phone last shared (ms), for "waiting since".
782    pub phone_shared: Option<i64>,
783    /// Whether a part's sealed file is on this device.
784    pub media_here: &'a dyn Fn(&str) -> bool,
785}
786
787/// A conversation's id: its people's keys, sorted, joined.
788pub fn conversation_id(with: &[String]) -> String {
789    let mut keys: Vec<&str> = with.iter().map(String::as_str).filter(|k| !k.is_empty()).collect();
790    keys.sort_unstable();
791    keys.dedup();
792    keys.join(",")
793}
794
795/// "Dr Martin", "Dr Martin, Alice", "01 99 00 12 34".
796fn title_of(with: &[String], v: &Viewer) -> String {
797    with.iter().map(|k| (v.name_of)(k).unwrap_or_else(|| calls::shown_number(k, v.region))).collect::<Vec<_>>().join(", ")
798}
799
800/// A moment's day and time, for a thread: ("Today", "14:02"), ("Monday 5
801/// October", "14:02"), ("Friday 3 May 2024", "09:15").
802fn day_and_time(at: i64, v: &Viewer) -> (String, String) {
803    let local = calls::local(at, v.now);
804    let mut day = calls::capitalized(&calls::day_words(v.tr, local.date(), v.now.date()));
805    if local.year() != v.now.year() && local.date() < v.now.date().yesterday().unwrap_or(local.date()) {
806        day = format!("{day} {}", local.year());
807    }
808    (day, local.strftime("%H:%M").to_string())
809}
810
811/// "14:02" today, "Yesterday 14:02", "Monday 5 October 14:02".
812fn when_words(at: i64, v: &Viewer) -> String {
813    let local = calls::local(at, v.now);
814    let time = local.strftime("%H:%M").to_string();
815    if local.date() == v.now.date() {
816        time
817    } else {
818        format!("{} {time}", calls::capitalized(&calls::day_words(v.tr, local.date(), v.now.date())))
819    }
820}
821
822/// What a part is, in a word for the page.
823fn kind_of(ct: &str) -> &'static str {
824    match ct.split('/').next().unwrap_or("") {
825        "image" => "picture",
826        "audio" => "sound",
827        "video" => "video",
828        "text" if ct.contains("vcard") || ct.contains("x-vcard") => "card",
829        "text" => "text",
830        _ if ct.contains("vcard") => "card",
831        _ => "file",
832    }
833}
834
835/// A part as the page shows it: what it is and its size; too large, or not
836/// downloaded, said; on its way when its file has not come yet. The SMIL,
837/// which only lays the message out, and the text parts (in the words) are not shown apart.
838fn shown(part: &Part, v: &Viewer) -> Option<Shown> {
839    if part.ct == "application/smil" || part.state == "text" || (kind_of(&part.ct) == "text" && part.hash.is_empty()) {
840        return None;
841    }
842    let kind = kind_of(&part.ct);
843    let size = crate::view::size(v.tr, usize::try_from(part.size).unwrap_or(usize::MAX));
844    let mut args = crate::i18n::args();
845    args.set("size", size);
846    let (said, hash) = match part.state.as_str() {
847        "too-big" => (v.tr.text("texts-part-too-big", Some(&args)), String::new()),
848        "not-downloaded" => (v.tr.text("texts-part-not-downloaded", None), String::new()),
849        _ if !(v.media_here)(&part.hash) => (v.tr.text("texts-part-coming", Some(&args)), String::new()),
850        _ => (v.tr.text(&format!("texts-part-{kind}"), Some(&args)), part.hash.clone()),
851    };
852    Some(Shown { kind: kind.into(), name: part.name.clone(), said, hash, ct: part.ct.clone() })
853}
854
855/// The conversations, newest first: every text's, and those you wrote to from a computer.
856pub fn conversations(texts: &[Text], requests: &[Request], v: &Viewer) -> Vec<Conversation> {
857    let mut by: BTreeMap<String, Conversation> = BTreeMap::new();
858    let mut add = |with: Vec<String>, at: i64, last: String| {
859        let id = conversation_id(&with);
860        if id.is_empty() {
861            return;
862        }
863        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 });
864        if at >= entry.at {
865            entry.at = at;
866            entry.last = last;
867            entry.when = when_words(at, v);
868        }
869    };
870    for t in texts {
871        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() };
872        add(t.with.clone(), t.at, first);
873    }
874    for r in requests {
875        add(vec![phones::key(&r.to, v.region)], r.written, r.body.chars().take(80).collect());
876    }
877    let mut list: Vec<Conversation> = by.into_values().collect();
878    list.sort_by(|a, b| b.at.cmp(&a.at).then_with(|| a.id.cmp(&b.id)));
879    list
880}
881
882/// A conversation's messages, oldest first: its texts, deleted ones said, and
883/// the texts you wrote to it from a computer with their states; a request
884/// Android filed is said on its row, once.
885pub fn messages(id: &str, texts: &[Text], requests: &[Request], outcomes: &[Outcome], v: &Viewer) -> Vec<Message> {
886    let key_of_row: BTreeMap<&str, &str> = outcomes.iter().filter(|o| !o.row.is_empty()).map(|o| (o.row.as_str(), o.key.as_str())).collect();
887    let mut out: Vec<Message> = Vec::new();
888    let mut shown_keys = BTreeSet::new();
889    for t in texts.iter().filter(|t| conversation_id(&t.with) == id) {
890        let key = if t.key.is_empty() { key_of_row.get(t.id.as_str()).map(|k| k.to_string()).unwrap_or_default() } else { t.key.clone() };
891        let said = (!key.is_empty() && requests.iter().any(|r| r.key == key)).then(|| self::said(state_of(&key, outcomes), v.phone_shared, v.tr, v.now));
892        if !key.is_empty() {
893            shown_keys.insert(key.clone());
894        }
895        let (day, time) = day_and_time(t.at, v);
896        out.push(Message {
897            id: format!("{}/{}", t.device, t.id),
898            at: t.at,
899            when: when_words(t.at, v),
900            day,
901            time,
902            direction: t.direction.clone(),
903            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() },
904            body: t.body.clone(),
905            picture: t.picture,
906            parts: t.parts.iter().filter_map(|p| shown(p, v)).collect(),
907            deleted: t.deleted > 0,
908            key,
909            said,
910            to: String::new(),
911        });
912    }
913    for r in requests.iter().filter(|r| conversation_id(&[phones::key(&r.to, v.region)]) == id && !shown_keys.contains(&r.key)) {
914        let outcome = state_of(&r.key, outcomes);
915        let (day, time) = day_and_time(r.written, v);
916        out.push(Message {
917            id: format!("request/{}", r.key),
918            at: r.written,
919            when: when_words(r.written, v),
920            day,
921            time,
922            direction: "out".into(),
923            from: String::new(),
924            body: r.body.clone(),
925            picture: false,
926            parts: Vec::new(),
927            deleted: false,
928            key: r.key.clone(),
929            said: Some(said(outcome, v.phone_shared, v.tr, v.now)),
930            to: r.to.clone(),
931        });
932    }
933    out.sort_by(|a, b| (a.at, &a.id).cmp(&(b.at, &b.id)));
934    out
935}
936
937/// One text found by `search`, with its conversation.
938#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
939pub struct Found {
940    /// Its conversation's id (`conversation_id`), as `messages` takes it, and its title.
941    pub conversation: String,
942    pub title: String,
943    pub message: Message,
944}
945
946/// The texts holding every word of `query`, case and accents aside, in their
947/// words, a part's name, or their people's names or numbers; the newest
948/// first, `limit` at most. For a search of the archive (the MCP server's).
949pub fn search(query: &str, texts: &[Text], requests: &[Request], outcomes: &[Outcome], v: &Viewer, limit: usize) -> Vec<Found> {
950    let words: Vec<String> = crate::rules::fold(query).split(' ').filter(|w| !w.is_empty()).map(str::to_string).collect();
951    if words.is_empty() {
952        return Vec::new();
953    }
954    // Each conversation's people, said once: names and numbers as written and as keyed.
955    let mut people: BTreeMap<String, (String, String)> = BTreeMap::new();
956    let mut found: Vec<&Text> = texts
957        .iter()
958        .filter(|t| {
959            let id = conversation_id(&t.with);
960            let (_, said) = people.entry(id).or_insert_with(|| {
961                let title = title_of(&t.with, v);
962                let numbers = t.with.iter().map(|k| format!("{k} {}", calls::shown_number(k, v.region))).collect::<Vec<_>>().join(" ");
963                let said = crate::rules::fold(&format!("{title} {numbers}"));
964                (title, said)
965            });
966            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(" "));
967            words.iter().all(|w| own.contains(w.as_str()) || said.contains(w.as_str()))
968        })
969        .collect();
970    found.sort_by(|a, b| b.at.cmp(&a.at).then_with(|| a.id.cmp(&b.id)));
971    found.truncate(limit);
972    found
973        .into_iter()
974        .filter_map(|t| {
975            let id = conversation_id(&t.with);
976            let row = format!("{}/{}", t.device, t.id);
977            let message = messages(&id, std::slice::from_ref(t), requests, outcomes, v).into_iter().find(|m| m.id == row)?;
978            Some(Found { title: people.get(&id).map(|(title, _)| title.clone()).unwrap_or_default(), conversation: id, message })
979        })
980        .collect()
981}
982
983/// The SIM a conversation last used, on the phone that sends: its texts' `sub`, the newest; -1 none.
984pub fn last_sim(id: &str, phone: &str, texts: &[Text]) -> i32 {
985    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)
986}
987
988/// A part's words for its kind: "texts-part-picture" and the others take a `size`.
989pub const PART_KINDS: [&str; 6] = ["picture", "sound", "video", "card", "file", "text"];
990
991#[cfg(test)]
992mod tests {
993    use super::*;
994
995    /// A stand-in for the sealer: reversible, and visibly not plain.
996    struct Stand;
997
998    impl Sealer for Stand {
999        fn seal(&self, plain: &str) -> String {
1000            format!("sealed:{}", plain.bytes().map(|b| format!("{:02x}", b ^ 0x5a)).collect::<String>())
1001        }
1002        fn open(&self, sealed: &str) -> Option<String> {
1003            let hex = sealed.strip_prefix("sealed:")?;
1004            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();
1005            String::from_utf8(bytes?).ok()
1006        }
1007    }
1008
1009    /// Thursday 8 October 2026, 10:00 in Paris.
1010    const NOW: i64 = 1_791_446_400_000;
1011    const MINUTE: i64 = 60_000;
1012
1013    fn fr() -> Option<&'static Region> {
1014        phones::region_named("FR")
1015    }
1016
1017    fn scratch(name: &str) -> PathBuf {
1018        let dir = std::env::temp_dir().join(format!("sioul-texts-{name}-{}", std::process::id()));
1019        let _ = std::fs::remove_dir_all(&dir);
1020        dir
1021    }
1022
1023    fn request(key: &str, phone: &str, written: i64) -> Request {
1024        Request { key: key.into(), phone: phone.into(), to: "01 99 00 12 34".into(), body: "J'arrive dans dix minutes.".into(), sub: -1, written, ahead: 0, again: String::new(), device: "desk".into() }
1025    }
1026
1027    /// The phone's steps, as `texts.rs` in the app takes them: read every
1028    /// request, decide, claim before sending; returns the keys sent.
1029    fn step(dir: &Path, requests: &[Request], here: &str, now: i64, ahead: i64) -> Vec<String> {
1030        let ledger = Ledger::open(dir, now).unwrap();
1031        let mut sent = Vec::new();
1032        for r in requests {
1033            if verdict(&Request { ahead, ..r.clone() }, here, now, &ledger, fr()) == Verdict::Send {
1034                record(dir, &Claim { key: r.key.clone(), at: now, state: "claimed".into(), to: phones::key(&r.to, fr()), hash: words_hash(&r.body) }).unwrap();
1035                sent.push(r.key.clone());
1036            }
1037        }
1038        sent
1039    }
1040
1041    #[test]
1042    fn a_request_is_sent_once_whatever_is_read_again() {
1043        let dir = scratch("once");
1044        Ledger::open(&dir, NOW - 10 * MINUTE).unwrap();
1045        let r = request("a1", "phone", NOW - MINUTE);
1046        assert_eq!(step(&dir, std::slice::from_ref(&r), "phone", NOW, 0), ["a1"]);
1047        for later in [NOW + MINUTE, NOW + 2 * MINUTE] {
1048            assert!(step(&dir, &[r.clone(), r.clone()], "phone", later, 0).is_empty());
1049        }
1050        assert_eq!(Ledger::open(&dir, NOW).unwrap().state("a1"), Some("claimed"));
1051        let _ = std::fs::remove_dir_all(&dir);
1052    }
1053
1054    #[test]
1055    fn a_lost_ledger_s_mark_refuses_the_requests_written_before_it() {
1056        let dir = scratch("lost");
1057        Ledger::open(&dir, NOW - 10 * MINUTE).unwrap();
1058        let r = request("b1", "phone", NOW - 3 * MINUTE);
1059        assert_eq!(step(&dir, std::slice::from_ref(&r), "phone", NOW - 2 * MINUTE, 0), ["b1"]);
1060        std::fs::remove_dir_all(&dir).unwrap();
1061        let ledger = Ledger::open(&dir, NOW).unwrap();
1062        assert_eq!(verdict(&r, "phone", NOW, &ledger, fr()), Verdict::BeforeMark, "within its 15 minutes, still never again");
1063        assert!(step(&dir, &[r], "phone", NOW, 0).is_empty());
1064        assert_eq!(step(&dir, &[request("b2", "phone", NOW + MINUTE)], "phone", NOW + MINUTE, 0), ["b2"]);
1065        let _ = std::fs::remove_dir_all(&dir);
1066    }
1067
1068    #[test]
1069    fn two_phones_each_send_only_their_own_and_two_computers_both_send() {
1070        let (a, b) = (scratch("phone-a"), scratch("phone-b"));
1071        Ledger::open(&a, NOW - 10 * MINUTE).unwrap();
1072        Ledger::open(&b, NOW - 10 * MINUTE).unwrap();
1073        let from_desk = request("c1", "phone-a", NOW - MINUTE);
1074        let from_laptop = Request { device: "laptop".into(), ..request("c2", "phone-a", NOW - MINUTE) };
1075        let to_b = request("c3", "phone-b", NOW - MINUTE);
1076        let all = [from_desk, from_laptop, to_b];
1077        assert_eq!(step(&a, &all, "phone-a", NOW, 0), ["c1", "c2"], "two computers, two keys, two texts");
1078        assert_eq!(step(&b, &all, "phone-b", NOW, 0), ["c3"]);
1079        let _ = (std::fs::remove_dir_all(&a), std::fs::remove_dir_all(&b));
1080    }
1081
1082    #[test]
1083    fn an_expired_request_and_clocks_apart() {
1084        let dir = scratch("expired");
1085        Ledger::open(&dir, NOW - DAY_MS).unwrap();
1086        let ledger = Ledger::open(&dir, NOW).unwrap();
1087        assert_eq!(verdict(&request("d1", "phone", NOW - 16 * MINUTE), "phone", NOW, &ledger, fr()), Verdict::Expired);
1088        assert_eq!(verdict(&request("d2", "phone", NOW - 14 * MINUTE), "phone", NOW, &ledger, fr()), Verdict::Send);
1089        // The computer's clock an hour ahead, as it measured: written "15:00" is 14:00 here.
1090        let ahead = 60 * MINUTE;
1091        assert_eq!(verdict(&Request { ahead, ..request("d3", "phone", NOW + ahead - 20 * MINUTE) }, "phone", NOW, &ledger, fr()), Verdict::Expired);
1092        assert_eq!(verdict(&Request { ahead, ..request("d4", "phone", NOW + ahead - 5 * MINUTE) }, "phone", NOW, &ledger, fr()), Verdict::Send);
1093        // Not measured, and dated well ahead: the clocks disagree, never sent late.
1094        assert_eq!(verdict(&request("d5", "phone", NOW + 30 * MINUTE), "phone", NOW, &ledger, fr()), Verdict::Clocks);
1095        let _ = std::fs::remove_dir_all(&dir);
1096    }
1097
1098    #[test]
1099    fn short_numbers_groups_and_empty_words_are_refused() {
1100        let ledger = Ledger { mark: Mark { since: 0 }, claims: Vec::new() };
1101        let with = |to: &str, body: &str| verdict(&Request { to: to.into(), body: body.into(), ..request("e", "phone", NOW) }, "phone", NOW, &ledger, fr());
1102        assert_eq!(with("36179", "STOP"), Verdict::Refused("short-number"));
1103        assert_eq!(with("01 99 00 12 34, 04 65 71 00 42", "Bonjour"), Verdict::Refused("several"));
1104        assert_eq!(with("01 99 00 12 34", "   "), Verdict::Refused("empty"));
1105        assert_eq!(with("01 99 00 12 34", &"x".repeat(WRITE_MAX + 1)), Verdict::Refused("too-long"));
1106        assert_eq!(with("+33 1 99 00 12 34", "Bonjour"), Verdict::Send);
1107    }
1108
1109    #[test]
1110    fn a_crash_between_the_claim_and_the_result_is_said_never_sent_again() {
1111        let claim = Claim { key: "f1".into(), at: NOW, state: "claimed".into(), to: "+33199001234".into(), hash: words_hash("Bonjour") };
1112        assert!(doubt_after(&claim, None, NOW + MINUTE).is_none(), "Android's word may still come");
1113        let found = doubt_after(&claim, Some("sms-412".into()), NOW + 3 * MINUTE).unwrap();
1114        assert_eq!((found.state.as_str(), found.row.as_str()), ("sent", "sms-412"));
1115        assert_eq!(doubt_after(&claim, None, NOW + 3 * MINUTE).unwrap().state, "doubt");
1116        let ledger = Ledger { mark: Mark { since: 0 }, claims: vec![claim] };
1117        assert_eq!(verdict(&request("f1", "phone", NOW + 3 * MINUTE), "phone", NOW + 3 * MINUTE, &ledger, fr()), Verdict::Decided);
1118    }
1119
1120    #[test]
1121    fn the_ledger_is_flushed_before_android_is_asked_and_trimmed_after_a_month() {
1122        let dir = scratch("ledger");
1123        record(&dir, &Claim { key: "g1".into(), at: NOW - 31 * DAY_MS, state: "done".into(), ..Claim::default() }).unwrap();
1124        record(&dir, &Claim { key: "g2".into(), at: NOW, state: "claimed".into(), ..Claim::default() }).unwrap();
1125        assert!(std::fs::read_to_string(dir.join(LEDGER)).unwrap().contains("\"g2\""));
1126        assert_eq!(trim_ledger(&dir, NOW).unwrap(), 1);
1127        let ledger = Ledger::open(&dir, NOW).unwrap();
1128        assert!(!ledger.has("g1") && ledger.has("g2"));
1129        let _ = std::fs::remove_dir_all(&dir);
1130    }
1131
1132    fn message(id: &str, at: i64, body: &str) -> Text {
1133        Text { id: id.into(), at, thread: "7".into(), direction: "in".into(), with: vec!["+33199001234".into()], body: body.into(), sub: 1, ..Text::default() }
1134    }
1135
1136    #[test]
1137    fn the_history_is_sealed_padded_kept_whole_and_a_deletion_marks_it() {
1138        let dir = scratch("lines");
1139        let own = own_file(&dir, LOG, "phone");
1140        let id = |t: &Text| t.line_id();
1141        let old = NOW - 3 * 365 * DAY_MS;
1142        assert_eq!(append(&own, &[message("sms-1", old, "Bonjour"), message("sms-2", NOW, "Votre rendez-vous est déplacé.")], &Stand, Some(&id)).unwrap(), 2);
1143        assert_eq!(append(&own, &[message("sms-2", NOW, "Votre rendez-vous est déplacé.")], &Stand, Some(&id)).unwrap(), 0, "read again: once");
1144        let raw = std::fs::read_to_string(&own).unwrap();
1145        assert!(!raw.contains("Bonjour") && !raw.contains("199001234"), "sealed at rest");
1146        for line in raw.lines() {
1147            assert_eq!(Stand.open(line).unwrap().len() % PAD_STEP, 0);
1148        }
1149        // Three years old, and still there: no window, no trim by age.
1150        let later = NOW + 400 * DAY_MS;
1151        assert_eq!(trim_own(&dir, "phone", &Stand, later).unwrap() + trim_others(&dir, "desk", &Stand, later).unwrap(), 0);
1152        assert_eq!(read_logs(&dir, &Stand).len(), 2);
1153        // Deleted on the phone: a line of its own; the text stays, marked.
1154        let deletion = Text { deleted: NOW + MINUTE, ..message("sms-1", old, "") };
1155        assert_eq!(append(&own, &[deletion], &Stand, Some(&id)).unwrap(), 1);
1156        let back = read_logs(&dir, &Stand);
1157        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)]);
1158        let _ = std::fs::remove_dir_all(&dir);
1159    }
1160
1161    #[test]
1162    fn parts_are_counted_as_android_counts_them() {
1163        assert_eq!(parts(""), (0, true));
1164        assert_eq!(parts("Bonjour, à demain !"), (1, true));
1165        assert_eq!(parts(&"a".repeat(160)), (1, true));
1166        assert_eq!(parts(&"a".repeat(161)), (2, true));
1167        assert_eq!(parts(&"€".repeat(80)), (1, true), "two septets each: 160");
1168        assert_eq!(parts(&"€".repeat(81)), (2, true));
1169        assert_eq!(parts("Ça va ? 🙂"), (1, false), "an emoji: UCS-2");
1170        assert_eq!(parts(&"ç".repeat(71)), (2, false), "ç is no GSM letter (Ç is)");
1171    }
1172
1173    /// A thread's separators: each message's day in words, the year only when another, and its time.
1174    #[test]
1175    fn a_thread_says_each_message_s_day_and_time() {
1176        let tr = Translator::new("en");
1177        let now = paris(NOW);
1178        let names = |_: &str| None;
1179        let here = |_: &str| false;
1180        let v = viewer(&now, &tr, &names, &here);
1181        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() };
1182        let texts = [row("sms-1", NOW - 400 * 86_400_000), row("sms-2", NOW - 86_400_000), row("sms-3", NOW - MINUTE)];
1183        let said: Vec<(String, String)> = messages("+33199001234", &texts, &[], &[], &v).into_iter().map(|m| (m.day, m.time)).collect();
1184        assert_eq!(said[1], ("Yesterday".to_string(), "10:00".to_string()));
1185        assert_eq!(said[2], ("Today".to_string(), "09:59".to_string()));
1186        assert!(said[0].0.ends_with(" 2025") && said[0].0.contains("September"), "another year said: {:?}", said[0]);
1187    }
1188
1189    #[test]
1190    fn the_archive_is_searched_by_words_names_and_numbers_newest_first() {
1191        let tr = Translator::new("en");
1192        let now = paris(NOW);
1193        let names = |k: &str| (k == "+33199001234").then(|| "Pharmacie Sainte-Hélène".to_string());
1194        let here = |_: &str| false;
1195        let v = viewer(&now, &tr, &names, &here);
1196        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() };
1197        let texts = [
1198            row("sms-1", NOW - 3 * MINUTE, "+33199001234", "Votre ordonnance est prête."),
1199            row("sms-2", NOW - 2 * MINUTE, "+33465710042", "Le colis est arrivé au relais."),
1200            row("sms-3", NOW - MINUTE, "+33199001234", "Ordonnance renouvelée, à retirer demain."),
1201        ];
1202        let ids = |query: &str, limit: usize| search(query, &texts, &[], &[], &v, limit).into_iter().map(|f| f.message.id).collect::<Vec<_>>();
1203        assert_eq!(ids("ORDONNANCE", 10), ["phone/sms-3", "phone/sms-1"], "case aside, newest first");
1204        assert_eq!(ids("arrive", 10), ["phone/sms-2"], "accents aside");
1205        assert_eq!(ids("helene pret", 10), ["phone/sms-1"], "a name and a word, both");
1206        assert_eq!(ids("04 65 71", 10), ["phone/sms-2"], "a number as written");
1207        assert_eq!(ids("ordonnance", 1), ["phone/sms-3"]);
1208        assert!(ids("  ", 10).is_empty());
1209        let found = &search("colis", &texts, &[], &[], &v, 10)[0];
1210        assert_eq!((found.conversation.as_str(), found.title.as_str()), ("+33465710042", "04 65 71 00 42"));
1211    }
1212
1213    fn viewer<'a>(now: &'a Zoned, tr: &'a Translator, names: &'a dyn Fn(&str) -> Option<String>, here: &'a dyn Fn(&str) -> bool) -> Viewer<'a> {
1214        Viewer { now, tr, region: fr(), name_of: names, phone_shared: Some(NOW - 2 * MINUTE), media_here: here }
1215    }
1216
1217    fn paris(ms: i64) -> Zoned {
1218        jiff::Timestamp::from_millisecond(ms).unwrap().to_zoned(jiff::tz::TimeZone::get("Europe/Paris").unwrap())
1219    }
1220
1221    #[test]
1222    fn the_page_shows_conversations_and_each_request_s_state_in_words() {
1223        let tr = Translator::new("en");
1224        let now = paris(NOW);
1225        let names = |k: &str| (k == "+33199001234").then(|| "Dr Martin".to_string());
1226        let here = |_: &str| true;
1227        let v = viewer(&now, &tr, &names, &here);
1228        let texts = vec![
1229            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() },
1230            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() },
1231            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() },
1232        ];
1233        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) }];
1234        let outcomes = vec![
1235            Outcome { key: "k1".into(), state: "handed".into(), at: NOW - 5 * MINUTE, ..Outcome::default() },
1236            Outcome { key: "k1".into(), state: "sent".into(), at: NOW - 5 * MINUTE, row: "sms-9".into(), ..Outcome::default() },
1237            Outcome { key: "k3".into(), state: "expired".into(), at: NOW - 2 * MINUTE, ..Outcome::default() },
1238        ];
1239        let list = conversations(&texts, &requests, &v);
1240        assert_eq!(list.iter().map(|c| c.title.as_str()).collect::<Vec<_>>(), ["Dr Martin", "04 65 71 00 42"]);
1241        let shown = messages("+33199001234", &texts, &requests, &outcomes, &v);
1242        assert_eq!(shown.iter().map(|m| (m.direction.as_str(), m.key.as_str())).collect::<Vec<_>>(), [("in", ""), ("out", "k1"), ("out", "k2")]);
1243        assert_eq!(shown[1].said.as_ref().unwrap().text, "Sent at 09:55.");
1244        assert_eq!(shown[2].said.as_ref().unwrap().text, "Waiting for your phone (it last shared at 09:58).");
1245        let other = messages("+33465710042", &texts, &requests, &outcomes, &v);
1246        let expired = other.iter().find(|m| m.key == "k3").unwrap().said.clone().unwrap();
1247        assert!(expired.again && expired.state == "expired", "{expired:?}");
1248        assert_eq!(last_sim("+33199001234", "phone", &texts), 2);
1249    }
1250
1251    #[test]
1252    fn a_message_s_media_are_said_with_their_sizes_or_why_they_are_not_here() {
1253        let tr = Translator::new("en");
1254        let now = paris(NOW);
1255        let names = |_: &str| None;
1256        let here = |hash: &str| hash == "aa";
1257        let v = viewer(&now, &tr, &names, &here);
1258        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() };
1259        let mms = Text {
1260            id: "mms-31".into(),
1261            at: NOW - MINUTE,
1262            direction: "in".into(),
1263            with: vec!["+33465710042".into(), "+33199001234".into()],
1264            from: "+33465710042".into(),
1265            body: "Le colis.".into(),
1266            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")],
1267            device: "phone".into(),
1268            ..Text::default()
1269        };
1270        let shown = messages(&conversation_id(&mms.with), std::slice::from_ref(&mms), &[], &[], &v);
1271        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();
1272        assert_eq!(said.len(), 4, "the SMIL and the words are not shown apart: {said:?}");
1273        assert_eq!(said[0].0, "picture");
1274        assert!(said[0].2, "a picture here opens");
1275        assert!(said[1].1.contains("on your phone") && !said[1].2, "{said:?}");
1276        assert!(said[2].1.contains("its way") && !said[2].2, "its file not come yet: {said:?}");
1277        assert!(said[3].1.contains("not downloaded"), "{said:?}");
1278        assert_eq!(shown[0].from, "04 65 71 00 42", "a group says who wrote");
1279        assert_eq!(kept(std::slice::from_ref(&mms), &here), (1, 220_000), "only what is here is counted");
1280    }
1281
1282    #[test]
1283    fn delivered_is_said_only_when_the_carrier_says_so() {
1284        let tr = Translator::new("en");
1285        let now = paris(NOW);
1286        let sent = [Outcome { key: "h1".into(), state: "sent".into(), at: NOW + MINUTE, ..Outcome::default() }];
1287        assert_eq!(said(state_of("h1", &sent), None, &tr, &now).state, "sent", "no report: sent, never 'not delivered'");
1288        let delivered = [sent[0].clone(), Outcome { key: "h1".into(), state: "delivered".into(), at: NOW + 2 * MINUTE, ..Outcome::default() }];
1289        assert_eq!(said(state_of("h1", &delivered), None, &tr, &now).text, "Delivered at 10:02.");
1290    }
1291}