Skip to main content

sioul_app/
texts.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Texts read and sent through your phone, as this device does its part
5//! (docs/texts.md; the rules are `sioul_core::texts`, the reading and the
6//! sending Java's: android/…/Texts.java, TextSend.java).
7//!
8//! - **On the phone, at each exchange** (`light_step`, under one lock for its
9//!   processes): Android's word on each text sent copied into its outcomes;
10//!   each request decided (`texts::verdict`), claimed in the private ledger,
11//!   flushed, before Java hands it to Android; a claim with no word from
12//!   Android looked for among the texts Sioul sent, else said in doubt.
13//! - **On the phone, never inside an exchange** (`heavy_step`: after the
14//!   background service's exchange, or in a thread of the window's): the
15//!   whole history read a batch at a time (`BATCH` texts, `MEDIA_BATCH`
16//!   bytes of media put as sealed blobs), every column kept, while the phone
17//!   charges; then what the phone's texts changed, when they did
18//!   (`import_now`); the texts deleted on the phone marked so, once a day;
19//!   the sizes the settings say.
20//! - **On a computer**: the Texts page (`view`, `conversation`), a text
21//!   written for the phone to send (`send`, `again`); the media's blobs
22//!   brought and kept sealed (`fetch_media`, in a thread, never inside an
23//!   exchange); how far this computer's clock may run ahead of the phone's.
24//! - Every line, and every media file kept here, sealed at rest with a key
25//!   made from the sharing key (`sioul_sync::textseal`): no sharing key here,
26//!   nothing is read or written.
27
28use crate::backend::{QtThread, load_config, tr};
29use cxx_qt_lib::QString;
30use jiff::Zoned;
31use serde::{Deserialize, Serialize};
32use serde_json::{Value, json};
33use sioul_core::config::{Config, config_dir, state_dir};
34use sioul_core::phones;
35use sioul_core::porch::Senders;
36use sioul_core::texts::{self as rules, Claim, Outcome, Part, Request, Text, Verdict};
37use sioul_sync::textseal::TextSeal;
38use std::collections::{BTreeMap, BTreeSet};
39use std::path::{Path, PathBuf};
40use std::sync::Mutex;
41use std::sync::atomic::{AtomicBool, Ordering};
42
43/// Whether this device reads and sends texts: a phone. SIOUL_TEXTS makes a computer's step run too, to look at it.
44fn phone() -> bool {
45    cfg!(target_os = "android") || std::env::var_os("SIOUL_TEXTS").is_some()
46}
47
48/// The demo profile (`SIOUL_DEMO=1`, which tools/demo/run.sh sets in its
49/// sandbox with no network, and nothing else does): its texts are stand-ins
50/// written plain by tools/demo/make-demo.py, its phone a stand-in.
51fn demo() -> bool {
52    std::env::var_os("SIOUL_DEMO").is_some()
53}
54
55/// What seals the lines and the media: the sharing key's, or the demo's stand-in (plain; a line marked).
56enum Seal {
57    Key(TextSeal),
58    Demo,
59}
60
61impl rules::Sealer for Seal {
62    fn seal(&self, plain: &str) -> String {
63        match self {
64            Seal::Key(seal) => seal.seal(plain),
65            Seal::Demo => format!("demo:{plain}"),
66        }
67    }
68
69    fn open(&self, sealed: &str) -> Option<String> {
70        match self {
71            Seal::Key(seal) => seal.open(sealed),
72            Seal::Demo => sealed.strip_prefix("demo:").map(str::to_string),
73        }
74    }
75}
76
77impl Seal {
78    fn seal_bytes(&self, plain: &[u8]) -> Vec<u8> {
79        match self {
80            Seal::Key(seal) => seal.seal_bytes(plain),
81            Seal::Demo => plain.to_vec(),
82        }
83    }
84
85    fn open_bytes(&self, sealed: &[u8]) -> Option<Vec<u8>> {
86        match self {
87            Seal::Key(seal) => seal.open_bytes(sealed),
88            Seal::Demo => Some(sealed.to_vec()),
89        }
90    }
91}
92
93/// The sharing's folder and key: where the media's blobs go and come from.
94type Vault = (PathBuf, [u8; 32]);
95
96/// This device's name in the sharing, the sealer, and the sharing's folder
97/// and key, while the part "texts" is on here (the demo: no folder).
98fn sealed() -> Option<(String, Seal, Option<Vault>)> {
99    if demo() && !phone() {
100        return Some(("demo-desk".into(), Seal::Demo, None));
101    }
102    let (here, vault) = crate::share::vault()?;
103    let (folder, key) = vault?;
104    crate::share::shares(rules::PART).then(|| (here, Seal::Key(TextSeal::new(&key)), Some((folder, key))))
105}
106
107fn root() -> PathBuf {
108    rules::folder()
109}
110
111fn private() -> PathBuf {
112    rules::private_folder()
113}
114
115fn now_ms() -> i64 {
116    jiff::Timestamp::now().as_millisecond()
117}
118
119/// A private file written whole beside, then put in place.
120fn write_whole(path: &Path, bytes: &[u8]) -> Result<(), String> {
121    let fail = |e: std::io::Error| format!("{}: {e}", path.display());
122    if let Some(dir) = path.parent() {
123        std::fs::create_dir_all(dir).map_err(fail)?;
124    }
125    let fresh = path.with_extension(format!("{}.new", std::process::id()));
126    std::fs::write(&fresh, bytes).map_err(fail)?;
127    std::fs::rename(&fresh, path).map_err(|e| {
128        let _ = std::fs::remove_file(&fresh);
129        fail(e)
130    })
131}
132
133// ---------------------------------------------------------------- this device's choices
134
135/// This device's own texts settings (`texts.toml` in the configuration, never
136/// shared): on a computer, the phone that sends; on a phone, the largest
137/// media file it brings to your computers (MB; none: `MEDIA_CAP_MB`).
138#[derive(Debug, Clone, Default, Serialize, Deserialize)]
139struct Choice {
140    #[serde(default, skip_serializing_if = "String::is_empty")]
141    phone: String,
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    media_cap_mb: Option<u64>,
144    /// On a computer, the most a multimedia message from here may weigh,
145    /// below the carrier's (kB: 300, for older phones and some networks
146    /// abroad); none, the carrier's own (Settings ▸ Texts).
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    mms_kb: Option<u64>,
149}
150
151fn choice_path() -> PathBuf {
152    config_dir().join("texts.toml")
153}
154
155fn choice() -> Choice {
156    std::fs::read_to_string(choice_path()).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
157}
158
159/// The sizes offered for the largest media file (MB): the blobs take 64 MB at most.
160const CAPS_MB: [u64; 5] = [1, 5, 10, 25, 50];
161
162/// The largest media file brought to your computers, in MB.
163fn cap_mb() -> u64 {
164    choice().media_cap_mb.unwrap_or(rules::MEDIA_CAP_MB).clamp(1, sioul_sync::blobs::LARGEST >> 20)
165}
166
167// ---------------------------------------------------------------- the phone: what Android is asked
168
169/// What the import asks Android (Java's `Texts.call`, through
170/// `steps::java`): the phone's own provider of texts; a stand-in in tests.
171trait Android {
172    fn ask(&self, verb: &str, asked: &Value) -> Value;
173}
174
175/// This phone's Android.
176struct Phone;
177
178impl Android for Phone {
179    fn ask(&self, verb: &str, asked: &Value) -> Value {
180        crate::steps::java(verb, &asked.to_string())
181    }
182}
183
184/// Where the import writes: this phone's log, its reader, the media copied from Android for a moment.
185struct Files {
186    log: PathBuf,
187    reader: PathBuf,
188    incoming: PathBuf,
189}
190
191impl Files {
192    /// This phone's: its log in the data folder, the rest in the private texts folder.
193    fn of(here: &str) -> Files {
194        Files { log: rules::own_file(&root(), rules::LOG, here), reader: Reader::path(), incoming: private().join("incoming") }
195    }
196}
197
198// ---------------------------------------------------------------- the phone: the whole history
199
200/// Rows of the provider's two tables (`texts::Row`), as `reader.json` keeps
201/// them: `[id, date]` each, in order.
202#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(default)]
204struct Rows {
205    sms: Vec<[i64; 2]>,
206    mms: Vec<[i64; 2]>,
207}
208
209impl Rows {
210    fn of(rows: impl IntoIterator<Item = rules::Row>) -> Rows {
211        let mut out = Rows::default();
212        for row in rows {
213            if row.mms {
214                out.mms.push([row.id, row.date]);
215            } else {
216                out.sms.push([row.id, row.date]);
217            }
218        }
219        out.sms.sort_unstable();
220        out.sms.dedup();
221        out.mms.sort_unstable();
222        out.mms.dedup();
223        out
224    }
225
226    fn set(&self) -> BTreeSet<rules::Row> {
227        let rows = |mms: bool, list: &[[i64; 2]]| list.iter().map(|[id, date]| rules::Row { mms, id: *id, date: *date }).collect::<Vec<_>>();
228        rows(false, &self.sms).into_iter().chain(rows(true, &self.mms)).collect()
229    }
230
231    fn len(&self) -> usize {
232        self.sms.len() + self.mms.len()
233    }
234
235    fn is_empty(&self) -> bool {
236        self.len() == 0
237    }
238}
239
240/// The reader's state and the import's measures, private to the phone
241/// (`texts/reader.json` in the state folder): the rows its log holds, those
242/// still to read, when the import began, what the phone holds and how much
243/// has gone.
244#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
245#[serde(default)]
246struct Reader {
247    /// `READER_VERSION`; 0, the first version's (30 days, before the
248    /// archive); 2, marks of the ids read, oldest first; 3, ranges of the
249    /// ids read, newest first. Both knew a row by its id alone, which the
250    /// plain texts' table gives again to the next text once the newest one
251    /// was deleted: such a text was never read. Version 4 knows each row by
252    /// its id and its date (`texts::Row`).
253    version: u32,
254    /// The rows this phone's log holds (texts deleted on the phone among
255    /// them), made from the log itself when `indexed` is false (a reader of
256    /// an older version, or one lost), and again at each day's look.
257    logged: Rows,
258    indexed: bool,
259    /// Rows that appeared since a step and are not read yet: read at the
260    /// next steps, on the battery too.
261    arrived: Rows,
262    /// The history still to read (a first import, or a block of old texts
263    /// put at once): the newest first by date, all of it while the phone
264    /// charges, its newest meanwhile.
265    backlog: Rows,
266    /// The newest text read (ms): on the battery, the history is read only above it.
267    newest_at: i64,
268    /// When the history was found (ms): a block of old texts, or what the
269    /// log of a reader of an older version lacked; for a first import,
270    /// `started`. On the battery, nothing older than a day before it is read.
271    since: i64,
272    started: i64,
273    /// What the phone holds, as last measured (texts; media bytes), and when.
274    total_texts: u64,
275    total_media: u64,
276    measured: i64,
277    /// What has gone to your computers: the texts the log holds, the media bytes put in the sharing.
278    done_texts: u64,
279    done_media: u64,
280    /// When the deletions were last looked for.
281    looked: i64,
282    /// A history waits (`backlog`): all of it only while the phone charges
283    /// (`import_now`), its newest meanwhile (`recent_now`).
284    behind: bool,
285    /// Rows that came wait still (`arrived`: their media over a step's
286    /// share): the next step reads on, on the battery too.
287    left: bool,
288}
289
290/// The reader of the archive. The first version read 30 days: its marks
291/// would pass the history, so its reader starts again from the first row.
292/// The second and third knew rows by their ids alone: the log tells which
293/// rows it holds (`Reader::index`), and what it lacks is read as a history.
294const READER_VERSION: u32 = 4;
295/// How many multimedia messages, at most, a step reads beside the texts.
296const MMS_SHARE: usize = 100;
297/// A day, in milliseconds.
298const DAY_MS: i64 = 86_400_000;
299
300impl Reader {
301    fn path() -> PathBuf {
302        private().join(rules::READER)
303    }
304
305    fn load() -> Reader {
306        Reader::load_at(&Reader::path())
307    }
308
309    fn load_at(path: &Path) -> Reader {
310        let fresh = || Reader { version: READER_VERSION, ..Reader::default() };
311        match std::fs::read_to_string(path).ok().and_then(|t| serde_json::from_str::<Reader>(&t).ok()) {
312            Some(reader) if reader.version == READER_VERSION => reader,
313            // Its rows known by their ids alone: at the next step the log
314            // tells which it holds, and what it lacks is read as a history.
315            Some(reader) if reader.version == 2 || reader.version == 3 => Reader { version: READER_VERSION, indexed: false, logged: Rows::default(), arrived: Rows::default(), backlog: Rows::default(), ..reader },
316            Some(_) => {
317                // The first version's stores, in the state folder then: sealed copies of 30 days, read again whole now.
318                if let Some(dir) = path.parent() {
319                    for old in ["log", "send", "outcome", "ask", "answer"] {
320                        let _ = std::fs::remove_dir_all(dir.join(old));
321                    }
322                }
323                eprintln!("sioul: texts: the reader starts again from the first text, for the archive");
324                fresh()
325            }
326            None => fresh(),
327        }
328    }
329
330    fn save(&self) -> Result<(), String> {
331        self.save_at(&Reader::path())
332    }
333
334    fn save_at(&self, path: &Path) -> Result<(), String> {
335        write_whole(path, serde_json::to_string(self).map_err(|e| e.to_string())?.as_bytes())
336    }
337
338    /// What `texts-ids` measured: how many texts, how many media bytes.
339    fn measure(&mut self, answer: &Value, now: i64) {
340        let count = |name: &str| answer[name].as_array().map_or(0, Vec::len) as u64;
341        self.total_texts = count("sms") + count("mms");
342        self.total_media = answer["media"].as_u64().unwrap_or(0);
343        self.measured = now;
344    }
345
346    /// What the log holds, read from the log itself: its rows, its newest
347    /// text, how many texts and media bytes have gone (never a count a step
348    /// cut short left behind).
349    fn index(&mut self, log: &[Text]) {
350        let texts: Vec<&Text> = log.iter().filter(|t| t.deleted == 0).collect();
351        self.logged = Rows::of(texts.iter().filter_map(|t| t.row()));
352        self.newest_at = texts.iter().map(|t| t.at).max().unwrap_or(0);
353        self.done_texts = self.logged.len() as u64;
354        self.done_media = texts.iter().map(|t| t.media_bytes()).sum();
355        self.indexed = true;
356    }
357}
358
359/// Every row the phone holds, from a `texts-ids` answer: each id with its
360/// own date (`sms_at`, `mms_at`). None when the dates did not come with the
361/// ids: then nothing is read for this step, never every row taken as new.
362fn listed(answer: &Value) -> Option<BTreeSet<rules::Row>> {
363    let mut out = BTreeSet::new();
364    for (mms, ids, dates) in [(false, "sms", "sms_at"), (true, "mms", "mms_at")] {
365        let (ids, dates) = (answer[ids].as_array()?, answer[dates].as_array()?);
366        if ids.len() != dates.len() {
367            return None;
368        }
369        for (id, date) in ids.iter().zip(dates) {
370            out.insert(rules::Row { mms, id: id.as_i64()?, date: date.as_i64()? });
371        }
372    }
373    Some(out)
374}
375
376/// A row as Java read it (Texts.sms, Texts.mms; android/jvm-checks/TextsCheck.java): every column.
377#[derive(Debug, Clone, Default, Deserialize)]
378#[serde(default)]
379struct Raw {
380    kind: String,
381    fields: BTreeMap<String, Value>,
382    addrs: Vec<BTreeMap<String, Value>>,
383    parts: Vec<BTreeMap<String, Value>>,
384}
385
386fn int(fields: &BTreeMap<String, Value>, name: &str) -> i64 {
387    fields.get(name).and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))).unwrap_or(0)
388}
389
390fn string(fields: &BTreeMap<String, Value>, name: &str) -> String {
391    fields.get(name).and_then(Value::as_str).unwrap_or_default().to_string()
392}
393
394/// A multimedia message's address types (`addr.type`): who sent it, to whom.
395const MMS_FROM: i64 = 137;
396const MMS_TO: i64 = 151;
397/// A multimedia message still to download (`m_type`: Android has only its notification).
398const MMS_NOTIFICATION: i64 = 130;
399/// What Android writes for "this phone" in a message's addresses.
400const OWN_ADDRESS: &str = "insert-address-token";
401
402/// A part as it travels: its words in the line; its content said, to be put
403/// in the sharing (`bring_media`) when it is not over `cap` bytes.
404fn part_of(columns: &BTreeMap<String, Value>, waiting: bool, cap: u64) -> Part {
405    let ct = string(columns, "ct");
406    let words = ct.starts_with("text/plain") || ct == "application/smil";
407    let size = int(columns, "size");
408    let state = if words {
409        "text"
410    } else if waiting || size < 0 {
411        "not-downloaded"
412    } else if u64::try_from(size).unwrap_or(0) > cap {
413        "too-big"
414    } else {
415        "here"
416    };
417    let name = ["name", "cl", "fn"].iter().map(|n| string(columns, n)).find(|n| !n.is_empty()).unwrap_or_default();
418    // The other columns as they came; the size is Android's measure, not a column.
419    let mut fields = columns.clone();
420    for held in ["seq", "ct", "text", "size"] {
421        fields.remove(held);
422    }
423    Part { seq: int(columns, "seq"), ct, name, size: u64::try_from(size).unwrap_or(0), text: if words { string(columns, "text") } else { String::new() }, hash: String::new(), state: state.into(), fields }
424}
425
426/// A row Java read, as it travels: its people keyed (its conversation's,
427/// else its own addresses), its words, its parts said (their content left for
428/// `bring_media`), every column kept as it came.
429fn text_of(raw: &Raw, threads: &BTreeMap<String, Vec<String>>, region: Option<&phones::Region>, cap: u64) -> Text {
430    let key = |a: &str| phones::key(a, region);
431    let f = &raw.fields;
432    let thread = int(f, "thread_id").to_string();
433    let people: Vec<String> = threads.get(&thread).map(|list| list.iter().map(|a| key(a)).filter(|k| !k.is_empty()).collect()).unwrap_or_default();
434    let sub = i32::try_from(int(f, "sub_id")).ok().filter(|_| f.contains_key("sub_id")).unwrap_or(-1);
435    if raw.kind == "mms" {
436        let outgoing = matches!(int(f, "msg_box"), 2 | 4);
437        let addresses = |kind: i64| -> Vec<String> { raw.addrs.iter().filter(|a| int(a, "type") == kind).map(|a| string(a, "address")).filter(|a| !a.is_empty() && a != OWN_ADDRESS).map(|a| key(&a)).filter(|k| !k.is_empty()).collect() };
438        let from = addresses(MMS_FROM).into_iter().next().unwrap_or_default();
439        let with = if !people.is_empty() {
440            people
441        } else if outgoing {
442            addresses(MMS_TO)
443        } else {
444            [from.clone()].into_iter().filter(|k| !k.is_empty()).collect()
445        };
446        let waiting = int(f, "m_type") == MMS_NOTIFICATION;
447        let parts: Vec<Part> = raw.parts.iter().map(|p| part_of(p, waiting, cap)).collect();
448        let body = parts.iter().filter(|p| p.ct.starts_with("text/plain")).map(|p| p.text.as_str()).collect::<Vec<_>>().join("\n");
449        Text {
450            id: rules::Row { mms: true, id: int(f, "_id"), date: int(f, "date") }.name(),
451            // The multimedia provider counts in seconds.
452            at: int(f, "date") * 1000,
453            thread,
454            direction: if outgoing { "out".into() } else { "in".into() },
455            from: if outgoing || with.len() < 2 { String::new() } else { from },
456            with,
457            body,
458            sub,
459            picture: parts.iter().any(|p| p.ct.starts_with("image/")),
460            parts,
461            fields: f.clone(),
462            addrs: raw.addrs.clone(),
463            ..Text::default()
464        }
465    } else {
466        let address = key(&string(f, "address"));
467        let with = if !people.is_empty() { people } else { [address.clone()].into_iter().filter(|k| !k.is_empty()).collect() };
468        let outgoing = matches!(int(f, "type"), 2 | 4 | 5 | 6);
469        Text {
470            id: rules::Row { mms: false, id: int(f, "_id"), date: int(f, "date") }.name(),
471            at: int(f, "date"),
472            thread,
473            direction: if outgoing { "out".into() } else { "in".into() },
474            from: if outgoing || with.len() < 2 { String::new() } else { address },
475            with,
476            body: string(f, "body"),
477            sub,
478            fields: f.clone(),
479            ..Text::default()
480        }
481    }
482}
483
484fn raws(answer: &Value) -> Vec<Raw> {
485    answer["texts"].as_array().map(|a| a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect()).unwrap_or_default()
486}
487
488/// Each part of a message that travels, copied from Android into the
489/// private folder, hashed, put in the sharing as a sealed blob (never inside
490/// a line), the copy deleted; within `budget` bytes, the first part of a
491/// step always. Whether every part was brought (else the message waits for
492/// the next step).
493fn bring_media(android: &dyn Android, incoming: &Path, text: &mut Text, vault: &Vault, budget: &mut u64) -> bool {
494    let (folder, key) = vault;
495    for part in text.parts.iter_mut().filter(|p| p.state == "here" && p.hash.is_empty()) {
496        if part.size > *budget && *budget < rules::MEDIA_BATCH {
497            return false;
498        }
499        let copy = incoming.join(format!("{}-{}", text.id, part.seq));
500        let answer = android.ask("texts-part", &json!({ "id": int(&part.fields, "_id"), "target": copy.display().to_string() }));
501        if answer["allowed"] != true {
502            return false;
503        }
504        if answer["copied"].as_i64().unwrap_or(-1) < 0 {
505            // Gone meanwhile, or never downloaded: said so.
506            part.state = "not-downloaded".into();
507            continue;
508        }
509        let put = sioul_sync::blobs::hash_file(&copy).map_err(|e| e.to_string()).and_then(|(hash, size)| sioul_sync::blobs::put(folder, key, &copy, &hash).map(|_| (hash, size)));
510        let _ = std::fs::remove_file(&copy);
511        match put {
512            Ok((hash, size)) => {
513                part.hash = hash;
514                part.size = size;
515                *budget = budget.saturating_sub(size);
516            }
517            Err(e) => {
518                eprintln!("sioul: texts: {e}");
519                return false;
520            }
521        }
522    }
523    true
524}
525
526/// The conversations' people of a `texts-read` answer, by thread.
527fn threads_of(answer: &Value) -> BTreeMap<String, Vec<String>> {
528    answer["threads"]
529        .as_object()
530        .map(|o| o.iter().map(|(id, people)| (id.clone(), people.as_array().map(|a| a.iter().filter_map(|p| p.as_str().map(str::to_string)).collect()).unwrap_or_default())).collect())
531        .unwrap_or_default()
532}
533
534/// What a step read: its texts as they travel, the media bytes put in the sharing.
535#[derive(Default)]
536struct Read {
537    texts: Vec<Text>,
538    media: u64,
539}
540
541/// The rows `wanted` read from Android in one question, taken in that order,
542/// each as it travels, its media put in the sharing within `budget` (the
543/// first medium of a step always). A multimedia message whose media do not
544/// fit stops the multimedia messages there for this step; the plain texts
545/// go on. A row gone meanwhile, or whose id another row took meanwhile, is
546/// left to the next listing. None when Android did not answer.
547fn read_rows(android: &dyn Android, files: &Files, wanted: &[rules::Row], vault: &Vault, region: Option<&phones::Region>, budget: &mut u64) -> Option<Read> {
548    if wanted.is_empty() {
549        return Some(Read::default());
550    }
551    let ids = |mms: bool| wanted.iter().filter(|r| r.mms == mms).map(|r| r.id).collect::<Vec<_>>();
552    let answer = android.ask("texts-read", &json!({ "sms_ids": ids(false), "mms_ids": ids(true), "limit": wanted.len() }));
553    if answer["allowed"] != true {
554        return None;
555    }
556    let threads = threads_of(&answer);
557    let cap = cap_mb() << 20;
558    let mut rows: BTreeMap<(bool, i64), Raw> = raws(&answer).into_iter().map(|raw| ((raw.kind == "mms", int(&raw.fields, "_id")), raw)).collect();
559    let mut read = Read::default();
560    let mut mms_on = true;
561    for row in wanted {
562        if row.mms && !mms_on {
563            continue;
564        }
565        let Some(raw) = rows.remove(&(row.mms, row.id)) else { continue };
566        if int(&raw.fields, "date") != row.date {
567            continue;
568        }
569        let mut text = text_of(&raw, &threads, region, cap);
570        if row.mms {
571            let before = *budget;
572            if !bring_media(android, &files.incoming, &mut text, vault, budget) {
573                mms_on = false;
574                continue;
575            }
576            read.media += before - *budget;
577        }
578        read.texts.push(text);
579    }
580    Some(read)
581}
582
583/// Texts added to this phone's log, each line once: how many were written, or why none could be.
584fn write_log(log: &Path, seal: &Seal, texts: &[Text]) -> Result<usize, String> {
585    let line = |t: &Text| t.line_id();
586    rules::append(log, texts, seal, Some(&line))
587}
588
589/// One step of the import (docs/texts.md, "Reading, on the phone: paced").
590/// Every row the phone holds is listed with its date (`texts-ids`), and
591/// those its log lacks are the ones to read, wherever their ids are (the
592/// plain texts' table gives a deleted newest text's id to the next one; a
593/// provider emptied starts again at 1). Rows that appeared since the last
594/// step are read at once, on the battery too, oldest first; 500 or more at
595/// once are no texts that came but a block of old ones (SMS Backup &
596/// Restore restoring a history), read as a first import is: the newest
597/// first by date, with `all` (on the charger) `BATCH` at a time, else only
598/// those newer than the newest text read before and than a day before the
599/// history was found, so that a text that comes meanwhile reaches your
600/// computers within a minute or two. Media within `MEDIA_BATCH` bytes, the
601/// first always. A step with nothing to read asks Android one listing.
602/// Whether anything was written.
603#[allow(clippy::too_many_arguments)]
604fn import_step(android: &dyn Android, files: &Files, seal: &Seal, vault: &Vault, region: Option<&phones::Region>, now: i64, all: bool) -> bool {
605    let mut reader = Reader::load_at(&files.reader);
606    let loaded = reader.clone();
607    let first = reader.started == 0;
608    if first {
609        reader.started = now;
610    }
611    // A reader of an older version, or one lost: the log tells which rows it holds.
612    let older = !reader.indexed;
613    if older {
614        reader.index(&rules::read_file::<Text>(&files.log, seal));
615    }
616    let answer = android.ask("texts-ids", &json!({ "measure": false }));
617    let Some(listed) = (answer["allowed"] == true).then(|| listed(&answer)).flatten() else {
618        if first || older {
619            let _ = reader.save_at(&files.reader);
620        }
621        return false;
622    };
623    let logged = reader.logged.set();
624    let (was_backlog, was_arrived) = (reader.backlog.set(), reader.arrived.set());
625    let unread: Vec<rules::Row> = listed.iter().filter(|r| !logged.contains(r)).copied().collect();
626    let fresh: Vec<rules::Row> = unread.iter().filter(|r| !was_backlog.contains(r) && !was_arrived.contains(r)).copied().collect();
627    let mut backlog: BTreeSet<rules::Row> = unread.iter().filter(|r| was_backlog.contains(r)).copied().collect();
628    let mut arrived: BTreeSet<rules::Row> = unread.iter().filter(|r| was_arrived.contains(r)).copied().collect();
629    if first || older || fresh.len() >= rules::BATCH {
630        // A history: a first import, what the log of an older reader lacks,
631        // or a block of old texts put at once.
632        if !fresh.is_empty() {
633            reader.since = now;
634        }
635        backlog.extend(fresh);
636    } else {
637        arrived.extend(fresh);
638    }
639    // What came first, oldest first; then the history, newest first by date:
640    // on the battery only what is dated within a day before the history was
641    // found (its last day: a few texts of a restore, and every row that came
642    // with it, a picture dated in seconds under a text read in that second
643    // included: review of 16 October 2026, LOW 2).
644    let floor = reader.since.max(reader.started) - DAY_MS;
645    let mut came: Vec<rules::Row> = arrived.iter().copied().collect();
646    came.sort_by_key(|r| (r.at(), r.id));
647    let mut history: Vec<rules::Row> = backlog.iter().copied().filter(|r| all || r.at() >= floor).collect();
648    history.sort_by(|a, b| (b.at(), b.id).cmp(&(a.at(), a.id)));
649    let mut wanted: Vec<rules::Row> = Vec::new();
650    let mut pictures = 0;
651    for row in came.into_iter().chain(history) {
652        if wanted.len() >= rules::BATCH {
653            break;
654        }
655        if row.mms {
656            if pictures >= MMS_SHARE {
657                continue;
658            }
659            pictures += 1;
660        }
661        wanted.push(row);
662    }
663    let mut budget = rules::MEDIA_BATCH;
664    let read = read_rows(android, files, &wanted, vault, region, &mut budget).unwrap_or_default();
665    // Rows logged before and no longer listed: deleted, or re-dated by their provider.
666    let gone: BTreeSet<(bool, i64)> = logged.difference(&listed).map(|r| (r.mms, r.id)).collect();
667    // A conversation deleted in Sioul: its texts are read (counted as logged, never read again) and never written.
668    let marks = rules::Marks::read_all(files.log.parent().and_then(Path::parent).unwrap_or(Path::new("")), seal);
669    let shown: Vec<Text> = read.texts.iter().filter(|t| !marks.hides(t)).cloned().collect();
670    let written = match write_log(&files.log, seal, &shown) {
671        Ok(written) => {
672            // In the log now (a row it held already, read again after a stop, too).
673            let rows: Vec<rules::Row> = read.texts.iter().filter_map(Text::row).collect();
674            // A row read under a new date where its old line is logged: that line marked moved now, never "deleted" later.
675            if rows.iter().any(|r| gone.contains(&(r.mms, r.id))) {
676                let marks = rules::moved_marks(&rules::read_file::<Text>(&files.log, seal), &listed, now);
677                if let Err(e) = write_log(&files.log, seal, &marks) {
678                    eprintln!("sioul: texts: {e}");
679                }
680            }
681            for row in &rows {
682                backlog.remove(row);
683                arrived.remove(row);
684            }
685            reader.logged = Rows::of(logged.into_iter().chain(rows));
686            reader.newest_at = read.texts.iter().map(|t| t.at).fold(reader.newest_at, i64::max);
687            reader.done_media += read.media;
688            written
689        }
690        Err(e) => {
691            // Not in the log: read again at the next step.
692            eprintln!("sioul: texts: {e}");
693            0
694        }
695    };
696    reader.backlog = Rows::of(backlog);
697    reader.arrived = Rows::of(arrived);
698    reader.behind = !reader.backlog.is_empty();
699    reader.left = !reader.arrived.is_empty();
700    reader.done_texts = reader.logged.len() as u64;
701    // Written only when it changed: most steps find nothing new, and it holds every row of the log.
702    if reader != loaded
703        && let Err(e) = reader.save_at(&files.reader)
704    {
705        eprintln!("sioul: texts: {e}");
706    }
707    if written > 0 {
708        // Never what they say: how many, and how much.
709        eprintln!("sioul: texts: {written} read for your computers ({} so far, {} bytes of media)", reader.done_texts, reader.done_media);
710    }
711    written > 0
712}
713
714/// Once a day: what the phone holds measured for the settings; the log's
715/// own counts taken again (`Reader::index`); and each text gone from the
716/// phone marked so in the log, a line each, once (the archive keeps the
717/// text): a row the phone no longer lists, by its id and its date, so that
718/// a text deleted and the next one taking its id are told apart. The rows
719/// to read are the steps' own (`import_step`). Whether a line was written.
720fn look_again(android: &dyn Android, files: &Files, seal: &Seal, now: i64) -> bool {
721    let mut reader = Reader::load_at(&files.reader);
722    if now - reader.looked < DAY_MS {
723        return false;
724    }
725    let answer = android.ask("texts-ids", &json!({ "measure": true }));
726    if answer["allowed"] != true {
727        return false;
728    }
729    let Some(listed) = listed(&answer) else {
730        // A table Android could not list whole: the look waits an hour, never
731        // listing (and measuring) again at every step meanwhile (review of
732        // 16 October 2026, LOW 4); nothing is said deleted.
733        reader.looked = now - DAY_MS + LOOK_AGAIN_MS;
734        if let Err(e) = reader.save_at(&files.reader) {
735            eprintln!("sioul: texts: {e}");
736        }
737        return false;
738    };
739    reader.looked = now;
740    reader.measure(&answer, now);
741    let log: Vec<Text> = rules::read_file(&files.log, seal);
742    reader.index(&log);
743    if let Err(e) = reader.save_at(&files.reader) {
744        eprintln!("sioul: texts: {e}");
745    }
746    // A row whose date its provider rewrote: its old line marked moved, under its new name (review of
747    // 16 October 2026, LOW 1: it showed twice, once "deleted on your phone"); the rest gone, marked deleted.
748    let mut marks = rules::moved_marks(&log, &listed, now);
749    let moved: BTreeSet<String> = marks.iter().map(|t| t.id.clone()).collect();
750    let marked: BTreeSet<&str> = log.iter().filter(|t| t.deleted > 0).map(|t| t.id.as_str()).collect();
751    marks.extend(
752        log.iter()
753            .filter(|t| t.deleted == 0 && !marked.contains(t.id.as_str()) && !moved.contains(&t.id) && t.row().is_some_and(|r| !listed.contains(&r)))
754            .map(|t| Text { id: t.id.clone(), at: t.at, direction: t.direction.clone(), with: t.with.clone(), deleted: now, ..Text::default() }),
755    );
756    let written = write_log(&files.log, seal, &marks).unwrap_or_else(|e| {
757        eprintln!("sioul: texts: {e}");
758        0
759    });
760    if written > 0 {
761        eprintln!("sioul: texts: {written} deleted on the phone, marked so");
762    }
763    written > 0
764}
765
766// ---------------------------------------------------------------- the phone: sending
767
768/// A day's look that could not list every row is tried again this long after.
769const LOOK_AGAIN_MS: i64 = 3_600_000;
770
771/// One result, as Java wrote it (TextSend.line): a text's part sent or
772/// delivered; or a multimedia message's word ("mms"): Android's code, the
773/// row it filed, the HTTP status, the carrier's answer (M-Send.conf, hex).
774#[derive(Debug, Clone, Default, Deserialize)]
775#[serde(default)]
776struct PartResult {
777    key: String,
778    kind: String,
779    part: i64,
780    parts: i64,
781    code: i64,
782    uri: String,
783    status: i64,
784    at: i64,
785    http: i64,
786    conf: String,
787}
788
789/// Android's code for a part not sent, as the words say it (SmsManager's RESULT_ERROR_…).
790fn reason_of(code: i64) -> &'static str {
791    match code {
792        2 => "radio-off",
793        4 => "no-service",
794        5 => "limit",
795        18 | 30 | 31 => "no-sim",
796        _ => "other",
797    }
798}
799
800/// "content://sms/414" → "sms-414", "content://mms/sent/31" → "mms-31": the
801/// row as the log names it, its date read after (`name_rows`).
802fn row_of(uri: &str) -> String {
803    let uri = uri.trim();
804    let table = if uri.starts_with("content://mms/") { "mms" } else { "sms" };
805    uri.rsplit('/').next().filter(|id| !id.is_empty() && id.chars().all(|c| c.is_ascii_digit())).map(|id| format!("{table}-{id}")).unwrap_or_default()
806}
807
808/// Each text's outcomes from its parts' results: sent when every part was;
809/// not sent, and why, when one was not; delivered when the carrier said every
810/// part was received (never "not delivered" for a report that did not come).
811fn outcomes_from(results: &[PartResult]) -> Vec<Outcome> {
812    let mut by: BTreeMap<&str, Vec<&PartResult>> = BTreeMap::new();
813    for r in results.iter().filter(|r| !r.key.is_empty() && r.kind != "mms") {
814        by.entry(r.key.as_str()).or_default().push(r);
815    }
816    let mut out = Vec::new();
817    for (key, parts) in by {
818        let count = parts.iter().map(|p| p.parts.max(1)).max().unwrap_or(1);
819        let sent: Vec<&&PartResult> = parts.iter().filter(|p| p.kind == "sent").collect();
820        let at = |list: &[&&PartResult]| list.iter().map(|p| p.at).max().unwrap_or(0);
821        if let Some(failed) = sent.iter().find(|p| p.code != -1) {
822            out.push(Outcome { key: key.into(), state: "failed".into(), at: failed.at, reason: reason_of(failed.code).into(), ..Outcome::default() });
823            continue;
824        }
825        let done: BTreeSet<i64> = sent.iter().map(|p| p.part).collect();
826        if done.len() as i64 >= count {
827            let row = sent.iter().map(|p| row_of(&p.uri)).find(|r| !r.is_empty()).unwrap_or_default();
828            out.push(Outcome { key: key.into(), state: "sent".into(), at: at(&sent), row, ..Outcome::default() });
829            let delivered: Vec<&&PartResult> = parts.iter().filter(|p| p.kind == "delivered" && (0..0x20).contains(&p.status)).collect();
830            if delivered.iter().map(|p| p.part).collect::<BTreeSet<_>>().len() as i64 >= count {
831                out.push(Outcome { key: key.into(), state: "delivered".into(), at: at(&delivered), ..Outcome::default() });
832            }
833        }
834    }
835    out
836}
837
838/// Hex's bytes; none for what is not hex.
839fn unhex(hex: &str) -> Option<Vec<u8>> {
840    let hex = hex.trim();
841    if hex.len() % 2 != 0 {
842        return None;
843    }
844    (0..hex.len()).step_by(2).map(|i| u8::from_str_radix(hex.get(i..i + 2)?, 16).ok()).collect()
845}
846
847/// Each multimedia message's outcome from its result (`texts::took`, with
848/// the HTTP status Java read): sent (Android's Ok and the carrier's), not
849/// sent and why, in doubt when each of Android's tries ended on the network
850/// ("network": the carrier may have it); and the keys whose result is
851/// Android's Ok without the carrier's answer, for the sent box to say
852/// (`Step::copy_results`). Never "delivered": no report is asked.
853fn mms_outcomes(results: &[PartResult]) -> (Vec<Outcome>, Vec<(String, i64)>) {
854    let (mut out, mut look) = (Vec::new(), Vec::new());
855    for r in results.iter().filter(|r| r.kind == "mms" && !r.key.is_empty()) {
856        let conf = unhex(&r.conf).and_then(|bytes| sioul_core::mms::conf(&bytes));
857        match rules::took(r.code, conf.as_ref(), r.http) {
858            rules::Took::Sent => out.push(Outcome { key: r.key.clone(), state: "sent".into(), at: r.at, row: row_of(&r.uri), ..Outcome::default() }),
859            rules::Took::Failed(why) => out.push(Outcome { key: r.key.clone(), state: "failed".into(), at: r.at, reason: why.into(), ..Outcome::default() }),
860            rules::Took::Doubt(why) => out.push(Outcome { key: r.key.clone(), state: "doubt".into(), at: r.at, reason: why.into(), ..Outcome::default() }),
861            rules::Took::Look => look.push((r.key.clone(), r.at)),
862        }
863    }
864    (out, look)
865}
866
867/// Whether Java could look in the provider (`Texts.findMms`): the permission
868/// given and the query answered. A look not made is never read as "not
869/// there": made again at the next step.
870fn looked(answer: &Value) -> bool {
871    answer["allowed"] == true && answer["error"] != true
872}
873
874/// The row Sioul's multimedia message was filed as in the sent box, by its
875/// key (`tr_id`), as Java found it (`Texts.findMms`: Sioul's rows alone):
876/// its name in the log; none in the sent box (a failed one is out of sight).
877fn sent_row(answer: &Value) -> Option<String> {
878    answer["found"].as_array()?.iter().find(|row| row["box"] == 2).map(|row| rules::Row { mms: true, id: row["id"].as_i64().unwrap_or(0), date: row["date"].as_i64().unwrap_or(0) }.name())
879}
880
881/// The carrier's refusal Android read for a message it filed among the
882/// failed (box 5), its status (`resp_st`, as `Texts.findMms` gives it), when
883/// it read one: none when Android filed it there with no answer it could
884/// read (`SendRequest.persistIfRequired`).
885fn failed_status(answer: &Value) -> Option<u8> {
886    answer["found"].as_array()?.iter().filter(|row| row["box"] == 5).filter_map(|row| u8::try_from(row["status"].as_i64()?).ok()).find(|status| *status > sioul_core::mms::OK)
887}
888
889/// Java's results set aside at the last trim (`trim_results`), read beside
890/// its file until the next.
891const RESULTS_ASIDE: &str = "results-aside.jsonl";
892
893/// Java's results (`TextSend.Result`'s lines): its file, and the one set
894/// aside at the last trim; only those of the outcomes' month
895/// (`rules::KEPT_DAYS`), so that a result whose outcome was trimmed is
896/// never said, written and shared again (the review of 18 October 2026, I-f).
897fn results_of(private: &Path, now: i64) -> Vec<PartResult> {
898    [rules::RESULTS, RESULTS_ASIDE]
899        .iter()
900        .flat_map(|name| std::fs::read_to_string(private.join(name)).unwrap_or_default().lines().filter_map(|l| serde_json::from_str::<PartResult>(l).ok()).collect::<Vec<_>>())
901        .filter(|r| now - r.at <= rules::KEPT_DAYS * DAY_MS)
902        .collect()
903}
904
905/// Java's results file kept to the outcomes' month, once a day, in the
906/// phone's step under its lock (the review of 18 October 2026, I-f). Java
907/// appends to it by its name at any moment, from either of the phone's
908/// processes: it is never written over. It is set aside by a rename, and
909/// the lines kept are appended to the new one Java's next line makes; the
910/// file set aside is read too until the next trim, which first brings back
911/// the line Java may have appended to it as it was renamed.
912fn trim_results(private: &Path, now: i64) -> std::io::Result<()> {
913    let current = private.join(rules::RESULTS);
914    let aside = private.join(RESULTS_ASIDE);
915    let kept = |text: &str| -> Vec<String> { text.lines().filter(|l| serde_json::from_str::<PartResult>(l).is_ok_and(|r| now - r.at <= rules::KEPT_DAYS * DAY_MS)).map(str::to_string).collect() };
916    let append = |lines: &[String]| -> std::io::Result<()> {
917        if lines.is_empty() {
918            return Ok(());
919        }
920        use std::io::Write;
921        let mut file = std::fs::OpenOptions::new().create(true).append(true).open(&current)?;
922        file.write_all(lines.iter().map(|l| format!("{l}\n")).collect::<String>().as_bytes())?;
923        file.sync_all()
924    };
925    if let Ok(text) = std::fs::read_to_string(&aside) {
926        let there: BTreeSet<String> = std::fs::read_to_string(&current).unwrap_or_default().lines().map(str::to_string).collect();
927        append(&kept(&text).into_iter().filter(|l| !there.contains(l)).collect::<Vec<_>>())?;
928        std::fs::remove_file(&aside)?;
929    }
930    match std::fs::rename(&current, &aside) {
931        Ok(()) => append(&kept(&std::fs::read_to_string(&aside)?)),
932        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
933        Err(e) => Err(e),
934    }
935}
936
937/// What each SIM's carrier lets the phone send, as Java read it (`Texts.limits`).
938fn limits_from(answer: &Value) -> Vec<rules::Limits> {
939    answer["limits"].as_array().map(|all| all.iter().filter_map(|l| serde_json::from_value::<rules::Limits>(l.clone()).ok()).filter(|l| l.max > 0).collect()).unwrap_or_default()
940}
941
942/// Android's own maximum when the carrier's file says nothing (bytes): 300 kB.
943const ANDROID_MAX: u64 = 300 * 1024;
944
945/// Whether a multimedia message's media are all in the sharing (the folder,
946/// or the copy fetched from its server beside it), not opened.
947fn sealed_here(vault: &Vault, hash: &str) -> bool {
948    let (folder, key) = vault;
949    let there = |dir: &Path| std::fs::metadata(dir.join("blobs").join(sioul_sync::blobs::name(key, hash))).is_ok_and(|m| m.len() > 0);
950    there(folder) || sioul_sync::remote::overlay(folder).is_some_and(|copy| there(&copy))
951}
952
953/// A multimedia message made ready on the phone, before its claim.
954#[derive(Debug, Clone, PartialEq, Eq)]
955enum Prepared {
956    /// Its message written, to go from that SIM.
957    Ready { sub: i32 },
958    /// A medium not here yet: no claim; asked for, looked at again at the next step.
959    Waiting,
960    /// Refused, said: larger than its SIM's carrier takes, or that carrier takes none.
961    Refused(&'static str),
962}
963
964/// What the phone's quick step works with: where it reads and writes (the
965/// data folder's texts, the private texts folder, the other copies of the
966/// requests, the sharing's memory), the sharing's folder and key, Android
967/// (a stand-in in tests).
968struct Step<'a> {
969    here: &'a str,
970    seal: &'a Seal,
971    region: Option<&'static phones::Region>,
972    root: PathBuf,
973    private: PathBuf,
974    copies: Vec<PathBuf>,
975    memory: PathBuf,
976    vault: Option<&'a Vault>,
977    android: &'a dyn Android,
978    /// Where a device that asks stands in the sharing now (`standing_of`):
979    /// a request is sent only for one counted, refused for one gone, left
980    /// to wait for one not known yet (`texts::verdict`).
981    standing: &'a dyn Fn(&str) -> rules::Standing,
982}
983
984impl Step<'_> {
985    /// This phone's.
986    fn here<'a>(here: &'a str, seal: &'a Seal, region: Option<&'static phones::Region>, vault: Option<&'a Vault>, standing: &'a dyn Fn(&str) -> rules::Standing) -> Step<'a> {
987        Step { here, seal, region, root: root(), private: private(), copies: crate::share::texts_copies(), memory: crate::share::memory_path(), vault, android: &Phone, standing }
988    }
989
990    /// Outcomes added to this phone's own file, each (key, state) once.
991    fn say(&self, outcomes: &[Outcome]) -> bool {
992        let id = |o: &Outcome| format!("{}/{}", o.key, o.state);
993        match rules::append(&rules::own_file(&self.root, rules::OUTCOME, self.here), outcomes, self.seal, Some(&id)) {
994            Ok(n) => n > 0,
995            Err(e) => {
996                eprintln!("sioul: texts: {e}");
997                false
998            }
999        }
1000    }
1001
1002    fn record(&self, claim: &Claim) {
1003        if let Err(e) = rules::record(&self.private, claim) {
1004            eprintln!("sioul: texts: {e}");
1005        }
1006    }
1007
1008    /// Where a multimedia message waits for Android, written before its
1009    /// claim: Android reads it by its address (Sioul's file provider), and
1010    /// it goes once its outcome is written.
1011    fn pdu_path(&self, key: &str) -> PathBuf {
1012        self.private.join(OUTGOING).join(format!("{}.pdu", key.chars().filter(char::is_ascii_hexdigit).collect::<String>()))
1013    }
1014
1015    /// Java's results copied into this phone's outcomes, each row named
1016    /// (`name_rows`: those not said yet), and the ledger told. A multimedia
1017    /// message Android took with no answer of the carrier's is looked for in
1018    /// the sent box: there, sent; else not sent (Android filed it among the
1019    /// failed, which Sioul cannot see). A multimedia message's file goes once
1020    /// its outcome is written.
1021    fn copy_results(&self, now: i64) -> bool {
1022        let results = results_of(&self.private, now);
1023        if results.is_empty() {
1024            return false;
1025        }
1026        let said: BTreeSet<String> = rules::read_file::<Outcome>(&rules::own_file(&self.root, rules::OUTCOME, self.here), self.seal).iter().map(|o| format!("{}/{}", o.key, o.state)).collect();
1027        let mut outcomes = outcomes_from(&results);
1028        let (mms, look) = mms_outcomes(&results);
1029        outcomes.extend(mms);
1030        for (key, at) in look {
1031            if ["sent", "failed", "doubt"].iter().any(|state| said.contains(&format!("{key}/{state}"))) {
1032                continue;
1033            }
1034            let answer = self.android.ask("texts-find-mms", &json!({ "key": key }));
1035            // The sent box not readable now (the permission withdrawn, the query failed): looked at again at the
1036            // next step, and by the look for lost claims until Android's last word; never said refused for it.
1037            if !looked(&answer) {
1038                continue;
1039            }
1040            // In the sent box: sent. Among the failed with the carrier's refusal Android read: not sent, and why.
1041            // Else Android filed it among the failed with an answer nobody could read, after the carrier's HTTP
1042            // 2xx (Android's Ok comes only after one): the carrier may have it (the review's re-check, I-g).
1043            outcomes.push(match (sent_row(&answer), failed_status(&answer)) {
1044                (Some(row), _) => Outcome { key, state: "sent".into(), at, row, ..Outcome::default() },
1045                (None, Some(status)) => Outcome { key, state: "failed".into(), at, reason: rules::mms_failure(-1, Some(status)).into(), ..Outcome::default() },
1046                (None, None) => Outcome { key, state: "doubt".into(), at, reason: "answer".into(), ..Outcome::default() },
1047            });
1048        }
1049        let mut new: Vec<Outcome> = outcomes.iter().filter(|o| !said.contains(&format!("{}/{}", o.key, o.state))).cloned().collect();
1050        name_rows(self.android, &mut new);
1051        let changed = self.say(&new);
1052        let ledger = rules::Ledger::open(&self.private, now).ok();
1053        for o in &outcomes {
1054            if ledger.as_ref().is_some_and(|l| l.state(&o.key) != Some("done")) {
1055                self.record(&Claim { key: o.key.clone(), at: now, state: "done".into(), ..Claim::default() });
1056            }
1057            let _ = std::fs::remove_file(self.pdu_path(&o.key));
1058        }
1059        changed
1060    }
1061
1062    /// A multimedia message made ready before its claim (decision 12 and
1063    /// 13): its media looked for first, by their files alone (one not here
1064    /// yet asked for by its name, nothing else done, nothing claimed); then
1065    /// opened from the sharing, each checked against its hash; its
1066    /// message built (`sioul_core::mms::encode`) and measured against what its
1067    /// SIM's carrier takes now (Android's live value, never one raised:
1068    /// larger, refused in words, never left to Android's code 5); written and
1069    /// flushed where Android will read it. The SIM: as asked, else the
1070    /// conversation's last (`sim_for`).
1071    fn prepare(&self, r: &Request, log: &std::cell::OnceCell<Vec<Text>>, now: i64) -> Prepared {
1072        let Some(vault) = self.vault else { return Prepared::Waiting };
1073        if r.key.len() != 32 || !r.key.bytes().all(|b| b.is_ascii_hexdigit()) {
1074            return Prepared::Refused("other");
1075        }
1076        // Its media first, by a look at their files alone: one not here yet is asked for by its name, and nothing
1077        // else is done (neither the log nor Android asked, no medium opened), so that every step while a message
1078        // waits for its media, a dose's alarm's included, stays as quick as one with nothing to send (the review
1079        // of 18 October 2026, L-a). The log is read in the step that sends.
1080        let absent: Vec<rules::Medium> = r.media.iter().filter(|m| !sealed_here(vault, &m.hash)).cloned().collect();
1081        if !absent.is_empty() {
1082            self.want(&r.media, &absent);
1083            return Prepared::Waiting;
1084        }
1085        let to = phones::key(&r.to, self.region);
1086        let sub = self.sim_for(r, log);
1087        let answer = self.android.ask("texts-limits", &json!({ "sub": sub }));
1088        let limits = limits_from(&answer);
1089        let carrier = limits.iter().find(|l| l.sub == sub).or_else(|| limits.first());
1090        if carrier.is_some_and(|l| !l.mms) {
1091            return Prepared::Refused("no-mms");
1092        }
1093        let max = carrier.map_or(ANDROID_MAX, |l| l.max);
1094        // Never more brought than could go.
1095        if r.media.iter().map(|m| m.size).sum::<u64>() > max {
1096            return Prepared::Refused("too-large");
1097        }
1098        let (folder, key) = vault;
1099        let _ = std::fs::create_dir_all(self.private.join(OUTGOING));
1100        let opened = self.private.join(OUTGOING).join(format!("{}.media", self.pdu_path(&r.key).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default()));
1101        let mut bytes = Vec::new();
1102        let mut missing = Vec::new();
1103        for medium in &r.media {
1104            let ok = sioul_sync::blobs::get(folder, key, &medium.hash, &opened).is_ok();
1105            let read = if ok { std::fs::read(&opened).ok() } else { None };
1106            let _ = std::fs::remove_file(&opened);
1107            match read {
1108                Some(read) if read.len() as u64 == medium.size => bytes.push(read),
1109                Some(_) => return Prepared::Refused("media-kind"),
1110                None => missing.push(medium.clone()),
1111            }
1112        }
1113        self.want(&r.media, &missing);
1114        if !missing.is_empty() {
1115            return Prepared::Waiting;
1116        }
1117        let media: Vec<sioul_core::mms::Medium> = r.media.iter().zip(&bytes).map(|(m, b)| sioul_core::mms::Medium { ct: &m.ct, bytes: b }).collect();
1118        let message = sioul_core::mms::Message { key: &r.key, to: &[to.as_str()], date: now / 1000, words: &r.words, media: &media };
1119        let Ok(pdu) = sioul_core::mms::encode(&message) else { return Prepared::Refused("other") };
1120        if pdu.len() as u64 > max {
1121            return Prepared::Refused("too-large");
1122        }
1123        match write_flushed(&self.pdu_path(&r.key), &pdu) {
1124            Ok(()) => Prepared::Ready { sub },
1125            // A disk too full, a moment: no claim, tried again at the next step.
1126            Err(e) => {
1127                eprintln!("sioul: texts: {e}");
1128                Prepared::Waiting
1129            }
1130        }
1131    }
1132
1133    /// The SIM a request about to go leaves from: as asked, else the
1134    /// conversation's last on this phone, its log read then and once a step
1135    /// at most (`log`): never for requests decided already, which a step
1136    /// meets at every exchange and inside a dose's alarm (the review of 18
1137    /// October 2026, M-1).
1138    fn sim_for(&self, r: &Request, log: &std::cell::OnceCell<Vec<Text>>) -> i32 {
1139        if r.sub >= 0 {
1140            return r.sub;
1141        }
1142        let texts = log.get_or_init(|| {
1143            #[cfg(test)]
1144            LOG_READS.with(|reads| reads.set(reads.get() + 1));
1145            rules::read_logs(&self.root, self.seal)
1146        });
1147        rules::last_sim(&rules::conversation_id(&[phones::key(&r.to, self.region)]), self.here, texts)
1148    }
1149
1150    /// A multimedia message's media asked for by their names (`blobs::Wanted::media`,
1151    /// which the pull's fetch brings from the server, and Sioul keeping the
1152    /// folder itself too): those missing; those here no longer.
1153    fn want(&self, media: &[rules::Medium], missing: &[rules::Medium]) {
1154        let Some((_, key)) = self.vault else { return };
1155        let name = |m: &rules::Medium| sioul_sync::blobs::name(key, &m.hash);
1156        let wanted = sioul_sync::blobs::Wanted::load(&self.memory);
1157        let asked = missing.iter().any(|m| !wanted.media.contains_key(&name(m)));
1158        let done = media.iter().filter(|m| !missing.contains(m)).any(|m| wanted.media.contains_key(&name(m)));
1159        if !asked && !done {
1160            return;
1161        }
1162        let now = jiff::Timestamp::now().as_second();
1163        let _ = sioul_sync::blobs::Wanted::change(&self.memory, |wanted| {
1164            for m in media.iter().filter(|m| !missing.contains(m)) {
1165                wanted.media.remove(&name(m));
1166            }
1167            for m in missing {
1168                wanted.media.entry(name(m)).or_insert_with(|| sioul_sync::blobs::Want { hash: m.hash.clone(), size: m.size, since: now, ..Default::default() });
1169            }
1170        });
1171    }
1172
1173    /// Each request decided once, under the ledger's lock (both processes of the
1174    /// phone may step): claimed and flushed before Android is asked; the others
1175    /// said (expired, refused, clocks apart) and written in the ledger, so that
1176    /// they are decided once too. A multimedia message is made ready first
1177    /// (`prepare`), and claimed only once every medium is here and checked:
1178    /// until then nothing is claimed, and at its expiry it is said that its
1179    /// media did not come. Whether an outcome was written.
1180    fn decide(&self, now: i64) -> bool {
1181        // The records' copy, then the same requests in their own small files, the
1182        // sharing folder's and the copy fetched from its server: one reaches this
1183        // phone within its quarter of an hour, whatever the records' size
1184        // (docs/database.md, "Sent to the server too").
1185        let roots: Vec<PathBuf> = std::iter::once(self.root.clone()).chain(self.copies.iter().cloned()).collect();
1186        let requests = requests_from(&roots, self.seal);
1187        if requests.iter().all(|r| r.phone != self.here) {
1188            return false;
1189        }
1190        let mine = |r: &&Request| r.phone == self.here;
1191        // The phone's log, read only when a request about to go names no SIM (`sim_for`).
1192        let log = std::cell::OnceCell::new();
1193        // The multimedia messages that may go, made ready outside the ledger's
1194        // lock (the step's lock keeps the phone's other process out).
1195        let mut ready: BTreeMap<String, Prepared> = BTreeMap::new();
1196        if let Ok(ledger) = rules::Ledger::open(&self.private, now) {
1197            for r in requests.iter().filter(mine).filter(|r| r.multimedia()) {
1198                if rules::verdict(r, self.here, now, &ledger, self.region, self.standing) == Verdict::Send {
1199                    ready.insert(r.key.clone(), self.prepare(r, &log, now));
1200                }
1201            }
1202        }
1203        let ledger_path = self.private.join(rules::LEDGER);
1204        let mut said = Vec::new();
1205        let mut to_send = Vec::new();
1206        let mut to_send_mms: Vec<(Request, i32)> = Vec::new();
1207        let claimed = sioul_core::filelock::with_lock(&ledger_path, || -> Result<(), String> {
1208            let ledger = rules::Ledger::open(&self.private, now)?;
1209            for r in &requests {
1210                let verdict = rules::verdict(r, self.here, now, &ledger, self.region, self.standing);
1211                let (state, reason) = match verdict {
1212                    // Its device not known yet: judged again at the next step.
1213                    Verdict::NotMine | Verdict::Decided | Verdict::Wait => continue,
1214                    Verdict::Send if r.multimedia() => match ready.get(&r.key) {
1215                        Some(Prepared::Ready { sub }) => {
1216                            rules::record(&self.private, &Claim { key: r.key.clone(), at: now, state: "claimed".into(), to: phones::key(&r.to, self.region), hash: rules::words_hash(&r.words), mms: true, ..Claim::default() })?;
1217                            to_send_mms.push((r.clone(), *sub));
1218                            continue;
1219                        }
1220                        Some(Prepared::Refused(why)) => ("refused", *why),
1221                        // A medium not here yet: nothing claimed, nothing said.
1222                        _ => continue,
1223                    },
1224                    Verdict::Send => {
1225                        rules::record(&self.private, &Claim { key: r.key.clone(), at: now, state: "claimed".into(), to: phones::key(&r.to, self.region), hash: rules::words_hash(&r.body), ..Claim::default() })?;
1226                        to_send.push(r.clone());
1227                        continue;
1228                    }
1229                    Verdict::Expired if r.multimedia() && self.vault.is_some_and(|vault| r.media.iter().any(|m| !sealed_here(vault, &m.hash))) => ("expired", "media"),
1230                    Verdict::Expired => ("expired", ""),
1231                    Verdict::Clocks => ("clocks", ""),
1232                    Verdict::BeforeMark => ("refused", "before-mark"),
1233                    Verdict::Refused(why) => ("refused", why),
1234                };
1235                rules::record(&self.private, &Claim { key: r.key.clone(), at: now, state: state.into(), ..Claim::default() })?;
1236                said.push(Outcome { key: r.key.clone(), state: state.into(), at: now, reason: reason.into(), ..Outcome::default() });
1237                if r.multimedia() {
1238                    // Asked for no more.
1239                    self.want(&r.media, &[]);
1240                }
1241            }
1242            Ok(())
1243        });
1244        // Made ready and not claimed (refused, or decided meanwhile): its file goes.
1245        for (key, prepared) in &ready {
1246            if !to_send_mms.iter().any(|(r, _)| &r.key == key) && *prepared != Prepared::Waiting {
1247                let _ = std::fs::remove_file(self.pdu_path(key));
1248            }
1249        }
1250        if let Err(e) = claimed {
1251            // A claim not flushed: nothing is handed to Android.
1252            eprintln!("sioul: texts: {e}");
1253            for (r, _) in &to_send_mms {
1254                let _ = std::fs::remove_file(self.pdu_path(&r.key));
1255            }
1256            return self.say(&said);
1257        }
1258        for r in to_send {
1259            // The SIM: as asked, else the conversation's last on this phone.
1260            let sub = self.sim_for(&r, &log);
1261            let answer = self.android.ask("texts-send", &json!({ "key": r.key, "to": phones::key(&r.to, self.region), "body": r.body, "sub": sub }));
1262            if answer["handed"] == true {
1263                self.record(&Claim { key: r.key.clone(), at: now_ms(), state: "handed".into(), ..Claim::default() });
1264                // Their next texts come through at once for twenty minutes, as after a reply (`replies`).
1265                crate::replies::texted(&phones::key(&r.to, self.region));
1266                said.push(Outcome { key: r.key.clone(), state: "handed".into(), at: now_ms(), ..Outcome::default() });
1267                eprintln!("sioul: texts: one handed to Android ({} part(s))", answer["parts"].as_i64().unwrap_or(1));
1268            } else if answer.is_null() {
1269                // No answer from Java at all: whether Android took it is not known. Looked for later, never sent again.
1270                eprintln!("sioul: texts: Android's answer did not come; the text is looked for later");
1271            } else {
1272                let reason = answer["reason"].as_str().unwrap_or("other").to_string();
1273                self.record(&Claim { key: r.key.clone(), at: now_ms(), state: "done".into(), ..Claim::default() });
1274                said.push(Outcome { key: r.key.clone(), state: "failed".into(), at: now_ms(), reason, ..Outcome::default() });
1275            }
1276        }
1277        for (r, sub) in to_send_mms {
1278            let path = self.pdu_path(&r.key);
1279            let answer = self.android.ask("texts-send-mms", &json!({ "key": r.key, "sub": sub, "path": path }));
1280            if answer["handed"] == true {
1281                self.record(&Claim { key: r.key.clone(), at: now_ms(), state: "handed".into(), ..Claim::default() });
1282                crate::replies::texted(&phones::key(&r.to, self.region));
1283                said.push(Outcome { key: r.key.clone(), state: "handed".into(), at: now_ms(), ..Outcome::default() });
1284                eprintln!("sioul: texts: one multimedia message handed to Android");
1285            } else if answer.is_null() {
1286                // Looked for by its key 40 minutes on, never sent again.
1287                eprintln!("sioul: texts: Android's answer did not come; the multimedia message is looked for later");
1288            } else {
1289                let reason = answer["reason"].as_str().unwrap_or("other").to_string();
1290                self.record(&Claim { key: r.key.clone(), at: now_ms(), state: "done".into(), ..Claim::default() });
1291                said.push(Outcome { key: r.key.clone(), state: "failed".into(), at: now_ms(), reason, ..Outcome::default() });
1292                let _ = std::fs::remove_file(&path);
1293            }
1294        }
1295        self.say(&said)
1296    }
1297
1298    /// A claim left without Android's word (Sioul stopped between the claim and
1299    /// the hand-off, or a phone restarted while Android waited): a text's
1300    /// looked for among the texts Sioul sent since, by its number and its
1301    /// words' hash, two minutes on; a multimedia message's by its key (the
1302    /// provider's `tr_id`), handed or not, at each step from
1303    /// `LOOK_AFTER_MMS_MS` on, until Android's last word (`MMS_LAST_WORD_MS`:
1304    /// it may still go until then). Found, sent; else in doubt. Never sent
1305    /// again.
1306    fn look_for_lost(&self, now: i64) -> bool {
1307        let Ok(ledger) = rules::Ledger::open(&self.private, now) else { return false };
1308        let lost: Vec<&Claim> = ledger.claims.iter().filter(|c| c.state == "claimed" && !c.mms && ledger.state(&c.key) == Some("claimed") && now - c.at >= rules::LOOK_AFTER_MS).collect();
1309        let lost_mms: Vec<&Claim> = ledger.claims.iter().filter(|c| c.state == "claimed" && c.mms && matches!(ledger.state(&c.key), Some("claimed" | "handed")) && now - c.at >= rules::LOOK_AFTER_MMS_MS).collect();
1310        let mut said = Vec::new();
1311        if !lost.is_empty() {
1312            let since = lost.iter().map(|c| c.at).min().unwrap_or(now) - 60_000;
1313            let answer = self.android.ask("texts-find", &json!({ "since": since }));
1314            if answer["allowed"] == true {
1315                let sent: Vec<Raw> = raws(&answer);
1316                for claim in lost {
1317                    let found = sent
1318                        .iter()
1319                        .find(|r| int(&r.fields, "date") >= claim.at - 60_000 && rules::words_hash(&string(&r.fields, "body")) == claim.hash && phones::key(&string(&r.fields, "address"), self.region) == claim.to)
1320                        .map(|r| rules::Row { mms: false, id: int(&r.fields, "_id"), date: int(&r.fields, "date") }.name());
1321                    if let Some(outcome) = rules::doubt_after(claim, found, now) {
1322                        self.record(&Claim { key: claim.key.clone(), at: now, state: "done".into(), ..Claim::default() });
1323                        said.push(outcome);
1324                    }
1325                }
1326            }
1327        }
1328        for claim in lost_mms {
1329            let answer = self.android.ask("texts-find-mms", &json!({ "key": claim.key }));
1330            // A look not made is made again at the next step; past Android's last word, the doubt all the same.
1331            if !looked(&answer) && now - claim.at < rules::MMS_LAST_WORD_MS {
1332                continue;
1333            }
1334            let found = if looked(&answer) { sent_row(&answer) } else { None };
1335            if let Some(outcome) = rules::doubt_after(claim, found, now) {
1336                self.record(&Claim { key: claim.key.clone(), at: now, state: "done".into(), ..Claim::default() });
1337                let _ = std::fs::remove_file(self.pdu_path(&claim.key));
1338                said.push(outcome);
1339            }
1340        }
1341        self.say(&said)
1342    }
1343
1344    /// What each SIM's carrier lets this phone send, said in its outcomes
1345    /// (decision 5: a computer offers Attach… only for a phone that said it,
1346    /// and reduces to it): when it changed, else once a day. Numbers only,
1347    /// never a carrier's name.
1348    fn publish_limits(&self, now: i64) -> bool {
1349        let limits = limits_from(&self.android.ask("texts-limits", &json!({})));
1350        if limits.is_empty() {
1351            return false;
1352        }
1353        let own = rules::read_file::<Outcome>(&rules::own_file(&self.root, rules::OUTCOME, self.here), self.seal);
1354        let last = own.iter().filter(|o| o.key.is_empty() && o.state == "limits").max_by_key(|o| o.at);
1355        if last.is_some_and(|o| o.limits == limits && (0..DAY_MS).contains(&(now - o.at))) {
1356            return false;
1357        }
1358        let line = Outcome { state: "limits".into(), at: now, limits, ..Outcome::default() };
1359        let id = |o: &Outcome| format!("limits/{}", o.at);
1360        match rules::append(&rules::own_file(&self.root, rules::OUTCOME, self.here), std::slice::from_ref(&line), self.seal, Some(&id)) {
1361            Ok(n) => n > 0,
1362            Err(e) => {
1363                eprintln!("sioul: texts: {e}");
1364                false
1365            }
1366        }
1367    }
1368
1369    /// A multimedia message's file left behind (a crash before its claim, an
1370    /// outcome written by another way): gone once its key is done, or an
1371    /// hour old with no claim.
1372    fn sweep_outgoing(&self, now: i64) {
1373        let Ok(entries) = std::fs::read_dir(self.private.join(OUTGOING)) else { return };
1374        let ledger = rules::Ledger::open(&self.private, now).ok();
1375        for entry in entries.filter_map(Result::ok) {
1376            let name = entry.file_name().to_string_lossy().to_string();
1377            let key = name.trim_end_matches(".pdu").trim_end_matches(".media");
1378            let state = ledger.as_ref().and_then(|l| l.state(key).map(str::to_string));
1379            let old = entry.metadata().ok().and_then(|m| m.modified().ok()).and_then(|t| t.elapsed().ok()).is_some_and(|age| age.as_secs() > 3600);
1380            if state.as_deref() == Some("done") || (state.is_none() && old) || (state.as_deref().is_some_and(|s| !matches!(s, "claimed" | "handed")) && old) {
1381                let _ = std::fs::remove_file(entry.path());
1382            }
1383        }
1384    }
1385}
1386
1387/// Where a multimedia message waits on the phone, in the private texts folder.
1388const OUTGOING: &str = "outgoing";
1389
1390#[cfg(test)]
1391thread_local! {
1392    /// How many times this thread's steps read the phone's log (`Step::sim_for`): tests count them.
1393    static LOG_READS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1394}
1395
1396/// A private file written whole beside, flushed to the disk, then put in place.
1397fn write_flushed(path: &Path, bytes: &[u8]) -> Result<(), String> {
1398    use std::io::Write;
1399    let fail = |e: std::io::Error| format!("{}: {e}", path.display());
1400    if let Some(dir) = path.parent() {
1401        std::fs::create_dir_all(dir).map_err(fail)?;
1402    }
1403    let beside = path.with_extension("part");
1404    let mut options = std::fs::OpenOptions::new();
1405    options.create(true).write(true).truncate(true);
1406    #[cfg(unix)]
1407    std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
1408    let mut file = options.open(&beside).map_err(fail)?;
1409    file.write_all(bytes).map_err(fail)?;
1410    file.sync_all().map_err(fail)?;
1411    std::fs::rename(&beside, path).map_err(fail)
1412}
1413
1414/// When the carriers' limits were last asked in this process (ms): once an hour at most.
1415static LIMITS_ASKED: Mutex<i64> = Mutex::new(0);
1416/// When Java's results were last trimmed in this process (ms): once a day.
1417static RESULTS_TRIMMED: Mutex<i64> = Mutex::new(0);
1418
1419/// Where a device stands, by the sharing's two sets (`share::asking`).
1420fn standing_in(counted: &BTreeSet<String>, gone: &BTreeSet<String>, device: &str) -> rules::Standing {
1421    if counted.contains(device) {
1422        rules::Standing::Counted
1423    } else if gone.contains(device) {
1424        rules::Standing::Gone
1425    } else {
1426        rules::Standing::Unknown
1427    }
1428}
1429
1430/// Where each device that may ask this phone stands in the sharing now
1431/// (`share::asking`), for the replies' step (replies.rs).
1432pub(crate) fn standing_of(folder: &Path, key: &[u8; 32], here: &str, now: i64) -> impl Fn(&str) -> rules::Standing {
1433    let (counted, gone) = sioul_sync::share::asking(folder, key, &crate::share::memory_path(), here, now);
1434    move |device: &str| standing_in(&counted, &gone, device)
1435}
1436
1437/// The quick part of the phone's step, at every exchange: Android's words
1438/// copied, the requests decided and sent, a lost claim looked for, what the
1439/// carriers let it send said (once an hour at most), what a multimedia
1440/// message left behind swept. Under one lock for the phone's processes.
1441/// Whether something was written.
1442fn light_step(here: &str, seal: &Seal, region: Option<&'static phones::Region>, vault: Option<&Vault>) -> bool {
1443    // Where the devices stand in the sharing, read only when a request not decided yet asks (`texts::verdict`).
1444    let standings: std::cell::OnceCell<(BTreeSet<String>, BTreeSet<String>)> = std::cell::OnceCell::new();
1445    let standing = |device: &str| {
1446        let (counted, gone) = standings.get_or_init(|| vault.map(|(folder, key)| sioul_sync::share::asking(folder, key, &crate::share::memory_path(), here, now_ms())).unwrap_or_default());
1447        standing_in(counted, gone, device)
1448    };
1449    let step = Step::here(here, seal, region, vault, &standing);
1450    sioul_core::filelock::with_lock(&step.private.join(".step"), || {
1451        let now = now_ms();
1452        let mut changed = step.copy_results(now);
1453        let trim_due = RESULTS_TRIMMED.lock().is_ok_and(|mut last| {
1454            let due = now - *last >= DAY_MS;
1455            if due {
1456                *last = now;
1457            }
1458            due
1459        });
1460        if trim_due && let Err(e) = trim_results(&step.private, now) {
1461            eprintln!("sioul: texts: Java's results not trimmed: {e}");
1462        }
1463        changed |= step.decide(now);
1464        changed |= step.look_for_lost(now);
1465        let due = LIMITS_ASKED.lock().is_ok_and(|mut asked| {
1466            let due = now - *asked >= 3_600_000;
1467            if due {
1468                *asked = now;
1469            }
1470            due
1471        });
1472        if due {
1473            changed |= step.publish_limits(now);
1474        }
1475        step.sweep_outgoing(now);
1476        changed
1477    })
1478}
1479
1480/// Each outcome's row by its name in the log (`texts::Row::name`): Android
1481/// says a sent message's row by its id alone, which an older one may have
1482/// held (the plain texts' table gives a deleted newest text's id again); the
1483/// row's date, read from Android now, tells which it is (a multimedia
1484/// message's in seconds). A row Android no longer holds is left unnamed,
1485/// never named after an older message.
1486fn name_rows(android: &dyn Android, outcomes: &mut [Outcome]) {
1487    let bare = |row: &str, table: &str| row.strip_prefix(table).and_then(|rest| rest.strip_prefix('-')).filter(|id| !id.contains('@')).and_then(|id| id.parse::<i64>().ok());
1488    let sms_ids: Vec<i64> = outcomes.iter().filter_map(|o| bare(&o.row, "sms")).collect();
1489    let mms_ids: Vec<i64> = outcomes.iter().filter_map(|o| bare(&o.row, "mms")).collect();
1490    if sms_ids.is_empty() && mms_ids.is_empty() {
1491        return;
1492    }
1493    let answer = android.ask("texts-read", &json!({ "sms_ids": sms_ids, "mms_ids": mms_ids, "limit": sms_ids.len() + mms_ids.len() }));
1494    let dates: BTreeMap<(bool, i64), i64> = if answer["allowed"] == true { raws(&answer).iter().map(|r| ((r.kind == "mms", int(&r.fields, "_id")), int(&r.fields, "date"))).collect() } else { BTreeMap::new() };
1495    for o in outcomes.iter_mut() {
1496        for (mms, table) in [(false, "sms"), (true, "mms")] {
1497            if let Some(id) = bare(&o.row, table) {
1498                o.row = dates.get(&(mms, id)).map(|date| rules::Row { mms, id, date: *date }.name()).unwrap_or_default();
1499            }
1500        }
1501    }
1502}
1503
1504/// The requests under each root (`<root>/send/<device>.jsonl`), each key
1505/// once, the first root's first: the ledger then decides each once.
1506fn requests_from(roots: &[PathBuf], seal: &Seal) -> Vec<Request> {
1507    let mut requests: Vec<Request> = Vec::new();
1508    for root in roots {
1509        for request in rules::read_requests(root, seal) {
1510            if !requests.iter().any(|known| known.key == request.key) {
1511                requests.push(request);
1512            }
1513        }
1514    }
1515    requests
1516}
1517
1518/// The part that may take a while, never inside an exchange: the import's
1519/// next step when due (`import_now`: caught up, what came; a history, all of
1520/// it, on the charger), else the newest texts while a history waits for the
1521/// charger (`recent_now`); and the day's look for deletions. Under its own
1522/// lock: one import at a time on the phone.
1523fn heavy_step(here: &str, seal: &Seal, vault: &Vault, region: Option<&phones::Region>) -> bool {
1524    sioul_core::filelock::with_lock(&private().join(".import"), || {
1525        let now = now_ms();
1526        let (texts_changed, plugged) = changes(true);
1527        let reader = Reader::load();
1528        let files = Files::of(here);
1529        let mut changed = false;
1530        if import_now(&reader, texts_changed, plugged, now) {
1531            changed = import_step(&Phone, &files, seal, vault, region, now, plugged);
1532        } else if recent_now(&reader, texts_changed) {
1533            changed = import_step(&Phone, &files, seal, vault, region, now, false);
1534        }
1535        changed |= look_again(&Phone, &files, seal, now);
1536        // A conversation deleted in Sioul on a computer: its texts leave this log, every copy following.
1537        match rules::drop_hidden(&files.log, &rules::Marks::read_all(&root(), seal), seal) {
1538            Ok(gone) => changed |= gone > 0,
1539            Err(e) => eprintln!("sioul: texts: {e}"),
1540        }
1541        changed
1542    })
1543}
1544
1545/// Java's word on the phone (`Texts.changes`): whether its texts changed
1546/// since it was last asked with `clear` (a text came, was sent, deleted or
1547/// read: Android's provider watched by the background service; not watched
1548/// in this process, or no answer: yes, as at every step before), and whether
1549/// the phone is on its charger.
1550fn changes(clear: bool) -> (bool, bool) {
1551    let answer = crate::steps::java("texts-changes", &json!({ "clear": clear }).to_string());
1552    (answer["changed"] != false, answer["plugged"] == true)
1553}
1554
1555/// Whether the import reads the phone's texts now, at `now` (ms;
1556/// docs/texts.md, "Reading, on the phone: paced"). A history (the first
1557/// import's, or a block of old texts put at once), all of it only while the
1558/// phone charges: it may write and send hundreds of megabytes, on mobile
1559/// data too. Caught up, when the phone's texts changed since the last look,
1560/// when the last step left texts that came (their media over its share), and
1561/// with the day's look for deletions all the same (a change Android did not
1562/// say): most steps find nothing new, and ask Android nothing.
1563fn import_now(reader: &Reader, changed: bool, plugged: bool, now: i64) -> bool {
1564    if reader.started == 0 || reader.behind { plugged } else { changed || reader.left || !reader.indexed || now - reader.looked >= DAY_MS }
1565}
1566
1567/// Whether the newest texts are read now while a history waits for the
1568/// charger (`import_now`): at a first import, at the first step of a reader
1569/// of an older version (its log tells which rows it holds), then each time
1570/// the phone's texts changed, on the battery too; only the rows that came
1571/// and the history's newest (`import_step`), so that a text that comes
1572/// during a long import reaches your computers within a minute or two.
1573fn recent_now(reader: &Reader, changed: bool) -> bool {
1574    reader.started == 0 || !reader.indexed || (reader.behind && changed)
1575}
1576
1577/// A bulk import going on, on the charger or waiting for it (`import_now`):
1578/// the sharing's full rounds wait meanwhile (`steps`), so that it does not
1579/// restate the records again and again (8 October 2026: a first import made
1580/// six full rounds, each sent twice).
1581pub(crate) fn importing_in_bulk() -> bool {
1582    phone() && sealed().is_some() && Reader::load().behind
1583}
1584
1585/// The background service's step, after its exchange: the quick part, then
1586/// the import's next batch. Whether something was written to share now.
1587pub(crate) fn phone_step() -> bool {
1588    if !phone() {
1589        return false;
1590    }
1591    let Some((here, seal, Some(vault))) = sealed() else { return false };
1592    let region = sioul_core::reach::region(&load_config());
1593    let quick = light_step(&here, &seal, region, Some(&vault));
1594    heavy_step(&here, &seal, &vault, region) || quick
1595}
1596
1597// ---------------------------------------------------------------- every device
1598
1599/// When this device last took its old requests and outcomes out (ms): once a day.
1600static TRIMMED: Mutex<i64> = Mutex::new(0);
1601/// The import (a phone's window) or the media's fetching (a computer) running in a thread of this process.
1602static WORKING: AtomicBool = AtomicBool::new(false);
1603
1604/// Before an exchange: on a phone, the quick part of its step; on a
1605/// computer, how far its clock may run ahead of the phone's. Then, in a
1606/// thread of its own that never holds the exchange: the import's next batch
1607/// (a phone's window; the background service runs it after its exchange) or
1608/// the media brought here (a computer); what came said to the window (`qt`)
1609/// as an exchange's news are (`shared_in`), so that the pages showing texts
1610/// read them again at once. Once a day, old requests and outcomes out
1611/// (texts never).
1612pub(crate) fn before_exchange(qt: Option<&QtThread>) {
1613    let Some((here, seal, vault)) = sealed() else { return };
1614    if phone() {
1615        light_step(&here, &seal, sioul_core::reach::region(&load_config()), vault.as_ref());
1616    } else {
1617        measure_clock();
1618    }
1619    if vault.is_some() && !crate::steps::in_service() && !WORKING.swap(true, Ordering::AcqRel) {
1620        let qt = qt.cloned();
1621        std::thread::spawn(move || {
1622            let mut came = false;
1623            if let Some((here, seal, Some(vault))) = sealed() {
1624                came = if phone() { heavy_step(&here, &seal, &vault, sioul_core::reach::region(&load_config())) } else { fetch_media(&seal, &vault) };
1625            }
1626            WORKING.store(false, Ordering::Release);
1627            if came && let Some(qt) = qt {
1628                let store = if phone() { "data/texts/log/" } else { "data/texts/media/" };
1629                let _ = qt.queue(move |mut sioul| sioul.as_mut().shared_in(QString::from(store)));
1630            }
1631        });
1632    }
1633    let now = now_ms();
1634    let due = TRIMMED.lock().map(|mut last| {
1635        let due = now - *last > 86_400_000;
1636        if due {
1637            *last = now;
1638        }
1639        due
1640    });
1641    if due.unwrap_or(false) {
1642        let done = rules::trim_own(&root(), &here, &seal, now).and_then(|_| rules::trim_others(&root(), &here, &seal, now));
1643        if phone() {
1644            let _ = rules::trim_ledger(&private(), now);
1645        }
1646        if let Err(e) = done {
1647            eprintln!("sioul: texts: {e}");
1648        }
1649    }
1650}
1651
1652// ---------------------------------------------------------------- the media kept here
1653
1654/// A media file kept here, sealed: `texts/media/<hash>` in the data folder.
1655fn media_path(hash: &str) -> PathBuf {
1656    root().join(rules::MEDIA).join(hash.chars().filter(char::is_ascii_hexdigit).collect::<String>())
1657}
1658
1659fn here_media(hash: &str) -> bool {
1660    !hash.is_empty() && media_path(hash).exists()
1661}
1662
1663/// The phones' logs as last seen by `fetch_media` (each file's size and
1664/// time), and whether media were left for next time: nothing new, nothing done.
1665static FETCHED: Mutex<(Vec<(u64, u128)>, bool)> = Mutex::new((Vec::new(), true));
1666
1667/// The media the texts name and this device has not yet: brought from the
1668/// sharing's blobs, sealed here, `MEDIA_BATCH` bytes at a time (the first
1669/// always); one the sync app has not brought yet waits for next time.
1670/// Whether one was brought.
1671fn fetch_media(seal: &Seal, vault: &Vault) -> bool {
1672    let (folder, key) = vault;
1673    let mut logs: Vec<(u64, u128)> = std::fs::read_dir(root().join(rules::LOG))
1674        .into_iter()
1675        .chain(std::fs::read_dir(root().join(rules::SEND)))
1676        .flatten()
1677        .filter_map(|entry| entry.ok()?.metadata().ok())
1678        .map(|m| (m.len(), m.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(0, |d| d.as_nanos())))
1679        .collect();
1680    logs.sort_unstable();
1681    if FETCHED.lock().is_ok_and(|seen| seen.0 == logs && !seen.1) {
1682        return false;
1683    }
1684    let mut wanted: Vec<(String, u64)> = rules::read_logs(&root(), seal).iter().flat_map(|t| t.parts.iter()).filter(|p| p.state == "here" && !p.hash.is_empty() && !here_media(&p.hash)).map(|p| (p.hash.clone(), p.size)).collect();
1685    // A multimedia message written on another computer, within the last day: its media, so that it
1686    // shows here too (the phone's log names them once it sent it, read back with its parts).
1687    let since = now_ms() - DAY_MS;
1688    wanted.extend(rules::read_requests(&root(), seal).iter().filter(|r| r.written >= since).flat_map(|r| r.media.iter()).filter(|m| !here_media(&m.hash)).map(|m| (m.hash.clone(), m.size)));
1689    wanted.sort();
1690    wanted.dedup();
1691    let mut budget = rules::MEDIA_BATCH;
1692    let (mut brought, mut left) = (0, false);
1693    // Sioul keeping the folder itself fetches a sealed file only by its
1694    // name (`blobs::Wanted::media`): the media not in its copy are asked
1695    // for, those brought are no longer (review of 17 October 2026, M4).
1696    let asking = crate::share::keeps_itself();
1697    let (mut ask, mut have): (Vec<(String, String, u64)>, Vec<String>) = (Vec::new(), Vec::new());
1698    for (hash, size) in wanted {
1699        if size > budget && budget < rules::MEDIA_BATCH {
1700            left = true;
1701            break;
1702        }
1703        let incoming = private().join("incoming").join(&hash);
1704        if let Some(dir) = incoming.parent() {
1705            let _ = std::fs::create_dir_all(dir);
1706        }
1707        if sioul_sync::blobs::get(folder, key, &hash, &incoming).is_err() {
1708            if asking {
1709                ask.push((sioul_sync::blobs::name(key, &hash), hash.clone(), size));
1710            }
1711            left = true;
1712            continue;
1713        }
1714        if asking {
1715            have.push(sioul_sync::blobs::name(key, &hash));
1716        }
1717        let bytes = std::fs::read(&incoming);
1718        let _ = std::fs::remove_file(&incoming);
1719        let Ok(bytes) = bytes else {
1720            left = true;
1721            continue;
1722        };
1723        if let Err(e) = write_whole(&media_path(&hash), &seal.seal_bytes(&bytes)) {
1724            eprintln!("sioul: texts: {e}");
1725            left = true;
1726            break;
1727        }
1728        budget = budget.saturating_sub(bytes.len() as u64);
1729        brought += 1;
1730    }
1731    if asking && (!ask.is_empty() || !have.is_empty()) {
1732        let now = jiff::Timestamp::now().as_second();
1733        let _ = sioul_sync::blobs::Wanted::change(&crate::share::memory_path(), |wanted| {
1734            for name in &have {
1735                wanted.media.remove(name);
1736            }
1737            for (name, hash, size) in ask {
1738                wanted.media.entry(name).or_insert_with(|| sioul_sync::blobs::Want { hash, size, since: now, ..Default::default() });
1739            }
1740        });
1741    }
1742    if let Ok(mut seen) = FETCHED.lock() {
1743        *seen = (logs, left);
1744    }
1745    if brought > 0 {
1746        eprintln!("sioul: texts: {brought} media file(s) brought");
1747    }
1748    brought > 0
1749}
1750
1751/// A medium the page shows that is not here: Sioul keeping the folder itself
1752/// asks for it again as if new, once it had stopped asking (three days in
1753/// vain, `sioul_sync::remote::sealed_step`; review of 18 October 2026, L-8).
1754fn ask_media_again(hash: &str) {
1755    if hash.is_empty() || !crate::share::keeps_itself() {
1756        return;
1757    }
1758    let Some((_, _, Some((_, key)))) = sealed() else { return };
1759    let name = sioul_sync::blobs::name(&key, hash);
1760    let now = jiff::Timestamp::now().as_second();
1761    let _ = sioul_sync::blobs::Wanted::change(&crate::share::memory_path(), |wanted| {
1762        if let Some(want) = wanted.media.get_mut(&name)
1763            && now - want.since >= 3 * 86_400
1764        {
1765            (want.since, want.again, want.tries) = (now, 0, 0);
1766        }
1767    });
1768}
1769
1770/// Where a media file is opened for the page: Sioul's cache (yours alone), emptied when the page closes.
1771fn opened_folder() -> PathBuf {
1772    sioul_core::config::cache_dir().join(rules::FOLDER)
1773}
1774
1775/// A media file opened for the page or saved: its plain copy in `opened_folder`; none when it is not here.
1776fn open_media(hash: &str, ct: &str) -> Option<PathBuf> {
1777    let path = media_path(hash);
1778    let extension = ct.split('/').nth(1).and_then(|s| s.split(['+', ';', '.', '-']).next()).filter(|e| !e.is_empty() && e.chars().all(|c| c.is_ascii_alphanumeric())).unwrap_or("bin");
1779    let target = opened_folder().join(format!("{}.{extension}", path.file_name()?.to_string_lossy()));
1780    // Opened already for the page: not read and opened again each time a row shows it.
1781    if target.exists() {
1782        return Some(target);
1783    }
1784    let (_, seal, _) = sealed()?;
1785    let plain = seal.open_bytes(&std::fs::read(&path).ok()?)?;
1786    write_whole(&target, &plain).ok()?;
1787    Some(target)
1788}
1789
1790// ---------------------------------------------------------------- a computer: the clock
1791
1792/// How far this computer's clock may run ahead of a phone's (`texts/clock.json`
1793/// in the state folder): for each phone, the last time its entry said, and
1794/// the least gap seen in the last day.
1795#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1796struct Clock {
1797    #[serde(default)]
1798    phones: BTreeMap<String, Gap>,
1799}
1800
1801#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1802struct Gap {
1803    /// The phone's entry's last `exported` (seconds, the phone's clock).
1804    seen: i64,
1805    /// The least gap (ms) and when it was seen.
1806    least: i64,
1807    at: i64,
1808}
1809
1810impl Clock {
1811    fn path() -> PathBuf {
1812        private().join("clock.json")
1813    }
1814}
1815
1816/// At each exchange: the sending phone's entry's time, when it changed,
1817/// compared with this computer's clock; the least such gap of the last day
1818/// is how far this clock may run ahead of the phone's (transit included: an
1819/// upper bound, so that a request looks older, never younger).
1820fn measure_clock() {
1821    // Every half minute at most: the devices' entries are opened to read it.
1822    static LOOKED: Mutex<i64> = Mutex::new(0);
1823    let now = now_ms();
1824    if let Ok(mut looked) = LOOKED.lock() {
1825        if now - *looked < 30_000 {
1826            return;
1827        }
1828        *looked = now;
1829    }
1830    let Some(phone) = sending_phone() else { return };
1831    let mut clock: Clock = std::fs::read_to_string(Clock::path()).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
1832    let gap = clock.phones.entry(phone.id.clone()).or_default();
1833    if phone.exported <= 0 || phone.exported == gap.seen {
1834        return;
1835    }
1836    let sample = now - phone.exported * 1000;
1837    gap.seen = phone.exported;
1838    if gap.at == 0 || now - gap.at > 86_400_000 || sample < gap.least {
1839        gap.least = sample;
1840        gap.at = now;
1841    }
1842    if let Ok(text) = serde_json::to_string(&clock) {
1843        let _ = write_whole(&Clock::path(), text.as_bytes());
1844    }
1845}
1846
1847/// How far this clock may run ahead of a phone's (ms): the least gap of the last day, 0 unknown.
1848pub(crate) fn ahead_of(phone: &str) -> i64 {
1849    let clock: Clock = std::fs::read_to_string(Clock::path()).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
1850    clock.phones.get(phone).filter(|g| now_ms() - g.at <= 86_400_000).map_or(0, |g| g.least.max(0))
1851}
1852
1853// ---------------------------------------------------------------- a computer: the phone that sends
1854
1855/// The phones in the sharing, the one chosen to send first, else the one that shared last.
1856pub(crate) fn phones() -> Vec<sioul_sync::devices::Entry> {
1857    let Some((_, Some((folder, key)))) = crate::share::vault() else { return Vec::new() };
1858    let mut all: Vec<sioul_sync::devices::Entry> = sioul_sync::devices::all(&folder, &key).0.into_iter().filter(|d| d.kind == sioul_sync::devices::PHONE).collect();
1859    let chosen = choice();
1860    all.sort_by_key(|d| (d.id != chosen.phone, -d.exported));
1861    all
1862}
1863
1864fn sending_phone() -> Option<sioul_sync::devices::Entry> {
1865    if demo() && !phone() {
1866        // The demo's stand-in phone, which shared two minutes ago.
1867        let exported = jiff::Timestamp::now().as_second() - 120;
1868        return Some(sioul_sync::devices::Entry { id: "demo-phone".into(), name: String::new(), kind: sioul_sync::devices::PHONE.into(), exported, ..Default::default() });
1869    }
1870    phones().into_iter().next()
1871}
1872
1873// ---------------------------------------------------------------- a computer: the page
1874
1875fn names(senders: std::rc::Rc<Senders>) -> impl Fn(&str) -> Option<String> {
1876    move |key: &str| {
1877        let judged = senders.judge_number(key);
1878        (!judged.card.trim().is_empty()).then_some(judged.card)
1879    }
1880}
1881
1882thread_local! {
1883    /// The settings and your lists and address books, as the page last read
1884    /// them: read again when the settings' file changed, or after a minute.
1885    static LOOKS: std::cell::RefCell<Option<(Option<(u64, std::time::SystemTime)>, std::time::Instant, std::rc::Rc<Config>, std::rc::Rc<Senders>)>> = const { std::cell::RefCell::new(None) };
1886}
1887
1888/// The settings and your senders for the page's looks (`LOOKS`): each
1889/// opening of a conversation read them twice, on the window's thread.
1890fn looks() -> (std::rc::Rc<Config>, std::rc::Rc<Senders>) {
1891    let stamp = std::fs::metadata(config_dir().join("config.toml")).ok().map(|m| (m.len(), m.modified().unwrap_or(std::time::UNIX_EPOCH)));
1892    LOOKS.with(|cell| {
1893        let mut kept = cell.borrow_mut();
1894        if let Some((s, at, config, senders)) = kept.as_ref()
1895            && *s == stamp
1896            && at.elapsed() < std::time::Duration::from_secs(60)
1897        {
1898            return (std::rc::Rc::clone(config), std::rc::Rc::clone(senders));
1899        }
1900        let config = std::rc::Rc::new(load_config());
1901        let senders = std::rc::Rc::new(Senders::load(&config));
1902        *kept = Some((stamp, std::time::Instant::now(), std::rc::Rc::clone(&config), std::rc::Rc::clone(&senders)));
1903        (config, senders)
1904    })
1905}
1906
1907/// The texts, the requests and their outcomes as last read for this device:
1908/// the whole history is opened once, not at each look of the page (9,800
1909/// sealed lines read twice per conversation opened froze the window for
1910/// seconds); then only the lines the phones' logs added (`rules::Logs`: the
1911/// page reads itself again at each exchange that brings a text), and the
1912/// requests and outcomes again when their files changed (size and time).
1913/// The texts each once across phones (`rules::one_each`), with the country
1914/// their numbers were keyed by, without the rows' raw columns (`fields`,
1915/// `addrs`), which only the fingerprints read.
1916struct Kept {
1917    here: String,
1918    logs: rules::Logs,
1919    stamp: Vec<(PathBuf, u64, std::time::SystemTime)>,
1920    region: Option<&'static str>,
1921    texts: std::sync::Arc<Vec<Text>>,
1922    requests: std::sync::Arc<Vec<Request>>,
1923    outcomes: std::sync::Arc<Vec<Outcome>>,
1924    /// Every device's marks (read, hidden), and the texts without those hidden (23 October 2026).
1925    marks: rules::Marks,
1926    visible: std::sync::Arc<Vec<Text>>,
1927}
1928
1929static KEPT: Mutex<Option<Kept>> = Mutex::new(None);
1930
1931/// Each file of the requests and the outcomes, its size and time.
1932fn stamps(root: &Path) -> Vec<(PathBuf, u64, std::time::SystemTime)> {
1933    let mut all = Vec::new();
1934    for store in [rules::SEND, rules::OUTCOME] {
1935        for entry in std::fs::read_dir(root.join(store)).into_iter().flatten().flatten() {
1936            if let Ok(meta) = entry.metadata() {
1937                all.push((entry.path(), meta.len(), meta.modified().unwrap_or(std::time::UNIX_EPOCH)));
1938            }
1939        }
1940    }
1941    all.sort();
1942    all
1943}
1944
1945/// The texts, the requests and their outcomes (`KEPT`): the logs read on
1946/// where they grew, the texts made again only then (or for another
1947/// country); the requests and outcomes read again when a file changed. The
1948/// texts each once, a text found in two phones' logs (a restore onto a new
1949/// phone) shown once (`rules::one_each`).
1950#[allow(clippy::type_complexity)]
1951fn records(here: &str, seal: &Seal) -> (std::sync::Arc<Vec<Text>>, std::sync::Arc<Vec<Request>>, std::sync::Arc<Vec<Outcome>>) {
1952    let root = root();
1953    let stamp = stamps(&root);
1954    let region = sioul_core::reach::region(&looks().0);
1955    let mut kept = KEPT.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1956    let k = match kept.take() {
1957        Some(k) if k.here == here => kept.insert(k),
1958        _ => kept.insert(Kept {
1959            here: here.to_string(),
1960            logs: rules::Logs::default(),
1961            stamp: Vec::new(),
1962            region: None,
1963            texts: std::sync::Arc::default(),
1964            requests: std::sync::Arc::default(),
1965            outcomes: std::sync::Arc::default(),
1966            marks: rules::Marks::default(),
1967            visible: std::sync::Arc::default(),
1968        }),
1969    };
1970    let first = k.stamp.is_empty() && k.texts.is_empty() && k.requests.is_empty();
1971    let mut texts_changed = first;
1972    if k.logs.refresh(&root, seal) || k.region != region.map(|r| r.code) {
1973        // Without their raw columns, which only the fingerprints read (`Logs`).
1974        k.texts = std::sync::Arc::new(k.logs.one_each(region));
1975        k.region = region.map(|r| r.code);
1976        texts_changed = true;
1977    }
1978    // A conversation deleted in Sioul, on any device: its texts until then are not shown here.
1979    let marks = rules::Marks::read_all(&root, seal);
1980    if texts_changed || marks.hidden != k.marks.hidden {
1981        k.visible = if marks.hidden.is_empty() { std::sync::Arc::clone(&k.texts) } else { std::sync::Arc::new(rules::visible(&k.texts, &marks)) };
1982    }
1983    k.marks = marks;
1984    if first || k.stamp != stamp {
1985        k.requests = std::sync::Arc::new(rules::read_requests(&root, seal));
1986        k.outcomes = std::sync::Arc::new(rules::read_outcomes(&root, seal));
1987        k.stamp = stamp;
1988    }
1989    let requests = if k.marks.hidden.is_empty() {
1990        std::sync::Arc::clone(&k.requests)
1991    } else {
1992        // The texts written from here to a conversation deleted since: gone with it.
1993        let hidden = &k.marks.hidden;
1994        std::sync::Arc::new(k.requests.iter().filter(|r| hidden.get(&phones::key(&r.to, region)).is_none_or(|upto| r.written > *upto)).cloned().collect())
1995    };
1996    (std::sync::Arc::clone(&k.visible), requests, std::sync::Arc::clone(&k.outcomes))
1997}
1998
1999/// Every device's marks, and every text with those hidden: for the Porch's
2000/// folding (a notification of a text deleted since folds into nothing shown).
2001fn marked(here: &str, seal: &Seal) -> (rules::Marks, std::sync::Arc<Vec<Text>>) {
2002    let _ = records(here, seal);
2003    let kept = KEPT.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
2004    kept.as_ref().map_or_else(Default::default, |k| (k.marks.clone(), std::sync::Arc::clone(&k.texts)))
2005}
2006
2007/// The Porch's notices of texts not read yet (23 October 2026): one per
2008/// conversation, its sender and the newest's first words, opening it in
2009/// Texts; [] where texts are not read here.
2010pub(crate) fn notices() -> Value {
2011    if phone() || !readable() {
2012        return json!([]);
2013    }
2014    let Some((here, seal, _)) = sealed() else { return json!([]) };
2015    let (config, senders) = looks();
2016    let now = Zoned::now();
2017    let name_of = names(senders);
2018    let region = sioul_core::reach::region(&config);
2019    let (texts, _, _) = records(&here, &seal);
2020    let (marks, _) = marked(&here, &seal);
2021    let mut list: Vec<(i64, Value)> = rules::unread(&texts, &marks, now.timestamp().as_millisecond())
2022        .into_iter()
2023        .map(|(id, unread)| {
2024            let newest = unread[0];
2025            let who = newest.with.iter().map(|k| name_of(k).unwrap_or_else(|| sioul_core::calls::shown_number(k, region))).collect::<Vec<_>>().join(", ");
2026            let first: String = if newest.body.is_empty() { tr().text("phonemsgs-picture", None) } else { newest.body.chars().take(80).collect() };
2027            (newest.at, json!({ "conversation": id, "who": who, "first": first, "when": rules::time_of_day(newest.at, &now), "count": unread.len() }))
2028        })
2029        .collect();
2030    list.sort_by(|a, b| b.0.cmp(&a.0));
2031    Value::Array(list.into_iter().map(|(_, v)| v).collect())
2032}
2033
2034/// Whether a phone's notification of a text (its sender's key, its time) is
2035/// a text the Texts page holds, or held before its conversation was
2036/// deleted: folded into it, never a second item on the Porch. None where
2037/// texts are not read here: every notification stays a line.
2038pub(crate) fn folding() -> Option<impl Fn(&str, i64) -> bool> {
2039    if phone() || !readable() {
2040        return None;
2041    }
2042    let (here, seal, _) = sealed()?;
2043    let (_, all) = marked(&here, &seal);
2044    Some(move |key: &str, at_ms: i64| rules::folded_into(key, at_ms, &all).is_some())
2045}
2046
2047/// A conversation opened in Texts: read up to its newest text, on every
2048/// device, and the phone's notifications of it seen (gone from the Porch's notices).
2049fn mark_read(here: &str, seal: &Seal, id: &str, texts: &[Text]) {
2050    let newest = texts.iter().filter(|t| t.direction == "in" && rules::conversation_id(&t.with) == id).map(|t| t.at).max().unwrap_or(0);
2051    let read = KEPT.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref().and_then(|k| k.marks.read.get(id).copied()).unwrap_or(0);
2052    if newest > read {
2053        let mark = rules::TextMark { at: now_ms(), conversation: id.to_string(), read: newest, ..rules::TextMark::default() };
2054        if let Err(e) = rules::write_mark(&root(), here, &mark, seal) {
2055            eprintln!("sioul: texts: {e}");
2056        }
2057    }
2058    crate::phonemsgs::seen_for(&id.split(',').map(str::to_string).collect::<Vec<_>>());
2059}
2060
2061/// A conversation deleted in Sioul (23 October 2026): Sioul is never the
2062/// phone's SMS app (it asks no SMS role, docs/texts.md), so Android lets it
2063/// delete nothing from the phone's provider. Its texts until now leave
2064/// Sioul on every device (a sealed mark; the phone writes its log again
2065/// without them, so every computer's copy follows); the phone's own
2066/// texting app keeps them. A text that comes later starts it again.
2067fn delete(id: &str) -> Value {
2068    let Some((here, seal, _)) = sealed() else { return json!({ "said": tr().text("texts-page-off", None) }) };
2069    let (texts, requests, _) = records(&here, &seal);
2070    let region = sioul_core::reach::region(&looks().0);
2071    let upto = texts
2072        .iter()
2073        .filter(|t| rules::conversation_id(&t.with) == id)
2074        .map(|t| t.at)
2075        .chain(requests.iter().filter(|r| phones::key(&r.to, region) == id).map(|r| r.written))
2076        .max()
2077        .unwrap_or(0)
2078        .max(now_ms());
2079    let mark = rules::TextMark { at: now_ms(), conversation: id.to_string(), read: upto, hidden: upto };
2080    if let Err(e) = rules::write_mark(&root(), &here, &mark, &seal) {
2081        return json!({ "said": e });
2082    }
2083    crate::phonemsgs::seen_for(&id.split(',').map(str::to_string).collect::<Vec<_>>());
2084    forget_hidden_media(&here, &seal);
2085    json!({ "said": "" })
2086}
2087
2088/// The media of hidden texts gone from this device's copies, unless a text shown holds them too.
2089fn forget_hidden_media(here: &str, seal: &Seal) {
2090    let (marks, all) = marked(here, seal);
2091    let shown: BTreeSet<&str> = all.iter().filter(|t| !marks.hides(t)).flat_map(|t| t.parts.iter().map(|p| p.hash.as_str())).collect();
2092    for hash in all.iter().filter(|t| marks.hides(t)).flat_map(|t| t.parts.iter().map(|p| p.hash.as_str())).filter(|h| !h.is_empty() && !shown.contains(h)) {
2093        let _ = std::fs::remove_file(media_path(hash));
2094    }
2095}
2096
2097/// The Texts page's list (TextsPage.qml): {can (the part on, a phone that
2098/// sends), phone: {id, name, shared, said}, conversations, said, never,
2099/// empty (the list's words when it has no row)}; without them, `can` false and
2100/// why. With `query`, the conversations holding a text with all its words (or
2101/// whose people's names or numbers do), each with the newest such text as its
2102/// last words.
2103fn view(query: &str) -> Value {
2104    let tr = tr();
2105    let Some((here, seal, _)) = sealed() else { return json!({ "can": false, "said": tr.text("texts-page-off", None), "conversations": [] }) };
2106    let Some(phone) = sending_phone() else { return json!({ "can": false, "said": tr.text("texts-page-no-phone", None), "conversations": [] }) };
2107    let (config, senders) = looks();
2108    let now = Zoned::now();
2109    let name_of = names(senders);
2110    let shared = (phone.exported > 0).then_some(phone.exported * 1000);
2111    let v = rules::Viewer { now: &now, tr, region: sioul_core::reach::region(&config), name_of: &name_of, phone_shared: shared, media_here: &here_media };
2112    let (texts, requests, _) = records(&here, &seal);
2113    let mut conversations = rules::conversations(&texts, &requests, &v);
2114    let query = query.trim();
2115    if !query.is_empty() {
2116        // The newest text found in each conversation: its words, under its name.
2117        let mut found: BTreeMap<String, String> = BTreeMap::new();
2118        for f in rules::search(query, &texts, &requests, &[], &v, usize::MAX) {
2119            found.entry(f.conversation).or_insert_with(|| f.message.body.chars().take(80).collect());
2120        }
2121        conversations.retain(|c| found.contains_key(&c.id));
2122        for c in &mut conversations {
2123            if let Some(words) = found.get(&c.id).filter(|w| !w.is_empty()) {
2124                c.last = words.clone();
2125            }
2126        }
2127    }
2128    let mut rows = with_drafts(conversations, &sioul_core::textdraft::all(&seal), &v);
2129    // Unread until opened here (23 October 2026).
2130    let (marks, _) = marked(&here, &seal);
2131    let unread = rules::unread(&texts, &marks, now.timestamp().as_millisecond());
2132    if let Value::Array(list) = &mut rows {
2133        for row in list.iter_mut() {
2134            let id = row["id"].as_str().unwrap_or_default().to_string();
2135            row["unread"] = json!(unread.get(&id).map_or(0, Vec::len));
2136        }
2137    }
2138    if !query.is_empty()
2139        && let Value::Array(list) = &mut rows
2140    {
2141        // A draft's conversation with no text yet: kept when its name or number holds the words.
2142        let words = sioul_core::rules::fold(query);
2143        list.retain(|row| row["at"].as_i64().unwrap_or(0) > 0 || words.split(' ').all(|w| sioul_core::rules::fold(&format!("{} {}", row["title"].as_str().unwrap_or(""), row["id"].as_str().unwrap_or(""))).contains(w)));
2144    }
2145    let shared_at = shared.map(|ms| rules::time_of_day(ms, &now)).unwrap_or_default();
2146    let mut args = sioul_core::i18n::args();
2147    args.set("time", shared_at.clone());
2148    json!({
2149        "can": true,
2150        "phone": { "id": phone.id, "name": phone.name, "shared": shared_at, "said": if shared_at.is_empty() { String::new() } else { tr.text("texts-page-shared", Some(&args)) } },
2151        "conversations": rows,
2152        "said": "",
2153        "never": tr.text("texts-page-never", None),
2154        "empty": tr.text(if query.is_empty() { "texts-page-empty" } else { "texts-search-none" }, None),
2155    })
2156}
2157
2158/// The conversations with the drafts an AI agent wrote for them (`textdraft`,
2159/// docs/mcp.md, "Texts"): how many wait in each, said under its last words;
2160/// a draft to someone with no conversation yet makes one, first.
2161fn with_drafts(conversations: Vec<rules::Conversation>, drafts: &[sioul_core::textdraft::TextDraft], v: &rules::Viewer) -> Value {
2162    let waiting = |id: &str| drafts.iter().filter(|d| d.conversation == id).count();
2163    let mut out: Vec<Value> = Vec::new();
2164    let mut new: Vec<&str> = drafts.iter().map(|d| d.conversation.as_str()).filter(|id| !conversations.iter().any(|c| c.id == *id)).collect();
2165    new.sort_unstable();
2166    new.dedup();
2167    for id in new {
2168        let title = id.split(',').map(|k| (v.name_of)(k).unwrap_or_else(|| sioul_core::calls::shown_number(k, v.region))).collect::<Vec<_>>().join(", ");
2169        out.push(json!({ "id": id, "with": [id], "title": title, "last": "", "when": "", "at": 0, "group": false, "drafts": waiting(id) }));
2170    }
2171    for c in conversations {
2172        let mut row = serde_json::to_value(&c).unwrap_or_default();
2173        row["drafts"] = json!(waiting(&c.id));
2174        out.push(row);
2175    }
2176    Value::Array(out)
2177}
2178
2179/// One conversation (TextsPage.qml): {id, title, messages, can_write, group,
2180/// attach (Attach offered: the phone said what its carrier takes), attach_said
2181/// (else why not)}.
2182fn conversation(id: &str) -> Value {
2183    let Some((here, seal, _)) = sealed() else { return json!({ "messages": [] }) };
2184    let (config, senders) = looks();
2185    let now = Zoned::now();
2186    let name_of = names(senders);
2187    let phone = sending_phone();
2188    let shared = phone.as_ref().filter(|p| p.exported > 0).map(|p| p.exported * 1000);
2189    let region = sioul_core::reach::region(&config);
2190    let v = rules::Viewer { now: &now, tr: tr(), region, name_of: &name_of, phone_shared: shared, media_here: &here_media };
2191    let (texts, requests, outcomes) = records(&here, &seal);
2192    let messages = rules::messages(id, &texts, &requests, &outcomes, &v);
2193    // Opened here: read, on every device, and its notices gone from the Porch.
2194    mark_read(&here, &seal, id, &texts);
2195    let with: Vec<String> = id.split(',').map(str::to_string).collect();
2196    let title = with.iter().map(|k| name_of(k).unwrap_or_else(|| sioul_core::calls::shown_number(k, region))).collect::<Vec<_>>().join(", ");
2197    // What an AI agent drafted for it: waiting for you, never sent by itself.
2198    let drafts: Vec<Value> = sioul_core::textdraft::all(&seal).into_iter().filter(|d| d.conversation == id).map(|d| json!({ "id": d.id, "body": d.body, "when": rules::time_of_day(d.written, &now) })).collect();
2199    let attach = attach_limit(phone.as_ref(), id);
2200    json!({
2201        "id": id,
2202        "title": title,
2203        "messages": messages,
2204        "drafts": drafts,
2205        "can_write": with.len() == 1,
2206        "group": with.len() > 1,
2207        "attach": attach.is_ok(),
2208        "attach_said": attach.err().map_or(String::new(), |key| tr().text(key, None)),
2209    })
2210}
2211
2212/// A draft an AI agent wrote, used (its words, for the box: sending stays
2213/// yours) or discarded: taken out of the drafts either way. {body}, "" when gone.
2214fn draft_taken(id: &str) -> Value {
2215    let Some((_, seal, _)) = sealed() else { return json!({ "body": "" }) };
2216    let body = sioul_core::textdraft::all(&seal).into_iter().find(|d| d.id == id).map(|d| d.body).unwrap_or_default();
2217    let _ = sioul_core::textdraft::remove(id, &seal);
2218    json!({ "body": body })
2219}
2220
2221/// A text written here for the phone to send: refused at once when the
2222/// phone would refuse it (said why), else a request with a key of its own in
2223/// this device's file, which the sharing carries. {said, shared, key}.
2224fn send(to: &str, body: &str, again: &str, from: &str) -> (Value, bool) {
2225    let tr = tr();
2226    let Some((here, seal, _)) = sealed() else { return (json!({ "said": tr.text("texts-page-off", None), "shared": false }), false) };
2227    // From the phone asked (a reply on the Porch: the phone the text came to), else the one that sends.
2228    let phone = if from.is_empty() || (demo() && !phone()) { sending_phone() } else { phones().into_iter().find(|p| p.id == from) };
2229    let Some(phone) = phone else { return (json!({ "said": tr.text("texts-page-no-phone", None), "shared": false }), false) };
2230    let region = sioul_core::reach::region(&load_config());
2231    if let Some(why) = rules::refusal(to, body, region) {
2232        return (json!({ "said": tr.text(&format!("texts-refused-{why}"), None), "shared": false }), false);
2233    }
2234    let request = Request { key: rules::new_key(), phone: phone.id.clone(), to: to.trim().to_string(), body: body.to_string(), sub: -1, written: now_ms(), ahead: ahead_of(&phone.id), again: again.to_string(), ..Request::default() };
2235    match rules::write_request(&root(), &here, &request, &seal) {
2236        Ok(_) => {
2237            // Its small file to the server now, not behind the records.
2238            crate::share::texts_now();
2239            (json!({ "said": "", "shared": true, "key": request.key }), true)
2240        }
2241        Err(e) => (json!({ "said": e, "shared": false }), false),
2242    }
2243}
2244
2245/// Send again: a request of its own, a new key, the same number and words;
2246/// a multimedia message's media again, sealed in the sharing already.
2247fn again(key: &str) -> (Value, bool) {
2248    let Some((_, seal, _)) = sealed() else { return (json!({ "said": tr().text("texts-page-off", None), "shared": false }), false) };
2249    match rules::read_requests(&root(), &seal).into_iter().find(|r| r.key == key) {
2250        Some(old) if old.multimedia() => request_mms(&old.to, &old.words, old.media, key, &old.phone),
2251        Some(old) => send(&old.to, &old.body, key, &old.phone),
2252        None => (json!({ "said": "", "shared": false }), false),
2253    }
2254}
2255
2256/// A multimedia message written here for the phone to send (docs/texts.md,
2257/// "Multimedia messages sent from a computer"; the media reduced already,
2258/// each its content type and bytes): refused at once when the phone would
2259/// refuse it (said why); else each medium sealed into the sharing's blobs
2260/// (content-addressed: one sent twice travels once) and into this
2261/// computer's own media, so that the message shows it at once, then the
2262/// request, whose media go up before it (`media_to_send`). Never a file's
2263/// own name: a medium is its content alone. {said, shared, key}.
2264pub(crate) fn send_mms(to: &str, words: &str, media: &[(String, Vec<u8>)], from: &str) -> (Value, bool) {
2265    let tr = tr();
2266    let Some((_, seal, Some((folder, key)))) = sealed() else { return (json!({ "said": tr.text("texts-page-off", None), "shared": false }), false) };
2267    let staging = private().join("staging");
2268    let mut named: Vec<rules::Medium> = Vec::new();
2269    for (i, (ct, bytes)) in media.iter().enumerate() {
2270        let source = staging.join(i.to_string());
2271        let hashed = write_whole(&source, bytes).and_then(|()| sioul_sync::blobs::hash_file(&source).map_err(|e| format!("{}: {e}", source.display())));
2272        let _ = std::fs::remove_file(&source);
2273        match hashed {
2274            Ok((hash, size)) => named.push(rules::Medium { hash, ct: ct.clone(), size }),
2275            Err(e) => return (json!({ "said": e, "shared": false }), false),
2276        }
2277    }
2278    let region = sioul_core::reach::region(&load_config());
2279    if let Some(why) = rules::refusal_mms(to, words, &named, &[], region) {
2280        return (json!({ "said": tr.text(&format!("texts-refused-{why}"), None), "shared": false }), false);
2281    }
2282    for ((_, bytes), medium) in media.iter().zip(&named) {
2283        let source = staging.join(&medium.hash);
2284        let sealed_there = write_whole(&source, bytes).and_then(|()| sioul_sync::blobs::put(&folder, &key, &source, &medium.hash).map(|_| ()));
2285        let _ = std::fs::remove_file(&source);
2286        if let Err(e) = sealed_there {
2287            return (json!({ "said": e, "shared": false }), false);
2288        }
2289        if !here_media(&medium.hash)
2290            && let Err(e) = write_whole(&media_path(&medium.hash), &seal.seal_bytes(bytes))
2291        {
2292            eprintln!("sioul: texts: {e}");
2293        }
2294    }
2295    request_mms(to, words, named, "", from)
2296}
2297
2298/// What one multimedia message from here may weigh (decision 5).
2299#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2300enum Weigh {
2301    /// The phone has not said what its carrier takes (its Sioul older, or not
2302    /// stepped since its update): no media offered yet.
2303    Unknown,
2304    /// Its carrier takes no multimedia messages from it.
2305    None,
2306    /// At most these bytes, whole; the carrier's largest picture's long side (0 none).
2307    Up { max: u64, side: u32 },
2308}
2309
2310/// The smaller choice of Settings ▸ Texts (kB).
2311const LOW_KB: u64 = 300;
2312
2313/// What one multimedia message from here to a conversation (`id`, its
2314/// number's key) may weigh, from a phone (decision 5): as the phone last
2315/// said its carriers take (`texts::limits_of`), for the SIM the conversation
2316/// last used (`weighed`).
2317fn weigh(phone: &str, id: &str) -> Weigh {
2318    let Some((here, seal, _)) = sealed() else { return Weigh::Unknown };
2319    let (texts, _, outcomes) = records(&here, &seal);
2320    weighed(rules::limits_of(&outcomes, phone, now_ms()).map(|(_, limits)| limits), rules::last_sim(id, phone, &texts), choice().mms_kb)
2321}
2322
2323/// What a message may weigh by what the phone said (`limits`, none before
2324/// it said): its carrier's for `sim` (the conversation's last; when it is
2325/// unknown, -1, or the phone did not name it, the smallest of those that
2326/// take multimedia messages), and no more than chosen here (`chosen_kb`,
2327/// Settings ▸ Texts: 300 kB); with the carrier's largest picture's long side.
2328fn weighed(limits: Option<&[rules::Limits]>, sim: i32, chosen_kb: Option<u64>) -> Weigh {
2329    let Some(limits) = limits else { return Weigh::Unknown };
2330    let carrier = limits.iter().find(|l| sim >= 0 && l.sub == sim).or_else(|| limits.iter().filter(|l| l.mms).min_by_key(|l| l.max)).or_else(|| limits.first());
2331    match carrier {
2332        None => Weigh::Unknown,
2333        Some(l) if !l.mms => Weigh::None,
2334        // A line with no size is none the phone wrote (`limits_from` keeps sizes only).
2335        Some(l) if l.max == 0 => Weigh::Unknown,
2336        // Never above Android's own ceiling for a multimedia message, whatever a line says.
2337        Some(l) => Weigh::Up { max: chosen_kb.map_or(l.max, |kb| (kb * 1024).min(l.max)).min(rules::MMS_MAX_EVER), side: l.width.max(l.height) },
2338    }
2339}
2340
2341/// Where the files attached on the page wait, made fit (Sioul's cache, yours alone).
2342fn attached_folder() -> PathBuf {
2343    sioul_core::config::cache_dir().join("texts-attached")
2344}
2345
2346/// Whether `path` is a file in `folder` (the files attached, made fit),
2347/// its links and `..` resolved: none elsewhere is sent as a medium.
2348fn made_in(folder: &Path, path: &Path) -> bool {
2349    match (std::fs::canonicalize(folder), std::fs::canonicalize(path)) {
2350        (Ok(folder), Ok(path)) => path.starts_with(&folder) && path != folder && path.is_file(),
2351        _ => false,
2352    }
2353}
2354
2355/// One attaching at a time: a second one waits, then makes its own anew.
2356static ATTACHING: Mutex<()> = Mutex::new(());
2357/// Each attaching's turn, and each letting go's: an attaching whose turn
2358/// passed (a newer one asked, or the files let go meanwhile) makes nothing,
2359/// or deletes what it made. The window never waits on `ATTACHING`.
2360static ATTACH_TURN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2361
2362/// At the window's start (lib.rs `run`, once this process is the profile's
2363/// Sioul): what an earlier run left attached on the Texts page, one that quit
2364/// or crashed with files attached, goes (the review of 18 October 2026, I-6).
2365#[cfg(not(target_os = "android"))]
2366pub(crate) fn at_start() {
2367    let_attached_go();
2368}
2369
2370/// The files attached let go, and any attaching under way told so: the window's thread never waits.
2371fn let_attached_go() {
2372    ATTACH_TURN.fetch_add(1, Ordering::SeqCst);
2373    let _ = std::fs::remove_dir_all(attached_folder());
2374}
2375
2376/// The files chosen on the page (`sources`, files or their addresses) made
2377/// fit for one multimedia message to a conversation (`attach::make`; what an
2378/// earlier attaching made goes first), off the window's thread:
2379/// {round (as asked: the page keeps its latest), media: [{source, kind, ct,
2380/// path, url, size, said}], said: [why each other did not go], limit (its
2381/// words), max (bytes)}. Nothing travels until Send.
2382pub(crate) fn attach(json: &str) -> String {
2383    let asked: Value = serde_json::from_str(json).unwrap_or_default();
2384    let tr = tr();
2385    let round = asked["round"].as_i64().unwrap_or(0);
2386    let id = asked["id"].as_str().unwrap_or_default().to_string();
2387    let from = asked["phone"].as_str().unwrap_or_default();
2388    let phone = if from.is_empty() || (demo() && !phone()) { sending_phone() } else { phones().into_iter().find(|p| p.id == from) };
2389    let sources: Vec<PathBuf> = asked["sources"].as_array().map(|all| all.iter().filter_map(|s| s.as_str()).map(crate::backend::local_path).filter(|p| !p.as_os_str().is_empty()).collect()).unwrap_or_default();
2390    let turn = ATTACH_TURN.fetch_add(1, Ordering::SeqCst) + 1;
2391    let (max, side) = match attach_limit(phone.as_ref(), &id) {
2392        Ok(limit) => limit,
2393        Err(key) => return json!({ "round": round, "media": [], "said": [tr.text(key, None)], "limit": "", "max": 0 }).to_string(),
2394    };
2395    let _one = ATTACHING.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
2396    let passed = || ATTACH_TURN.load(Ordering::SeqCst) != turn;
2397    if passed() {
2398        return json!({ "round": round, "media": [], "said": [], "limit": "", "max": 0 }).to_string();
2399    }
2400    let made = attach_made(&sources, max, side, round);
2401    // Let go while it was made: nothing of it stays.
2402    if passed() {
2403        let _ = std::fs::remove_dir_all(attached_folder());
2404    }
2405    made
2406}
2407
2408/// What a multimedia message from here to a conversation may weigh (bytes)
2409/// and its pictures' long side at most (0 none), from the phone that sends
2410/// (`weigh`); else why nothing can be attached, its words' key: no phone, a
2411/// group (a later step), the phone not having said what its carrier takes
2412/// (Attach is offered only once it has, decision 11), its carrier taking none.
2413fn attach_limit(phone: Option<&sioul_sync::devices::Entry>, id: &str) -> Result<(u64, u32), &'static str> {
2414    let Some(phone) = phone else { return Err("texts-page-no-phone") };
2415    if id.contains(',') {
2416        return Err("texts-group-read-only");
2417    }
2418    match weigh(&phone.id, id) {
2419        Weigh::Unknown => Err("texts-attach-gate"),
2420        Weigh::None => Err("texts-sending-no-mms"),
2421        Weigh::Up { max, side } => Ok((max, side)),
2422    }
2423}
2424
2425/// The files chosen made fit (`attach::make`), on a computer.
2426#[cfg(not(target_os = "android"))]
2427fn attach_made(sources: &[PathBuf], max: u64, side: u32, round: i64) -> String {
2428    let tr = tr();
2429    let folder = attached_folder();
2430    let _ = std::fs::remove_dir_all(&folder);
2431    let (made, left) = crate::attach::make(sources, max, side, &folder);
2432    let media: Vec<Value> = made
2433        .iter()
2434        .map(|m| {
2435            let mut args = sioul_core::i18n::args();
2436            args.set("size", size_words(m.size));
2437            // The file chosen, for the page to make them again (one removed); never sent.
2438            json!({ "source": m.source, "kind": m.kind, "ct": m.ct, "path": m.path, "url": crate::backend::file_url(Path::new(&m.path)), "size": m.size, "said": tr.text(&format!("texts-attached-{}", m.kind), Some(&args)) })
2439        })
2440        .collect();
2441    let said: Vec<String> = left
2442        .iter()
2443        .map(|l| {
2444            let mut args = sioul_core::i18n::args();
2445            args.set("name", Path::new(&l.source).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default());
2446            args.set("size", size_words(l.size));
2447            args.set("limit", size_words(max));
2448            args.set("kind", l.format);
2449            tr.text(&format!("texts-attach-left-{}", l.why), Some(&args))
2450        })
2451        .collect();
2452    json!({ "round": round, "media": media, "said": said, "limit": size_words(max), "max": max }).to_string()
2453}
2454
2455/// A phone attaches nothing: it is the one that sends.
2456#[cfg(target_os = "android")]
2457fn attach_made(_: &[PathBuf], _: u64, _: u32, round: i64) -> String {
2458    json!({ "round": round, "media": [], "said": [tr().text("texts-refused-other", None)], "limit": "", "max": 0 }).to_string()
2459}
2460
2461/// How much a multimedia message takes, as its words are typed: {parts,
2462/// said ("One multimedia message (412 kB of 600 kB)"), over, max (bytes)},
2463/// its media counted by their lengths alone (`mms::size_with_lengths`).
2464fn parts_mms(body: &str, media: &[Value], id: &str, from: &str) -> Value {
2465    let phone = if from.is_empty() || (demo() && !phone()) { sending_phone() } else { phones().into_iter().find(|p| p.id == from) };
2466    let max = match phone.map(|p| weigh(&p.id, id)) {
2467        Some(Weigh::Up { max, .. }) => max,
2468        _ => 0,
2469    };
2470    let cts: Vec<String> = media.iter().map(|m| m["ct"].as_str().unwrap_or("application/octet-stream").to_string()).collect();
2471    let lengths: Vec<usize> = media.iter().map(|m| m["size"].as_u64().unwrap_or(0) as usize).collect();
2472    let empty: Vec<sioul_core::mms::Medium> = cts.iter().map(|ct| sioul_core::mms::Medium { ct, bytes: &[] }).collect();
2473    let key = "0".repeat(32);
2474    // To the longest number, as the media were made (`attach::room_for`): never less than the phone's own.
2475    let message = sioul_core::mms::Message { key: &key, to: &[sioul_core::mms::LONGEST_NUMBER], date: jiff::Timestamp::now().as_second(), words: body, media: &empty };
2476    let size = sioul_core::mms::size_with_lengths(&message, &lengths).unwrap_or(0) as u64;
2477    let mut args = sioul_core::i18n::args();
2478    args.set("size", size_words(size));
2479    args.set("limit", size_words(max));
2480    json!({ "parts": 1, "said": tr().text("texts-parts-mms", Some(&args)), "over": max > 0 && size > max, "max": max })
2481}
2482
2483/// Settings ▸ Texts, on a computer: how much a multimedia message from here
2484/// may weigh, the carrier's or less (300 kB), and what the phone said its
2485/// carrier takes, read only. {limit: a setting's row, phone: its lines}.
2486fn sending() -> Value {
2487    let tr = tr();
2488    let phone = sending_phone();
2489    let limits = sealed().map(|(here, seal, _)| records(&here, &seal).2).and_then(|outcomes| phone.as_ref().and_then(|p| rules::limits_of(&outcomes, &p.id, now_ms()).map(|(_, l)| l.to_vec()))).unwrap_or_default();
2490    // Never above what this computer prepares, whatever a line says (`rules::MMS_MAX_EVER`).
2491    let carrier = limits.iter().filter(|l| l.mms).map(|l| l.max).min().unwrap_or(614_400).min(rules::MMS_MAX_EVER);
2492    let size = |bytes: u64| {
2493        let mut args = sioul_core::i18n::args();
2494        args.set("size", size_words(bytes));
2495        args
2496    };
2497    let lines = limit_lines(&limits);
2498    let choices = vec![json!({ "value": "carrier", "label": tr.text("texts-sending-limit-carrier", Some(&size(carrier))) }), json!({ "value": "300", "label": tr.text("texts-sending-limit-low", Some(&size(LOW_KB * 1024))) })];
2499    json!({
2500        "limit": { "key": "limit", "kind": "choice", "label": tr.text("texts-sending-limit", None), "help": tr.text("texts-sending-limit-help", None), "value": if choice().mms_kb.is_some() { "300" } else { "carrier" }, "choices": choices },
2501        "phone": lines,
2502    })
2503}
2504
2505/// What a phone's carriers take, in words, read only: one line, or one a SIM
2506/// in its order ("SIM 2: multimedia messages up to 600 kB. Pictures up to
2507/// 2592 × 1944 pixels."); none said yet.
2508fn limit_lines(limits: &[rules::Limits]) -> Vec<String> {
2509    let tr = tr();
2510    let size = |bytes: u64| {
2511        let mut args = sioul_core::i18n::args();
2512        args.set("size", size_words(bytes));
2513        args
2514    };
2515    // The picture size the carrier advises, which the pictures attached on a computer keep to.
2516    let pictures = |l: &rules::Limits| {
2517        if l.width == 0 || l.height == 0 {
2518            return String::new();
2519        }
2520        let mut args = sioul_core::i18n::args();
2521        args.set("width", i64::from(l.width));
2522        args.set("height", i64::from(l.height));
2523        format!(" {}", tr.text("texts-sending-pictures", Some(&args)))
2524    };
2525    match limits {
2526        [] => vec![tr.text("texts-sending-phone-none", None)],
2527        [one] if one.mms => vec![tr.text("texts-sending-phone", Some(&size(one.max))) + &pictures(one)],
2528        [_] => vec![tr.text("texts-sending-no-mms", None)],
2529        several => several
2530            .iter()
2531            .enumerate()
2532            .map(|(n, l)| {
2533                let mut args = size(l.max);
2534                args.set("sim", i64::try_from(n + 1).unwrap_or(0));
2535                if l.mms { tr.text("texts-sending-phone-sim", Some(&args)) + &pictures(l) } else { tr.text("texts-sending-no-mms-sim", Some(&args)) }
2536            })
2537            .collect(),
2538    }
2539}
2540
2541/// The most a multimedia message from here may weigh: the carrier's ("carrier") or 300 kB ("300").
2542fn set_limit(value: &str) -> Result<(), String> {
2543    let path = choice_path();
2544    sioul_core::filelock::with_lock(&path, || {
2545        let mut chosen = choice();
2546        chosen.mms_kb = (value.trim() == "300").then_some(LOW_KB);
2547        write_whole(&path, toml::to_string(&chosen).map_err(|e| e.to_string())?.as_bytes())
2548    })
2549}
2550
2551/// A multimedia message's request, its media sealed in the sharing already.
2552fn request_mms(to: &str, words: &str, media: Vec<rules::Medium>, again: &str, from: &str) -> (Value, bool) {
2553    let tr = tr();
2554    let Some((here, seal, _)) = sealed() else { return (json!({ "said": tr.text("texts-page-off", None), "shared": false }), false) };
2555    let phone = if from.is_empty() || (demo() && !phone()) { sending_phone() } else { phones().into_iter().find(|p| p.id == from) };
2556    let Some(phone) = phone else { return (json!({ "said": tr.text("texts-page-no-phone", None), "shared": false }), false) };
2557    let region = sioul_core::reach::region(&load_config());
2558    if let Some(why) = rules::refusal_mms(to, words, &media, &[], region) {
2559        return (json!({ "said": tr.text(&format!("texts-refused-{why}"), None), "shared": false }), false);
2560    }
2561    let request = Request { key: rules::new_key(), phone: phone.id.clone(), to: to.trim().to_string(), sub: -1, written: now_ms(), ahead: ahead_of(&phone.id), again: again.to_string(), words: words.to_string(), media, ..Request::default() };
2562    match rules::write_request(&root(), &here, &request, &seal) {
2563        Ok(_) => {
2564            // Its media, then its small file, to the server now, not behind the records.
2565            crate::share::texts_now();
2566            (json!({ "said": "", "shared": true, "key": request.key }), true)
2567        }
2568        Err(e) => (json!({ "said": e, "shared": false }), false),
2569    }
2570}
2571
2572/// The media of this computer's multimedia messages that may still go (written
2573/// within their quarter of an hour), by their sealed files' names in the
2574/// sharing and the size each was sealed at: they go up first on the urgent
2575/// lane (`remote::send_urgent`), before the request that names them.
2576pub(crate) fn media_to_send() -> BTreeMap<String, u64> {
2577    let Some((here, seal, Some((folder, key)))) = sealed() else { return BTreeMap::new() };
2578    let now = now_ms();
2579    let mut out = BTreeMap::new();
2580    for r in rules::read_file::<Request>(&rules::own_file(&root(), rules::SEND, &here), &seal) {
2581        if !r.multimedia() || now - r.written > rules::EXPIRY_MS + rules::AHEAD_MS {
2582            continue;
2583        }
2584        for medium in &r.media {
2585            let name = sioul_sync::blobs::name(&key, &medium.hash);
2586            if let Ok(meta) = std::fs::metadata(folder.join("blobs").join(&name)) {
2587                out.insert(name, meta.len());
2588            }
2589        }
2590    }
2591    out
2592}
2593
2594/// How many texts a draft takes: {parts, said}.
2595fn parts(body: &str) -> Value {
2596    let (count, gsm) = rules::parts(body);
2597    let mut args = sioul_core::i18n::args();
2598    args.set("count", i64::try_from(count).unwrap_or(0));
2599    args.set("characters", i64::try_from(body.chars().count()).unwrap_or(0));
2600    let said = if count == 0 { String::new() } else { tr().text(if gsm { "texts-parts" } else { "texts-parts-unicode" }, Some(&args)) };
2601    json!({ "parts": count, "said": said })
2602}
2603
2604/// "1.2 MB".
2605fn size_words(bytes: u64) -> String {
2606    sioul_core::view::size(tr(), usize::try_from(bytes).unwrap_or(usize::MAX))
2607}
2608
2609/// What this device keeps of the texts, for its part's line in the sharing
2610/// panel ("Keeps 4,830 texts and 210 MB of media here."); "" when none.
2611pub(crate) fn kept_words() -> String {
2612    let Some((here, seal, _)) = sealed() else { return String::new() };
2613    let (texts, _, _) = records(&here, &seal);
2614    if texts.is_empty() {
2615        return String::new();
2616    }
2617    let (count, bytes) = rules::kept(&texts, &here_media);
2618    let mut args = sioul_core::i18n::args();
2619    args.set("count", i64::try_from(count).unwrap_or(0));
2620    args.set("size", size_words(bytes));
2621    tr().text("texts-kept", Some(&args))
2622}
2623
2624// ---------------------------------------------------------------- Settings ▸ This phone ▸ Texts
2625
2626/// The phone's tab (TextsSetup.qml): {android, state (each permission, the
2627/// SIMs), on (the part here), sharing, progress (what the phone holds and how
2628/// much has gone), importing (more to go), cap (the setting row of the largest
2629/// media file brought)}.
2630fn setup() -> Value {
2631    let here = sioul_sync::share::Here::load(&state_dir());
2632    let android = cfg!(target_os = "android");
2633    let state = if android { crate::steps::java("texts-state", "{}") } else { Value::Null };
2634    let mut reader = Reader::load();
2635    // Measured before the import begins; then each day, with the look for deletions.
2636    if android && state["read"] == true && reader.measured == 0 {
2637        let answer = crate::steps::java("texts-ids", &json!({ "measure": true }).to_string());
2638        if answer["allowed"] == true {
2639            reader.measure(&answer, now_ms());
2640            let _ = reader.save();
2641        }
2642    }
2643    let tr = tr();
2644    let mut args = sioul_core::i18n::args();
2645    args.set("count", i64::try_from(reader.total_texts).unwrap_or(0));
2646    args.set("size", size_words(reader.total_media));
2647    args.set("done", i64::try_from(reader.done_texts).unwrap_or(0));
2648    args.set("done_size", size_words(reader.done_media));
2649    // A heavy import waits for the charger (`import_now`): said, calmly.
2650    let goes_now = !android || (reader.started > 0 && !reader.behind) || changes(false).1;
2651    let progress = match (reader.measured, reader.done_texts) {
2652        (0, _) => String::new(),
2653        (_, 0) if goes_now => tr.text("texts-setup-holds", Some(&args)),
2654        (_, 0) => format!("{} {}", tr.text("texts-setup-holds", Some(&args)), tr.text("texts-setup-charging", None)),
2655        (_, done) if done >= reader.total_texts => tr.text("texts-setup-done", Some(&args)),
2656        _ if goes_now => tr.text("texts-setup-progress", Some(&args)),
2657        _ => tr.text("texts-setup-progress-charging", Some(&args)),
2658    };
2659    let choices: Vec<Value> = CAPS_MB
2660        .iter()
2661        .map(|mb| {
2662            let mut args = sioul_core::i18n::args();
2663            args.set("size", size_words(mb << 20));
2664            json!({ "value": mb.to_string(), "label": tr.text("texts-setup-cap-value", Some(&args)) })
2665        })
2666        .collect();
2667    json!({
2668        "android": android,
2669        "state": state,
2670        "on": here.shares(rules::PART, &load_config()),
2671        "sharing": here.folder.is_some(),
2672        "progress": progress,
2673        "importing": reader.measured > 0 && reader.done_texts < reader.total_texts,
2674        // Asked and not given: on Android 15 and later the steps that hold whether
2675        // Android keeps restricted settings from Sioul or not ("guarded": no app can
2676        // read which); before, a question refused for good ("at-once").
2677        "guard": guard(&state),
2678        "cap": { "key": "cap", "kind": "choice", "label": tr.text("texts-setup-cap", None), "help": tr.text("texts-setup-cap-help", None), "value": cap_mb().to_string(), "choices": choices },
2679        // What its carriers take for a multimedia message, as Android says it now: read only (decision 5).
2680        "limits": if android { limit_lines(&limits_from(&crate::steps::java("texts-limits", "{}"))) } else { Vec::new() },
2681    })
2682}
2683
2684/// What the tab says beside its question once the texts' permissions were
2685/// asked and are still not all given (`Texts.state`): "guarded" on Android 15
2686/// and later, where restricted settings may keep them from an app installed
2687/// from a file (13 and 14 keep notification access only); "at-once" when
2688/// Android answered without showing a question ("Don't allow" chosen twice);
2689/// else "".
2690fn guard(state: &Value) -> &'static str {
2691    let given = state["read"] == true && state["receive"] == true && state["send"] == true;
2692    if given || state["asked"] != true {
2693        ""
2694    } else if state["api"].as_i64().unwrap_or(0) >= 35 {
2695        "guarded"
2696    } else if state["at_once"] == true {
2697        "at-once"
2698    } else {
2699        ""
2700    }
2701}
2702
2703/// The largest media file brought to your computers, chosen on the phone (MB).
2704fn set_cap(value: &str) -> Result<(), String> {
2705    let mb: u64 = value.trim().parse().map_err(|_| format!("{value}?"))?;
2706    let path = choice_path();
2707    sioul_core::filelock::with_lock(&path, || {
2708        let mut chosen = choice();
2709        chosen.media_cap_mb = (mb != rules::MEDIA_CAP_MB).then_some(mb.clamp(1, sioul_sync::blobs::LARGEST >> 20));
2710        write_whole(&path, toml::to_string(&chosen).map_err(|e| e.to_string())?.as_bytes())
2711    })
2712}
2713
2714/// What the window asks (`Sioul::texts`): "view" {query}, "conversation" {id},
2715/// "send" {to, body}, "send-mms" {to, words, media: [{ct, path}], phone} (a
2716/// multimedia message, its media made fit already by `attach`: a file
2717/// elsewhere is refused), "again" {key}, "parts"
2718/// {body, media: [{ct, size}], id, phone} (with media, how much the message
2719/// takes), "sending" and "limit" {value} (Settings ▸ Texts on a computer), "media" {hash, ct} (its
2720/// address, to show or open it), "save-media" {hash, ct, path}, "close" (the
2721/// page closed: the media opened for it and the files attached deleted),
2722/// "unattach" (the files attached let go), "setup", "ask" (Android's
2723/// question for the permissions), "info" (Sioul's app info), "part" {on},
2724/// "cap" {value}; and "access…", what Android withholds here (`access::call`).
2725/// Its JSON, and whether what travels changed (to share now).
2726pub(crate) fn call(verb: &str, json: &str) -> (String, bool) {
2727    let asked: Value = serde_json::from_str(json).unwrap_or_default();
2728    let text = |key: &str| asked[key].as_str().unwrap_or_default().to_string();
2729    let (answer, shared) = match verb {
2730        "view" => (view(&text("query")), false),
2731        "conversation" => (conversation(&text("id")), false),
2732        "send" => send(&text("to"), &text("body"), "", &text("phone")),
2733        // A multimedia message: its media reduced already, each {ct, path} (a file of this device's).
2734        "send-mms" => {
2735            let asked_media = asked["media"].as_array().cloned().unwrap_or_default();
2736            // Only files the page made fit (`attach`, its own folder): never one as it was chosen, with its own details.
2737            let folder = attached_folder();
2738            let media: Vec<(String, Vec<u8>)> = asked_media
2739                .iter()
2740                .filter_map(|m| {
2741                    let path = crate::backend::local_path(m["path"].as_str()?);
2742                    made_in(&folder, &path).then_some(())?;
2743                    Some((m["ct"].as_str()?.to_string(), std::fs::read(&path).ok()?))
2744                })
2745                .collect();
2746            if media.len() == asked_media.len() {
2747                let sent = send_mms(&text("to"), &text("words"), &media, &text("phone"));
2748                // Sealed into the sharing and here: the files made fit for it go.
2749                if sent.1 {
2750                    let_attached_go();
2751                }
2752                sent
2753            } else {
2754                (json!({ "said": tr().text("texts-refused-other", None), "shared": false }), false)
2755            }
2756        }
2757        "again" => again(&text("key")),
2758        "delete" => {
2759            let done = delete(&text("id"));
2760            let mut tab = view("");
2761            tab["said"] = done["said"].clone();
2762            (tab, true)
2763        }
2764        "parts" => match asked["media"].as_array().filter(|media| !media.is_empty()) {
2765            Some(media) => (parts_mms(&text("body"), media, &text("id"), &text("phone")), false),
2766            None => (parts(&text("body")), false),
2767        },
2768        "sending" => (sending(), false),
2769        "limit" => {
2770            let problem = set_limit(&text("value")).err().unwrap_or_default();
2771            let mut tab = sending();
2772            tab["said"] = json!(problem);
2773            (tab, false)
2774        }
2775        // An AI agent's draft: its words for the box, or gone; nothing is sent.
2776        "draft-use" | "draft-discard" => (draft_taken(&text("id")), false),
2777        "media" => {
2778            let url = open_media(&text("hash"), &text("ct")).map(|p| crate::backend::file_url(&p)).unwrap_or_default();
2779            if url.is_empty() {
2780                ask_media_again(&text("hash"));
2781            }
2782            (json!({ "url": url }), false)
2783        }
2784        "save-media" => {
2785            let target = crate::backend::local_path(&text("path"));
2786            let saved = !target.as_os_str().is_empty() && open_media(&text("hash"), &text("ct")).is_some_and(|p| std::fs::copy(p, &target).is_ok());
2787            (json!({ "saved": saved }), false)
2788        }
2789        "close" => {
2790            let _ = std::fs::remove_dir_all(opened_folder());
2791            let_attached_go();
2792            (json!({}), false)
2793        }
2794        // The files attached let go (removed from the box, another conversation).
2795        "unattach" => {
2796            let_attached_go();
2797            (json!({}), false)
2798        }
2799        "setup" => (setup(), false),
2800        // Sioul's app info, where Android's restricted settings and the permissions are.
2801        "info" => {
2802            crate::steps::java("texts-info", "{}");
2803            (setup(), false)
2804        }
2805        // What Android withholds here, for the join's result and the Porch (access.rs).
2806        v if v.starts_with("access") => (crate::access::call(v, &asked), false),
2807        "ask" => {
2808            crate::steps::java("texts-ask", "{}");
2809            (setup(), false)
2810        }
2811        "part" => {
2812            let problem = crate::share::set_part(rules::PART, asked["on"].as_bool().unwrap_or(false));
2813            let mut tab = setup();
2814            tab["said"] = json!(problem);
2815            (tab, problem.is_empty())
2816        }
2817        "cap" => {
2818            let problem = set_cap(&text("value")).err().unwrap_or_default();
2819            let mut tab = setup();
2820            tab["said"] = json!(problem);
2821            (tab, false)
2822        }
2823        _ => (json!({}), false),
2824    };
2825    (answer.to_string(), shared)
2826}
2827
2828/// Whether a phone sends texts for this computer: the part on here, and its
2829/// texts read here (its log): a text that came to it is answered from it.
2830pub(crate) fn sends_texts(phone_id: &str) -> bool {
2831    if demo() && !phone() {
2832        return phone_id == "demo-phone";
2833    }
2834    !phone_id.is_empty() && sealed().is_some() && rules::own_file(&root(), rules::LOG, phone_id).is_file()
2835}
2836
2837/// The texts this computer wrote from a phone to a number (`dial`) since a
2838/// moment (ms), oldest first, for the Porch's reply under a line
2839/// (`replies::Mine`): the words and their state in the texts' own words ("Sent at 14:03.").
2840pub(crate) fn sent_to(phone_id: &str, dial: &str, since_ms: i64) -> Vec<sioul_core::replies::Mine> {
2841    let Some((here, seal, _)) = sealed() else { return Vec::new() };
2842    let (_, requests, outcomes) = records(&here, &seal);
2843    let region = sioul_core::reach::region(&load_config());
2844    let to = phones::key(dial, region);
2845    // When the phone the text goes from last shared, never another's.
2846    let shared = if demo() && !phone() { sending_phone() } else { phones().into_iter().find(|p| p.id == phone_id) }.filter(|p| p.exported > 0).map(|p| p.exported * 1000);
2847    let now = Zoned::now();
2848    let mut sent: Vec<&Request> = requests.iter().filter(|r| r.device == here && r.phone == phone_id && r.written >= since_ms && phones::key(&r.to, region) == to).collect();
2849    sent.sort_by_key(|r| r.written);
2850    sent.into_iter()
2851        .map(|r| {
2852            let said = rules::said(rules::state_of(&r.key, &outcomes), shared, tr(), &now, r.multimedia());
2853            sioul_core::replies::Mine { key: r.key.clone(), time: rules::time_of_day(r.written, &now), text: r.body.clone(), said: said.text, waiting: said.state == "waiting", again: said.again, texts: true }
2854        })
2855        .collect()
2856}
2857
2858/// The numbers this computer texted (`phones::key`) whose phone took the text
2859/// since a moment (ms, the phone's clock): handed, sent or delivered then.
2860pub(crate) fn texted_since(since_ms: i64) -> Vec<String> {
2861    let Some((here, seal, _)) = sealed() else { return Vec::new() };
2862    let (_, requests, outcomes) = records(&here, &seal);
2863    let region = sioul_core::reach::region(&load_config());
2864    let took = |key: &str| outcomes.iter().any(|o| o.key == key && matches!(o.state.as_str(), "handed" | "sent" | "delivered") && o.at >= since_ms);
2865    requests.iter().filter(|r| r.device == here && took(&r.key)).map(|r| phones::key(&r.to, region)).collect()
2866}
2867
2868/// Whether the Texts page has something to show here: the part on, and a phone that sends.
2869pub(crate) fn readable() -> bool {
2870    sealed().is_some() && sending_phone().is_some()
2871}
2872
2873/// A person's conversation on the Texts page, from their numbers as written:
2874/// the first whole one, keyed; "" while texts are not read here.
2875pub(crate) fn conversation_of(numbers: &[String]) -> String {
2876    if sealed().is_none() {
2877        return String::new();
2878    }
2879    let region = sioul_core::reach::region(&load_config());
2880    numbers.iter().map(|n| phones::key(n.trim().trim_start_matches(sioul_core::porch::TEL), region)).find(|k| phones::is_whole(k)).unwrap_or_default()
2881}
2882
2883#[cfg(test)]
2884mod tests {
2885    use super::*;
2886
2887    /// Thursday 8 October 2026, 10:00 in Paris.
2888    const NOW: i64 = 1_791_446_400_000;
2889
2890    fn part(key: &str, kind: &str, part: i64, parts: i64, code: i64, uri: &str, status: i64, at: i64) -> PartResult {
2891        PartResult { key: key.into(), kind: kind.into(), part, parts, code, uri: uri.into(), status, at, ..PartResult::default() }
2892    }
2893
2894    /// A request found in its small file (the sharing folder's, the server's
2895    /// copy) before the records bring it, read beside the records' copy:
2896    /// each key once, its device by its file's name.
2897    #[test]
2898    fn requests_are_read_from_their_small_files_too_each_once() {
2899        let base = std::env::temp_dir().join(format!("sioul-texts-copies-{}", std::process::id()));
2900        let _ = std::fs::remove_dir_all(&base);
2901        let seal = Seal::Key(TextSeal::new(&[3u8; 32]));
2902        let desk = "0f8fad5b-d9cb-469f-a165-70867728950e";
2903        let request = |key: &str| Request { key: key.into(), phone: "phone".into(), to: "0199001234".into(), body: "On my way".into(), sub: -1, written: NOW, ..Request::default() };
2904        let (records, folder, fetched) = (base.join("records"), base.join("folder"), base.join("fetched"));
2905        rules::append(&rules::own_file(&records, rules::SEND, desk), &[request("a")], &seal, None).unwrap();
2906        rules::append(&rules::own_file(&folder, rules::SEND, desk), &[request("a"), request("b")], &seal, None).unwrap();
2907        rules::append(&rules::own_file(&fetched, rules::SEND, desk), &[request("a"), request("b"), request("c")], &seal, None).unwrap();
2908        let read = requests_from(&[records.clone(), folder, fetched], &seal);
2909        assert_eq!(read.iter().map(|r| r.key.as_str()).collect::<Vec<_>>(), ["a", "b", "c"]);
2910        assert!(read.iter().all(|r| r.device == desk), "{read:?}");
2911        // Nothing but the records: as before.
2912        assert_eq!(requests_from(&[records, base.join("none")], &seal).len(), 1);
2913        let _ = std::fs::remove_dir_all(&base);
2914    }
2915
2916    #[test]
2917    fn a_text_s_parts_make_one_outcome_each_and_delivered_only_when_reported() {
2918        let results = [
2919            part("a", "sent", 0, 2, -1, "content://sms/414", -1, NOW),
2920            part("a", "sent", 1, 2, -1, "content://sms/414", -1, NOW + 1000),
2921            part("a", "delivered", 0, 2, -1, "", 0, NOW + 5000),
2922            part("b", "sent", 0, 1, -1, "content://sms/415", -1, NOW),
2923            part("b", "delivered", 0, 1, -1, "", 0, NOW + 3000),
2924            part("c", "sent", 0, 1, 4, "", -1, NOW),
2925            part("d", "sent", 0, 2, -1, "content://sms/416", -1, NOW),
2926        ];
2927        let said: Vec<(String, String, String)> = outcomes_from(&results).into_iter().map(|o| (o.key, o.state, if o.row.is_empty() { o.reason } else { o.row })).collect();
2928        assert_eq!(said, [
2929            ("a".to_string(), "sent".to_string(), "sms-414".to_string()),
2930            ("b".into(), "sent".into(), "sms-415".into()),
2931            ("b".into(), "delivered".into(), String::new()),
2932            ("c".into(), "failed".into(), "no-service".into()),
2933        ], "a: one part of two delivered, no word; d: one part of two sent, not yet");
2934    }
2935
2936    /// The rows as Java writes them (android/jvm-checks/TextsCheck.java):
2937    /// every column kept; a text's people from its conversation; a received
2938    /// multimedia message's sender, words and picture, a big video left on
2939    /// the phone, one still to download said; a group read as a group.
2940    #[test]
2941    fn what_java_reads_is_kept_whole_and_read_here() {
2942        let json = r#"{"texts":[
2943            {"kind":"sms","fields":{"_id":412,"thread_id":7,"address":"01 99 00 12 34","date":1791446340000,"date_sent":1791446338000,"type":1,"read":1,"seen":1,"body":"Votre rendez-vous est déplacé.","sub_id":2,"service_center":"+33609001390","protocol":0,"status":-1}},
2944            {"kind":"mms","fields":{"_id":31,"thread_id":9,"date":1791446340,"msg_box":1,"m_type":132,"sub_id":1},
2945             "addrs":[{"address":"+33465710042","type":137,"charset":106},{"address":"insert-address-token","type":151,"charset":106}],
2946             "parts":[{"_id":70,"seq":-1,"ct":"application/smil","text":"<smil/>"},{"_id":71,"seq":0,"ct":"image/jpeg","name":"a.jpg","size":220000,"cid":"<a>"},{"_id":72,"seq":1,"ct":"video/mp4","cl":"b.mp4","size":30000000},{"_id":73,"seq":2,"ct":"text/plain","text":"Le colis est arrivé.","chset":106}]},
2947            {"kind":"mms","fields":{"_id":32,"thread_id":11,"date":1791446350,"msg_box":1,"m_type":130},"addrs":[{"address":"+33465710042","type":137}],"parts":[]},
2948            {"kind":"sms","fields":{"_id":413,"thread_id":12,"address":"+33465710042","date":1791446360000,"type":1,"body":"Pour le groupe."}}],
2949            "threads":{"7":["0199001234"],"9":["+33465710042"],"12":["+33465710042","+33199001234"]},"sms":413,"mms":32,"allowed":true}"#;
2950        let answer: Value = serde_json::from_str(json).unwrap();
2951        let region = phones::region_named("FR");
2952        let threads: BTreeMap<String, Vec<String>> = answer["threads"].as_object().unwrap().iter().map(|(k, v)| (k.clone(), v.as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_string()).collect())).collect();
2953        let texts: Vec<Text> = raws(&answer).iter().map(|r| text_of(r, &threads, region, 10 << 20)).collect();
2954        let sms = &texts[0];
2955        assert_eq!((sms.id.as_str(), sms.with.as_slice(), sms.sub, sms.direction.as_str(), sms.at), ("sms-412@1791446340000", &["+33199001234".to_string()][..], 2, "in", 1_791_446_340_000), "named by its id and its own date");
2956        assert_eq!(sms.fields.len(), 13, "every column kept");
2957        let mms = &texts[1];
2958        assert_eq!((mms.id.as_str(), mms.at, mms.with.as_slice(), mms.body.as_str(), mms.picture, mms.sub), ("mms-31@1791446340", 1_791_446_340_000, &["+33465710042".to_string()][..], "Le colis est arrivé.", true, 1));
2959        assert_eq!(mms.parts.iter().map(|p| p.state.as_str()).collect::<Vec<_>>(), ["text", "here", "too-big", "text"]);
2960        assert_eq!((mms.parts[1].name.as_str(), mms.parts[2].name.as_str(), mms.parts[1].size), ("a.jpg", "b.mp4", 220_000));
2961        assert_eq!((mms.parts[1].fields.get("cid"), mms.parts[3].fields.get("chset"), mms.parts[3].fields.get("text")), (Some(&json!("<a>")), Some(&json!(106)), None), "the other columns kept, none twice");
2962        assert_eq!(mms.addrs.len(), 2);
2963        assert_eq!((texts[2].parts.len(), texts[2].with.as_slice()), (0, &["+33465710042".to_string()][..]), "nothing to download yet");
2964        assert_eq!(texts[3].with, ["+33465710042", "+33199001234"], "a group, from its conversation");
2965        assert_eq!(texts[3].from, "+33465710042");
2966        let waiting = Raw { kind: "mms".into(), parts: vec![[("ct".to_string(), json!("image/png")), ("size".to_string(), json!(-1))].into_iter().collect()], ..Raw::default() };
2967        assert_eq!(text_of(&waiting, &threads, region, 10 << 20).parts[0].state, "not-downloaded");
2968        assert_eq!(row_of("content://sms/414"), "sms-414");
2969        assert_eq!(row_of(""), "");
2970    }
2971
2972    /// The import asks Android only when the phone's texts changed; a heavy
2973    /// one (the first, of the whole history, or one a full batch left behind)
2974    /// only while the phone charges.
2975    #[test]
2976    fn a_heavy_import_waits_for_the_charger() {
2977        let first = Reader::default();
2978        assert!(!import_now(&first, true, false, NOW), "the whole history, on the battery: it waits");
2979        assert!(import_now(&first, false, true, NOW), "on the charger");
2980        let caught_up = Reader { started: NOW - 1_000, looked: NOW - 3_600_000, indexed: true, ..Reader::default() };
2981        assert!(import_now(&caught_up, true, false, NOW), "a new text, on the battery");
2982        assert!(!import_now(&caught_up, false, false, NOW) && !import_now(&caught_up, false, true, NOW), "nothing changed: Android not asked");
2983        assert!(import_now(&caught_up, false, false, NOW + 86_400_000), "the day's look: read all the same");
2984        let behind = Reader { started: NOW - 1_000, behind: true, ..caught_up.clone() };
2985        assert!(!import_now(&behind, true, false, NOW), "a full batch left more: on the charger only");
2986        assert!(import_now(&behind, false, true, NOW), "on the charger, batch after batch");
2987        // A reader written before this mark: caught up; its first step at once, on the battery too (its log indexed).
2988        let older: Reader = serde_json::from_str(r#"{"version":2,"sms":10,"mms":3,"started":1}"#).unwrap();
2989        assert!(!older.behind && import_now(&older, true, false, NOW));
2990        assert!(import_now(&older, false, false, NOW) && recent_now(&older, false));
2991    }
2992
2993    #[test]
2994    fn nothing_runs_on_a_computer_without_the_part() {
2995        if phone() || demo() {
2996            return;
2997        }
2998        assert!(!phone_step());
2999        let (answer, shared) = call("send", r#"{"to":"01 99 00 12 34","body":"Bonjour"}"#);
3000        assert!(!shared, "{answer}");
3001    }
3002
3003    /// A stand-in phone's provider of texts, answering as Java does
3004    /// (Texts.read, Texts.ids, Texts.part; android/jvm-checks/TextsCheck.java):
3005    /// plain texts and multimedia messages by their ids, each with its own
3006    /// date, oldest first; a part's bytes; the rows Android hides (in the
3007    /// outbox) left out of all; a table it could not list whole left out of
3008    /// the listing (`unlisted`); and what it was asked, to count.
3009    #[derive(Default)]
3010    struct Provider {
3011        sms: std::cell::RefCell<BTreeMap<i64, Value>>,
3012        mms: std::cell::RefCell<BTreeMap<i64, Value>>,
3013        media: std::cell::RefCell<BTreeMap<i64, Vec<u8>>>,
3014        hidden: std::cell::RefCell<BTreeSet<i64>>,
3015        hidden_mms: std::cell::RefCell<BTreeSet<i64>>,
3016        unlisted: std::cell::Cell<Option<&'static str>>,
3017        asked: std::cell::RefCell<Vec<(String, usize)>>,
3018    }
3019
3020    impl Android for Provider {
3021        fn ask(&self, verb: &str, asked: &Value) -> Value {
3022            let (hidden, hidden_mms) = (self.hidden.borrow(), self.hidden_mms.borrow());
3023            // Those named, else those after the mark.
3024            let chosen = |list: &Value, mark: &Value, id: i64| match list.as_array() {
3025                Some(ids) => ids.iter().any(|v| v.as_i64() == Some(id)),
3026                None => id > mark.as_i64().unwrap_or(0),
3027            };
3028            let answer = match verb {
3029                "texts-read" => {
3030                    let limit = asked["limit"].as_u64().unwrap_or(500) as usize;
3031                    let mut texts = Vec::new();
3032                    for (id, fields) in self.sms.borrow().iter().filter(|(id, _)| !hidden.contains(id) && chosen(&asked["sms_ids"], &asked["sms"], **id)) {
3033                        if texts.len() < limit {
3034                            texts.push(json!({ "kind": "sms", "fields": fields, "id": id }));
3035                        }
3036                    }
3037                    for (_, row) in self.mms.borrow().iter().filter(|(id, _)| !hidden_mms.contains(id) && chosen(&asked["mms_ids"], &asked["mms"], **id)) {
3038                        if texts.len() < limit {
3039                            let mut row = row.clone();
3040                            row["kind"] = json!("mms");
3041                            texts.push(row);
3042                        }
3043                    }
3044                    json!({ "texts": texts, "threads": {}, "allowed": true })
3045                }
3046                "texts-ids" => {
3047                    let sms: Vec<(i64, i64)> = self.sms.borrow().iter().filter(|(id, _)| !hidden.contains(id)).map(|(id, f)| (*id, f["date"].as_i64().unwrap())).collect();
3048                    let mms: Vec<(i64, i64)> = self.mms.borrow().iter().filter(|(id, _)| !hidden_mms.contains(id)).map(|(id, r)| (*id, r["fields"]["date"].as_i64().unwrap())).collect();
3049                    let mut out = json!({
3050                        "sms": sms.iter().map(|r| r.0).collect::<Vec<_>>(),
3051                        "sms_at": sms.iter().map(|r| r.1).collect::<Vec<_>>(),
3052                        "mms": mms.iter().map(|r| r.0).collect::<Vec<_>>(),
3053                        "mms_at": mms.iter().map(|r| r.1).collect::<Vec<_>>(),
3054                        "media": self.media.borrow().values().map(Vec::len).sum::<usize>(),
3055                        "allowed": true,
3056                    });
3057                    if let Some(table) = self.unlisted.get()
3058                        && let Some(listing) = out.as_object_mut()
3059                    {
3060                        listing.remove(table);
3061                        listing.remove(&format!("{table}_at"));
3062                    }
3063                    out
3064                }
3065                "texts-part" => {
3066                    let target = PathBuf::from(asked["target"].as_str().unwrap_or_default());
3067                    match self.media.borrow().get(&asked["id"].as_i64().unwrap_or(0)) {
3068                        Some(bytes) => {
3069                            std::fs::create_dir_all(target.parent().unwrap()).unwrap();
3070                            std::fs::write(&target, bytes).unwrap();
3071                            json!({ "copied": bytes.len(), "allowed": true })
3072                        }
3073                        None => json!({ "copied": -1, "allowed": true }),
3074                    }
3075                }
3076                _ => Value::Null,
3077            };
3078            let rows = answer["texts"].as_array().map_or(0, Vec::len);
3079            self.asked.borrow_mut().push((verb.to_string(), rows));
3080            answer
3081        }
3082    }
3083
3084    impl Provider {
3085        fn text(&self, id: i64, at: i64, body: &str) {
3086            self.sms.borrow_mut().insert(id, json!({ "_id": id, "thread_id": 3, "address": "04 65 71 00 42", "date": at, "date_sent": at, "type": 1, "read": 1, "body": body }));
3087        }
3088        fn picture(&self, id: i64, at: i64) {
3089            let part = 1000 + 10 * id;
3090            self.mms.borrow_mut().insert(id, json!({
3091                "fields": { "_id": id, "thread_id": 4, "date": at / 1000, "msg_box": 1, "m_type": 132, "sub_id": 1 },
3092                "addrs": [{ "address": "+33465710042", "type": 137, "charset": 106 }],
3093                "parts": [{ "_id": part, "mid": id, "seq": 0, "ct": "image/png", "name": "colis.png", "size": 4 }, { "_id": part + 1, "mid": id, "seq": 1, "ct": "text/plain", "text": "Le colis." }],
3094            }));
3095            self.media.borrow_mut().insert(part, vec![0x89, b'P', b'N', u8::try_from(id % 200).unwrap()]);
3096        }
3097        /// What was asked since the last look: each question and the rows it gave.
3098        fn questions(&self) -> Vec<(String, usize)> {
3099            std::mem::take(&mut *self.asked.borrow_mut())
3100        }
3101    }
3102
3103    /// A phone of the tests: its log, reader and media in a folder of its own, the sharing's folder and key, the seal.
3104    struct Bench {
3105        base: PathBuf,
3106        files: Files,
3107        vault: Vault,
3108        seal: Seal,
3109    }
3110
3111    impl Bench {
3112        fn new(name: &str) -> Bench {
3113            let base = std::env::temp_dir().join(format!("sioul-texts-{name}-{}", std::process::id()));
3114            let _ = std::fs::remove_dir_all(&base);
3115            let files = Files { log: base.join("data/texts/log/phone.jsonl"), reader: base.join("state/texts/reader.json"), incoming: base.join("state/texts/incoming") };
3116            let vault: Vault = (base.join("folder"), [5u8; 32]);
3117            std::fs::create_dir_all(&vault.0).unwrap();
3118            Bench { base, files, vault, seal: Seal::Key(TextSeal::new(&[6u8; 32])) }
3119        }
3120        fn step(&self, phone: &Provider, now: i64, charging: bool) -> bool {
3121            import_step(phone, &self.files, &self.seal, &self.vault, phones::region_named("FR"), now, charging)
3122        }
3123        fn look(&self, phone: &Provider, now: i64) -> bool {
3124            look_again(phone, &self.files, &self.seal, now)
3125        }
3126        fn log(&self) -> Vec<Text> {
3127            rules::read_file::<Text>(&self.files.log, &self.seal)
3128        }
3129        fn reader(&self) -> Reader {
3130            Reader::load_at(&self.files.reader)
3131        }
3132        /// The texts the log holds (deletion marks aside), by their rows.
3133        fn rows(&self) -> Vec<rules::Row> {
3134            self.log().iter().filter(|t| t.deleted == 0).filter_map(Text::row).collect()
3135        }
3136        /// Every row the phone holds is in the log, each once.
3137        fn each_once(&self, phone: &Provider) {
3138            let rows = self.rows();
3139            let once: BTreeSet<rules::Row> = rows.iter().copied().collect();
3140            assert_eq!(once.len(), rows.len(), "no row twice");
3141            let listed = listed(&phone.ask("texts-ids", &json!({}))).unwrap();
3142            assert!(listed.is_subset(&once), "every row read: {} missing", listed.difference(&once).count());
3143        }
3144    }
3145
3146    impl Drop for Bench {
3147        fn drop(&mut self) {
3148            let _ = std::fs::remove_dir_all(&self.base);
3149        }
3150    }
3151
3152    /// A long history never holds back a text that comes meanwhile
3153    /// (docs/texts.md, "Reading, on the phone: paced"). A first import reads
3154    /// the newest first by date, on the battery only the last day's; a text
3155    /// that comes then is read at the next step; on the charger the rest,
3156    /// newest first, 500 at a time. Caught up, a block of old texts put at
3157    /// once (SMS Backup & Restore restoring a history, with a text that came
3158    /// after it) is read the same way; a text the outbox hid, once Android
3159    /// shows it. Each row once in the log, none passed.
3160    /// A conversation deleted in Sioul on a computer (23 October 2026): the
3161    /// phone writes its log again without its texts, never reads them back
3162    /// in, and a text that comes later is read as any.
3163    #[test]
3164    fn a_conversation_deleted_in_sioul_leaves_the_phones_log_for_good() {
3165        let bench = Bench::new("hidden");
3166        let phone = Provider::default();
3167        for id in 1..=3 {
3168            phone.text(id, NOW - 3_600_000 + id * 60_000, "Votre code : 123456");
3169        }
3170        assert!(bench.step(&phone, NOW, false));
3171        assert_eq!(bench.rows().len(), 3);
3172        let root = bench.base.join("data/texts");
3173        let mark = rules::TextMark { at: NOW, conversation: "+33465710042".into(), read: NOW, hidden: NOW };
3174        rules::write_mark(&root, "computer", &mark, &bench.seal).unwrap();
3175        assert_eq!(rules::drop_hidden(&bench.files.log, &rules::Marks::read_all(&root, &bench.seal), &bench.seal).unwrap(), 3);
3176        assert!(bench.rows().is_empty(), "gone from the log, so from every computer's copy");
3177        // The phone's provider still holds them (its own app keeps them): never read back in.
3178        assert!(!bench.step(&phone, NOW + 60_000, false));
3179        assert!(bench.rows().is_empty());
3180        // One Android showed late, dated before the deletion: read, never written.
3181        phone.text(5, NOW - 1_000, "Votre code : 654321");
3182        bench.step(&phone, NOW + 90_000, false);
3183        assert!(bench.rows().is_empty(), "dated before it was deleted: hidden too");
3184        // A text that comes later shows again.
3185        phone.text(4, NOW + 120_000, "Votre colis arrive.");
3186        assert!(bench.step(&phone, NOW + 180_000, false));
3187        assert_eq!(bench.rows().iter().map(|r| r.id).collect::<Vec<_>>(), [4]);
3188    }
3189
3190    #[test]
3191    fn a_text_that_comes_during_a_long_import_goes_first() {
3192        let bench = Bench::new("import");
3193        let day = 86_400_000;
3194        let phone = Provider::default();
3195        for id in 1..=1_200 {
3196            phone.text(id, NOW - 700 * day + id * 60_000, "Un ancien texte.");
3197        }
3198        for id in 1..=3 {
3199            phone.picture(id, NOW - 600 * day + id * 60_000);
3200        }
3201        for id in 1_201..=1_205 {
3202            phone.text(id, NOW - 3 * 3_600_000 + id, "Ce matin.");
3203        }
3204        let ids = |rows: &[rules::Row]| rows.iter().map(|r| r.id).collect::<Vec<_>>();
3205        // The first step, on the battery: this morning's alone, newest first; the history waits for the charger.
3206        assert!(recent_now(&bench.reader(), false) && !import_now(&bench.reader(), true, false, NOW));
3207        assert!(bench.step(&phone, NOW, false));
3208        assert_eq!(ids(&bench.rows()), [1_205, 1_204, 1_203, 1_202, 1_201]);
3209        let reader = bench.reader();
3210        assert!(reader.behind && !import_now(&reader, true, false, NOW + 60_000) && recent_now(&reader, true));
3211        assert!(!recent_now(&reader, false), "nothing new: Android not asked");
3212        // A text comes meanwhile: read at the next step, on the battery.
3213        phone.text(1_206, NOW + 60_000, "Et maintenant ?");
3214        assert!(bench.step(&phone, NOW + 70_000, false));
3215        assert_eq!(ids(&bench.rows()).last(), Some(&1_206));
3216        // On the charger: the rest, newest first by date (the pictures, then the texts), 500 rows a step.
3217        let mut steps = 0;
3218        while bench.reader().behind {
3219            assert!(bench.step(&phone, NOW + 100_000, true));
3220            steps += 1;
3221            assert!(steps <= 3, "1,203 rows: three steps");
3222        }
3223        let rows = bench.rows();
3224        assert_eq!(rows.len(), 1_209);
3225        assert_eq!((rows[6].mms, rows[6].id, rows[9].mms, rows[9].id), (true, 3, false, 1_200), "the history's newest first");
3226        assert!(bench.log().iter().filter(|t| t.id.starts_with("mms-")).all(|t| t.parts[0].state == "here" && !t.parts[0].hash.is_empty()), "the pictures put in the sharing");
3227        assert!(!bench.reader().behind, "caught up");
3228        // Caught up, a restore puts 900 old texts at once, one of them in the outbox for now, then a text comes.
3229        for id in 1_207..=2_106 {
3230            phone.text(id, NOW - 500 * day + id * 60_000, "Restauré.");
3231        }
3232        phone.hidden.borrow_mut().insert(1_500);
3233        phone.text(2_107, NOW + 3_600_000, "Après la restauration.");
3234        assert!(import_now(&bench.reader(), true, false, NOW + 3_700_000), "caught up: a new text, on the battery");
3235        assert!(bench.step(&phone, NOW + 3_700_000, false), "the text that came, not the block");
3236        assert_eq!(ids(&bench.rows()).last(), Some(&2_107));
3237        assert_eq!(bench.rows().len(), 1_210);
3238        assert!(bench.reader().behind);
3239        let mut steps = 0;
3240        while bench.reader().behind {
3241            assert!(bench.step(&phone, NOW + 3_800_000, true));
3242            steps += 1;
3243            assert!(steps <= 2, "899 rows: two steps");
3244        }
3245        assert_eq!(bench.rows()[1_210].id, 2_106, "the block's newest first");
3246        // Out of the outbox: read at the next step, a row that came.
3247        phone.hidden.borrow_mut().clear();
3248        assert!(bench.step(&phone, NOW + 3_900_000, false));
3249        assert_eq!(ids(&bench.rows()).last(), Some(&1_500));
3250        bench.each_once(&phone);
3251        assert!(!bench.look(&phone, NOW + 2 * day), "nothing gone");
3252        let reader = bench.reader();
3253        assert_eq!((reader.done_texts, reader.logged.len(), reader.behind, reader.left), (2_110, 2_110, false, false));
3254    }
3255
3256    /// The plain texts' table gives a deleted newest text's id to the next
3257    /// text (no AUTOINCREMENT): that text is read at the next step, under a
3258    /// name of its own; the deleted one is marked so, once; and a text Sioul
3259    /// sent that took such an id has its state on its own row, never on the
3260    /// older text (the review of 16 October 2026, r1).
3261    #[test]
3262    fn a_text_that_takes_a_deleted_text_s_id_is_read() {
3263        let bench = Bench::new("reused");
3264        let phone = Provider::default();
3265        for id in 1..=10 {
3266            phone.text(id, NOW - 3_600_000 + id * 1_000, "Avant.");
3267        }
3268        assert!(bench.step(&phone, NOW - 3_000_000, true));
3269        assert!(!bench.reader().behind);
3270        // The newest deleted; the next text takes its id.
3271        phone.sms.borrow_mut().remove(&10);
3272        phone.text(10, NOW, "Un nouveau texte, sous le même numéro.");
3273        assert!(bench.step(&phone, NOW + 60_000, false), "read at the next step, on the battery");
3274        let log = bench.log();
3275        assert_eq!(log.len(), 11);
3276        assert!(log.iter().any(|t| t.id == rules::Row { mms: false, id: 10, date: NOW }.name() && t.body.starts_with("Un nouveau")));
3277        // The day's look: the older text of that id gone, said once.
3278        assert!(bench.look(&phone, NOW + 2 * 86_400_000));
3279        assert!(!bench.look(&phone, NOW + 4 * 86_400_000));
3280        let shown = rules::fold_logs(bench.log().into_iter().map(|t| Text { device: "phone".into(), ..t }).collect());
3281        assert_eq!(shown.iter().filter(|t| t.deleted > 0).map(|t| t.body.as_str()).collect::<Vec<_>>(), ["Avant."]);
3282        assert_eq!(shown.len(), 11);
3283        // A text sent from a computer whose row took that id again: named by its date, never the older text's.
3284        let mut sent = vec![Outcome { key: "k".into(), state: "sent".into(), at: NOW, row: "sms-10".into(), ..Outcome::default() }, Outcome { key: "g".into(), state: "sent".into(), at: NOW, row: "sms-77".into(), ..Outcome::default() }];
3285        name_rows(&phone, &mut sent);
3286        assert_eq!((sent[0].row.clone(), sent[1].row.clone()), (rules::Row { mms: false, id: 10, date: NOW }.name(), String::new()), "a row Android no longer holds: unnamed");
3287    }
3288
3289    /// A provider emptied (every conversation deleted, its storage cleared)
3290    /// starts its ids at 1 again: the texts after it are read, and those
3291    /// before it marked deleted (r1c).
3292    #[test]
3293    fn a_provider_emptied_and_filled_again_is_read() {
3294        let bench = Bench::new("emptied");
3295        let phone = Provider::default();
3296        for id in 1..=100 {
3297            phone.text(id, NOW - 86_400_000 + id * 60_000, "Avant.");
3298        }
3299        assert!(bench.step(&phone, NOW - 3_600_000, true));
3300        phone.sms.borrow_mut().clear();
3301        for id in 1..=3 {
3302            phone.text(id, NOW + id * 1_000, "Après.");
3303        }
3304        assert!(bench.step(&phone, NOW + 60_000, false));
3305        assert_eq!(bench.log().iter().filter(|t| t.body == "Après.").count(), 3);
3306        assert!(bench.look(&phone, NOW + 2 * 86_400_000));
3307        assert_eq!(bench.log().iter().filter(|t| t.deleted > 0).count(), 100, "each of the old ones said deleted, once");
3308        bench.each_once(&phone);
3309    }
3310
3311    /// A first import whose own texts sit under a restored block (by their
3312    /// ids), and a text that comes while the restore goes on (an id inside
3313    /// it): on the battery, by date, these come first, never the block (r2, r2b).
3314    #[test]
3315    fn the_newest_by_date_come_first_wherever_their_ids() {
3316        let day = 86_400_000;
3317        // r2: this phone's own 50 texts of this morning, then a restored block of 2,000 above them.
3318        let bench = Bench::new("own-under");
3319        let phone = Provider::default();
3320        for id in 1..=50 {
3321            phone.text(id, NOW - 5 * 3_600_000 + id * 60_000, "Ce matin, sur ce téléphone.");
3322        }
3323        for id in 51..=2_050 {
3324            phone.text(id, NOW - 400 * day + id * 60_000, "Restauré.");
3325        }
3326        assert!(bench.step(&phone, NOW, false));
3327        let rows = bench.rows();
3328        assert_eq!((rows.len(), rows.iter().all(|r| r.id <= 50)), (50, true), "this phone's own, not the block");
3329        // r2b: caught up, a restore of 3,000 rows with a text that came halfway through.
3330        let bench = Bench::new("during");
3331        let phone = Provider::default();
3332        for id in 1..=10 {
3333            phone.text(id, NOW - 3_600_000 + id, "Avant.");
3334        }
3335        assert!(bench.step(&phone, NOW - 3_000_000, true));
3336        for id in 11..=1_510 {
3337            phone.text(id, NOW - 300 * day + id * 60_000, "Restauré.");
3338        }
3339        phone.text(1_511, NOW, "Pendant la restauration.");
3340        for id in 1_512..=3_011 {
3341            phone.text(id, NOW - 300 * day + id * 60_000, "Restauré.");
3342        }
3343        assert!(bench.step(&phone, NOW + 60_000, false));
3344        assert_eq!(bench.rows().iter().map(|r| r.id).filter(|id| *id > 10).collect::<Vec<_>>(), [1_511], "the text that came, alone");
3345    }
3346
3347    /// While a history waits: a picture whose date (in seconds) is under the
3348    /// newest text read, and one composed before a reply and sent after it,
3349    /// are read at the next step on the battery, as rows that came (r6, r6b).
3350    #[test]
3351    fn pictures_that_come_are_read_whatever_their_dates() {
3352        let bench = Bench::new("pictures");
3353        let day = 86_400_000;
3354        let phone = Provider::default();
3355        for id in 1..=600 {
3356            phone.text(id, NOW - 400 * day + id * 60_000, "Restauré.");
3357        }
3358        assert!(!bench.step(&phone, NOW - 2 * day, false), "a history: nothing of the last day");
3359        assert!(bench.reader().behind);
3360        // r6: a text read at 10:01:00.700, then a picture downloaded at 10:01:00.900 (its date: 10:01:00).
3361        phone.text(601, NOW + 60_700, "Une photo arrive.");
3362        assert!(bench.step(&phone, NOW + 60_800, false));
3363        phone.picture(1, NOW + 60_900);
3364        assert!(bench.step(&phone, NOW + 61_000, false), "the picture, on the battery");
3365        assert!(bench.rows().iter().any(|r| r.mms && r.id == 1));
3366        // r6b: a picture composed (in the outbox, hidden), a reply received and read, then the picture sent.
3367        phone.picture(2, NOW + 120_000);
3368        phone.hidden_mms.borrow_mut().insert(2);
3369        phone.picture(3, NOW + 180_000);
3370        assert!(bench.step(&phone, NOW + 190_000, false));
3371        phone.hidden_mms.borrow_mut().clear();
3372        assert!(bench.step(&phone, NOW + 240_000, false), "the picture sent, on the battery");
3373        assert!(bench.rows().iter().any(|r| r.mms && r.id == 2));
3374        assert!(bench.reader().behind, "the history waits still");
3375    }
3376
3377    /// What a step asks Android (MEDIUM 3 of the review): caught up or with a
3378    /// history waiting, a change with nothing new costs one listing; a text
3379    /// that came, the listing and that row; on the charger, the listing and
3380    /// 500 rows.
3381    #[test]
3382    fn a_step_with_a_history_waiting_costs_what_a_caught_up_step_costs() {
3383        let bench = Bench::new("cost");
3384        let phone = Provider::default();
3385        for id in 1..=200 {
3386            phone.text(id, NOW - 3_600_000 + id, "Avant.");
3387        }
3388        assert!(bench.step(&phone, NOW - 3_000_000, true));
3389        phone.questions();
3390        assert!(!bench.step(&phone, NOW, false));
3391        assert_eq!(phone.questions(), [("texts-ids".to_string(), 0)], "caught up, nothing new");
3392        for id in 201..=3_500 {
3393            phone.text(id, NOW - 400 * 86_400_000 + id * 60_000, "Restauré.");
3394        }
3395        assert!(!bench.step(&phone, NOW + 1_000, false), "the block found: nothing of it on the battery");
3396        phone.questions();
3397        assert!(!bench.step(&phone, NOW + 2_000, false));
3398        assert_eq!(phone.questions(), [("texts-ids".to_string(), 0)], "a history waits, nothing new");
3399        phone.text(3_501, NOW + 3_000, "Un texte arrive.");
3400        assert!(bench.step(&phone, NOW + 4_000, false));
3401        assert_eq!(phone.questions(), [("texts-ids".to_string(), 0), ("texts-read".to_string(), 1)], "a text came: it alone");
3402        assert!(bench.step(&phone, NOW + 5_000, true));
3403        assert_eq!(phone.questions(), [("texts-ids".to_string(), 0), ("texts-read".to_string(), 500)], "on the charger");
3404    }
3405
3406    /// A reader of version 2 or 3, which knew rows by their ids alone: the
3407    /// log tells which rows it holds, and what it lacks is read (a text that
3408    /// took a deleted text's id under version 2, lost then); the counts are
3409    /// the log's own, whatever a stop between the log and the reader left.
3410    #[test]
3411    fn a_reader_of_an_older_version_reads_what_its_log_lacks() {
3412        let bench = Bench::new("older");
3413        let phone = Provider::default();
3414        // The log as version 2 wrote it: rows 1 to 10 by their ids alone.
3415        let old: Vec<Text> = (1..=10).map(|id| Text { id: format!("sms-{id}"), at: NOW - 7_200_000 + id * 1_000, direction: "in".into(), with: vec!["+33465710042".into()], body: "Avant.".into(), ..Text::default() }).collect();
3416        rules::append(&bench.files.log, &old, &bench.seal, Some(&|t: &Text| t.line_id())).unwrap();
3417        for id in 1..=9 {
3418            phone.text(id, NOW - 7_200_000 + id * 1_000, "Avant.");
3419        }
3420        // Text 10 deleted, the next taking its id: version 2 never read it.
3421        phone.text(10, NOW - 600_000, "Perdu par la version 2.");
3422        std::fs::create_dir_all(bench.files.reader.parent().unwrap()).unwrap();
3423        for older in [r#"{"version":2,"sms":10,"mms":0,"started":1,"done_texts":7}"#, r#"{"version":3,"sms":10,"sms_read":[[0,10]],"started":1,"done_texts":7}"#] {
3424            std::fs::write(&bench.files.reader, older).unwrap();
3425            let reader = bench.reader();
3426            assert_eq!((reader.version, reader.indexed), (READER_VERSION, false), "{older}");
3427        }
3428        assert!(bench.step(&phone, NOW, false), "the text version 2 lost, read");
3429        assert!(bench.log().iter().any(|t| t.body.starts_with("Perdu")));
3430        assert_eq!(bench.reader().done_texts, 11, "the log's own count");
3431        assert!(bench.look(&phone, NOW + 2 * 86_400_000), "the older text of that id: deleted");
3432        bench.each_once(&phone);
3433        // A line written and the reader not saved (a stop between them): read again once, the count right.
3434        phone.text(11, NOW + 3 * 86_400_000, "Après un arrêt.");
3435        let line = Text { id: rules::Row { mms: false, id: 11, date: NOW + 3 * 86_400_000 }.name(), at: NOW + 3 * 86_400_000, direction: "in".into(), with: vec!["+33465710042".into()], body: "Après un arrêt.".into(), ..Text::default() };
3436        rules::append(&bench.files.log, &[line], &bench.seal, Some(&|t: &Text| t.line_id())).unwrap();
3437        assert!(!bench.step(&phone, NOW + 3 * 86_400_000 + 1_000, false), "in the log already: no line twice");
3438        assert_eq!(bench.reader().done_texts, 12);
3439        bench.each_once(&phone);
3440    }
3441
3442    /// A table Android could not list whole is left out of its listing
3443    /// (`Texts.ids`): nothing is read at that step, and the day's look says
3444    /// nothing deleted, never "none there" taken from "not known"; listed
3445    /// again, every row comes.
3446    #[test]
3447    fn a_table_not_listed_is_never_taken_for_an_empty_one() {
3448        let bench = Bench::new("unlisted");
3449        let phone = Provider::default();
3450        for id in 1..=5 {
3451            phone.text(id, NOW - 3_600_000 + id, "Avant.");
3452        }
3453        phone.picture(1, NOW - 3_500_000);
3454        assert!(bench.step(&phone, NOW - 3_000_000, true));
3455        for table in ["sms", "mms"] {
3456            phone.unlisted.set(Some(table));
3457            phone.text(5 + bench.log().len() as i64, NOW, "Pendant.");
3458            assert!(!bench.step(&phone, NOW + 1_000, false), "{table} not listed: nothing read");
3459            assert!(!bench.look(&phone, NOW + 2 * 86_400_000), "{table} not listed: nothing said deleted");
3460            assert!(bench.log().iter().all(|t| t.deleted == 0));
3461        }
3462        phone.unlisted.set(None);
3463        assert!(bench.step(&phone, NOW + 3 * 86_400_000, false));
3464        assert!(!bench.look(&phone, NOW + 4 * 86_400_000), "nothing gone");
3465        bench.each_once(&phone);
3466    }
3467
3468    /// A row that comes in the step that finds a history (a block of old
3469    /// texts put at once), dated under the newest text read (a picture's date
3470    /// is in seconds): read on the battery all the same, with the history's
3471    /// last day (review of 16 October 2026, LOW 2).
3472    #[test]
3473    fn a_row_that_comes_with_a_block_is_read_on_the_battery() {
3474        let bench = Bench::new("with-block");
3475        let phone = Provider::default();
3476        phone.text(1, NOW + 60_700, "Une photo arrive.");
3477        assert!(bench.step(&phone, NOW + 60_800, true));
3478        assert!(!bench.reader().behind);
3479        // The block (600 old texts) and, in the same step, a picture downloaded at 10:01:00.900 (dated 10:01:00).
3480        for id in 2..=601 {
3481            phone.text(id, NOW - 400 * DAY_MS + id * 60_000, "Restauré.");
3482        }
3483        phone.picture(1, NOW + 60_900);
3484        assert!(bench.step(&phone, NOW + 61_000, false));
3485        assert!(bench.rows().iter().any(|r| r.mms && r.id == 1), "the picture, on the battery");
3486        assert!(bench.reader().behind, "the history waits for the charger");
3487    }
3488
3489    /// A row whose date its provider rewrote (an MMS downloaded or sent
3490    /// writes its date after its row; a text resent) is read again under its
3491    /// new date, and its old line marked moved, at once: computers show the
3492    /// message once, never once more "deleted on your phone"; a text that
3493    /// took a deleted text's id, its words other, leaves that one deleted
3494    /// (review of 16 October 2026, LOW 1), and so does one with the same
3495    /// words from the same person, hours later (review of 18 October 2026, L-3).
3496    #[test]
3497    fn a_row_its_provider_dated_again_is_one_text() {
3498        let bench = Bench::new("redated");
3499        let phone = Provider::default();
3500        phone.text(1, NOW - 600_000, "Bonjour.");
3501        phone.text(2, NOW - 500_000, "À demain.");
3502        phone.picture(3, NOW - 400_000);
3503        phone.text(4, NOW - 3 * 3_600_000, "Oui.");
3504        assert!(bench.step(&phone, NOW - 300_000, true));
3505        // The picture's row dated again a second later, the text's a millisecond: both read again.
3506        let picture = phone.mms.borrow_mut().remove(&3).unwrap();
3507        let mut moved = picture.clone();
3508        moved["fields"]["date"] = json!((NOW - 399_000) / 1000);
3509        phone.mms.borrow_mut().insert(3, moved);
3510        phone.text(1, NOW - 599_999, "Bonjour.");
3511        // Text 2 deleted, and its id taken by another text.
3512        phone.text(2, NOW - 100_000, "Autre chose.");
3513        // Her "Oui." deleted, and its id taken by her next "Oui.", hours later: another text.
3514        phone.text(4, NOW - 50_000, "Oui.");
3515        assert!(bench.step(&phone, NOW, false));
3516        let log = bench.log();
3517        let mut marks: Vec<(String, bool)> = log.iter().filter(|t| t.deleted > 0).map(|t| (t.id.split('@').next().unwrap_or_default().to_string(), !t.moved.is_empty())).collect();
3518        marks.sort();
3519        assert_eq!(marks, [("mms-3".to_string(), true), ("sms-1".to_string(), true)], "the re-dated rows' old lines marked moved, at once; text 2 not (other words), nor text 4 (the same words, hours later)");
3520        assert!(bench.look(&phone, NOW + DAY_MS));
3521        let shown = rules::fold_logs(bench.log().into_iter().map(|t| Text { device: "phone".into(), ..t }).collect());
3522        assert_eq!(shown.iter().filter(|t| t.body == "Bonjour.").count(), 1, "the text once, under its new date");
3523        assert_eq!(shown.iter().filter(|t| t.id.starts_with("mms-3@")).count(), 1, "the picture once");
3524        assert!(shown.iter().all(|t| t.deleted == 0 || t.body == "À demain." || t.body == "Oui."), "only the texts truly deleted said so");
3525        assert!(shown.iter().any(|t| t.body == "À demain." && t.deleted > 0));
3526        assert!(shown.iter().any(|t| t.body == "Autre chose." && t.deleted == 0));
3527        let mut oui: Vec<(i64, bool)> = shown.iter().filter(|t| t.body == "Oui.").map(|t| (t.at, t.deleted > 0)).collect();
3528        oui.sort_unstable();
3529        assert_eq!(oui, [(NOW - 3 * 3_600_000, true), (NOW - 50_000, false)], "her first \"Oui.\" deleted on the phone, kept here; her next one there");
3530    }
3531
3532    /// A day's look whose listing is not whole waits an hour: never listing
3533    /// and measuring every medium again at every step meanwhile (review of
3534    /// 16 October 2026, LOW 4); listed whole, done for the day.
3535    #[test]
3536    fn a_look_that_cannot_list_waits_an_hour() {
3537        let bench = Bench::new("look-wait");
3538        let phone = Provider::default();
3539        phone.text(1, NOW - 3_600_000, "Avant.");
3540        assert!(bench.step(&phone, NOW - 3_000_000, true));
3541        phone.unlisted.set(Some("mms"));
3542        phone.questions();
3543        let day = NOW + DAY_MS;
3544        assert!(!bench.look(&phone, day));
3545        assert_eq!(phone.questions().iter().filter(|(verb, _)| verb == "texts-ids").count(), 1);
3546        assert_eq!(bench.reader().looked, day - DAY_MS + LOOK_AGAIN_MS, "tried again in an hour");
3547        assert!(!import_now(&bench.reader(), false, false, day + 60_000), "the day's look no longer due at every step");
3548        assert!(!bench.look(&phone, day + 30 * 60_000));
3549        assert!(phone.questions().is_empty(), "nothing asked within the hour");
3550        phone.unlisted.set(None);
3551        assert!(!bench.look(&phone, day + LOOK_AGAIN_MS + 1), "nothing gone");
3552        assert_eq!(bench.reader().looked, day + LOOK_AGAIN_MS + 1, "listed whole: done for the day");
3553    }
3554
3555    /// A log that cannot be written (a full disk): its rows are not taken as
3556    /// read, and come at the next step that can write (LOW 5).
3557    #[test]
3558    fn rows_a_log_could_not_take_are_read_again() {
3559        let bench = Bench::new("unwritable");
3560        let phone = Provider::default();
3561        phone.text(1, NOW, "Bonjour.");
3562        std::fs::create_dir_all(&bench.files.log).unwrap();
3563        assert!(!bench.step(&phone, NOW + 1_000, true));
3564        assert_eq!(bench.reader().logged.len(), 0);
3565        std::fs::remove_dir_all(&bench.files.log).unwrap();
3566        assert!(bench.step(&phone, NOW + 2_000, false));
3567        bench.each_once(&phone);
3568    }
3569
3570    /// A restore onto a phone that had read no text yet, its import begun a
3571    /// month before: on the battery, only the texts of the day before the
3572    /// block was found, never the month's history.
3573    #[test]
3574    fn a_block_found_late_reads_only_its_last_day_on_the_battery() {
3575        let bench = Bench::new("late");
3576        let day = 86_400_000;
3577        let phone = Provider::default();
3578        // A month ago: a first import, of nothing.
3579        assert!(!bench.step(&phone, NOW - 30 * day, false));
3580        assert!(!bench.reader().behind);
3581        // Now: 600 texts of the last twenty days, put at once.
3582        for id in 1..=600 {
3583            phone.text(id, NOW - 20 * day + id * 2_880_000, "Restauré.");
3584        }
3585        assert!(bench.step(&phone, NOW, false));
3586        let read = bench.log();
3587        assert!(!read.is_empty() && read.iter().all(|t| t.at >= NOW - day), "the last day's alone: {}", read.len());
3588        assert_eq!(read.len(), 31, "a text every 48 minutes: 31 from a day before to now");
3589    }
3590
3591    // ---------------------------------------------------------------- multimedia messages, on the phone
3592
3593    const MINUTE: i64 = 60_000;
3594
3595    /// Android, as the phone's quick step asks it about multimedia messages:
3596    /// its carrier's values, what it was handed (and the message's bytes),
3597    /// the rows it filed by key, the dates of rows named.
3598    #[derive(Default)]
3599    struct Carrier {
3600        max: u64,
3601        mms: bool,
3602        /// Each multimedia message handed: its key, its SIM, its bytes.
3603        handed: std::cell::RefCell<Vec<(String, i64, Vec<u8>)>>,
3604        /// The rows filed, by key: (id, date in seconds, box).
3605        filed: std::cell::RefCell<BTreeMap<String, (i64, i64, i64)>>,
3606        /// What Android answers a hand-off: none for the default (taken).
3607        refuse: Option<&'static str>,
3608        /// The provider's query fails (`Texts.findMms` says so).
3609        look_fails: std::cell::Cell<bool>,
3610        /// The carrier's status Android read for a row, by key (`resp_st`).
3611        statuses: std::cell::RefCell<BTreeMap<String, i64>>,
3612    }
3613
3614    impl Android for Carrier {
3615        fn ask(&self, verb: &str, asked: &Value) -> Value {
3616            match verb {
3617                "texts-limits" => json!({ "limits": [{ "sub": 1, "max": self.max, "width": 2592, "height": 1944, "recipients": 20, "group": true, "mms": self.mms }] }),
3618                "texts-send-mms" => {
3619                    if let Some(why) = self.refuse {
3620                        return json!({ "handed": false, "reason": why });
3621                    }
3622                    let bytes = std::fs::read(asked["path"].as_str().unwrap_or_default()).unwrap_or_default();
3623                    self.handed.borrow_mut().push((asked["key"].as_str().unwrap_or_default().to_string(), asked["sub"].as_i64().unwrap_or(-9), bytes));
3624                    json!({ "handed": true })
3625                }
3626                "texts-find-mms" if self.look_fails.get() => json!({ "allowed": true, "found": [], "error": true }),
3627                "texts-find-mms" => {
3628                    let key = asked["key"].as_str().unwrap_or_default();
3629                    let status = self.statuses.borrow().get(key).copied().unwrap_or(0);
3630                    let found: Vec<Value> = self.filed.borrow().get(key).map(|(id, date, mbox)| json!({ "id": id, "date": date, "box": mbox, "status": status })).into_iter().collect();
3631                    json!({ "allowed": true, "found": found })
3632                }
3633                "texts-read" => {
3634                    let rows: Vec<Value> = self.filed.borrow().values().map(|(id, date, _)| json!({ "kind": "mms", "fields": { "_id": id, "date": date } })).collect();
3635                    json!({ "allowed": true, "texts": rows })
3636                }
3637                "texts-find" => json!({ "allowed": true, "texts": [] }),
3638                _ => Value::Null,
3639            }
3640        }
3641    }
3642
3643    /// A sharing that counts every device.
3644    fn every_device(_: &str) -> rules::Standing {
3645        rules::Standing::Counted
3646    }
3647
3648    /// A phone's quick step on folders of its own: its data, its private
3649    /// texts folder, the sharing's memory, folder and key.
3650    struct Desk {
3651        base: PathBuf,
3652        vault: Vault,
3653        seal: Seal,
3654    }
3655
3656    impl Desk {
3657        fn new(name: &str) -> Desk {
3658            let base = std::env::temp_dir().join(format!("sioul-mms-{name}-{}", std::process::id()));
3659            let _ = std::fs::remove_dir_all(&base);
3660            std::fs::create_dir_all(base.join("folder")).unwrap();
3661            std::fs::create_dir_all(base.join("share")).unwrap();
3662            // The ledger's mark, a day before: the requests come after it.
3663            rules::Ledger::open(&base.join("private"), NOW - DAY_MS).unwrap();
3664            Desk { vault: (base.join("folder"), [5u8; 32]), seal: Seal::Key(TextSeal::new(&[6u8; 32])), base }
3665        }
3666
3667        fn step<'a>(&'a self, android: &'a dyn Android) -> Step<'a> {
3668            self.step_counting(android, &every_device)
3669        }
3670
3671        /// The same, the devices standing as `standing` says.
3672        fn step_counting<'a>(&'a self, android: &'a dyn Android, standing: &'a dyn Fn(&str) -> rules::Standing) -> Step<'a> {
3673            Step { here: "phone", seal: &self.seal, region: phones::region_named("FR"), root: self.base.join("data"), private: self.base.join("private"), copies: Vec::new(), memory: self.base.join("share").join("memory.json"), vault: Some(&self.vault), android, standing }
3674        }
3675
3676        /// A request written on a computer: its line in that computer's file, its writer sealed in it.
3677        fn asks(&self, request: &Request) {
3678            rules::write_request(&self.base.join("data"), "desk", request, &self.seal).unwrap();
3679        }
3680
3681        /// A medium sealed into the sharing, as the computer that wrote the message seals it.
3682        fn seals(&self, bytes: &[u8]) -> rules::Medium {
3683            let source = self.base.join("medium");
3684            std::fs::write(&source, bytes).unwrap();
3685            let (hash, size) = sioul_sync::blobs::hash_file(&source).unwrap();
3686            sioul_sync::blobs::put(&self.vault.0, &self.vault.1, &source, &hash).unwrap();
3687            rules::Medium { hash, ct: "image/jpeg".into(), size }
3688        }
3689
3690        /// The medium's name and description, not sealed (its blob not there yet).
3691        fn described(&self, bytes: &[u8]) -> rules::Medium {
3692            let source = self.base.join("medium");
3693            std::fs::write(&source, bytes).unwrap();
3694            let (hash, size) = sioul_sync::blobs::hash_file(&source).unwrap();
3695            rules::Medium { hash, ct: "image/jpeg".into(), size }
3696        }
3697
3698        fn outcomes(&self) -> Vec<Outcome> {
3699            rules::read_file::<Outcome>(&rules::own_file(&self.base.join("data"), rules::OUTCOME, "phone"), &self.seal)
3700        }
3701
3702        fn states(&self, key: &str) -> Vec<(String, String)> {
3703            self.outcomes().into_iter().filter(|o| o.key == key).map(|o| (o.state, o.reason)).collect()
3704        }
3705
3706        fn ledger(&self) -> rules::Ledger {
3707            rules::Ledger::open(&self.base.join("private"), NOW).unwrap()
3708        }
3709
3710        /// Java's word on a multimedia message, as TextSend.Result writes it.
3711        fn result(&self, key: &str, code: i64, uri: &str, conf: &[u8], at: i64) {
3712            self.result_http(key, code, uri, conf, 0, at);
3713        }
3714
3715        /// The same, with the HTTP status Android adds to its code 4 (0 none).
3716        fn result_http(&self, key: &str, code: i64, uri: &str, conf: &[u8], http: i64, at: i64) {
3717            use std::io::Write as _;
3718            let hex: String = conf.iter().map(|b| format!("{b:02x}")).collect();
3719            let line = json!({ "key": key, "kind": "mms", "code": code, "uri": uri, "http": http, "conf": hex, "at": at });
3720            let path = self.base.join("private").join(rules::RESULTS);
3721            let mut file = std::fs::OpenOptions::new().create(true).append(true).open(path).unwrap();
3722            writeln!(file, "{line}").unwrap();
3723        }
3724
3725        fn wanted(&self) -> Vec<String> {
3726            sioul_sync::blobs::Wanted::load(&self.base.join("share").join("memory.json")).media.into_keys().collect()
3727        }
3728    }
3729
3730    impl Drop for Desk {
3731        fn drop(&mut self) {
3732            let _ = std::fs::remove_dir_all(&self.base);
3733        }
3734    }
3735
3736    /// The carrier's answer: taken (Ok, its Message-ID), or not and why.
3737    fn conf(key: &str, status: u8) -> Vec<u8> {
3738        let mut out = vec![0x8C, 0x81, 0x98];
3739        out.extend_from_slice(key.as_bytes());
3740        out.extend_from_slice(&[0x00, 0x8D, 0x92, 0x92, status, 0x8B]);
3741        out.extend_from_slice(b"msg-1\0");
3742        out
3743    }
3744
3745    fn mms(key: &str, media: Vec<rules::Medium>, written: i64) -> Request {
3746        Request { key: key.into(), phone: "phone".into(), to: "01 99 00 12 34".into(), words: "Voilà l’entrée.".into(), media, sub: 1, written, ..Request::default() }
3747    }
3748
3749    const KEY_A: &str = "0f3c9a5e7b2d4f6a8c1e3b5d7f9a2c4e";
3750    const KEY_B: &str = "1a2b3c4d5e6f708192a3b4c5d6e7f809";
3751
3752    /// A multimedia message is claimed only once each medium is here and
3753    /// checked (asked for by its name meanwhile, nothing claimed), built as
3754    /// Android sends it, handed once whatever is read again, and said sent
3755    /// on Android's Ok and the carrier's, its row named, its file gone
3756    /// (decisions 12 and 13).
3757    #[test]
3758    fn a_multimedia_message_goes_once_its_media_are_here() {
3759        let desk = Desk::new("goes");
3760        let carrier = Carrier { max: 614_400, mms: true, ..Carrier::default() };
3761        let picture: Vec<u8> = (0..3_000u32).map(|i| (i % 251) as u8).collect();
3762        let medium = desk.described(&picture);
3763        desk.asks(&mms(KEY_A, vec![medium.clone()], NOW - MINUTE));
3764        let step = desk.step(&carrier);
3765        // Its picture not in the sharing yet: nothing claimed, nothing said, asked for by its name.
3766        assert!(!step.decide(NOW));
3767        assert!(desk.ledger().state(KEY_A).is_none() && desk.outcomes().is_empty());
3768        assert_eq!(desk.wanted(), [sioul_sync::blobs::name(&desk.vault.1, &medium.hash)]);
3769        // Here: claimed, built, handed.
3770        desk.seals(&picture);
3771        assert!(step.decide(NOW + MINUTE));
3772        assert_eq!(desk.states(KEY_A), [("handed".to_string(), String::new())]);
3773        assert!(desk.ledger().claims.iter().any(|c| c.key == KEY_A && c.state == "claimed" && c.mms), "a multimedia message's claim");
3774        assert!(desk.wanted().is_empty(), "asked for no more");
3775        {
3776            let handed = carrier.handed.borrow();
3777            assert_eq!(handed.len(), 1);
3778            let (key, sub, pdu) = &handed[0];
3779            assert_eq!((key.as_str(), *sub), (KEY_A, 1));
3780            assert_eq!(&pdu[..3], [0x8C, 0x80, 0x98], "an M-Send.req");
3781            assert!(pdu.windows(KEY_A.len()).any(|w| w == KEY_A.as_bytes()), "its key as its transaction id");
3782            assert!(pdu.windows(b"+33199001234/TYPE=PLMN".len()).any(|w| w == b"+33199001234/TYPE=PLMN"));
3783            assert!(pdu.windows(picture.len()).any(|w| w == picture.as_slice()), "the picture whole");
3784            assert!(!pdu.windows(b".media".len()).any(|w| w == b".media") && pdu.windows(b"image1.jpg".len()).any(|w| w == b"image1.jpg"), "plain names");
3785        }
3786        // Read again, by the records and by its small file: never handed again.
3787        assert!(!step.decide(NOW + 2 * MINUTE));
3788        assert_eq!(carrier.handed.borrow().len(), 1, "never twice");
3789        // Android's Ok and the carrier's: sent, its row named, its file gone, the ledger done.
3790        carrier.filed.borrow_mut().insert(KEY_A.into(), (31, (NOW + 2 * MINUTE) / 1000, 2));
3791        desk.result(KEY_A, -1, "content://mms/sent/31", &conf(KEY_A, 0x80), NOW + 2 * MINUTE);
3792        assert!(step.copy_results(NOW + 3 * MINUTE));
3793        let sent = desk.outcomes().into_iter().find(|o| o.key == KEY_A && o.state == "sent").unwrap();
3794        assert_eq!(sent.row, format!("mms-31@{}", (NOW + 2 * MINUTE) / 1000));
3795        assert_eq!(desk.ledger().state(KEY_A), Some("done"));
3796        assert!(!step.pdu_path(KEY_A).exists(), "its file gone");
3797        // Looked for later: nothing more, it is done.
3798        assert!(!step.look_for_lost(NOW + 50 * MINUTE));
3799    }
3800
3801    /// Larger than its SIM's carrier takes, or a carrier that takes none:
3802    /// refused, said, decided once; a medium of another size than its
3803    /// request says, refused; Android refusing it, not sent, said.
3804    #[test]
3805    fn a_multimedia_message_its_carrier_cannot_take_is_refused_in_words() {
3806        let desk = Desk::new("refused");
3807        let picture: Vec<u8> = (0..20_000u32).map(|i| (i % 253) as u8).collect();
3808        let medium = desk.seals(&picture);
3809        desk.asks(&mms(KEY_A, vec![medium.clone()], NOW - MINUTE));
3810        let small = Carrier { max: 10_000, mms: true, ..Carrier::default() };
3811        assert!(desk.step(&small).decide(NOW));
3812        assert_eq!(desk.states(KEY_A), [("refused".to_string(), "too-large".to_string())]);
3813        assert!(small.handed.borrow().is_empty() && !desk.step(&small).pdu_path(KEY_A).exists());
3814        desk.asks(&mms(KEY_B, vec![medium], NOW - MINUTE));
3815        let none = Carrier { max: 614_400, mms: false, ..Carrier::default() };
3816        assert!(desk.step(&none).decide(NOW));
3817        assert_eq!(desk.states(KEY_B), [("refused".to_string(), "no-mms".to_string())]);
3818        // Android refusing the hand-off: not sent, said; its file gone.
3819        let key_c = "2b3c4d5e6f708192a3b4c5d6e7f80910";
3820        desk.asks(&mms(key_c, vec![desk.seals(b"another picture")], NOW - MINUTE));
3821        let refusing = Carrier { max: 614_400, mms: true, refuse: Some("permission"), ..Carrier::default() };
3822        assert!(desk.step(&refusing).decide(NOW));
3823        assert_eq!(desk.states(key_c), [("failed".to_string(), "permission".to_string())]);
3824        assert!(!desk.step(&refusing).pdu_path(key_c).exists());
3825    }
3826
3827    /// Its media never came within its quarter of an hour: expired, and said
3828    /// that they did not reach the phone; asked for no more.
3829    #[test]
3830    fn a_multimedia_message_whose_media_never_came_expires_said_so() {
3831        let desk = Desk::new("late");
3832        let carrier = Carrier { max: 614_400, mms: true, ..Carrier::default() };
3833        desk.asks(&mms(KEY_A, vec![desk.described(b"a picture that never came")], NOW - 10 * MINUTE));
3834        assert!(!desk.step(&carrier).decide(NOW));
3835        assert_eq!(desk.wanted().len(), 1);
3836        assert!(desk.step(&carrier).decide(NOW + 6 * MINUTE));
3837        assert_eq!(desk.states(KEY_A), [("expired".to_string(), "media".to_string())]);
3838        assert!(desk.wanted().is_empty(), "asked for no more");
3839        assert!(carrier.handed.borrow().is_empty());
3840    }
3841
3842    /// The carrier's answer missing (Android's Ok alone): the sent box says,
3843    /// there, sent; else not sent (Android filed it among the failed). A
3844    /// carrier's refusal and Android's own codes, each in its words.
3845    #[test]
3846    fn a_multimedia_message_s_result_is_read_with_its_carrier_s_answer() {
3847        let desk = Desk::new("results");
3848        let carrier = Carrier::default();
3849        carrier.filed.borrow_mut().insert(KEY_A.into(), (40, NOW / 1000, 2));
3850        let (k3, k4, k5) = ("3c4d5e6f708192a3b4c5d6e7f8091011", "4d5e6f708192a3b4c5d6e7f809101112", "5e6f708192a3b4c5d6e7f80910111213");
3851        desk.result(KEY_A, -1, "content://mms/sent/40", &[], NOW);
3852        desk.result(KEY_B, -1, "", &[], NOW);
3853        desk.result(k3, -1, "content://mms/sent/41", &conf(k3, 0xE5), NOW);
3854        desk.result(k4, 11, "", &[], NOW);
3855        desk.result(k5, 4, "", &conf(k5, 0xC2), NOW);
3856        // Code 4: every try ended on the network (no HTTP status), or the carrier answered 503 (the review's M-2).
3857        let (k6, k7) = ("6f708192a3b4c5d6e7f8091011121314", "708192a3b4c5d6e7f809101112131415");
3858        desk.result_http(k6, 4, "", &[], 0, NOW);
3859        desk.result_http(k7, 4, "", &[], 503, NOW);
3860        // The sent box unreadable a first time (the review's L-2): nothing said of KEY_B then.
3861        carrier.look_fails.set(true);
3862        assert!(desk.step(&carrier).copy_results(NOW + MINUTE));
3863        assert!(desk.states(KEY_B).is_empty(), "a look not made says nothing: {:?}", desk.states(KEY_B));
3864        carrier.look_fails.set(false);
3865        assert!(desk.step(&carrier).copy_results(NOW + 2 * MINUTE));
3866        let said: BTreeMap<String, (String, String)> = desk.outcomes().into_iter().map(|o| (o.key.clone(), (o.state, if o.row.is_empty() { o.reason } else { o.row }))).collect();
3867        assert_eq!(said[KEY_A], ("sent".to_string(), format!("mms-40@{}", NOW / 1000)), "Android's Ok, the sent box holds it");
3868        assert_eq!(said[KEY_B], ("doubt".to_string(), "answer".to_string()), "Android's Ok, in no box Sioul sees: the carrier may have it (I-g)");
3869        assert_eq!(said[k3], ("failed".to_string(), "carrier-content".to_string()));
3870        assert_eq!(said[k4], ("failed".to_string(), "mms-data-off".to_string()));
3871        assert_eq!(said[k5], ("failed".to_string(), "carrier-busy".to_string()));
3872        assert_eq!(said[k6], ("doubt".to_string(), "network".to_string()), "the carrier may have it");
3873        assert_eq!(said[k7], ("failed".to_string(), "carrier-refused".to_string()));
3874        // Read again: each said once.
3875        let count = desk.outcomes().len();
3876        desk.step(&carrier).copy_results(NOW + 2 * MINUTE);
3877        assert_eq!(desk.outcomes().len(), count);
3878    }
3879
3880    /// Android's Ok with the carrier's answer missing or unreadable: the sent
3881    /// box decides; filed among the failed with a refusal Android read, not
3882    /// sent and why; filed there with none, the carrier may have it (its
3883    /// HTTP 2xx came first), the doubt (the review's re-check, I-g).
3884    #[test]
3885    fn android_s_ok_with_an_answer_nobody_read_says_the_doubt() {
3886        let desk = Desk::new("ok-unread");
3887        let carrier = Carrier::default();
3888        let (k1, k2) = ("8192a3b4c5d6e7f80910111213141516", "92a3b4c5d6e7f8091011121314151617");
3889        carrier.filed.borrow_mut().insert(k1.into(), (60, NOW / 1000, 5));
3890        carrier.statuses.borrow_mut().insert(k1.into(), 0xE5);
3891        carrier.filed.borrow_mut().insert(k2.into(), (61, NOW / 1000, 5));
3892        desk.result(k1, -1, "", &[], NOW);
3893        desk.result(k2, -1, "", &[], NOW);
3894        assert!(desk.step(&carrier).copy_results(NOW + MINUTE));
3895        assert_eq!(desk.states(k1), [("failed".to_string(), "carrier-content".to_string())], "the refusal Android read");
3896        assert_eq!(desk.states(k2), [("doubt".to_string(), "answer".to_string())], "an answer nobody could read");
3897    }
3898
3899    /// Java's results are read for the outcomes' month alone, and trimmed
3900    /// to it once a day without being written over: a line Java appends as
3901    /// the file is set aside is read, and kept at the next trim (the
3902    /// review's re-check, I-f).
3903    #[test]
3904    fn java_s_results_are_kept_a_month_and_never_said_again() {
3905        let desk = Desk::new("results-trim");
3906        let carrier = Carrier::default();
3907        let (old, new) = ("a3b4c5d6e7f8091011121314151617a8", "b4c5d6e7f8091011121314151617a8b9");
3908        desk.result_http(old, 11, "", &[], 0, NOW - 40 * DAY_MS);
3909        desk.result_http(new, 11, "", &[], 0, NOW - DAY_MS);
3910        assert!(desk.step(&carrier).copy_results(NOW));
3911        assert!(desk.states(old).is_empty(), "past the month: its outcome trimmed long ago, never said again");
3912        assert_eq!(desk.states(new), [("failed".to_string(), "mms-data-off".to_string())]);
3913        let private = desk.base.join("private");
3914        trim_results(&private, NOW).unwrap();
3915        let lines = |name: &str| std::fs::read_to_string(private.join(name)).unwrap_or_default().lines().count();
3916        assert_eq!((lines(rules::RESULTS), lines(RESULTS_ASIDE)), (1, 2), "the month's line kept; the old file set aside");
3917        // Java appends a line to the file as it is set aside: read now, kept at the next trim.
3918        let late = "c5d6e7f8091011121314151617a8b9c0";
3919        let mut aside = std::fs::read_to_string(private.join(RESULTS_ASIDE)).unwrap();
3920        aside.push_str(&format!("{}\n", json!({ "key": late, "kind": "mms", "code": 11, "http": 0, "at": NOW })));
3921        std::fs::write(private.join(RESULTS_ASIDE), aside).unwrap();
3922        assert!(desk.step(&carrier).copy_results(NOW + MINUTE));
3923        assert_eq!(desk.states(late), [("failed".to_string(), "mms-data-off".to_string())]);
3924        trim_results(&private, NOW + DAY_MS).unwrap();
3925        let kept: Vec<String> = results_of(&private, NOW + DAY_MS).into_iter().map(|r| r.key).collect();
3926        assert!(kept.contains(&new.to_string()) && kept.contains(&late.to_string()) && !kept.contains(&old.to_string()), "{kept:?}");
3927        assert_eq!(lines(rules::RESULTS), 2, "the late line brought back once, the month's kept");
3928    }
3929
3930    /// A multimedia message's claim with no word from Android (a restart
3931    /// while it waited for a network, Sioul stopped after the hand-off):
3932    /// looked for by its key from 40 minutes on, handed or not; found, sent;
3933    /// not found, said in doubt only past Android's last word (100 minutes:
3934    /// the review's L-3), a look that fails made again; never sent again;
3935    /// its file gone.
3936    #[test]
3937    fn a_multimedia_message_with_no_word_is_looked_for_until_android_s_last_word() {
3938        let desk = Desk::new("lost");
3939        let carrier = Carrier { max: 614_400, mms: true, ..Carrier::default() };
3940        desk.asks(&mms(KEY_A, vec![desk.seals(b"picture a")], NOW - MINUTE));
3941        desk.asks(&mms(KEY_B, vec![desk.seals(b"picture b")], NOW - MINUTE));
3942        let step = desk.step(&carrier);
3943        assert!(step.decide(NOW));
3944        assert_eq!(carrier.handed.borrow().len(), 2);
3945        assert!(!step.look_for_lost(NOW + 39 * MINUTE), "Android may still wait for a network");
3946        carrier.filed.borrow_mut().insert(KEY_A.into(), (51, (NOW + 30 * MINUTE) / 1000, 2));
3947        assert!(step.look_for_lost(NOW + 41 * MINUTE));
3948        assert_eq!(desk.states(KEY_A).last().unwrap().0, "sent");
3949        assert_eq!(desk.states(KEY_B).last().unwrap().0, "handed", "it may still go");
3950        assert!(!step.pdu_path(KEY_A).exists() && step.pdu_path(KEY_B).exists());
3951        assert!(!step.look_for_lost(NOW + 70 * MINUTE));
3952        carrier.look_fails.set(true);
3953        assert!(!step.look_for_lost(NOW + 99 * MINUTE), "a look not made, before Android's last word: nothing said");
3954        carrier.look_fails.set(false);
3955        assert!(step.look_for_lost(NOW + 100 * MINUTE));
3956        assert_eq!(desk.states(KEY_B).last().unwrap().0, "doubt");
3957        assert!(!step.pdu_path(KEY_B).exists());
3958        assert!(!step.decide(NOW + 101 * MINUTE) && carrier.handed.borrow().len() == 2, "never sent again");
3959        assert!(!step.look_for_lost(NOW + 120 * MINUTE), "said once");
3960    }
3961
3962    /// Only the files the page made fit are sent: none elsewhere, through a
3963    /// link or `..` either.
3964    #[test]
3965    fn only_files_made_fit_are_sent() {
3966        let root = std::env::temp_dir().join(format!("sioul-made-in-{}", std::process::id()));
3967        let folder = root.join("texts-attached");
3968        std::fs::create_dir_all(&folder).unwrap();
3969        std::fs::write(folder.join("1-0.jpg"), b"made").unwrap();
3970        std::fs::write(root.join("IMG_0001.JPG"), b"chosen").unwrap();
3971        assert!(made_in(&folder, &folder.join("1-0.jpg")));
3972        assert!(!made_in(&folder, &root.join("IMG_0001.JPG")), "the file chosen itself");
3973        assert!(!made_in(&folder, &folder.join("..").join("IMG_0001.JPG")), "out by ..");
3974        assert!(!made_in(&folder, &folder), "the folder");
3975        assert!(!made_in(&folder, &folder.join("gone.jpg")), "none there");
3976        #[cfg(unix)]
3977        {
3978            std::os::unix::fs::symlink(root.join("IMG_0001.JPG"), folder.join("2-0.jpg")).unwrap();
3979            assert!(!made_in(&folder, &folder.join("2-0.jpg")), "a link out");
3980        }
3981        assert!(!made_in(&root.join("none"), &folder.join("1-0.jpg")), "no folder made");
3982        let _ = std::fs::remove_dir_all(&root);
3983    }
3984
3985    /// What the phone said its carriers take, read only: a line, or one a
3986    /// SIM, with the picture size its carrier advises when it said one.
3987    #[test]
3988    fn what_the_phone_said_is_shown_read_only() {
3989        let sim = |sub: i32, mms: bool, width: u32| rules::Limits { sub, max: 614_400, width, height: if width > 0 { 1944 } else { 0 }, recipients: 20, group: true, mms };
3990        assert_eq!(limit_lines(&[]).len(), 1, "none said yet");
3991        let one = limit_lines(&[sim(1, true, 2592)]);
3992        assert!(one.len() == 1 && one[0].contains("2592") && one[0].contains("1944"), "{one:?}");
3993        assert!(!limit_lines(&[sim(1, true, 0)])[0].contains('×'), "no picture size: none said");
3994        let two = limit_lines(&[sim(1, true, 2592), sim(2, false, 2592)]);
3995        assert!(two.len() == 2 && two[0].contains("2592") && !two[1].contains("2592"), "{two:?}");
3996    }
3997
3998    /// What a multimedia message from a computer may weigh (decision 5):
3999    /// nothing before the phone said; the conversation's SIM, else the
4000    /// smallest that takes them; 300 kB when chosen, never over the carrier's.
4001    #[test]
4002    fn what_a_message_from_here_may_weigh() {
4003        let sim = |sub: i32, mms: bool, max: u64| rules::Limits { sub, max, width: 2592, height: 1944, recipients: 20, group: true, mms };
4004        assert_eq!(weighed(None, 1, None), Weigh::Unknown, "before the phone said: no Attach");
4005        let two = [sim(1, true, 614_400), sim(2, false, 307_200)];
4006        assert_eq!(weighed(Some(&two), 1, None), Weigh::Up { max: 614_400, side: 2592 }, "the conversation's SIM");
4007        assert_eq!(weighed(Some(&two), 2, None), Weigh::None, "its SIM takes no multimedia message");
4008        assert_eq!(weighed(Some(&two), -1, None), Weigh::Up { max: 614_400, side: 2592 }, "no SIM known: those that take them");
4009        assert_eq!(weighed(Some(&two), 7, None), Weigh::Up { max: 614_400, side: 2592 }, "a SIM the phone did not name");
4010        assert_eq!(weighed(Some(&two), 1, Some(300)), Weigh::Up { max: 307_200, side: 2592 }, "300 kB chosen here");
4011        assert_eq!(weighed(Some(&[sim(1, true, 102_400)]), 1, Some(300)), Weigh::Up { max: 102_400, side: 2592 }, "never more than the carrier's");
4012        assert_eq!(weighed(Some(&[sim(1, true, 614_400), sim(3, true, 307_200)]), -1, None), Weigh::Up { max: 307_200, side: 2592 }, "the smallest of two");
4013        assert_eq!(weighed(Some(&[sim(1, true, 0)]), 1, None), Weigh::Unknown, "a line with no size");
4014        assert_eq!(weighed(Some(&[]), 1, None), Weigh::Unknown);
4015    }
4016
4017    /// The phone's log is read only when a request about to go names no SIM,
4018    /// once a step at most; never in a step whose requests are decided, as
4019    /// every step is but the one that sends, a dose's alarm's included (the
4020    /// review of 18 October 2026, M-1).
4021    #[test]
4022    fn a_quick_step_reads_the_log_only_for_a_text_about_to_go() {
4023        let desk = Desk::new("log-reads");
4024        a_log_of(&desk, 200);
4025        let carrier = Carrier { max: 614_400, mms: true, ..Carrier::default() };
4026        let reads = || LOG_READS.with(std::cell::Cell::get);
4027        let text = |key: &str, sub: i32| Request { key: key.into(), phone: "phone".into(), to: "01 99 00 12 34".into(), body: "J’arrive.".into(), sub, written: NOW - MINUTE, ..Request::default() };
4028        // Decided already, with no SIM: the log stays shut.
4029        desk.asks(&text(KEY_A, -1));
4030        rules::record(&desk.base.join("private"), &Claim { key: KEY_A.into(), at: NOW - MINUTE, state: "done".into(), ..Claim::default() }).unwrap();
4031        let step = desk.step(&carrier);
4032        let before = reads();
4033        step.copy_results(NOW);
4034        step.decide(NOW);
4035        step.look_for_lost(NOW);
4036        assert_eq!(reads(), before, "every request decided: the log never read");
4037        // About to go with its SIM named: shut still.
4038        desk.asks(&text(KEY_B, 1));
4039        step.decide(NOW);
4040        assert_eq!(reads(), before, "its SIM named");
4041        // A multimedia message waiting for a medium, no SIM named: the log stays shut, nothing opened (L-a).
4042        desk.asks(&Request { sub: -1, ..mms("4d5e6f708192a3b4c5d6e7f809101112", vec![desk.seals(b"here"), desk.described(b"on its way")], NOW - MINUTE) });
4043        let opened = std::fs::read_dir(desk.base.join("private")).map(|d| d.count()).unwrap_or(0);
4044        step.decide(NOW);
4045        assert_eq!(reads(), before, "a message waiting for its media");
4046        assert_eq!(std::fs::read_dir(desk.base.join("private")).map(|d| d.count()).unwrap_or(0), opened, "nothing opened or written for it");
4047        // Two about to go with none named: read once.
4048        desk.asks(&text("1b2c3d4e5f60718293a4b5c6d7e8f901", -1));
4049        desk.asks(&text("2c3d4e5f60718293a4b5c6d7e8f90112", -1));
4050        step.decide(NOW);
4051        assert_eq!(reads(), before + 1, "read once for both");
4052    }
4053
4054    /// Only a device the sharing counts asks the phone to send; a request
4055    /// whose sealed writer is not its file's device is passed over (the
4056    /// review of 18 October 2026, I-4).
4057    #[test]
4058    fn only_a_device_the_sharing_counts_asks_the_phone_to_send() {
4059        let desk = Desk::new("counted");
4060        let carrier = Carrier { max: 614_400, mms: true, ..Carrier::default() };
4061        desk.asks(&Request { key: KEY_A.into(), phone: "phone".into(), to: "01 99 00 12 34".into(), body: "J’arrive.".into(), sub: 1, written: NOW - MINUTE, ..Request::default() });
4062        // Not known yet (a new computer's first text before its records): it waits, nothing decided (I-a)…
4063        let unknown = |_: &str| rules::Standing::Unknown;
4064        assert!(!desk.step_counting(&carrier, &unknown).decide(NOW));
4065        assert!(desk.states(KEY_A).is_empty() && desk.ledger().state(KEY_A).is_none(), "waiting, nothing said");
4066        // …and goes once its records came, within its quarter of an hour.
4067        desk.step(&carrier).decide(NOW + MINUTE);
4068        assert_eq!(desk.ledger().state(KEY_A), Some("claimed"));
4069        // Gone (you forgot it): refused, said so.
4070        desk.asks(&Request { key: KEY_B.into(), phone: "phone".into(), to: "01 99 00 12 34".into(), body: "J’arrive.".into(), sub: 1, written: NOW, ..Request::default() });
4071        let gone = |_: &str| rules::Standing::Gone;
4072        assert!(desk.step_counting(&carrier, &gone).decide(NOW + MINUTE));
4073        assert_eq!(desk.states(KEY_B), [("refused".to_string(), "not-counted".to_string())]);
4074        // Not known for its whole quarter of an hour: expired, never sent.
4075        desk.asks(&Request { key: "3c4d5e6f708192a3b4c5d6e7f8091011".into(), phone: "phone".into(), to: "01 99 00 12 34".into(), body: "J’arrive.".into(), sub: 1, written: NOW, ..Request::default() });
4076        assert!(desk.step_counting(&carrier, &unknown).decide(NOW + 16 * MINUTE));
4077        assert_eq!(desk.states("3c4d5e6f708192a3b4c5d6e7f8091011"), [("expired".to_string(), String::new())]);
4078        // Moved into another device's file (the server could): passed over, never decided.
4079        let line = std::fs::read_to_string(rules::own_file(&desk.base.join("data"), rules::SEND, "desk")).unwrap();
4080        std::fs::write(rules::own_file(&desk.base.join("data"), rules::SEND, "laptop"), line).unwrap();
4081        assert!(rules::read_requests(&desk.base.join("data"), &desk.seal).iter().all(|r| r.device == "desk"), "the moved line passed over");
4082    }
4083
4084    /// A phone's log of `count` texts, sealed as the phone seals it, every
4085    /// column of a provider's row kept (made up: fiction numbers).
4086    fn a_log_of(desk: &Desk, count: usize) {
4087        let texts: Vec<Text> = (0..count)
4088            .map(|n| {
4089                let at = NOW - DAY_MS * 400 + n as i64 * 600_000;
4090                let number = format!("+3319900{:04}", n % 140);
4091                let body: String = (0..90).map(|i| char::from(b'a' + ((n * 7 + i) % 26) as u8)).collect();
4092                let mut fields = BTreeMap::new();
4093                for (k, v) in [("_id", json!(n)), ("thread_id", json!(n % 140)), ("address", json!(number)), ("person", Value::Null), ("date", json!(at)), ("date_sent", json!(at - 2_000)), ("protocol", json!(0)), ("read", json!(1)), ("status", json!(-1)), ("type", json!(1 + n % 2)), ("reply_path_present", json!(0)), ("subject", Value::Null), ("body", json!(body)), ("service_center", json!("+33199000000")), ("locked", json!(0)), ("sub_id", json!(1)), ("error_code", json!(0)), ("creator", json!("foundation.e.message")), ("seen", json!(1))] {
4094                    fields.insert(k.to_string(), v);
4095                }
4096                Text { id: format!("sms-{n}@{at}"), at, thread: (n % 140).to_string(), direction: if n % 2 == 0 { "in".into() } else { "out".into() }, with: vec![number], body, sub: 1, fields, ..Text::default() }
4097            })
4098            .collect();
4099        rules::append(&rules::own_file(&desk.base.join("data"), rules::LOG, "phone"), &texts, &desk.seal, None).unwrap();
4100    }
4101
4102    /// The quick step's time with 20,000 texts in the phone's log and only
4103    /// decided requests (a dose's alarm runs it: `health::alarm_decide` →
4104    /// `share::exchange_here` → `before_exchange` → `light_step`), best of
4105    /// five: `cargo test --release -p sioul-app quick_step_s_time -- --ignored --nocapture`.
4106    #[test]
4107    #[ignore]
4108    fn a_quick_step_s_time_with_twenty_thousand_texts() {
4109        let desk = Desk::new("quick-time");
4110        a_log_of(&desk, 20_000);
4111        let request = Request { key: KEY_A.into(), phone: "phone".into(), to: "01 99 00 12 34".into(), body: "J’arrive.".into(), sub: -1, written: NOW - MINUTE, ..Request::default() };
4112        desk.asks(&request);
4113        rules::record(&desk.base.join("private"), &Claim { key: KEY_A.into(), at: NOW - MINUTE, state: "done".into(), ..Claim::default() }).unwrap();
4114        let carrier = Carrier { max: 614_400, mms: true, ..Carrier::default() };
4115        let step = desk.step(&carrier);
4116        let mut best = std::time::Duration::MAX;
4117        for _ in 0..5 {
4118            let t = std::time::Instant::now();
4119            step.copy_results(NOW);
4120            step.decide(NOW);
4121            step.look_for_lost(NOW);
4122            step.sweep_outgoing(NOW);
4123            best = best.min(t.elapsed());
4124        }
4125        println!("the quick step, 20,000 texts in the log, its one request decided: {best:?} (best of five)");
4126        // A multimedia message waiting for one of its two media, the other here (the review's L-a).
4127        let here = desk.seals(&[7u8; 120_000]);
4128        let absent = desk.described(b"not here yet");
4129        // As a computer writes it: no SIM named.
4130        desk.asks(&Request { sub: -1, ..mms(KEY_B, vec![here, absent], NOW - MINUTE) });
4131        let mut waiting = std::time::Duration::MAX;
4132        for _ in 0..5 {
4133            let t = std::time::Instant::now();
4134            step.copy_results(NOW);
4135            step.decide(NOW);
4136            step.look_for_lost(NOW);
4137            step.sweep_outgoing(NOW);
4138            waiting = waiting.min(t.elapsed());
4139        }
4140        assert!(desk.ledger().state(KEY_B).is_none(), "still waiting");
4141        println!("the same with a multimedia message waiting for one of its two media: {waiting:?} (best of five)");
4142    }
4143
4144    /// What its carriers let the phone send, said once a day, and at once
4145    /// when it changes; never twice the same within the day.
4146    #[test]
4147    fn the_phone_says_what_its_carriers_let_it_send() {
4148        let desk = Desk::new("limits");
4149        let carrier = Carrier { max: 614_400, mms: true, ..Carrier::default() };
4150        let step = desk.step(&carrier);
4151        assert!(step.publish_limits(NOW));
4152        assert!(!step.publish_limits(NOW + 3_600_000), "the same, within the day");
4153        let lines = || desk.outcomes().into_iter().filter(|o| o.state == "limits").collect::<Vec<_>>();
4154        assert_eq!(lines().len(), 1);
4155        assert_eq!((lines()[0].key.as_str(), lines()[0].limits[0].max, lines()[0].limits[0].sub), ("", 614_400, 1));
4156        let changed = Carrier { max: 307_200, mms: true, ..Carrier::default() };
4157        assert!(desk.step(&changed).publish_limits(NOW + 2 * 3_600_000), "changed: said at once");
4158        assert!(desk.step(&changed).publish_limits(NOW + DAY_MS + 3 * 3_600_000), "a day on: said again");
4159        assert_eq!(lines().len(), 3);
4160        assert_eq!(rules::state_of("", &desk.outcomes()), None, "no request's state");
4161    }
4162}